Swift并发笔记

1.同步和异步

说到线程的执行方式,最基本的一组概念是同步和异步。所谓同步,就是在操作执行完成之前,运行操作的这个线程都会被占用,直到函数最终被抛出或返回。Swift5.5之前,func关键字声明的所有的函数都是同步的。

如果我们在主线程执行一个长时间的操作,就会导致UI操作被延后,造成卡顿:

Swift5.5之前的常见解决办法是将原本的同步操作转为异步操作,把实际长时间执行的任务放到另外的线程 (或者叫做后台线程) 运行,然后在操作结束时提供运行在主线程的回调,以供 UI 操作之用。

但是基于回调的异步操作存在以下缺陷:

1.回调地狱

2.错误处理隐藏在回调函数的参数中,无法用 throw 的方式明确地告知并强制调用侧去进行错误处理。

3.错误的completion调用:对回调函数的调用没有编译器保证,开发者可能会忘记调用 completion,或者多次调用 completion。

4.通过 DispatchQueue 进行线程调度很快会使代码复杂化。特别是如果线程调度的操作被隐藏在被调用的方法中的时候,不查看源码的话,在 (调用侧的) 回调函数中,几乎无法确定代码当前运行的线程状态。

5.对于正在执行的任务,没有很好的取消机制。

6.隐藏的线程调度:回调函数会在哪个线程被调用是未知的,使用者往往需要阅读文档或者源码才能确定。

2.串行和并行

另一组概念是串行和并行。同步方法里执行的同步操作一定是以串行方式在同一线程中发生的。当然异步操作也能在同步方法里串行执行。在不同线程中同时(特指某个时刻)执行的方式,我们称为并行。

3.Swift并发

在计算机科学中,并发指的是多个计算同时(指某个时段)执行的特性。Swift 并发指的就是异步和并行代码的组合。这在语义上,其实是传统并发的一个子集:它限制了实现并发的手段就是异步代码。Swift 5.5 设计了异步函数 的书写方法,在此基础上,利用结构化并发 确保运算步骤的交互和通信正确,利用 actor 模型确保共享的计算资源能在隔离的情况下被正确访问和操作。它们组合在一起,提供了一系列工具让开发者能简单地编写出稳定高效的并发代码。

4. 异步函数

4.1 async和await

函数声明的返回箭头前面,加上 async 关键字,就可以把一个函数声明为异步函数。

异步函数的 async 关键字会帮助编译器确保两件事情:

  • 它允许我们在函数体内部使用 await 关键字;
  • 它要求其他人在调用这个函数时,使用 await 关键字。

await 表示函数在此处可能会放弃当前线程,它是程序的潜在暂停点。放弃线程的能力,意味着异步方法可以被"暂停",这个线程可以被用来执行其他代码。如果这个线程是主线程的话,那么界面将不会卡顿。被 await 的语句将被底层机制分配到其他合适的线程,在执行完成后,之前的"暂停"将结束,异步方法从刚才的 await 语句后开始,继续向下执行。

编译器把异步函数切割成多个部分,每个部分拥有自己分离的存储空间,并可以由运行环境进行调度。我们可以把每个这种被切割后剩余的执行单元称作续体 (continuation),而一个异步函数,在执行时,就是多个续体依次运行的结果。

异步函数虽然具有放弃线程的能力,但它自己本身并不会使用这个能力:它只会通过对另外的异步函数进行方法调用,或是通过主动创建续体,才能有机会暂停。这些被调用的方法和续体,有时会要求当前异步函数放弃线程并等待某些事情完成 (比如续体完结)。当完成后,本来的函数将会继续执行。

await 充当的角色,就是标记出一个潜在的暂停点(suspend point)。在异步函数中,可能发生暂停的地方,编译器会要求我们明确使用 await 将它标记出来。当控制权回到异步函数中时,它会从之前停止的地方开始继续运行。虽然部分状态,比如原来的输入参数等,在 await 前后会被保留,但是返回到当前异步函数时,它并不一定还运行在和之前同样的线程中,异步函数所在类型中的实例成员也可能发生了变化。

但另一方面,await 仅仅只是一个潜在的暂停点,而非必然的暂停点。实际上会不会触发"暂停",需要看被调用的函数的具体实现和运行时提供的执行器是否需要触发暂停。很多的异步函数并不仅仅是异步函数,它们可能是某个 actor 中的同步函数,但作为 actor 的一部分运行,在外界调用时表现为异步函数。Swift 会保证这样的函数能切换到它们自己的 actor 隔离域里完成执行。

4.2 异步函数的优势

1.嵌套的回调可以被写为多个 async/await,不再有额外的队列派发所导致的缩进。

2.异步函数保有调用栈,因此可以使用 throws 和正常的返回值来分别表达错误路径和正常路径,调用者需要关心的结果被明确分为两类,且内层错误可以很容易地继续抛出到更外层,以便让合适的调用者进行处理。

3.使用 if 等语句时,行为模式和同步代码一致。这也为调试和测试代码提供了更易用和直观的工具。

