多线程(六)线程同步

程序中多个线程的存在引发了多个执行线程安全访问统一资源的潜在问题。两个或多个线程同时修改同一资源有可能以意想不到的方式互相干扰。

其中,多线程访问的同一资源,但该资源又只能被一个线程访问的资源称作为临界资源

这种干扰会带来意想不到的结果:

  • 程序性能问题或崩溃;
  • 数据错乱和数据安全;

为了避免在访问同一资源引起的这种线程间干扰——保护线程安全。

涉及到线程安全时,一个好的设计是最好的保护。避免共享资源,并尽量减少线程间的相互作用,这样可以让它们减少互相的干扰。但是一个完全无干扰的设计是不可能的。在线程必须交互的情况下,你需要使用同步工具,来确保当它们交互的时候是安全的。

在macOS和iOS体系下,同步工具有下面几种:

  • 原子操作;
  • 内存屏障和volatile变量;

本文针对以上内容会进行逐一阐述。

一、线程安全隐患

线程安全隐患我们展示了下面实例,相关源码见线程安全隐患

1. 存钱取钱

image-20181230162817536

2. 卖票

image-20181230162832793

3. 线程同步

下面是从Concurrent Programming: APIs and Challenges截取的线程同步:

线程A、B分别对整数加1,但是最后得到的结果,却不是预期的。

image-20181230144512531

对操作进行分别进行加锁。

image-20181230144517545

二、线程同步工具

2.1 原子操作

原子操作是同步的一个简单的形式,它处理简单的数据类型,可执行简单的数学和逻辑的运算操作。原子操作的优势是它们不妨碍竞争的线程。对于简单的操作,比如递增一个计数器,原子操作比使用锁具有更高的性能优势。

其中常见的atomic属性修饰符。atomic仅仅只是在gettersetter的时候是原子操作,并不能保证在其使用过程中线程安全。可以结合内存屏障一起使用确保线程安全。

更多对简单数据类型的操作,支持原子操作的API,请参考:

2.2 内存屏障

为了达到最佳性能,编译器通常会对汇编级指令进行重新排序,以尽可能保持处理器的指令流水线。作为这种优化的一部分,编译器可能会重新排序访问主内存的指令,因为它认为这样做不会产生不正确的数据。不幸的是,编译器并不总是能够检测到所有依赖于内存的操作。如果看似单独的变量实际上相互影响,编译器优化可能会以错误的顺序更新这些变量,从而产生潜在的错误结果。

内存屏障是一种确保内存操作的正确顺序的非阻塞性的同步工具。内存屏障是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

要使用内存屏障,只需要在代码中适当位置调用OSMemoryBarrier函数即可。

1
OSMemoryBarrier();  //在需要的地方加上,确保了一定会按照我们书写的顺序执行`

2.3 volatile变量

告诉编译器,在读取该变量数值的时候,应该直接从内存读取,而不是从寄存器读取。

简单讲就是实现了多线程资源共享的是同一份拷贝。比如一个变量,在多线程中,编译器会为每一个线程缓存一份,从而导致其中另一个线程修改了变量A,而其他线程还是使用了原来的变量A。假如加上volatile变量修饰符,则会保证所有的线程是用的同一份数据,其实就是内存中的数据。

内存屏障和volatile变量降低了编译器可执行的优化,因此你应该谨慎使用它们,只在有需要的地方时候,以确保正确性。

三、锁

锁是最常用的同步工具。你可以是使用锁来保护临界区(critical section),这些代码段在同一个时间只能允许被一个线程访问。比如,一个临界区可能会操作一个特定的数据结构,或使用了每次只能一个客户端访问的资源。

描述 实例
互斥锁 1. 互斥锁作为资源周围的保护屏障,是一种一次只允许访问一个线程的信号量。
2. 如果一个互斥体正在使用,而另一个线程试图获取它,则该线程将阻塞,直到互斥体被其原始持有者释放。
pthread_mutex
递归锁 1. 递归锁是互斥锁的变体。递归锁允许单个线程在释放之前多次获取锁定。其他线程会一直处于阻塞状态,直到锁的所有者释放该锁的次数与获取它的次数相同。
2. 递归锁主要在递归迭代期间使用,但也可能在多个方法需要分别获取锁的情况下使用。
NSRecursiveLock
读写锁 1. 读写锁也被称为共享排他锁。这种类型的锁通常用于较大规模的操作,如果经常读取受保护的数据结构并偶尔进行修改,则可显著地提高性能。
2. 在正常操作期间,多线程可同时访问数据结构。然而,当一个线程想要写入该结构时,它会阻塞,直到所有的读取器释放该锁,此时它获得锁并可以更新该结构。写入线程正在等待锁定时,新的读取器线程将阻塞,直到写入线程完成。
pthread_rwlock_t
分布锁 分布式锁在进程级别提供互斥访问。与真正的互斥锁不同,分布式锁不会阻塞进程或阻止进程运行。它只是报告锁何时忙,并让流程决定如何继续。
自旋锁 1. 自旋锁反复轮询其锁定条件,直到该条件成立。
2. 自旋锁最常用于预计等待锁定时间较短的多处理器系统。在这些情况下,轮询通常比拦截线程更有效,后者涉及上下文切换和线程数据结构的更新。
OSSpinLock
双重检查锁 1. 双重检查锁试图通过锁定之前测试锁定标准来降低获取锁的开销。
2. 双重检查的锁可能不安全,系统不提供对此不支持,也不鼓励使用它们。

