加入收藏 | 设为首页 | 会员中心 | 我要投稿 李大同 (https://www.lidatong.com.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 百科 > 正文

Swift 中关于并发的一切:第一部分 — 当前

发布时间:2020-12-14 05:13:42 所属栏目:百科 来源:网络整理
导读:原文地址:All about Concurrency in Swift - Part 1: The Present 原文作者:Umberto Raimondi 译文出自:掘金翻译计划 译者:Deepmissea 校对者:Feximin,zhangqippp 在 Swift 语言的当前版本中,并没有像其他现代语言如 Go 或 Rust 一样,包含任何原生的

原文地址:All about Concurrency in Swift - Part 1: The Present

原文作者:Umberto Raimondi

译文出自:掘金翻译计划

译者:Deepmissea

校对者:Feximin,zhangqippp


在 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 版本的并发是怎么处理的。


本文或其他文章的 playground 可以在 GitHub 或 Zipped 找到。


目录


  • 多线程与并发入门

  • 语言守护

  • 线程

  • 同步原语

o? NSLock

o? NSRecursiveLock

o? NSConditionLock

o? NSCondition

o? NSDistributedLock

o? OSAtomic 你在哪里?

o? 同步块

  • GCD: 大中枢派发

o? 调度队列

o? 使用队列

o? 屏障

o? 单例和 Dispatch_once

o? Dispatch Groups

o? Dispatch Work Items

o? Dispatch Semaphores

o? Dispatch Assertions

o? Dispatch Sources

  • 操作与可操作的队列

  • 闭幕后的思考

?

多线程与并发入门


现在,无论你构建的是哪一种应用,你迟早会考虑应用在多线程环境运行的情况。


具有多个处理器或者多核处理器的计算平台已经存在了几十年,而像 threadprocess 这样的概念甚至更久。


操作系统已经通过各种方式开放了这些能力给用户的程序,每个现代的框架或者应用都会实现一些涉及多线程的广为人知的设计模式,来提高程序的性能与灵活性。


在我们开始钻研如何处理 Swift 并发的细节之前,让我先简要地解释几个你需要知道的概念,然后再开始考虑你是使用Dispatch Queues 还是 Operation Queues


首先,你可能会问,虽然 Apple 的平台和框架使用了线程,但是我为什么要在自己的应用中引入它们呢?


有一些常见的情况,让多线程的使用合情合理:


  • 任务组分离:线程能从执行流程的角度,模块化你的程序。不同的线程用可预测方式,执行一组相同的任务,把他们与你程序的其他执行流程部分隔离,这样你会更容易理解程序当前的状态。

  • 独立数据的计算并行化:可以使用由硬件线程支持的多个软件线程(可以参考下一条),来并行化在原始输入数据结构的子集上运行的相同任务的多个副本。

  • 等待条件达成或 I/O 的一种简洁的实现方式:在执行 I/O 阻塞或其他类型的阻塞操作时,可以使用后台线程来干净地等待这些操作完成。使用线程可以改进你程序的整体设计,并且使处理阻塞问题变成细枝末节的事情。


但是,当多个线程执行你应用的代码时,一些从单线程的角度看起来无意义的假设就变得非常重要了。


在每个线程都独立地执行且没有数据共享的完美情况下,并发编程实际上并不比编写单线程执行的代码复杂多少。但是,就像经常发生的那样,你打算用多个线程操作同一数据,那就需要一种方式来规划对这些数据结构的访问,以确保该数据上的每个操作都按预期完成,而不会与其他线程有任何的交互操作。


并发编程需要来自语言和操作系统的额外保证,需要明确地说明在多个线程同时访问变量(或更一般的称之为“资源”)并尝试修改他们的值时,他们的状态是如何变化的。


语言需要定义一个内存模型,一组规则明确地列出在并发线程的运行下一些基本语句的行为,并且定义如何共享内存以及哪种内存访问是有效的。


多亏了这个(内存模型),用户有了一个线程运行行为可预知的语言,并且我们知道编译器将仅对遵循内存模型中定义的内容进行优化。


定义内存模型是语言进化的一个精妙的步骤,因为太严格的模型可能会限制编译器的自身发展。对于内存模型的过去策略,新的巧妙的优化会变得无效。


定义内存模型的例子:


  • 语言中哪些语句可以被认为是原子性的,哪些不是,哪些操作只能作为一个整体执行,其它线程看不到中间结果。比如必须知道变量是否被原子地初始化。

  • 如何处理变量在线程之间的共享,他们是否被默认缓存,以及他们是否会对被特定语言修饰符修饰的缓存行为产生影响。

  • 例如,用于标记和规划访问关键部分(那些操作共享资源的代码块)的并发操作符一次只允许一个线程访问一个特定的代码路径。


现在让我们回头聊聊在你程序中并发的使用。


为了正确处理并发问题,你要标识程序中的关键部分,然后用并发原语或并发化的数据结构来规划数据在不同线程之间的共享。


对代码或数据结构这些部分的强制访问规则打开了另一组问题,这些源于事实的问题就是,虽然期望的结果是每个线程都能够被执行,并有机会修改共享数据,但是在某些情况下,其中一些可能根本无法执行,或者数据可能以意想不到的和不可预测的方式改变。


你将面临一系列额外的挑战,并且必须处理一些常见的问题:


  • 竞争条件:同一数据上多个线程的操作,例如并发地读写,一系列操作的执行结果可能会变得无法预测,并且依赖于线程的执行顺序。

  • 资源争夺多个线程执行不同的任务,在尝试获取相同资源的时候,会增加安全获取所需资源的时间。获取这些资源延误的这些时间可能会导致意想不到的行为,或者可能需要你构建程序来规划对这些资源的访问。

  • 死锁多线程之间互相等待对方释放他们需要的资源/锁,这组线程将永远的被阻塞。

  • (线程)饥饿一个永远无法获取资源,或者一组有特定的顺序资源的线程,由于各种原因,它需要不断尝试去获取他们却永远失败。

  • 优先级反转具有低优先级的线程持续获取高优先级线程所需的资源,实质地反转了系统指定的优先级。

  • 非决定论与公平性我们无法对线程获取资源的时间和顺序做出臆断,这个延迟无法事前确定,而且它严重的受到线程间争夺的影响,线程甚至从不能获得一个资源。但是用于守护关键部分的并发原语也可以用来构建公平(fair)或者支持公平(fairness),确保所有等待的线程都能够访问关键部分,并且遵守请求顺序。


语言守护


即使在 Swift 语言本身没有并发性相关功能的时期,它仍然提供了一些有关如何访问属性的保证。


例如全局变量的初始化是原子性地,我们从不需要手动处理多个线程初始化同一个全局变量的并发情况,或者担心初始化还在进行的过程中看到一个只初始化了一部分的变量。


在下次讨论单例的实现时,我们会继续讨论这个特性。


但要记住的重要一点是,延迟属性的初始化并不是原子执行的,现在版本的语言并没有提供注释或修饰符来改变这一行为。


类属性的访问也不是原子的,如果你需要访问,那你不得不实现手动独占式的访问,使用锁或类似的机制。


线程


Foundation 提供了 Thread 类,内部基于 pthread,可以用来创建新的线程并执行闭包。


线程可以使用 Thread 类中的 detachNewThreadSelector:toTarget:withObject: 函数来创建,或者我们可以创建一个新的线程,声明一个自定义的 Thread 类,然后覆盖 main() 函数:


classMyThread : Thread {

? ? override func main(){

? ? ? ? print("Thread started,sleep for 2 seconds...")

? ? ? ? sleep(2)

? ? ? ? print("Done sleeping,exiting thread")

? ? }

}


但是自从 iOS 10 和 macOS Sierra 推出以后,所有平台终于可以使用初始化指定执行闭包的方式创建线程,本文中所有的例子仍会扩展基础的 Thread 类,这样你就不用担心为操作系统而做尝试了。



var t = Thread {

? ? print("Started!")

}


t.stackSize = 1024 * 16

t.start() ? ? ? ? ? ? ? //Time needed to spawn a thread around 100us


一旦我们有了一个线程实例,我们需要手动的启动它。作为一个可选步骤,我们也可以为线程定义栈的大小。


线程可以通过调用 exit() 来紧急停止,但是我们从不推荐这么做,因为它不会给你机会来干净利落地终止当前任务,如果你有需要,多数情况下你会选择自己实现终止逻辑,或者只需要使用 cancel() 函数,然后检查在主闭包中的 isCancelled 属性,以明确线程是否需要在它自然结束之前终止当前的工作。


同步原语


当我们有多个线程想要修改共享数据时,就很有必要通过一些方式来处理这些线程之间的同步,防止数据破损和非确定性行为。


通常,用于同步线程的基本套路是锁、信号量和监视器。


这些 Foundation 都提供了。


正如你要看到的,在 Swift 3 中,这些没有去掉 NS 前缀的类(对,他们都是引用类型)实现了这些结构,但是在 Swift 接下来的某个版本中也许会去掉。


NSLock


NSLock 是 Foundation 提供的基本类型的锁。


当一个线程尝试锁定一个对象时,可能会发生两件事,如果锁没有被前面的线程获取,那么当前线程将得到锁并执行,否则线程将会陷入等待,阻塞执行,直到锁的持有者解锁它。换句话说,在同一时间,锁是一种只能被一个线程获取(锁定)的对象,这可以让他们完美的监控对关键部分的访问。


NSLock 和 Foundation 的其他锁都是不公平的,意思是,当一系列线程在等待获取一个锁时,他们不会按照他们原来的锁定顺序来获取它。


你无法预估执行顺序。在线程争夺的情况下,当多个线程尝试获取资源时,有的线程可能会陷入饥饿,他们永远也不会获得他们等待的锁(或者不能及时的获得)。


没有竞争地获取锁所需要的时间,测量在 100 纳秒以内。但是在多个线程尝试获取锁定的资源时,这个时间会急速增长。所以,从性能的角度来讲,锁并不是处理资源分配的最佳方案。


让我们来看一个例子,例中有两个线程,记住由于锁会被谁获取的顺序无法确定,T1 连续获取两次锁的机会也会发生(但是不怎么常见)。


let lock = NSLock()


class LThread : Thread {

? ? varid:Int = 0


? ? convenience init(id:Int){

? ? ? ? self.init()

? ? ? ? self.id = id

? ? }


? ? ? ? lock.lock()

? ? ? ? print(String(id)+" acquired lock.")

? ? ? ? lock.unlock()

? ? ? ? iflock.try() {

? ? ? ? ? ? print(String(id)+" acquired lock again.")

? ? ? ? ? ? lock.unlock()

? ? ? ? }else{ ?// If already lockedmove along.

? ? ? ? ? ? print(String(id)+" couldn't acquire lock.")

? ? ? ? }

? ? ? ? print(String(id)+" exiting.")

var t1 = LThread(id:1)

var t2 = LThread(id:2)

t1.start()

t2.start()


在你决定使用锁之前,容我多说一句。由于你迟早会调试并发问题,要把锁的使用,限制在某种数据结构的范围内,而不是在代码库中的多个地方直接使用。


在调试并发问题的同时,检查有少量入口的同步数据结构的状态,比跟踪某个部分的代码处于锁定,并且还要记住多个功能的本地状态的方式更好。这会让你的代码走的更远并让你的并发结构更优雅。


NSRecursiveLock


递归锁能被已经持有锁的线程多次获取,在递归函数或者多次调用检查相同锁的函数时很有用处。不适用于基本的 NSLock。


let rlock = NSRecursiveLock()


classRThread : Thread {


? ? ? ? rlock.lock()

? ? ? ? print("Thread acquired lock")

? ? ? ? callMe()

? ? ? ? rlock.unlock()

? ? ? ? print("Exiting main")

? ? func callMe(){

? ? ? ? print("Exiting callMe")

var tr = RThread()

tr.start()


NSConditionLock


条件锁提供了可以独立于彼此的附加锁,用来支持更加复杂的锁定设置(比如生产者-消费者的场景)。


一个全局锁(无论特定条件如何都锁定)也是可用的,并且行为和经典的 NSLock 相似。


让我们看一个保护共享整数锁的简单的例子,每次生产者更新而消费者打印都会在屏幕上显示。


let NO_DATA = 1

let GOT_DATA = 2


let clock = NSConditionLock(condition: NO_DATA)

var SharedInt = 0


classProducerThread : Thread {


? ? ? ? for i in 0..<5 {="" clock.lock(whencondition:="" no_data)="" acquire="" the="" lock="" when="" no_data="" if="" we="" don't="" have="" to="" wait="" for="" consumers="" could="" just="" done="" clock.lock()="" sharedint="i" clock.unlock(withcondition:="" got_data)="" unlock="" and="" set="" as="" got_data="" }="" classconsumerthread="" :="" thread="" override="" func="" main(){="" i="" in0..<5="" print(i)="" let="" pt="ProducerThread()" ct="ConsumerThread()" ct.start()="" pt.start()<="" code="">


当创建锁的时候,我们需要指定一个由整数代表的初始条件。


lock(whenCondition:) 函数在条件符合时会获得锁,或者等待另一个线程用 unlock(withCondition:) 设置值来释放锁定。


对比基本锁的一个小改进是,我们可以对更复杂的场景进行稍微建模。


NSCondition


不要与条件锁产生混淆,一个条件提供了一种干净的方式来等待条件的发生。


当获取了锁的线程验证它需要的附加条件(一些资源,处于特定状态的另一个对象等等)不能满足时,它需要一种方式被搁置,一旦满足条件再继续它的工作。


这可以通过连续性或周期性地检查这种条件(繁忙等待)来实现,但是这么做,线程持有的锁会发生什么?在我们等待的时候是保持还是释放他们以至于在条件符合时重新获取他们?


条件提供了一个干净的方式来解决这个问题,一旦获取一个线程,就把它放进关于这个条件的一个等待列表中,它会在另一个线程发信号时,表示条件满足,而被唤醒。


让我们看个例子:


let cond = NSCondition()

var available = false

var SharedString = ""

classWriterThread : Thread {


? ? ? ? for _ in0..<5 5="" {="" cond.lock()="" sharedstring="

(编辑:李大同)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章
      热点阅读