4.异步函数必须有明确的退出路径:要么返回可用的值,要么抛出错误。编译器会保证异步函数结束时,调用者会且仅会收到一个结果,而不像原来忘记调用 completion 或者多次调用。

5.异步函数的函数签名和同步函数更加类似,框架开发者创建异步函数的阻力变小了。只要有异步操作的需求,将同步函数改写为异步函数的难度要远远小于把它改写为回调的难度。这也鼓励了框架和 API 的维护者提供异步函数版本。

6.开发者不再需要手动进行派发和关心线程调度。虽然在 await 后我们依然无法确定线程,但是可以使用 actor 类型来提供合理的隔离环境。

4.3 同步函数转异步函数

4.3.1 转换函数签名

对于基于回调的异步操作,一般性的转换原则就是将回调去掉,为函数加上 async 修饰。如果回调接受 Error? 表示错误的话,新的异步函数应当可以 throws,最后把回调参数当作异步函数的返回值即可。有些情况下,带有闭包的异步操作函数本身也具有返回值,这种情况会相对比较棘手。要么舍弃不必要的返回值,要么用inout参数来实现返回值的功能,但如果是从 Task 域外传递一个 inout Bool 的话,编译器将提示错误,所以这并不是一个完备的结解决方法。

异步函数具有极强的"传染性":一旦你把某个函数转变为异步函数后,对它进行调用的函数往往都需要转变为异步函数。为了保证迁移的顺利,Apple 建议进行从下向上的迁移:先对底层负责具体任务的最小单元开始,将它改变为异步函数,然后逐渐向上去修改它的调用者。

在为一个原本基于回调的函数添加异步函数版本的同时,暂时保留这个已有的回调版本。为原本的回调版本添加 @completionHandlerAsync 注解,告诉编译器存当前函数存在一个异步版本。这样,当使用者在其他异步函数中调用了这个回调版本时,编译器将提醒使用者可以迁移到更合适的异步版本。

如果回调版本的函数并非使用最后一个参数接受回调的话,还可以在标记里追加 completionHandlerIndex 对回调参数的位置进行指定:

4.3.2 使用续体改写函数

在异步函数被引入之前,处理和响应异步事件的主要方式是闭包回调和代理 (delegate) 方法。可能你的代码库里已经大量存在这样的处理方式了,如果你想要提供一套异步函数的接口,但在内部依然复用闭包回调或是代理方法的话,最方便的迁移方式就是捕获续体并暂停运行,然后在异步操作完成时告知这个续体结果,让异步函数从暂停点重新开始。

Swift 提供了一组全局函数,让我们暂停当前任务,并捕获当前的续体:

对于下面的闭包回调函数,利用 withUnsafeThrowingContinuation 进行包装:

在某个异步函数中调用 with*Continuation 后,这个异步函数(load)暂停,函数的剩余部分(可以认为此处有return语句)作为续体被捕获,代表续体的 UnsafeContinuation 或 CheckedContinuation 被传递给闭包参数,而这个闭包也会在当前的任务上下文中立即运行。这个 Continuation 上的 resume 函数,在未来必须且仅需被调用一次,来将控制权交回给调用者(load)。

除了在回调版本的异步代码中使用外,我们也可以把捕获到的续体暂存起来,这种方式很适合将 delegate 方式的异步操作转换为异步函数。

普通版本和 Throwing 版本的区别在于这个异步函数是否可以抛出错误,如果不可抛出,那么续体 Continuation 的泛型错误类型将被固定为 Never。Unsafe 和 Checked 版本的区别在于是否对 continuation 的调用状况进行运行时的检查。continuation 必须在未来继续,只是一个开发者和编译器的约定。Unsafe 的版本不进行任何检查,它假设开发者会正确使用这个 API:如果 continuation 没能继续 (也就是 continuation 在被释放前,它上面的任意一个 resume 方法都没有调用),那么异步函数将永远停留在暂停点不再继续;反过来,如果 resume 被调用了多次,程序的运行状态将出现错误。

和 Unsafe 的版本稍有不同,Checked 的版本能稍微给我们一些提示。在没能继续的情况下,运行时会在控制台进行输出:

在调用 resume 多次时,这个错误将产生崩溃,以避免像 Unsafe 版本那样进入到无法预测的状态:

由于 Checked 的一系列特性都和运行时相关,因此对续体的使用情况进行检查 (以及存储额外的调试信息),会带来额外的开销。因此,在一些性能关键的地方,在确认无误的情况下,使用 Unsafe 版本会提升一些性能。

4.3.3 Objective-C 自动转换

Objective-C 到 Swift

如果满足一定的书写规则,Swift 在导入 Objective-C 方法时,可以自动将它们作为异步函数导入。这可以让 Objective-C 框架中已有的异步 API 立即用在 Swift 异步的上下文中。

