iOS并发编程以及陷阱
并发编程是一个很有挑战的任务,它有许多错综复杂的问题和陷阱。在iOS开发中,当使用类似 Grand Central Dispatch(GCD)或 NSOperationQueue 的 API 时,很容易遗忘这些问题和陷阱。读了 objc.io 上几篇关于iOS多线程的文章比如 线程安全类的设计,以及 raywenderlich 上的 Grand Central Dispatch In-Depth:Part 1/2 , Grand Central Dispatch In-Depth:Part 2/2 ,还有这篇 iOS多线程到底不安全在哪里,受益匪浅,所以将它们记录下来,以增加自己的理解,因为是记录,所以有许多对原文的直接拷贝和翻译,在此一并做引用说明。为了获得更准确的资料,参考了苹果 Guides and Sample Code 中关于并发编程的章节。
并发编程相关的几个概念
进程(Process)简单来说,进程是指系统中正在运行的一个应用程序,每一个程序都是一个进程,并且进程之间是独立的,每个进程均运行在其专用且受保护的内存空间内。
线程(thread)是操作系统能够进行运算调度的最小单,是组成进程的子单元。线程是进程中的一个实体,是被系统独立调度和分派的基本单位。说得再具体一些,线程就是“一个CPU执行的一条无分叉的命令序列”。所有的并发编程 API 都是构建于线程之上的 —— 包括 GCD 和操作队列(operation queues)。
同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间、文件描述符等。但每个线程都拥有自己的栈,寄存器,本地存储(thread-local storage)。一个进程可以有很多线程,每条线程并行执行不同的任务,称为多线程。
多个线程可以在单核 CPU 上同时(或者至少看作同时)运行。操作系统将小的时间片分配给每一个线程,这样就能够让用户感觉到有多个任务在同时进行。如果 CPU 是多核的,那么多个线程就可以真正的并行处理,从而减少了完成某项操作所需要的总时间。
并发(Concurrent)/并行(Parallel) 很多人对并发/并行的概念感到困惑,按照我的理解,并发所描述的概念是“同时”运行多个线程,多个线程“同时”被处理。这里对同时加了引号,因为这些线程可能是在单核 CPU 上以分时(时间共享)的形式,在极短的时间片段间不停的切换运行(类似通信中的时分复用(TDM)),也可能是在多核 CPU 上以真正的并行方式同时运行。
关于并发和并行,可以用下面这张有趣的图解释:
并发与并行如果还是不能理解,或许应该看下这段准确的英文解释:
Concurrency and parallelism are often mentioned together, so it’s worth a short explanation to distinguish them from each other.
Separate parts of concurrent code can be executed “simultaneously”. However, it’s up to the system to decide how this happens — or if it happens at all.
Multi-core devices execute multiple threads at the same time via parallelism; however, in order for single-cored devices to achieve this, they must run a thread, perform a context switch, then run another thread or process. This usually happens quickly enough as to give the illusion of parallel execution
Although you may write your code to use concurrent execution under GCD, it’s up to GCD to decide how much parallelism is required. Parallelism requires concurrency, but concurrency does not guarantee parallelism.
The deeper point here is that concurrency is actually about structure. When you code with GCD in mind, you structure your code to expose the pieces of work that can run simultaneously, as well as the ones that must not be run simulataneously. If you want to delve more deeply into this subject, check out this excellent talk by Rob Pike.
临界区(Critical Section) 不能被两个线程同时执行的一段代码叫做临界区。因为这段代码通常操控着一个共享的临界资源(一次仅允许一个线程使用的共享资源),多个线程必须互斥的访问该临界资源。只能被单一线程/进程访问的共享资源,比如打印机等。
竞态条件(Race Condition) 软件系统的正确行为依赖于多个线程交替执行的时序时,就会发生竞态条件。常见的竟态条件为:
-
先检测后执行。执行依赖于检测的结果,而检测结果取决于多线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现问题。
对于 main 线程,如果文件a不存在,则创建文件a,但是在判断文件a不存在之后,Task线程创建了文件a,这时候先前的判断结果已经失效(main线程的执行依赖了一个错误的判断结果),此时文件a已经存在了,但是 main 线程还是会继续创建文件a,导致 Task 线程创建的文件a被覆盖、文件中的内容丢失等等问题。多线程环境中对同一个文件的操作要加锁。
- 延迟初始化(最典型即为单例)
static MyObject *instance = nil;
+ (instancetype)shareInstance
{
if (instance == nil) {
instance = [[MyObject alloc] init];
}
return instance;
}
假如线程thread1和线程thread2同时执行 shareInstance,thread1 看到 instance 为空,创建了一个新的 Obj 对象,此时 thread2 也需要判断 instance 是否为空,此时的 instance 是否为空取决于不可预测的时序:包括 thread1 创建 Obj 对象需要多长时间以及线程的调度方式,如果 thread2 检测时,instance为空,那么 thread2 也会创建一个 instance 对象
死锁(Deadlock) Two (or sometimes more) threads are said to be deadlocked if they all get stuck waiting for each other to complete or perform another action. The first can’t finish because it’s waiting for the second to finish. But the second can’t finish because it’s waiting for the first to finish.(翻译水平有限,英文可以更好的理解)
线程安全(Thread Safe) 是指代码在多线程或者并发任务下能够被安全调用,而不会引起任何问题(data corruption, crashing, etc)。非线程安全代码必须只能运行在单线程环境下。
上下文切换(Context Switch) A context switch is the process of storing and restoring execution state when you switch between executing different threads on a single process(进程).
iOS 和 OS X 中的并发编程
苹果的移动和桌面操作系统中提供了相同的并发编程API。 这里会介绍 pthread 、 NSThread 、GCD 、NSOperationQueue,以及 NSRunLoop。实际上把 run loop 也列在其中是有点奇怪,因为它并不能实现真正的并行,不过因为它与并发编程有很大的关系,因此值得我们进行一些深入了解。
需要重点关注的是,你无法控制你的代码在什么地方以及什么时候被调度,也无法控制执行多长时间后将被暂停,以便轮换执行别的任务。开发者可以使用 POSIX 线程 API,或者 Objective-C 中提供的对该 API 的封装 NSThread
,来创建自己的线程。下面这个小Demo利用 pthread 在一百万个数字中查找最小值和最大值,其中并发执行了 4 个线程。从该示例复杂的代码中,应该可以看出为什么你不会希望直接使用 pthread 。
struct inputInfo {
uint32_t *intputValues;
size_t count;
};
struct resultInfo {
uint32_t min;
uint32_t max;
};
void * findMinAndMax(void *arg)
{
struct inputInfo const * const info = (struct inputInfo *)arg;
uint32_t min = UINT32_MAX;
uint32_t max = 0;
for (size_t i = 0; i < info->count; i++) {
uint32_t temp = info->intputValues[0];
min = MIN(temp, min);
max = MAX(temp, max);
}
free(arg);
struct resultInfo *const result = (struct resultInfo *)malloc(sizeof(*result));
result->max = max;
result->min = min;
return result;
}
int main(int argc, const char * argv[])
{
// 使用随机数字填充 inputValues
size_t const count = 1000000;
uint32_t inputValues[count];
// 使用随机数字填充 inputValues
for (size_t i = 0; i < count; ++i) {
inputValues[i] = (uint32_t)i;
}
// 开始4个寻找最小值和最大值的线程
size_t const threadCount = 5;
pthread_t tid[threadCount];
for (size_t i = 0; i < threadCount; ++i) {
struct inputInfo * const info = (struct inputInfo *) malloc(sizeof(*info));
size_t offset = (count / threadCount) * i;
info->intputValues = inputValues + offset;
info->count = MIN(count - offset, count / threadCount);
int err = pthread_create(tid + i, NULL, &findMinAndMax, info);
NSCAssert(err == 0, @"pthread_create() failed: %d", err);
}
// 等待线程退出
struct resultInfo * results[threadCount];
for (size_t i = 0; i < threadCount; ++i) {
int err = pthread_join(tid[i], (void **) &(results[i]));
NSCAssert(err == 0, @"pthread_join() failed: %d", err);
}
// 寻找 min 和 max
uint32_t min = UINT32_MAX;
uint32_t max = 0;
for (size_t i = 0; i < threadCount; ++i) {
min = MIN(min, results[i]->min);
max = MAX(max, results[i]->max);
free(results[i]);
results[i] = NULL;
}
NSLog(@"min = %u", min);
NSLog(@"max = %u", max);
}
return 0;
NSThread 是 Objective-C 对 pthread 的封装,比直接使用 pthread 更方便些。但是不论使用 pthread 还是 NSThread 直接对线程操作,都是相对糟糕的编程体验。
直接使用线程可能会引发的一个问题是,如果你的代码和所基于的框架代码都创建自己的线程时,那么活动的线程数量有可能以指数级增长。这在大型工程中是一个常见问题。例如,在 8 核 CPU 中,你创建了 8 个线程来完全发挥 CPU 性能。然而在这些线程中你的代码所调用的框架代码也做了同样事情(因为它并不知道你已经创建的这些线程),这样会很快产生成成百上千的线程。代码的每个部分自身都没有问题,然而最后却还是导致了问题。使用线程并不是没有代价的,每个线程都会消耗一些内存和内核资源。
下面介绍两种基于队列的并发编程API:GCD 和 operation queue 。它们通过集中管理一个被大家协同使用的线程池,来解决创建过多线程导致的问题。
Grand Central Dispatch
为了让开发者更加容易的充分利用设备上的多核CPU,苹果在 OS X 10.6 和 iOS 4 中引入了 Grand Central Dispatch(GCD)。
通过GCD,开发者不必再直接跟线程打交道。GCD 不仅决定着你的代码块将在哪个线程被执行,它还根据可用的系统资源对这些线程进行管理。这样可以将开发者从线程管理的工作中解放出来,通过集中的管理线程,来缓解大量线程被创建的问题。
GCD中的两个核心概念是“任务”和“队列”,开发者只需专注于想要执行的“任务” block,然后添加到适当的“队列”中,这种形象的抽象方式更容易被人理解和使用。
GCD 公开有 5 个不同的队列:运行在主线程中的 main queue,3 个不同优先级的后台队列,以及一个优先级更低的后台队列(用于 I/O)。另外,开发者可以创建自定义队列:串行或者并行队列。自定义队列非常强大,在自定义队列中被调度的所有 block 任务最终都将被放入到系统的全局队列和线程池中,如下图所示:
系统队列和自定义队列我们强烈建议,在大多数情况下使用默认优先级的队列就可以了,如果执行的任务需要访问一些共享的资源,那么在不同优先级的队列中调度这些任务很快就会造成不可预期的行为。这样可能会引起程序的完全挂起,因为低优先级的任务阻塞了高优先级任务,使它不能被执行。
虽然 GCD 是一个低层级的 C 语言 API ,但是它使用起来非常的直接。不过这也容易使开发者忘记并发编程中的许多注意事项和陷阱,这些将在后面并发编程带来的问题中进行讨论。
串行队列(Serial Queues) 串行队列中的任务,每次只执行一个,先前的任务执行完毕后,才会执行下一个。当然,你不会知道一个block结束与下一个block开始之间的时间间隔是多少,如下图所示:
这些任务的执行时间是在GCD的控制之下,你唯一能够确定的是:GCD每次只执行一个任务,任务执行顺序就是它们被加入队列的顺序。
因为在串行队列中两个任务不可能并发运行,所以就没有可能会同时访问同一个临界区的风险。所以仅对于这些任务而言,这种运行机制能够保护临界区避免发生竟态条件。所以,如果访问临界区的唯一方式是通过被提交到那个串行队列中的任务,那么你可以确保临界区是安全的。
并发队列(Concurrent Queues) 在并发队列中,你能够保证的仅有一件事:任务的执行顺序就是它们被添加到队列中的顺序。对于每个任务的完成顺序、下一个任务什么时候开始以及在任意给定时间内正在运行的 blocks 数量都是不清楚的,这些完全取决去 GCD。下图展示了在GCD下4个并发任务的执行:
上图表达的意思是,一个 block 什么时候开始执行完全取决于 GCD,如果一个 blcok 的执行时间与另一个重叠,由 GCD 决定这个 block 是需要运行在另一个核心上,还是在同一个核心上通过上下文切换(context switch)的方式执行。
队列类型
系统提供了一个特殊的串行队列叫主队列(main queue),像其他串行队列一样,主队列中的任务每次执行一个,但是能够确定的是,主队列中的所有任务都在主线程执行,主线程是唯一允许更新UI的线程。主队列用来向 UIViews 对象发送消息或者发送通知。
系统还提供了4种不同优先级的全局并发队列(Global Dispatch Queues):background、low、default、high,优先级由低到高。需要注意的是,苹果的 API 也使用了这些队列,所以这些队列中并非只有你自己添加的任务。
最后,你还可以创建自定义串行/并发队列。也就是说,至少有五种队列可供选择:主队列、4个全局并发队列、自定义队列。
And that’s the big picture of dispatch queues!
GCD 的“艺术”归根结底在于选择合适的队列派发函数(dispatch function)将任务提交到特定队列中。下面举例说明几种常用的 dispatch function。
(1). dispatch_async
- (void)viewDidLoad
{
[super viewDidLoad];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
NSLog(@"First Log"); // 1
});
NSLog(@"Second Log"); // 2
}
下面这个动态图生动的呈现出究竟发生了什么,左侧是代码中的断点,右侧是相关队列的状态:
- 主队列按顺序向前执行任务,下一个任务就是实例化一个
UIViewController
,走到viewDidLoad
方法内; -
viewDidLoad
在主线程执行 - 现在主线程运行到了
viewDidLoad
内部,即将到达dispatch_async
-
dispatch_async
的 block 被添加到一个全局队列并且稍后将会执行它 - 在
dispatch_async
添加 block 到全局队列之后,viewDidLoad
继续向下走,主线程把注意力放在剩余的任务上。与此同时,全局队列也正在并发的处理它的任务。再次提醒,全局队列中的任务将以 "FIFO" 的顺序出队(分发下去),但是这些任务会被并发执行。 - 现在,被
dispatch_async
添加的 block 正在执行了 -
dispatch_async
的 block 执行完毕,所有的NSLog
语句都已经将输出打印到了控制台。尽管在这个小例子中先执行了第二条打印任务,随后才执行第一条,不过First Log
和Second Log
打印顺序不定 —— 取决于在那个特定时间硬件正在处理的事情,你没有办法控制或者知晓哪条语句先执行。
什么时候以及怎样使用 dispatch_async
和各种队列
- 自定义串行队列:当你想在后台串行的执行任务并跟踪这个任务的执行状态时,使用自定义串行队列是一个好的选择。这样能消除资源竞争,因为在同一时刻仅有一个任务正在执行。
- 主队列(Serial):通常,在并发队列中的一项任务处理完成后,就需要更新UI。此时你需要嵌套 block 把UI更新任务提交到主队列。如果你现在已经处于主队列,并调用
dispatch_async
将任务添加到主队列,此时你唯一能确保的是,这个新添加的任务将在当前方法执行完毕后的某个时间才被执行。 - 并发队列:通常使用并发队列在后台处理非UI操作。
(2). dispatch_sync
- (void)viewDidLoad
{
[super viewDidLoad];
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
NSLog(@"First Log"); // 1
});
NSLog(@"Second Log"); // 2
}
上图中各个步骤的说明如下:
- 主队列按顺序向前执行任务,下一个任务就是实例化一个
UIViewController
,走到viewDidLoad
方法内 -
viewDidLoad
在主线程执行 - 现在主线程运行到了
viewDidLoad
内部,即将到达dispatch_sync
-
dispatch_sync
的 block 被添加到一个global queue
并且稍后将会执行它。主线程会被阻塞,直到 block 执行完毕。与此同时,global queue
正在并发的处理任务;在这个global q ueue
中,所有 block 任务将按照 “FIFO” 的顺序出队,但是会被并发执行。 -
global queue
处理先前已经加入队列的任务(在 dispatch_sync block 之前添加到该全局队列中的任务) - 开始执行 dispatch_sync 的 block
- block 执行完毕,dispatch_sync 函数返回,主线程恢复
- viewDidLoad 方法执行完毕,主队列继续处理其他任务
dispatch_sync
函数将一个任务添加到一个队列中,会阻塞当前线程,直到该任务执行完毕。dispatch_async
不会等待任务执行完,当前线程会继续往下走,不会阻塞当前线程。使用 dispatch_sync 时应注意避免死锁(deadlock)!
Here's a quick overview of when and where to use dispatch_sync:
- Custom Serial Queue: Be VERY careful in this situation; if you're running in a queue and call dispatch_sync targeting the same queue, you will definitely create a deadlock.
- Main Queue (Serial): Be VERY careful for the same reasons as above; this situation also has potential for a deadlock condition.
- Concurrent Queue: This is a good candidate to sync work through dispatch barriers or when waiting for a task to complete so you can perform further processing.
(3). dispatch_after
使用 dispatch_after
延迟执行某个任务。比如在1秒后执行某个block。代码如下:
double delayInSeconds = 1.0;
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); // 1
dispatch_after(time, dispatch_get_main_queue(), ^(void){ // 2
// your task delay to execute
});
dispatch_after
works just like a delayed dispatch_async
!!
dispatch_after
在功能上就像延迟了的 dispatch_async
,你没有办法掌控任务的实际执行时间,并且一旦 dispatch_after
函数返回,就没有办法取消任务。
怎样使用 dispatch_after
- 自定义串行队列:在自定义串行队列中谨慎使用 dispatch_after
- 主队列(Serial):主队列中使用 dispatch_after 是一个好的选择
- 并发队列:谨慎使用,一般你很少在自定义并发队列中使用 dispatch_after
(4). Dispatch Group
- dispatch_group_create:创建 diapatch group
dispatch_group_t group = dispatch_group_create();
- dispatch_group_async:提交 block 到 dispatch queue 中,并将 block 和 group 关联起来
dispatch_group_async(group, queue, ^{
// block
});
- dispatch_group_wait:阻塞当前线程,等待 group 关联的所有 block 执行完毕或者到达指定时间。如果到达指定时间后,所有任务并没有全部完成,那么 dispatch_group_wait 返回一个非 0 的数,可以根据这个返回值,判断是否等待超时。如果设置为 DISPATCH_TIME_FOREVER ,意思是永远等待,直到所有 block 执行完毕。
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
- dispatch_group_notify:不阻塞当前线程,当 group 关联的所有 block 执行完毕后,回调通知
dispatch_group_notify(group, queue, ^{
// 所有 block 执行完毕的回调
});
注意:
dispatch_group_async(group, queue, ^{
// block
});
等价于
dispatch_group_enter(group);
dispatch_async(queue, ^{
// block
dispatch_group_leave(group);
});
when and how to use dispatch groups with the various queue types:
- Custom Serial Queue: This is a good candidate for notifications when a group of tasks completes.
- Main Queue (Serial): This is a good candidate as well in this scenario. You should be wary of using this on the main queue if you are waiting synchronously for the completion of all work since you don't want to hold up the main thread. However, the asynchronous model is an attractive way to update the UI once several long-running tasks finish such as network calls.
- Concurrent Queue: This as well is a good candidate for dispatch groups and completion notifications.
(5). dispatch_apply
提交 block 到 dispatch queue,并重复调用多次 (Submits a block to a dispatch queue for multiple invocations.)
dispatch_apply 就像 for 循环一样,并发执行每次的迭代任务。dispatch_apply 函数是同步的,直到所有任务执行完毕,才会返回。如果有大量迭代次数,并且每次迭代都仅处理少量工作,那么并不适合使用 dispatch_apply。
When is it appropriate to use dispatch_apply?
- Custom Serial Queue: A serial queue would completely negate the use of dispatch_apply; you might as well just use a normal for loop.
- Main Queue (Serial): Just as above, using this on a serial queue is a bad idea. Just use a normal for loop.
- Concurrent Queue: This is a good choice for concurrent looping, especially if you need to track the progress of your tasks.
(6). 信号量
信号量是持有计数的信号,使用它控制对有限资源的使用和访问。假设有一间房子,它对应一个进程,房子里的两个人就对应两个线程。这个房子(进程)有很多资源,比如花园、客厅、卫生间等,是所有人(线程)共享的。但是有些地方,比卫生间,最多只能有1个人能进去。怎么办呢,在卫生间门口挂1把钥匙。进去的人(线程)拿着钥匙进去(信号量 -1),外面的人(线程)没有钥匙就在门口等待,直到里面的人出来并把钥匙重新放回门口(信号量+1),此时外面等待的人再拿着这个钥匙进去,所有人(线程)就按照这种方式依次访问卫生间这个有限的资源。门口的钥匙数量就称为信号量(Semaphore)。信号量为0时需要等待,信号量不为零时,减去1而且不等待。
The semantics for using a dispatch semaphore are as follows:
-
When you create the semaphore using the
dispatch_semaphore_create
function, you can specify a positive integer indicating the number of resources available. -
In each task, call
dispatch_semaphore_wait
to wait on the semaphore. -
When the wait call returns, acquire the resource and do your work.
-
When you are done with the resource, release it and signal the semaphore by calling the
dispatch_semaphore_signal
function.
举个栗子:
dispatch_group_t group = dispatch_group_create();
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
NSMutableArray *mutableArr = [NSMutableArray array];
for (NSInteger i = 0; i < 10000; i++) {
dispatch_group_async(group, dispatch_get_global_queue(0, 0), ^{
/*
某个线程执行到这,如果信号量值为1,执行了wait方法后,信号量的值变成了0。并开始执行下面的代码。
*/
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
/*
这时候信号量的值为0,其它线程都处于等待状态。这样对 mutableArr 进行修改的线程,
在任意时刻都只有一个,能够保证多线程下读写 mutableArr 的安全性
*/
[mutableArr addObject:@(i)];
/*
执行结束,要调用signal方法,把信号量的值加1。
这样,其他等待的线程按照等待的先后顺序继续访问 mutableArr
*/
dispatch_semaphore_signal(semaphore);
});
}
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
NSLog(@"%@",mutableArr.lastObject);
信号量与互斥锁
- 信号量:关注的是信号,信号!可以使用在线程间和进程间。只要信号是允许的,线程就可以访问某个资源。
- 互斥锁:只能用于线程间。使用时会锁住某个资源,只允许当前一个线程访问,其他线程无法访问,处于等待状态。解锁后其他处于等待状态的线程被唤醒,然后按照等待排队顺序继续访问。
(7). dispatch_barrier 栅栏函数
dispatch_barrier_async函数的作用与barrier的意思相同,在进程管理中起到一个栅栏的作用,它等待所有位于 barrier 函数之前的队列中的任务执行完毕后,再执行barrier block 中的任务,并且等待 barrier block中的任务执行完毕之后,barrier函数后续的任务才会得到执行,该函数需要同dispatch_queue_create 函数生成的并发队列(concurrent queue) 一起使用。
举个栗子:
- (void)barrier
{
dispatch_queue_t concurrentQueue = dispatch_queue_create("12312312", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(concurrentQueue, ^{
NSLog(@"1");
});
dispatch_async(concurrentQueue, ^{
NSLog(@"2");
});
dispatch_barrier_async(concurrentQueue, ^{
NSLog(@"barrier");
});
dispatch_async(concurrentQueue, ^{
NSLog(@"3");
});
dispatch_async(concurrentQueue, ^{
NSLog(@"4");
});
}
输出:1 2 barrier 4 3 其中1 2 与 3 4 由于异步执行先后顺序可能有变,
但是 barrier 一定位于他们中间。
看一段官方文档能够更好的理解:
调用 dispatch_barrier_async 函数总会在 block 任务提交后立即返回,而不等待 block 被调用。
When the barrier block reaches the front of a private concurrent queue, it is not executed immediately. Instead, the queue waits until its currently executing blocks finish executing. At that point, the barrier block executes by itself. Any blocks submitted after the barrier block are not executed until the barrier block completes.
The queue you specify should be a concurrent queue that you create yourself using the dispatch_queue_create
function. If the queue you pass to this function is a serial queue or one of the global concurrent queues, this function behaves like the dispatch_async
function.
多线程并发带来的陷阱
使用并发编程会带来许多陷阱。一旦你做的事情超过了最基本的情况,对于并发执行的多任务之间的相互影响的不同状态的监视就会变得异常困难。 问题往往发生在一些不确定性(不可预见性)的地方,这使得在调试相关并发代码时更加困难。
关于并发编程的不可预见性有一个非常有名的例子:在1995年, NASA (美国宇航局)发送了开拓者号火星探测器,但是当探测器成功着陆在我们红色的邻居星球后不久,任务戛然而止,火星探测器莫名其妙的不停重启,在计算机领域内,遇到的这种现象被定为为优先级反转,也就是说低优先级的线程一直阻塞着高优先级的线程。稍后我们会看到关于这个问题的更多细节。在这里我们想说明的是,即使拥有丰富的资源和大量优秀工程师的智慧,并发也还是会在不少情况下反咬你一口。
并发编程中许多问题的根源就是在多线程中访问共享资源。资源可以是一个属性,一个对象,通用的内存、网络设备或者一个文件等等。在多线程中,任何一个共享的资源都可能是一个潜在的冲突点,你必须精心设计,以防止这种冲突的发生。
为了演示这类问题,我们举一个关于资源的简单示例:比如仅仅用一个整型值来做计数器。在程序运行过程中,我们有两个并行线程 A 和 B,这两个线程都尝试着同时增加计数器的值。问题来了,你通过 C 语言或 Objective-C 写的代码大多数情况下对于 CPU 来说不会仅仅是一条机器指令。要想增加计数器的值,当前的必须被从内存中读出,然后增加计数器的值,最后还需要将这个增加后的值写回内存中。
我们可以试着想一下,如果两个线程同时做上面涉及到的操作,会发生怎样的偶然。例如,线程 A 和 B 都从内存中读取出了计数器的值,假设为 17 ,然后线程A将计数器的值加1,并将结果 18 写回到内存中。同时,线程B也将计数器的值加 1 ,并将结果 18 写回到内存中。实际上,此时计数器的值已经被破坏掉了,因为计数器的值 17 被加 1 了两次,而它的值却是 18。
这个问题被叫做竞态条件,在多线程里面访问一个共享的资源,如果没有一种机制来确保在线程 A 结束访问一个共享资源之前,线程 B 就不会开始访问该共享资源的话,资源竞争的问题就总是会发生。如果你所写入内存的并不是一个简单的整数,而是一个更复杂的数据结构,可能会发生这样的现象:当第一个线程正在写入这个数据结构时,第二个线程却尝试读取这个数据结构,那么获取到的数据可能是新旧参半或者没有初始化。为了防止出现这样的问题,多线程需要一种互斥的机制来访问共享资源。
在实际的开发中,情况甚至要比上面介绍的更加复杂,因为现代 CPU 为了优化目的,往往会改变向内存读写数据的顺序。
互斥锁
互斥访问的意思就是同一时刻,只允许一个线程访问某个特定资源。为了保证这一点,每个希望访问共享资源的线程,首先需要获得一个共享资源的互斥锁,只有当某个线程对资源完成了操作,释放掉这个互斥锁,这样别的线程才有机会访问该共享资源。
除了确保互斥访问,还需要解决代码无序执行所带来的问题。如果不能确保 CPU 访问内存的顺序跟编程时的代码指令一样,那么仅仅依靠互斥访问是不够的。为了解决由 CPU 的优化策略引起的副作用,还需要引入内存屏障(Memory barrier)。通过设置 Memory barrier,来确保没有无序执行的指令能跨过屏障而执行。
当然,互斥锁自身的实现是需要没有竞争条件的。这实际上是非常重要的一个保证,并且需要在现代 CPU 上使用特殊的指令。更多关于原子操作(atomic operation)的信息,请阅读 Daniel 写的文章:底层并发技术。
从语言层面来说,在 Objective-C 中将属性以 atomic 的形式来声明,就能支持互斥锁了。事实上在默认情况下,属性就是 atomic 的。将一个属性声明为 atomic 表示每次访问该属性都会进行隐式的加锁和解锁操作。虽然最把稳的做法就是将所有的属性都声明为 atomic,但是加解锁这也会付出一定的代价。
在资源上的加锁会引发一定的性能代价。获取锁和释放锁的操作本身也需要没有竞态条件,这在多核系统中是很重要的。另外,在获取锁的时候,线程有时候需要等待,因为可能其它的线程已经获取过资源的锁了。这种情况下,线程会进入休眠状态。当其它线程释放掉相关资源的锁时,休眠的线程会得到通知。所有这些相关操作都是非常昂贵且复杂的。
锁也有不同的类型。当没有竞争时,有些锁在没有锁竞争的情况下性能很好,但是在有锁的竞争情况下,性能就会大打折扣。另外一些锁则在基本层面上就比较耗费资源,但是在竞争情况下,性能的恶化会没那么厉害。(锁的竞争是这样产生的:当一个或者多个线程尝试获取一个已经被别的线程获取过了的锁)。
在这里有一个东西需要进行权衡:获取和释放锁所是要带来开销的,因此你需要确保你不会频繁地进入和退出临界区段(比如获取和释放锁)。同时,如果你获取锁之后要执行一大段代码,这将带来锁竞争的风险:其它线程可能必须等待获取资源锁而无法工作。这并不是一项容易解决的任务。
我们经常能看到本来计划并行运行的代码,但实际上由于共享资源中配置了相关的锁,所以同一时间只有一个线程是处于激活状态的。对于你的代码会如何在多核上运行的预测往往十分重要,你可以使用 Instrument 的 CPU strategy view 来检查是否有效的利用了 CPU 的可用核数,进而得出更好的想法,以此来优化代码。
死锁
互斥锁解决了竞态条件的问题,但很不幸同时这也引入了一些其他问题,其中一个就是死锁。当多个线程在相互等待着对方的结束时,就会发生死锁,这时程序可能会被卡住。
看看下面的代码,它交换两个变量的值:
void swap(A, B)
{
lock(lockA);
lock(lockB);
int a = A;
int b = B;
A = b;
B = a;
unlock(lockB);
unlock(lockA);
}
大多数时候,这能够正常运行。但是当两个线程使用相反的值来同时调用上面这个方法时:
swap(X, Y); // 线程 1
swap(Y, X); // 线程 2
此时程序可能会由于死锁而被终止。线程 1 获得了 X 的一个锁,线程 2 获得了 Y 的一个锁。 接着它们会同时等待另外一把锁,但是永远都不会获得。
再说一次,你在线程之间共享的资源越多,你使用的锁也就越多,同时程序被死锁的概率也会变大。这也是为什么我们需要尽量减少线程间资源共享,并确保共享的资源尽量简单的原因之一。
资源饥饿(Starvation)
当你认为已经足够了解并发编程面临的问题时,又出现了一个新的问题。锁定的共享资源会引起读写问题。大多数情况下,限制资源一次只能有一个线程进行读取访问其实是非常浪费的。因此,在资源上没有写入锁的时候,持有一个读取锁是被允许的。这种情况下,如果一个持有读取锁的线程在等待获取写入锁的时候,其他希望读取资源的线程则因为无法获得这个读取锁而导致资源饥饿的发生。
为了解决这个问题,我们需要使用一个比简单的读/写锁更聪明的方法,例如给定一个 writer preference,或者使用 read-copy-update 算法。Daniel 在底层并发编程 API 中有介绍了如何用 GCD 实现一个多读取单写入的模式,这样就不会被写入资源饥饿的问题困扰了。
优先级反转
本节开头介绍了美国宇航局发射的开拓者号火星探测器在火星上遇到的并发问题。现在我们就来看看为什么开拓者号几近失败,以及为什么有时候我们的程序也会遇到相同的问题,该死的优先级反转。
优先级反转是指程序在运行时低优先级的任务阻塞了高优先级的任务,有效的反转了任务的优先级。由于 GCD 提供了拥有不同优先级的后台队列,甚至包括一个 I/O 队列,所以我们最好了解一下优先级反转的可能性。
高优先级和低优先级的任务之间共享资源时,就可能发生优先级反转。当低优先级的任务获得了共享资源的锁时,该任务应该迅速完成,并释放掉锁,这样高优先级的任务就可以在没有明显延时的情况下继续执行。然而高优先级任务会在低优先级的任务持有锁的期间被阻塞。如果这时候有一个中优先级的任务(该任务不需要那个共享资源),那么它就有可能会抢占低优先级任务而被执行,因为此时高优先级任务是被阻塞的,所以中优先级任务是目前所有可运行任务中优先级最高的。此时,中优先级任务就会阻塞着低优先级任务,导致低优先级任务不能释放掉锁,这也就会引起高优先级任务一直在等待锁的释放。
在你的实际代码中,可能不会像发生在火星的事情那样戏剧性地不停重启。遇到优先级反转时,一般没那么严重。
解决这个问题的方法,通常就是不要使用不同的优先级。通常最后你都会以让高优先级的代码等待低优先级的代码来解决问题。当你使用 GCD 时,总是使用默认的优先级队列(直接使用,或者作为目标队列)。如果你使用不同的优先级,很可能实际情况会让事情变得更糟糕。
从中得到的教训是,使用不同优先级的多个队列听起来虽然不错,但毕竟是纸上谈兵。它将让本来就复杂的并行编程变得更加复杂和不可预见。如果你在编程中,遇到高优先级的任务突然没理由地卡住了,可能你会想起本文,以及那个美国宇航局的工程师也遇到过的被称为优先级反转的问题。
总结
我们希望通过本文你能够了解到并发编程带来的复杂性和相关问题。并发编程中,无论是看起来多么简单的 API ,它们所能产生的问题会变得非常的难以观测,而且要想调试这类问题往往也都是非常困难的。
但另一方面,并发实际上是一个非常棒的工具。它充分利用了现代多核 CPU 的强大计算能力。在开发中,关键的一点就是尽量让并发模型保持简单,这样可以限制所需要的锁的数量。
我们建议采纳的安全模式是这样的:从主线程中提取出要使用到的数据,并利用一个操作队列在后台处理相关的数据,最后回到主队列中来发送你在后台队列中得到的结果。使用这种方式,你不需要自己做任何锁操作,这也就大大减少了犯错误的几率。