多线程技术的详解

这个问题被叫做竞态条件,在多线程里面访问一个共享的资源,如果没有一种机制来确保在线程 A 结束访问一个共享资源之前,线程 B 就不会开始访问该共享资源的话,资源竞争的问题就总是会发生。如果你所写入内存的并不是一个简单的整数,而是一个更复杂的数据结构,可能会发生这样的现象:当第一个线程正在写入这个数据结构时,第二个线程却尝试读取这个数据结构,那么获取到的数据可能是新旧参半或者没有初始化。为了防止出现这样的问题,多线程需要一种互斥的机制来访问共享资源。

在实际的开发中,情况甚至要比上面介绍的更加复杂,因为现代 CPU 为了优化目的,往往会改变向内存读写数据的顺序(乱序执行)。

互斥锁

互斥访问的意思就是同一时刻,只允许一个线程访问某个特定资源。为了保证这一点,每个希望访问共享资源的线程,首先需要获得一个共享资源的互斥锁,一旦某个线程对资源完成了操作,就释放掉这个互斥锁,这样别的线程就有机会访问该共享资源了。

除了确保互斥访问,还需要解决代码无序执行所带来的问题。如果不能确保 CPU 访问内存的顺序跟编程时的代码指令一样,那么仅仅依靠互斥访问是不够的。为了解决由 CPU 的优化策略引起的副作用,还需要引入内存屏障。通过设置内存屏障,来确保没有无序执行的指令能跨过屏障而执行。

当然,互斥锁自身的实现是需要没有竞争条件的。这实际上是非常重要的一个保证,并且需要在现代 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 的一个锁。 接着它们会同时等待另外一把锁,但是永远都不会获得。

再说一次,你在线程之间共享的资源越多,你使用的锁也就越多,同时程序被死锁的概率也会变大。这也是为什么我们需要尽量减少线程间资源共享,并确保共享的资源尽量简单的原因之一。建议阅读一下底层并发编程 API 中的全部使用异步分发一节。

资源饥饿(Starvation)

当你认为已经足够了解并发编程面临的问题时,又出现了一个新的问题。锁定的共享资源会引起读写问题。大多数情况下,限制资源一次只能有一个线程进行读取访问其实是非常浪费的。因此,在资源上没有写入锁的时候,持有一个读取锁是被允许的。这种情况下,如果一个持有读取锁的线程在等待获取写入锁的时候,其他希望读取资源的线程则因为无法获得这个读取锁而导致资源饥饿的发生。

为了解决这个问题,我们需要使用一个比简单的读/写锁更聪明的方法,例如给定一个 writer preference,或者使用 read-copy-update 算法。Daniel 在底层并发编程 API 中有介绍了如何用 GCD 实现一个多读取单写入的模式,这样就不会被写入资源饥饿的问题困扰了。

 

优先级反转

本节开头介绍了美国宇航局发射的开拓者号火星探测器在火星上遇到的并发问题。现在我们就来看看为什么开拓者号几近失败,以及为什么有时候我们的程序也会遇到相同的问题,该死的优先级反转

优先级反转是指程序在运行时低优先级的任务阻塞了高优先级的任务,有效的反转了任务的优先级。由于 GCD 提供了拥有不同优先级的后台队列,甚至包括一个 I/O 队列,所以我们最好了解一下优先级反转的可能性。

高优先级和低优先级的任务之间共享资源时,就可能发生优先级反转。当低优先级的任务获得了共享资源的锁时,该任务应该迅速完成,并释放掉锁,这样高优先级的任务就可以在没有明显延时的情况下继续执行。然而高优先级任务会在低优先级的任务持有锁的期间被阻塞。如果这时候有一个中优先级的任务(该任务不需要那个共享资源),那么它就有可能会抢占低优先级任务而被执行,因为此时高优先级任务是被阻塞的,所以中优先级任务是目前所有可运行任务中优先级最高的。此时,中优先级任务就会阻塞着低优先级任务,导致低优先级任务不能释放掉锁,这也就会引起高优先级任务一直在等待锁的释放。

在你的实际代码中,可能不会像发生在火星的事情那样戏剧性地不停重启。遇到优先级反转时,一般没那么严重。

解决这个问题的方法,通常就是不要使用不同的优先级。通常最后你都会以让高优先级的代码等待低优先级的代码来解决问题。当你使用 GCD 时,总是使用默认的优先级队列(直接使用,或者作为目标队列)。如果你使用不同的优先级,很可能实际情况会让事情变得更糟糕。

从中得到的教训是,使用不同优先级的多个队列听起来虽然不错,但毕竟是纸上谈兵。它将让本来就复杂的并行编程变得更加复杂和不可预见。如果你在编程中,遇到高优先级的任务突然没理由地卡住了,可能你会想起本文,以及那个美国宇航局的工程师也遇到过的被称为优先级反转的问题。

总结

我们希望通过本文你能够了解到并发编程带来的复杂性和相关问题。并发编程中,无论是看起来多么简单的 API ,它们所能产生的问题会变得非常的难以观测,而且要想调试这类问题往往也都是非常困难的。

但另一方面,并发实际上是一个非常棒的工具。它充分利用了现代多核 CPU 的强大计算能力。在开发中,关键的一点就是尽量让并发模型保持简单,这样可以限制所需要的锁的数量。

我们建议采纳的安全模式是这样的:从主线程中提取出要使用到的数据,并利用一个操作队列在后台处理相关的数据,最后回到主队列中来发送你在后台队列中得到的结果。使用这种方式,你不需要自己做任何锁操作,这也就大大减少了犯错误的几率。

转载于:https://my.oschina.net/u/3127531/blog/806329

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值