Swift 中关于并发的一切:第一部分 — 当前
在 Swift 语言的当前版本中,并没有像其他现代语言如 Go 或 Rust 一样,包含任何原生的并发功能。 如果你计划异步执行任务,并且需要处理由此产生的竞争条件时,你唯一的选择就是使用外部库,比如 libDispatch,或者 Foundation 和 OS 提供的同步原语。 在本系列教程的第一部分,我们会介绍 Swift 3 提供的功能,涵盖一切,从基础锁、线程和计时器,到语言守护和最近改善的 GCD 和操作队列。 我们也会介绍一些基础的并发概念和一些常见的并发模式。
klingon 示例代码中的关键部分 即使 pthread 库的函数和原语可以在任一个运行 Swift 的平台上使用,我们也不会在这里讨论,因为对于每个平台,都有更高级的方案。 NSTimer 类也不会在这里介绍,你可以看一看这里,来了解如何在 Swift 3 中使用它。 就像已多次公布的,Swift 4.0 之后的主要版本之一(不一定是 Swift 5)会扩展语言的功能,更好地定义内存模型,并包含了新的原生并发功能,可以不需要借助外部库来处理并发,实现并行化,定义了一种 Swift 方式来实现并发。 这是本系列下一篇文章讨论的内容,我们会讨论一些其他语言实现的替代方法和范式实现,和在 Swift 中他们是如何实现的。并且我们会分析一些用当前版本 Swift 完成的开源实现,这些实现中我们可以使用 Actor 范式,Go 的 CSP 通道,软件事务内存等特性。 第二篇文章将会完全是推测性的,它主要的目的是为你介绍这些主题,以便你以后可以参与到更热烈讨论当中,而这些讨论将会定义未来 Swift 版本的并发是怎么处理的。
目录
o? NSLock o? NSRecursiveLock o? NSConditionLock o? NSCondition o? NSDistributedLock o? OSAtomic 你在哪里? o? 同步块
o? 调度队列 o? 使用队列 o? 屏障 o? 单例和 Dispatch_once o? Dispatch Groups o? Dispatch Work Items o? Dispatch Semaphores o? Dispatch Assertions o? Dispatch Sources
?
现在,无论你构建的是哪一种应用,你迟早会考虑应用在多线程环境运行的情况。 具有多个处理器或者多核处理器的计算平台已经存在了几十年,而像 thread 和 process 这样的概念甚至更久。 操作系统已经通过各种方式开放了这些能力给用户的程序,每个现代的框架或者应用都会实现一些涉及多线程的广为人知的设计模式,来提高程序的性能与灵活性。 在我们开始钻研如何处理 Swift 并发的细节之前,让我先简要地解释几个你需要知道的概念,然后再开始考虑你是使用Dispatch Queues 还是 Operation Queues。 首先,你可能会问,虽然 Apple 的平台和框架使用了线程,但是我为什么要在自己的应用中引入它们呢? 有一些常见的情况,让多线程的使用合情合理:
但是,当多个线程执行你应用的代码时,一些从单线程的角度看起来无意义的假设就变得非常重要了。 在每个线程都独立地执行且没有数据共享的完美情况下,并发编程实际上并不比编写单线程执行的代码复杂多少。但是,就像经常发生的那样,你打算用多个线程操作同一数据,那就需要一种方式来规划对这些数据结构的访问,以确保该数据上的每个操作都按预期完成,而不会与其他线程有任何的交互操作。 并发编程需要来自语言和操作系统的额外保证,需要明确地说明在多个线程同时访问变量(或更一般的称之为“资源”)并尝试修改他们的值时,他们的状态是如何变化的。 语言需要定义一个内存模型,一组规则明确地列出在并发线程的运行下一些基本语句的行为,并且定义如何共享内存以及哪种内存访问是有效的。 多亏了这个(内存模型),用户有了一个线程运行行为可预知的语言,并且我们知道编译器将仅对遵循内存模型中定义的内容进行优化。 定义内存模型是语言进化的一个精妙的步骤,因为太严格的模型可能会限制编译器的自身发展。对于内存模型的过去策略,新的巧妙的优化会变得无效。 定义内存模型的例子:
现在让我们回头聊聊在你程序中并发的使用。 为了正确处理并发问题,你要标识程序中的关键部分,然后用并发原语或并发化的数据结构来规划数据在不同线程之间的共享。 对代码或数据结构这些部分的强制访问规则打开了另一组问题,这些源于事实的问题就是,虽然期望的结果是每个线程都能够被执行,并有机会修改共享数据,但是在某些情况下,其中一些可能根本无法执行,或者数据可能以意想不到的和不可预测的方式改变。 你将面临一系列额外的挑战,并且必须处理一些常见的问题:
即使在 Swift 语言本身没有并发性相关功能的时期,它仍然提供了一些有关如何访问属性的保证。 例如全局变量的初始化是原子性地,我们从不需要手动处理多个线程初始化同一个全局变量的并发情况,或者担心初始化还在进行的过程中看到一个只初始化了一部分的变量。 在下次讨论单例的实现时,我们会继续讨论这个特性。 但要记住的重要一点是,延迟属性的初始化并不是原子执行的,现在版本的语言并没有提供注释或修饰符来改变这一行为。 类属性的访问也不是原子的,如果你需要访问,那你不得不实现手动独占式的访问,使用锁或类似的机制。
Foundation 提供了 Thread 类,内部基于 pthread,可以用来创建新的线程并执行闭包。 线程可以使用 Thread 类中的 detachNewThreadSelector:toTarget:withObject: 函数来创建,或者我们可以创建一个新的线程,声明一个自定义的 Thread 类,然后覆盖 main() 函数:
但是自从 iOS 10 和 macOS Sierra 推出以后,所有平台终于可以使用初始化指定执行闭包的方式创建线程,本文中所有的例子仍会扩展基础的 Thread 类,这样你就不用担心为操作系统而做尝试了。
一旦我们有了一个线程实例,我们需要手动的启动它。作为一个可选步骤,我们也可以为线程定义栈的大小。 线程可以通过调用 exit() 来紧急停止,但是我们从不推荐这么做,因为它不会给你机会来干净利落地终止当前任务,如果你有需要,多数情况下你会选择自己实现终止逻辑,或者只需要使用 cancel() 函数,然后检查在主闭包中的 isCancelled 属性,以明确线程是否需要在它自然结束之前终止当前的工作。
当我们有多个线程想要修改共享数据时,就很有必要通过一些方式来处理这些线程之间的同步,防止数据破损和非确定性行为。 通常,用于同步线程的基本套路是锁、信号量和监视器。 这些 Foundation 都提供了。 正如你要看到的,在 Swift 3 中,这些没有去掉 NS 前缀的类(对,他们都是引用类型)实现了这些结构,但是在 Swift 接下来的某个版本中也许会去掉。
NSLock 是 Foundation 提供的基本类型的锁。 当一个线程尝试锁定一个对象时,可能会发生两件事,如果锁没有被前面的线程获取,那么当前线程将得到锁并执行,否则线程将会陷入等待,阻塞执行,直到锁的持有者解锁它。换句话说,在同一时间,锁是一种只能被一个线程获取(锁定)的对象,这可以让他们完美的监控对关键部分的访问。 NSLock 和 Foundation 的其他锁都是不公平的,意思是,当一系列线程在等待获取一个锁时,他们不会按照他们原来的锁定顺序来获取它。 你无法预估执行顺序。在线程争夺的情况下,当多个线程尝试获取资源时,有的线程可能会陷入饥饿,他们永远也不会获得他们等待的锁(或者不能及时的获得)。 没有竞争地获取锁所需要的时间,测量在 100 纳秒以内。但是在多个线程尝试获取锁定的资源时,这个时间会急速增长。所以,从性能的角度来讲,锁并不是处理资源分配的最佳方案。 让我们来看一个例子,例中有两个线程,记住由于锁会被谁获取的顺序无法确定,T1 连续获取两次锁的机会也会发生(但是不怎么常见)。
在你决定使用锁之前,容我多说一句。由于你迟早会调试并发问题,要把锁的使用,限制在某种数据结构的范围内,而不是在代码库中的多个地方直接使用。 在调试并发问题的同时,检查有少量入口的同步数据结构的状态,比跟踪某个部分的代码处于锁定,并且还要记住多个功能的本地状态的方式更好。这会让你的代码走的更远并让你的并发结构更优雅。
递归锁能被已经持有锁的线程多次获取,在递归函数或者多次调用检查相同锁的函数时很有用处。不适用于基本的 NSLock。
条件锁提供了可以独立于彼此的附加锁,用来支持更加复杂的锁定设置(比如生产者-消费者的场景)。 一个全局锁(无论特定条件如何都锁定)也是可用的,并且行为和经典的 NSLock 相似。 让我们看一个保护共享整数锁的简单的例子,每次生产者更新而消费者打印都会在屏幕上显示。
当创建锁的时候,我们需要指定一个由整数代表的初始条件。 lock(whenCondition:) 函数在条件符合时会获得锁,或者等待另一个线程用 unlock(withCondition:) 设置值来释放锁定。 对比基本锁的一个小改进是,我们可以对更复杂的场景进行稍微建模。
不要与条件锁产生混淆,一个条件提供了一种干净的方式来等待条件的发生。 当获取了锁的线程验证它需要的附加条件(一些资源,处于特定状态的另一个对象等等)不能满足时,它需要一种方式被搁置,一旦满足条件再继续它的工作。 这可以通过连续性或周期性地检查这种条件(繁忙等待)来实现,但是这么做,线程持有的锁会发生什么?在我们等待的时候是保持还是释放他们以至于在条件符合时重新获取他们? 条件提供了一个干净的方式来解决这个问题,一旦获取一个线程,就把它放进关于这个条件的一个等待列表中,它会在另一个线程发信号时,表示条件满足,而被唤醒。 让我们看个例子:
|