如果一个 Objective-C 函数存在函数参数,且该参数的返回值和整个函数本身的返回值类型都为 void 的话,该 Objective-C 函数就被推断为在执行潜在的基于回调的异步操作。对于这类潜在异步回调,如果它的闭包参数的参数名包含特定关键字,比如 completion,withCompletion,completionHandler,withCompletionHandler,completionBlock,withCompletionBlock 等,那么这个闭包的输入将被提取出来,自动映射成为异步函数的返回值。在调用时,withUnsafeContinuation (或者它的可抛出版本) 会被用来包装原来的闭包版本的方法,提供一个异步方法的版本。

在某些特定情况下,你可能会不想要这样的自动转换,通过为 Objective-C 的方法添加 NS_SWIFT_DISABLE_ASYNC,可以避免编译器为满足条件的方法生成 async 版本的 Swift 接口。

Swift 到 Objective-C

普通的 Swift 函数可以通过 @objc 暴露给 Objective-C。异步函数也遵守同样的规则:当一个 Swift 中的 async 函数被标记为 @objc 时,它在 Objective-C 中会由一个带有 completionHandler 的回调闭包版本表示。和 Objective-C 向 Swift 的转换一样,编译器也会为 Swift 到 Objective-C 的转换合成额外的实现。由于 Objective-C 中其实不存在可用的 Task 上下文环境,在实际调用 Swift 版本的异步函数前,会使用 Task.detached 创建一个完全游离的任务运行环境。

4.4 Async getter

在 Swift 5.5 中,getter 得到的强化,它可以使用 async 和 throws 修饰了:

异步 getter 在 actor 模型中非常常用:actor 的成员变量是被隔离在 actor 中的,外部对它的获取将导致隔离域切换,这是一个潜在的暂停。对于从隔离域外对 actor 中成员变量的读取,编译器将为我们合成对应的异步 getter 方法。

除了 getter 以外,通过下标的读取方法也得到了同样的特性,来提供类似的 async 和 throws 的支持:

async 和 throws 的支持,现在只针对属性 getter 和下标读取。对于计算属性的 setter 和下标写入,异步行为暂时还不支持。

5. 异步序列

5.1 同步序列和异步序列

5.2 异步迭代器

5.3 操作异步序列

5.4 AsyncStream

5.5 异步序列和响应式编程

6. 结构化并发

6.1 什么是结构化

"结构化" (structured) 这个词天生充满了美好的寓意:一切有条不紊、充满合理的逻辑和准则。但是结构化并不是天然的。

6.2 结构化编程

在代码块的概念出现后,一些基本的封装带来了新的控制流方式,包括我们今天最常使用的条件语句、循环语句以及函数调用。由它们所构成的编程范式,即是我们所熟悉的结构化编程:

相比于 goto,新控制流们拥有一个非常显著的特点:控制流从顶部入口开始,然后某些事情发生,最后控制流都在底部结束。除非死循环,否则从入口进入的代码最终一定会执行达到出口。

6.3 非结构化并发

程序的结构化并不意味着并发也是结构化的。相反,Swift 现存的并发模型面临的问题,恰恰和当年 goto 的情况类似。Swift 当前的并发手段,最常见的要属使用 Dispatch 库将任务派发,并通过回调函数获取结果:

在 foo 到达出口时,由 foo 初始化的派发任务可能并没有完成。在派发后,实际上从入口开始的单个控制流将被一分为二:其中一个正常地到达程序出口,而另一个则通过派发跳转,最终"不知所踪"。即使在一段时间后,派发出去的操作通过回调函数回到闭包中,但是它并没有关于原来调用者的信息 (比如调用栈等),这只不过是一次孤独的跳转。

6.4 结构化并发

如果要用一句话概括,那就是即使进行并发操作,也要保证控制流路径的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有的并发路径在出口时都应该处于完成 (或取消) 状态,并合并到一起。

这种将并发路径统合的做法,带来的一个非常明显的好处:它让抽象层重新有效。foo 现在是严格"自包含"的:在 foo 中产生的额外控制流路径,都将在 foo 中收束。这个方法现在回到了黑盒状态,在结构化并发的语境下,我们可以确信代码不会跳转到结构外,控制流最终会回到掌握之中。

为了将并发路径合并,程序需要具有暂停等待其他部分的能力。异步函数恰恰满足了这个条件:使用异步函数来获取暂停主控制流的能力,函数可以执行其他的异步并发操作并等待它们完成,最后主控制流和并发控制流统合后,从单一出口返回给调用者。这也是我们在之前就将异步函数称为结构化并发基础的原因。

6.5 基于Task的结构化并发模型

6.5.1 Task

对于同步函数,线程就是它的执行环境。而对于异步函数,Task则是它的执行环境。Swift 提供了一系列 Task 相关 API 来让开发者创建、组织、检查和取消任务。

使用 Task.init 就可以让我们获取一个任务执行的上下文环境,它接受一个 async 标记的闭包:

它继承当前任务上下文的优先级等特性,创建一个新的任务树根节点,我们可以在其中使用异步函数。这个异步闭包的返回值是 Success,它也会作为 Task 执行结束后的结果值,被传送到自身上下文之外。如果你是在一个异步上下文中创建Task 的话,还可以通过访问它的 value 属性来获取任务结束后的"返回值"。

