1 资源竞争与线程同步
在竞争态条件下,多个线程对同一竞态资源的抢夺会引发线程安全问题。竞态资源是对多个线程可见的共享资源,主要包括全局(非const)变量、静态(局部)变量、堆变量、资源文件等。
线程之间的竞争,可能带来一些列问题:
线程在操作某个共享资源的过程中被其他线程所打断,时间片耗尽而被迫切换到其他线程
共享资源被其他线程修改后的不到告知,造成线程间数据不一致
由于编译器优化等原因,若干操作指令的执行顺序被打乱,造成结果的不可预期
1.1 原子操作
原子操作,即不可分割开的操作;该操作一定是在同一个cpu时间片中完成,这样即使线程被切换,多个线程也不会看到同一块内存中不完整的数据。
原子表示不可分割的最小单元,具体来说是指在所处尺度空间或者层(layer)中不能观测到更为具体的内部实现与结构。对于计算机程序执行的最小单位是单条指令。我们可以通过参考各种cpu的指令操作手册,用其汇编指令编写原子操作。而这种方式太过于低效。
某些简单的表达式可以算作现代编程语言的最小执行单元
某些简单的表达式,其实编译之后的得到的汇编指令,不止一条,所以他们并不是真正意义原子的。以加法指令操作实现 x += n为例 ,gcc编译出来的汇编形式上如下:
...
movl 0xc(%ebp), %eax
addl $n, %eax
movl %eax, 0xc(%ebp)
...
复制代码
而将它放在所线程环境之中,显然也是不安全的:
dispatch_group_t group = dispatch_group_create();
__block int i = 1;
for (int k = 0; k < 300; k++) {
dispatch_group_enter(group);
dispatch_async(dispatch_get_global_queue(0, 0), ^{
++i;
dispatch_group_leave(group);
});
dispatch_group_enter(group);
dispatch_async(dispatch_get_global_queue(0, 0), ^{
--i;
dispatch_group_leave(group);
});
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@"----result=%d i=%d",self.pro1,i);
});
复制代码
上述例子中,全局变量i理论上应该最后得到1,而实际上却几率性得到0,-1,2,-2,1。
为了避免错误,很多操作系统或编译器都提供了一些常用原子化操作的内建函数或API,包括把一些实际是多条指令的常用表达式。上述操作中,将i++/i--,替换为 OSAtomicIncrement32(&i) / OSAtomicDecrement32(&i) ,将得到预期的结果1
下边列举了不同平台上原子操作API的部分例子
windows API
macOS/iOS API
gcc内建函数
作用
InterlockExchange
OSAtomicAdd32
AO_SWAP
原子的交换两个值
InterlockDecrement
OSAtomicDecrement32
AO_DEC
原子的减少一个值
InterlockIncrement
OSAtomicIncrement32
AO_INC
原子的增加一个值
InterlockXor
OSAtomicXor32
AO_XOR
原子的进行异或
在OC中,属性变量的atomoc修饰符,起到的作用跟上述API相似,编译器会通过锁定机制确保所修饰变量的原子性,而且它是默认情况下添加的。而在实际应用场景中,在操作属性值时一般会包含三步(读取、运算、写入),即便写操作是原子,也不能保证线程安全。而ios中同步锁的开销很大(macOS中没有类似问题),所以一般会加上nonatomic修饰。
@property (nonatomic,assign)int pro1;
复制代码
在实际业务中,通常是给核心业务代码加同步锁,使其整体变为原子的,而不是针对具体的属性读写方法。
1.2 可重入与线程安全
函数被重入 一个程序被重入,表示这个函数没有执行完成,由于外部因数或内部调用,又一次进入函数执行。函数被重入分两种情况
多个线程同时执行这个函数
函数自身(可能是经过多层调用之后)调用自身
可重入 一个函数称为可重入的,表明该函数被重入之后没有产生任何不良后果。
可重入函数具备以下特点:
不使用任何局部(静态)非const变量
不使用任何局部(静态)或全局的非const变量的指针
仅依赖调用方法提供的参数
不依赖任何单个资源提供的锁(互斥锁等)
不调用任何不可重入的函数
可重入是并发的强力保障,一个可重入函数可以在多线程环境下放心使用。也就是说在处理多线程问题时,我们可以讲程序拆分为若干可重入的函数,而把注意的焦点放在可重入函数之外的地方。
在函数式编程范式中,由于整个系统不需要维护多余数据变量,而是状态流方式。所以可以认为全是由一些可重入的函数组成的。所以函数式编程在高并发编程中有其先天的优势。
1.3 CPU的过度优化
1.3.1 乱序优化与内存屏障
cpu有动态调度机制,在执行过程中可能因为执行效率交换指令的顺序。而一些看似独立的变量实际上是相互影响,这种编译器优化会导致潜在不正确结果。
面对这种情况我们一般采用内存屏障(memory barrier)。其作用就相当于一个栅栏,迫使处理器来完成位于障碍前面的任何加载和存储操作,才允许它执行位于屏障之后的加载和存储操作。确保一个线程的内存操作总是按照预定的顺序完成。为了使用一个内存屏障,你只要在你代码里面需要的地方简单的调用 OSMemoryBarrier() 函数。
class A {
let lock = NSRecursiveLock()
var _a : A? = nil
var a : A? {
lock.lock()
if _a == nil {
let temp = A()
OSMemoryBarrier()
_a = temp
}
lock.unlock()
return _a
}
}
复制代码
值得注意的是,大部分锁类型都合并了内存屏障,来确保在进入临界区之前它前面的加载和存储指令都已经完成。
1.3.2 寄存器优化与volatile变量
在某些情况下编译器会把某些变量加载进入寄存器,而如果这些变量对多个线程可见,那么这种优化可能会阻止其他线程发现变量的任何变化,从而带来线程同步问题。
在变量之前加上关键字volatile可以强制编译器每次使用变量的时候都从内存里面加载。如果一个变量的值随时可能给编译器无法检测的外部源更改,那么你可以把该变量声明为volatile变量。在许多原子性操作API中,大量使用了volatile 标识符修饰。譬如 在系统库中,所有原子性变量都使用了
int32_tOSAtomicIncrement32( volatile int32_t *__theValue )
复制代码
##2.线程同步的主要方式--线程锁
线程同步最常用的方法是使用锁(Lock)。锁是一种非强制机制,每一个线程访问数据或资源之前,首先试图获取(Acquireuytreewq)锁,并在访问结束之后释放(release)。在锁已经被占用时获取锁,线程会等待,直到该锁被释放。
2.1 互斥锁(Mutex)
2.1.1 基本概念
互斥锁 是在很多平台上都比较常用的一种锁。它属于sleep-waiting类型的锁。即当锁处于占用状态时,其他线程会挂起,当锁被释放时,所有等待的线程都将被唤醒,再次对锁进行竞争。在挂起与释放过程中,涉及用户态与内核态之间的context切换,而这种切换是比较消耗性能的。
互斥锁和二元信号量很相似,唯一不同是只能由获取锁的线程释放而不能假手于人。在某些平台中,他是用二元信号量实现的。关于信号量,我们将在2.3中详细介绍。
互斥锁可以是多进程共享的,也可以是进程内线程可见的。它可以分为分为普通锁、检错锁、递归锁。让我们通过pthread中的pthread_mutex,来详细了解互斥锁的一些用法及注意事项。
2.1.2 pthread_mutex
pthread_mutex 是pthread中的互斥