为了避免不同线程更改同一数据,我们可以不创建具有同步问题的程序,或者说使用同步工具来避免。但是并不是所有情况下都不会涉及到同步问题,下面介绍几个已经提供的同步工具。
4.1.1 原子操作
原子操作是应用于简单数据类型上的同步方法,它的优点是不会阻塞线程竞争(block competing threads)。如果是比较简单的操作,增加一个变量的计数器之类的东西,可以比锁有更好的性能。
OS X和iOS中有很多为32位和64位值做数学运算或者逻辑操作的办法,包含了比较和交换(compare-and-swap),测试和设置(test-and-set),测试和清除(test-and-clear )操作。更多关于原子操作见后面“使用原子操作”一节或者OSAtomic.h
。
4.1.2 内存屏障和Volatile变量(Memory Barriers and Volatile Variables)
⚠️:在下面的翻译中关于Memory Barriers的翻译我会统一翻译为“内存屏障”。
为了能够达到最佳性能,编译器通常都会在汇编级指令对其重新排序(乱序执行),让处理的指令通道尽最大可能避免浪费。在这部分的优化中,当处理器认为不会生成错误数据时它将会对访问主内存的指令进行重新排序。不幸的是,编译器有时候不能检测到内存的操作。比如在某种情况下编译器看变量之间(只是看起来)是没有相互影响的,编译器可能就会去优化这些变量,这种操作就可能导致意想不到的事情发生。
内存屏障是一种非阻塞的同步工具,用于确保内存操作以正确的顺序执行。内存屏障就像一个围栏一样,先处理位于围栏前面的加载或者存储操作,然后才处理位于围栏后面的相关操作。内存屏障通常用于确保一个线程的内存操作以预期的顺序来执行。如果在没有内存障碍的情况下,可能会导致其他线程出现意料之外的结果(维基百科中memory barriers)。可以使用OSMemoryBarrier
函数来使用内存屏障。
⚠️维基百科:内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障
在C语言中,volatile关键字可以用来提醒编译器它后面所定义的变量随时有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。(这段出自维基百科Volatile变量)
编译器通常会把变量的值加载到寄存器(有限存贮容量的高速存贮部件)中以优化代码。对于局部变量并不会出现什么问题。如果这个变量对于另外一个线程是可读写的,那么这样优化的话可能会导致另一个线程无法注意到该变量的相关改动。用volatile关键字来修饰变量可以强制性的让编译器在每次使用该变量时都从对应的内存地址中读取。那么对于在外部某个地方对变量更改了,编译器是无法检测到的。
⚠️总结:对于volatile变量。由于普通变量编译器会做优化(因为cpu对于寄存器数据读取更快,编译器有可能将变量的值放在寄存器中),这就导致了多线程情况下数据更新问题。我们可以声明一定情况下可以声明为volatile。
不管是内存屏障还是volatile变量都会减少编译器可执行优化的次数,应尽可能少用。
4.1.3 锁
锁是最常用的同步工具之一。我们可以使用锁来保护关键部分代码,一段一次只允许一个线程访问的代码。例如关键部分在同一时间特定的数据或者一些资源仅仅支持一个client。通过使用锁可以排除其他线程影响代码的可能性。
下表列出了一些常用的锁,OS X和iOS提供了对这些锁的实现(并不是所有锁都有实现)。对于不支持的锁类型,会说明为什么没有在平台上实现的原因。
锁 | 描述(并非完全是官方文档翻译,有自己的添加) |
---|---|
⚠️⚠️⚠️->Mutex(互斥锁) | 互斥锁作为资源的周围的保护屏障。互斥锁作为信号的一种,它一次只允许访问一个线程。如果互斥锁正在被使用,另一个线程尝试去获取它,那么该线程将会被阻塞,直到互斥锁被原始持有者释放。如果多个线程竞争同一个互斥锁,那么一次只允许一个访问它。 |
Recursive lock(递归锁) | 递归锁是互斥锁的一个变体。递归锁允许单个线程在释放之前多次获取该锁。该线程获取锁的次数(getlock_count),该线程释放锁的次数(releaselock_count),只有在getlock_count==releaselock_count之后,其他线程才不会被阻塞。(这和递归定义一样,递归在每返回一层才会释放该层的变量,这里意思就是所有变量释放完成。这是我自己的理解方式) |
⚠️⚠️⚠️->Read-write lock(读写锁) | 读写锁也被称为共享-独占锁(shared-exclusive lock),经常用于读取数据的频率大于写数据的频率时。因为读写锁有三种状态:读模式加锁,写模式加锁,不加锁。当处于读模式加锁时它是以共享锁,可以同时读取多个数据。当处于写模式时它是独占锁,它会阻塞直到所有读取模式锁释放之后获取独占锁来更新数据。系统只支持使用POSIX线程的读写锁。 |
Distributed lock(分布式锁) | 分布式锁在进程级别提供互斥访问。不像真的互斥锁,分布式锁不会阻塞进程运行。它只是让我们知道锁定事件是否忙碌,并让进程决定该怎么做。 |
Spin lock(自旋锁) | 自旋锁会反复轮询加锁条件,直到条件为真。自旋锁常用于多处理器系统,其中锁的预期等待时间会很短。轮询通常会比阻塞线程更有效,涉及到线程切换上下文以及数据的更新。由于轮询的性质,系统不提供自旋锁的实现,但是可以在特定情况下自己实现。关于内核中实现自旋锁的见"内核编程指南"。唯一需要注意的是:自旋锁不会阻塞线程 |
Double-checked lock(双重锁) | 双重鉴定锁是在加锁之前测试是否一定要加锁来达到减少加锁的开销。系统是不推荐使用该锁的 |
写一点对于读写锁的一点理解:当处于读模式(此时可以同时多次读取),如果马上我需要写数据的话就要等到读模式下的锁释放之后,如果我又要在写之后继续读呢,同样要等到写模式锁完成之后(读->写->读)。
大多数锁包含了内存屏障,以确保进入临界区之前完成相关指令。
关于怎么使用锁在后面的"使用锁"一节中讲解。
4.1.4 条件
条件同样也是信号的一种,当条件为真时允许线程发出信号。条件通常用来指明资源的可用性,或者确保任务能够以指定的顺序来执行。当线程测试条件时会阻塞当前线程,只有当条件为真才会继续执行。否则会保持阻塞的状态,直到其他线程发出信号来显示地更改状态。条件和互斥锁的区别在于条件可以在同一时间被多个线程访问(上面表格中说了互斥锁一次只允许一个线程访问)。条件更多时候就像是一个门卫,根据不同的标准,允许不同的人(线程)通过。
当在管理待处理事件池时会用到条件。当事件处于事件队列中时会使用条件变量发出信号来等到线程。如果一个事件到达,该队列会适当的发出条件信号。如果线程已经处于等到状态时,那么线程将被唤醒,从队列中提取事件并处理该事件。如果两个事件在大致相同的时间进入队列,该队列将发出两次信号来唤醒两个线程。
系统为条件提供了几种不同的技术支持。后面使用"使用条件"会介绍使用详细信息。
4.15 Perform Selector Routines
Cocoa程序提供了更简便的方法以同步的方式将消息传递到单个线程。NSObject类声明了在程序活动线程上执行选择器的方法。在能够保证目标线程让它们以同步方式执行时通过异步的方式来传递消息。例如,我们可以使用Perform Selector将消息从分散的线程传递到程序主线程或者特定的其他线程。Perform Selector的每个请求在目标线程的Runloop中排队,然后按照接收它们的顺序来对请求进行处理。
更多关于Perform Selector可以见“剖析Runloop”中3.1.2.3一节。
本人总结TIPS
- 如果是比较简单的操作,增加一个变量的计数器之类的东西,可以比锁有更好的性能;
- 当处理器认为不会生成错误数据时它将会对访问主内存的指令进行重新排序;
对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障;
应仅对多线程并发安全的成员、函数加volatile修饰;
条件和互斥锁的区别在于条件可以在同一时间被多个线程访问;