使用Task.detached也可以创建一个新的任务根节点,不从当前任务中继承优先级和本地值等运行环境,完全新的游离任务环境。

任务状态

在任意的异步函数中,我们总可是使用 withUnsafeCurrentTask 来获取和检查当前任务。对于获取到的 task,可以访问它的 isCancelled 和 priority 属性检查它是否已经被取消以及当前的优先级。我们甚至可以调用 cancel() 来取消这个任务。

任务层级

对于以下代码:

上例中虽然 t1 和 t2 是在外层 Task 中再新生成并进行并发的,但是它们之间没有从属关系,并不是结构化的。这一点从 t: false 先于其他输出就可以看出,t1 和 t2 的执行都是在外层 Task 闭包结束后才进行的,它们逃逸出去了,这和结构化并发的收束规定不符。

想要创建结构化的并发任务,就需要让内层的 t1 和 t2 与外层 Task 具有某种从属关系。你可以已经猜到了,外层任务作为根节点,内层任务作为叶子节点,就可以使用树的数据结构,来描述各个任务的从属关系,并进而构建结构化的并发了。这个层级关系,和 UI 开发时的 View 层级关系十分相似。

通过用树的方式组织任务层级,我们可以获取下面这些有用特性:

  • 一个任务具有它自己的优先级和取消标识,它可以拥有若干个子任务 (叶子节点) 并在其中执行异步函数。
  • 当一个父任务被取消时,这个父任务的取消标识将被设置,并向下传递到所有的子任务中去。
  • 无论是正常完成还是抛出错误,子任务会将结果向上报告给父任务,在所有子任务正常完成或者抛出之前,父任务是不会被完成的。

当任务的根节点退出时,我们通过等待所有的子节点,来保证并发任务都已经退出。树形结构允许我们在某个子节点扩展出更多的二层子节点,来组织更复杂的任务。这个子节点也许要遵守同样的规则,等待它的二层子节点们完成后,它自身才能完成。这样一来,在这棵树上的所有任务就都结构化了。

在 Swift 并发中,在任务树上创建一个叶子节点,有两种方法:通过任务组 (task group) 或是通过 async let 的异步绑定语法。

6.5.2 async let 异步绑定

async let 被称为异步绑定,async let 和 let 类似,它定义一个本地常量,并通过等号右侧的表达式来初始化这个常量。区别在于,这个初始化表达式必须是一个异步函数的调用,通过将这个异步函数"绑定"到常量值上,Swift 会在当前 Task 上下文中创建一个并发执行的子任务,并在其中执行该异步函数。async let 赋值后,子任务会立即开始执行。如果想要获取执行的结果 (也就是子任务的返回值),可以对赋值的常量使用 await 等待它的完成。

如果没有 await,那么 Swift 并发会在被绑定的常量离开作用域时,隐式地将绑定的子任务取消掉,然后进行 await。

6.5.3 任务组

除了 async let 外,另一种创建结构化并发的方式,是使用任务组 (Task group):

withThrowingTaskGroup 和它的非抛出版本 withTaskGroup 提供了另一种创建结构化并发的组织方式。当在运行时才知道任务数量时,或是我们需要为不同的子任务设置不同优先级时,我们将只能选择使用 Task Group。在其他大部分情况下,async let 和 task group 可以混用甚至互相替代。

使用 withTaskGroup 可以开启一个新的任务组。使用addTask API 把新的任务添加到当前任务中。被添加的任务会在调度器获取到可用资源后立即开始执行。group 满足 AsyncSequence,因此我们可以使用 for await 的语法来获取子任务的执行结果。group 中的某个任务完成时,它的结果将被放到异步序列的缓冲区中。每当 group 的 next 会被调用时,如果缓冲区里有值,异步序列就将它作为下一个值给出;如果缓冲区为空,那么就等待下一个任务完成,这是异步序列的标准行为。for await 的结束意味着异步序列的 next 方法返回了 nil,此时group 中的子任务已经全部执行完毕了,withTaskGroup 的闭包也来到最后。

另外,通过调用 group 的 cancelAll,我们可以在适当的情况下将任务标记为取消。

如果我们需要比较"严肃"地界定结构化并发的起始,那么用任务组的闭包将它限制起来,并发的结构会显得更加清晰;而如果我们只是想要快速地并发开始少数几个任务,并减少其他模板代码的干扰,那么使用 async let 进行异步绑定,会让代码更简洁易读。

6.6 协作式任务取消

Swift 并发中对某个任务调用 cancel,做的事情只有两件:

  • 将自身任务的 isCancelled 标识置为 true。
  • 在结构化并发中,如果该任务有子任务,那么取消子任务。