锁的内容将会在多线程(八)锁中详细介绍。

四、线程安全设计的技巧

​ 同步工具是使代码线程安全的有用方法,但它们不是万能的。与非线程性能相比,使用太多的锁和其他类型的同步操作实际上会降低应用程序的线程性能。找到安全和性能之间的正确平衡是一门需要经验的艺术。下面提供的技巧可以帮助我们为应用程序选择适当的同步级别。

4.1 避免完全同步

​ 对于任何新的项目,甚至是现有的项目,设计代码和数据结构以避免同步的需求是最好的解决方案。尽管锁和其他同步工具很有用,但他们的确会影响应用程序的性能。如果整体设计造成特定资源之间的频繁争用,线程可能会等待时间更长。

​ 实现并发的最好方法是减少并发任务之间的交互和相互依赖。如果每个任务都在自己的专用数据集上运行,则不需要使用锁保护该数据。即使在两个任务共享一个通用数据集的情况下,也可以查看分区的方式或为每个任务提供自己的副本。当然,复制数据集也会带来成本,因此必须在作出决定之前权衡两者的成本。

4.2 了解同步的限制

同步工具只有在应用程序中的所有线程一直使用它们时才有效。如果创建一个互斥体来限制对特定资源的访问,则所有线程都必须在尝试操作资源之前获取相同的互斥体。如果不这样做会破坏互斥体提供的保护。

4.3 注意代码正确的威胁

在使用锁和内存屏障时,应该仔细考虑它们在代码中的位置。即使看起来很好的锁也能让你陷入虚假的安全感。

下面示例显示了看起来没有问题的代码之中的缺陷。基本前提是有一个包含一组不可变对象的可变数组。假设调用数组中第一个对象的方法。可以使用如下代码:

1
2
3
4
5
6
7
8
9
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[arrayLock unlock];

[anObject doSomething];

因为数组是可变的,在获取数组中的元素时进行加锁保护其不被其他线程修改。

然后,问题仍然存在,假如在获取到元素后,在第9行调用[anObject doSomething]时,数组获得的锁,已经释放。此时其他线程对数组执行操作,比如将数组所有元素删除。当调用[anObject doSomething]时,数组已经为空,anObject指向的对象已经无法访问,就会出现问题(如崩溃)。

接着,做了如下修正:

1
2
3
4
5
6
7
8
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject doSomething];
[arrayLock unlock];

通过把doSomething的调用移到锁的内部,你的代码可以保证该方法被调用的时候该对象还是有效的。

然而不幸的是,如果doSomething方法需要耗费很长的时间,就可能导致你的代码保持拥有该锁很长时间,这会产生一个性能瓶颈。

该代码的问题不是关键区域定义不清,而是实际问题是不可理解的。真正的问题是由其他线程引发的内存管理的问题。因为它可以被其他线程释放,最好的解决办法是在释放锁之前retain anObject。该解决方案涉及对象被释放,并没有引发一个强制的性能损失。

1
2
3
4
5
6
7
8
9
10
11
NSLock* arrayLock = GetArrayLock();
NSMutableArray* myArray = GetSharedArray();
id anObject;

[arrayLock lock];
anObject = [myArray objectAtIndex:0];
[anObject retain];
[arrayLock unlock];

[anObject doSomething];
[anObject release];

尽管前面的例子非常简单,它们说明了非常重要的一点。当它涉及到正确性时,你需要考虑不仅仅是问题的表面。内存管理和其他影响你设计的因素都有可能因为出现多个线程而受到影响,所以你必须考虑从上到下考虑这些问题。

参考

链接

  1. Threading Programming Guide
  2. atomic man
  3. 原子操作库
  4. 内存屏障
  5. volatile变量
  6. Concurrent Programming: APIs and Challenges

示例代码

  1. 线程安全隐患