cancel() 调用只负责维护一个布尔变量,仅此而已。它不会涉及其他任何事情:任务不会因为被取消而强制停止,也不会让自己提早返回。这也是为什么我们把 Swift 并发中的取消叫做"协作式取消"的原因:各个任务需要合作,才能达到最终停止执行的目标。父任务要做的工作就是向子任务传递 isCancelled,并将自身的 isCancelled 状态设置为 true。当父任务已经完成它自己的工作后,接下来的事情就要交给各个子任务的实现,它们要负责检查 isCancelled 并作出合适的响应。换言之,如果谁都没有检查 isCancelled 的话,协作式的取消就不成立了,整个任务层级向外将呈现出根本不支持取消操作的状态。

6.6.1 处理任务取消

"现在让我们来看看在任务中要如何实际利用 isCancelled 来停止异步任务。结构化并发要求异步函数的执行不超过任务作用域,因此在遇到任务取消时,如果我们想要进行处理并提前结束任务,大致只有两类选择:

  • 提前返回一个空值或者部分已经计算出来的值,让当前任务正常结束。
  • 通过抛出错误并汇报给父层级任务,让当前任务异常结束。

可以通过检查 Task.isCancelled来检查当前Task是否被取消。

"由于在处理任务取消时,这种"检测 isCancelled 布尔值"然后"抛出 CancellationError 错误"的模式十分常用,Swift 甚至把它们封装成了一个单独的方法,并放到了标准库中。在需要检查取消状态并抛出错误的时候,我们只需要调用 Task.checkCancellation 就可以了。

结构化并发里一个任务接受到子任务抛出的错误后,会先将其他子任务取消掉,然后再等待所有子任务结束后,把首先接到的错误抛出到更外层。Swift 并发中的取消,被视为错误抛出。因此为了支持取消操作,我们必须使用可抛出的 API 版本withThrowingTaskGroup。

6.6.2 取消的清理工作

在同步的世界中,为了避免在各个退出路径上重复写清理代码,我们往往使用 defer 来确保代码在离开作用域后进行调用。这个技巧在异步操作中也是适用的。

在使用 defer 时,只有在异步操作返回或者抛出时,defer 才会被触发。如果我们使用 checkCancellation 在每次 await 时检查取消的话,实际上抛出错误的时机会比任务被取消的时机要晚一些:在异步函数执行暂停期间的取消,并不会立即导致抛出,只有在下一次调用 checkCancellation 进行检查时,才进行抛出并触发 defer 进行资源清理。虽然在大部分情况下,这一点时间差应该不会带来问题,但是对于下面两种情况,我们可能会希望有一种更加"实时"的方法来处理取消。

  • 需要在取消发生的时刻,立即作出一些响应:比如关键资源的清理,或者想要获取精确的取消时间。
  • 在某些情况下,无法通过 checkCancellation 抛出错误时。假如使用的是外部的非 Swift 并发的异步实现 (例如包装了传统的 GCD 实现等),这种时候原来的异步实现往往不支持抛出错误,或者抛出的错误无法传递到 Swift 并发中,也无法用来取消任务。

这些情况下,我们可以考虑使用 withTaskCancellationHandler。它接受两个闭包:一个是待执行的异步任务 operation,另一个是当取消发生时会被立即调用的闭包 onCancel:

这个方法并不会创建任何新的任务上下文,它只负责为当前任务提供一个在取消时被调用的闭包。因为对 onCancel 的调用会在任务被取消时立即发生,它可能会在任何时间任意线程上下文被调用,所以 onCancel 接受的函数被标记为了 @Sendable。

6.6.3 隐式等待和任务暂停

在结构化并发中,会存在隐式 await 的情况。隐式等待的任务被取消时,错误不会被抛出到外层。不论我们最终需不需要子任务的返回值,都应该保持明确写出对 group 等待操作的好习惯。

7. actor模型和数据隔离

为了确保资源在不同运算之间被安全地共享和访问,以前通常的做法是将相关的代码放入一个串行的 dispatch queue 中,然后以同步的方式把对资源的访问派发到队列中去执行,这样我们可以避免多个线程同时对资源进行访问。但是它存在一些难以忽视的问题:

  • 大量且易错的模板代码:凡是涉及 results 的操作,都需要使用 queue.sync 包围起来,但是编译器并没有给我们任何保证。在某些时候忘了使用队列,编译器也不会进行任何提示,这种情况下内存依然存在危险。当有更多资源需要保护时,代码复杂度也将爆炸式上升。
  • 小心死锁:在一个 queue.sync 中调用另一个 queue.sync 的方法,会造成线程死锁。在代码简单的时候,这很容易避免,但是随着复杂度增加,想要理解当前代码运行是由哪一个队列派发的,它又运行在哪一个线程上,往往会伴随着严重的困难。必须精心设计,避免重复派发。

Swift 并发引入了一种在业界已经被多次证明有效的新的数据共享模型,actor 模型 (参与者模型),来解决这些问题。

actor 类型和 class 类型在结构上十分相似,它可以拥有初始化方法、普通的方法、属性以及下标。它们能够被扩展并满足协议,可以支持泛型等。和 class 的主要不同在于,actor 将保护其中的内部状态 (或者说存储属性),让自身免受数据竞争带来的困扰。这种保护通过 Swift 编译器的一系列限制来达成,这主要包括对 actor 中成员 (包括实例方法和属性) 的访问限制。在 actor 中,对属性的直接访问只允许发生在 self 里。从外部直接操作和访问内部状态的行为是被限制的,我们把这种限制称作 actor 隔离:actor 的成员被隔离在了 actor 自身所定义的隔离域 (actor isolated scope) 中。从 actor 外部持有对这个 actor 的引用,并对某个具有 actor 隔离特性的声明的访问,叫做跨 actor 调用。这种调用只有在异步时可以使用。

像是 visit() 和 visitorCount 这样的异步访问将被转换为消息,来请求 actor 在安全的时候执行对应的任务。这些消息就是投递到 actor 信箱中的"信件",调用者开始一个异步函数调用,直到 actor 处理完信箱中的对应的消息之前,这个调用都会被置于暂停状态。而在此期间,负责的线程可以去处理其他任务。

在底层,每一个 actor 对信箱中的消息处理是顺序进行的,这确保了在 actor 隔离的代码中,不会有两个同时运行的任务。也就确保了 actor 隔离的状态,不存在数据竞争。从实现角度来看:消息是一个异步调用的局部任务,每个 actor 实例都包含了它自己的串行执行器,这个执行器实际对作用域进行了限制。串行执行器负责在某个线程内循序执行这些局部任务 (包括处理消息,实际访问实例上的状态等)。从概念上,这和串行派发的 DispatchQueue 类似,但是 actor 在实际运行时,是基于协作式的线程派发和 Swift 异步函数续体的,相比于传统的线程调度,它是一个更轻量级的实现。

全局actor

如果需要保护的状态存在于 actor 外部,或者这些代码不可能汇集到一个单一的 actor 实例中时,我们可能会需要一种作用域更加宽广的隔离手段。

为了解决这个问题,我们可以声明并使用全局 actor。作为属性包装,它可以被任意地使用在某个属性或方法前,对这个属性或方法进行标注,把它限定在该全局 actor 的隔离域中。Swift 标准库中的 MainActor 就是这样一个全局 actor。

MainActor

我们完全可以为把主线程看作是一个特殊的 actor 隔离域:这个隔离域绑定的执行线程就是主线程,任何来自其他隔离域的调用,需要通过 await 来进行 actor 跳跃。在 Swift 中,这个特殊的 actor 就是 MainActor。

整个程序只有一个主线程,因此 MainActor 类型也只应该提供唯一一个对应主线程的 actor 隔离域。它通过 shared 来提供一个全局实例,以满足这个要求。所有被限制在 MainActor 隔离域中的代码,事实上都被隔离在 MainActor.shared 的 actor 隔离域中。

@MainActor 属性包装

按照添加的地方,@MainActor 有不同的作用。可以将整个类型都被标记为 @MainActor:这意味着其中所有的方法和属性都会被限定在 MainActor 规定的隔离域中。也可以只有部分方法被标记为 @MainActor。另外,对于定义在全局范围的变量或者函数,也可以用 @MainActor 限定它的作用返回。在使用它们时,需要切换到 MainActor 隔离域。和其他一般的 actor 一样,可以通过 await 来完成这个 actor 跳跃;也可以通过将 Task 闭包标记为 @MainActor 来将整个闭包"切换"到同样的隔离域,这样就可以使用同步的方式访问成员了。

自定义全局 actor

可重入

Sendable

在隔离域之间传递数据,为了保证数据安全,我们需要一种方法来对此进行检查,核心问题是:"我们应该在什么时候,以什么方式允许数据在不同的并发域中传递?"

这个问题的答案是相当明确的:只有那些不会在并发访问时发生竞争和危险的类型,可以在并发域之间自由传递。不过即使答案明确,它所涵盖的具体类型也是多种多样的:

  • 像是 struct 这样的所有成员都具有值语义,它自身也具有值语义的类型是安全的;
  • 即使是 class 这样的引用类型,只要它的成员都是不可变量并满足 Sendable 的话,它也是安全的;
  • 在内部存在数据保护机制的引用类型,比如 actor 类型或是成员访问时通过加锁来进行状态安全保证的类型;
  • 可以通过深拷贝 (deep copy) 来把内存结构完全复制的情况;
  • ...还有其他方式可以保证数据安全。

在 Swift 并发在设计针对跨越并发域的数据安全时,想要做到的事情有三件:

  • 对于那些跨越并发域时可能不受保护的可变状态,编译器则应该给出错误,以保证数据安全;
  • Swift 的并发设计,是鼓励使用值类型的。但是有些情况下引用类型确实可以带来更优秀的性能。对于 1 中的限制,应该为资深程序员留有余地,让他们可以可以自由设计 API,同时保证数据安全和性能;
  • Swift 5.5 之前已经存在大量的代码,如果强制开始 1 的话,可能会造成大量的编译错误。我们需要平滑和渐进式的迁移过程。

对于 1,我们使用 Sendable 来标记那些安全的类型;对于 2,Swift 留有 @unchecked Sendable 让开发者可以在需要时绕过编译器的检查;对于 3,Swift 5.5 中大部分关于 Sendable 的检查默认都是关闭的。

Sendable 协议

Sendable 和现存在 Swift 中的所有协议都不同,它是一个标志协议 (marker protocol),没有任何具体的要求:

Sendable 这样的标志协议具有的是语义上的属性,它完全是一个编译期间的辅助标记,只会由编译器使用,不会在运行期间产生任何影响。

虽然 Sendable 协议里没有任何要求,但是如果我们明确声明某个类型满足 Sendable 的话,编译器会对它的进行检查,来确认是否确实满足要求。

值类型:在同一模块中,如果一个 struct 满足它的所有成员都是 Sendable,我们甚至不需要明确地为它标记 Sendable。编译器将会帮助我们推断出这件事情,并自动让该类型满足协议。

class类型:要让 class 类型满足 Sendable,条件要严苛得多。而且想要让 class 类型满足 Sendable,我们必须明确进行声明:

  • 这个 class 必须是 final 的,不允许继承,否则任何它的子类都有可能添加破坏数据安全的成员。
  • 该 class 类型的成员必须都是 Sendable 的。
  • 所有的成员都必须使用 let 声明为不变量。

actor类型:所有的 actor 类型都可以随意地在并发域之间传递,它们都满足 Sendable:不论在哪个模块中,也不论 actor 拥有什么类型的存储成员,编译器都会为它们加上 Sendable。

函数类型:在 Swift 中,函数类型也是引用类型,它会在函数体内部持有对外部值的引用。在跨越并发域时,在函数体内的这些被引用的值可能会发生变化。想要保证数据安全的话,我们必须规定函数闭包所持有的值都满足 Sendable 且它们都为不变量。在 Swift 语法中,函数类型本身并不能满足任何协议。为了表示某个函数参数必须满足 Sendable,我们使用 @Sendable 对这个函数进行标注。

错误:另一类重要的会在并发域中传递的类型是各种错误:当异步函数出错时,我们会使用 throws 抛出错误,这在语义上其实相当于从并发域中返回了一个错误给调用者。基于安全要求,Error 类型应该始终是 Sendable 的。

@unchecked Sendable

actor 并不是引用类型保证数据安全的唯一手段,在 Swift 并发之前,为了 class 成员的数据安全,我们就已经有诸如加锁或者设定内部串行派发队列的方案了。这种在自身内部具有数据安全保证、原本就线程安全的类型,自然是可以在不同并发域之间安全传递的。但是 Sendable 的检查并没有办法在编译期间确定它的安全性,这种类型也无法直接被声明满足 Sendable。如果我们确信该类型是安全的,可以在 Sendable 前面加上 @unchecked 标记,这样编译器就会跳过类型的成员检查,相信开发者的判断,直接把这个类型认为是满足 Sendable 的。

渐进式迁移和 @_unsafeSendable
迁移的模块先于依赖的模块
开发的模块先于模块的用户

8. 并发线程模型

8.1 传统调度线程模型

线程数这样处理GCD队列里的工作的:

  • 当工作被推入GCD队列,系统会创建一个线程来执行这个工作。
  • 由于并发队列可以同时处理多个工作,因此系统会创建多个线程,直到我们占用了所有CPU核心。
  • 如果某个线程阻塞了,并且这个并发队列上还有需要完成的工作,GCD会创建更多的线程来完成剩余的工作。线程阻塞的时候,它会被操作系统挂起(被标记为不适合运行,操作系统调度器在选择线程进行执行的时候会忽略它),直到线程可以被取消阻塞。通过给处理器换上另一个线程,我们可以保证每个CPU核心每时每刻都有一个线程在执行工作。

GCD中容易引发过度并发:

  • 向系统提交了比CPU核心更多的线程
  • 线程爆炸的风险
  • 太多的线程导致性能损耗:内存开销-每个阻塞的线程在等待再次执行的时候,都会持有宝贵的内存和资源。调度开销-当新线程启动时,CPU 需要执行完整的线程上下文切换,以便从旧线程切换到开始执行新线程。线程分时 - 在内核有限且线程数量众多的情况下,这些线程的调度延迟超过了它们将要完成的有用工作量,因此,导致 CPU 的运行效率也较低。过多的上下文切换 - 交换线程(用于分时 CPU)的成本不可忽略。

8.2 协同式调度线程模型

为了解决线程爆炸的问题,Swift 并发旨在:

  • 在每个 CPU 内核上运行一个线程 - 仅创建与 CPU 内核数量一样多的线程
  • 将阻塞的线程替换为称为 continuations 的轻量级对象,以跟踪工作的恢复 - 这样,线程能够在工作项被阻塞时以低成本和高效的方式在工作项之间切换
  • 没有上下文切换 - 交换continuation不是完整的线程上下文切换,具有函数调用的成本

为了实现这种行为,操作系统需要一个线程不会阻塞的运行时契约,而这只有在语言能够为我们提供它的情况下才有可能。因此,Swift 的并发模型及其周围的语义在设计时就考虑到了这个目标。

Swift 的两个语言级特性使我们能够维护这个运行时契约:

1.await 和非阻塞线程

await 是异步等待

await 在等待异步函数的结果时不会阻止当前线程 - 相反,该函数可能会被暂停,线程将被释放以执行其他任务

2.任务树 - 跟踪 Swift 任务模型中的依赖关系

除非设定了 @MainActor,否则我们通过 Task API 提交给 Swift 并发运行的闭包,都会交给一个 com.apple.root.default-qos.cooperative的并发队列(注:模拟器上是串行队列)进行处理。用户可以通过Task API给闭包指定不同优先级的队列,从高到底分别是userInitiated、utility和background。每个队列可以最多创建CPU核心个数的线程。最终不同队列在一起时候,能创建的线程个数,又受一些先后顺序的影响,具体可以查看参考文章3。

如果我们习惯于调用 Task { ... },这意味着大多数工作都是在同一个并发队列(com.apple.root.default-qos.cooperative)上完成的。我们使用 Task { ... } 的次数越多,我们的应用程序就会越慢。

具体来说,Swift Concurrency 试图保护你免受线程爆炸的影响,它限制每个队列的线程数,使它们不超过 CPU 核心数。因此,如果你启动了太多Task,其中一些Task将被暂停,直到正在运行的Task数低于 CPU 核心数,或者直到某个Task到达 await 状态,这将触发切换到另一个待处理Task。

例如,如果你使用有 6 个核心的 iPhone 12 Pro,你将只能并发运行大约 8 个Task!为什么是 8 个?因为如果您首先运行具有最高优先级的 6 个Task,那么每个较低优先级的队列将被限制为 1 个Task。因此,6 个Task用于用户启动 + 1 个用于utility + 1 个用于background。但是,您可以稍微作弊并从最低优先级开始安排任务,然后您应该能够为每个 TaskPriority 获得 6 个并发Task。它以这种方式工作,以避免用低优先级任务阻止高优先级任务。

如果您不在Task中执行繁重的工作,问题不会很突出。但是如果你这样做了呢?如果您将同步图像调整大小设置为每个文件启动一个Task会怎样?这是一个更大的问题。现在,一些工作,即使是简单的工作,也将等待调整大小完成。

您可能一开始甚至没有意识到这个问题,因为您只是在 SwiftUI 视图中启动了一个任务,它通过 10 层代码传播,突然发现,如果您对它们使用相同的 TaskPriority,图像处理甚至会阻止简单的 UI 交互。

因此,即使同时运行 6 个任务(iPhone 12 Pro)也可能会完全阻塞您的 UI 交互。

你应该避免在任务中长时间运行同步工作。为此,你应该使用 GCD 和自定义队列。这样,你将避免无意中阻塞一些 TaskPriority。但是,你也应该知道,由于可能存在线程爆炸,不建议使用太多的自定义队列。因此,你可以尝试使用 OperationQueue 来限制并发性。

您应该注意设置正确的优先级。例如,只需从主线程调用 Task { ... },就可以以用户发起的优先级运行非常快速的用户发起的调用,但对于较重和不太重要的工作,如兑现数据、备份、同步等,应考虑其他优先级,如utility和background。示例:Task(priority: .background) { ... }。

如果您确实想在 Task 中运行一些同步繁重的工作,请确保使用较低的优先级来完成。此外,例如,如果你在一个循环中处理一些数据,那么你可以不时调用 await Task.yield()。此操作不执行任何操作,只允许切换到另一个待处理的 Task。这样,您可以确保您的队列不会被完全阻塞,因为至少在此期间您将进行一些任务切换。

9.参考资料

1.《Swift异步与并发编程》

2.Swift concurrency: Behind the scenes

3.How Does Swift Concurrency Prevent Thread Explosions?

相关推荐
晓纪同学26 分钟前
QT-简单视觉框架代码
开发语言·qt
威桑27 分钟前
Qt SizePolicy详解:minimum 与 minimumExpanding 的区别
开发语言·qt·扩张策略
飞飞-躺着更舒服30 分钟前
【QT】实现电子飞行显示器(简易版)
开发语言·qt
明月看潮生36 分钟前
青少年编程与数学 02-004 Go语言Web编程 16课题、并发编程
开发语言·青少年编程·并发编程·编程与数学·goweb
明月看潮生39 分钟前
青少年编程与数学 02-004 Go语言Web编程 17课题、静态文件
开发语言·青少年编程·编程与数学·goweb
Java Fans41 分钟前
C# 中串口读取问题及解决方案
开发语言·c#
盛派网络小助手1 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#
Chinese Red Guest1 小时前
python
开发语言·python·pygame
一棵星2 小时前
Java模拟Mqtt客户端连接Mqtt Broker
java·开发语言
飞飞-躺着更舒服3 小时前
【QT】实现电子飞行显示器(改进版)
开发语言·qt