Swift Concurrency 初探

什么是协程

协程, 又名纤程,是一种协作式的任务调度模式,程序可以主动挂起或者恢复执行。简单来说就是我们自己可以控制程序/函数的挂起和恢复,而不阻塞当前的执行线程。从这里大家可以看出,协程是一种比线程还轻量级的调度单元,无需类似进程,线程的上下文切换,不消耗CPU资源。

线程是操作系统层面的概念,协程是语言层面的概念。

下面我们通过 生产者-消费者模型 来看看到底什么是协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import time

def consumer():
r = ''

while True:
n = yield r
if not n: return
print('[CONSUMER] Consuming %s...' % n)
time.sleep(1)
r = '200 OK'

def produce(c):
c.__next__()
n = 0

while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)

c.close()

if __name__=='__main__':
c = consumer()
produce(c)

这里使用Python3的yield生成器实现了一个简单的生产-消费模型,简单来说就是生产一个,消费一个,直到循环结束。输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...

可以看到,整个流程没有创建任何线程锁,由一个线程执行,producer和consumer协作式完成任务。这种方式不会使得线程被block,可以被系统重新分配。例如我们使用协程调度进行I/O操作,那么I/O操作便不会阻塞当前线程,使得系统可以重新分配当前线程给其他任务。

Swift协程

Swift协程,也就是Swift Concurrency,是Apple在 Swift 5.5 新引入的并发机制,填补了其在协程领域的空白。总的来说,Swift Concurrency包含四个部分: async-let tasks, group task, unstructured tasks 和detached tasks.

Launched by Launchable from Lifetime Cancellation Inherits from origin
async-let tasks async let x async functions scoped to statement automatic priority, task-local values
Group tasks group.async withTaskGroup scoped to task group automatic priority, task-local values
Unstructured tasks Task anywhere unscoped via Task priority, task-local values, actor
Detached tasks Task.detached anywhere unscoped via Task nothing

协程使用

Conurrent Bindings

第一种使用方法是async let, 例如:

1
2
3
4
async let result1 = URLSession.shared.data(...)
async let result2= URLSession.shared.data(...)
try await result1
try await result2

这种方法适用于有顺序依赖关系,而且个数一定的场景。例如上传图片,一般来说上传分两步,第一步上传图片本身资源;第二部上传图片url。这样就可以利用Conurrent Bindings方式按步骤完成任务。

这里要引入Task Tree的概念。简单来说,以上我们提到的四种创建协程的方式都是基于Task来完成的,只不过前两个树状结构的tasks,后两个是无结构的tasks。那什么是树状结构Task呢?

上图是Apple WWDC 2021 Explore structured concurrency in Swift 的截图,从图中可以很直观的看出task之间的关系: fetchOne作为parent task,旗下有两个sub task, 一个是获取image metadata,另一个是获取image data。使用这种结构的好处就是:我们不必关心task的生命周期, 系统会根据task tree的完成情况,根据结构自动退出,进而按需抛出异常。

取消task并不意味着task的执行立即结束,系统会标记该task的结果不再需要了;也意味着我们还需要手动检查task是否取消。

Group Task

Group task是Swift协程的另外一种结构化方式,和async let的显著区别就是可以 “多并发” 的发起多个任务,然后再通过group将结果 “join”起来。

parent task和sub tasks的关系和variable binding的相同。这里唯一要注意的就是 group.async (已过期,需要使用addTask) 是一个sendable修饰的closure,这种closure是无法capture普通mutable variables,只能capture可以安全访问的变量,例如actor修饰的type,实现了同步机制的class,或者是直接返回value type。

Unstructured tasks

Swift协程也支持非结构化的Task。 例如在UI线程调用Task以及调用超出当前scope的Task。Explore structured concurrency in Swift 提到了两种非结构Task, 都很好理解: 第一种如下图,是在显示cell的时候,调用fetch函数, 创建Task:

之后在endDisplay的时候取消Task(一行代码,就不贴了)。这里的Task会继承主线程的context,原因是Task使用了主线程上下文环境的变量,也就意味着fetch本身会发生在main thread。如果不使用主线程上下文变量,则系统会分配其他线程执行Task。

第二种是Detached Task,他的生命周期和调用它的scope并无绑定关系,可以执行一些低优先级任务,例如下图:

这种Task按理说也是要我们手动去cancel的,不过例如本地缓存这样的功能,个人感觉不是必须要支持cancel;不过在使用非结构化的Task时,要记住考虑Task取消的情况,这属于一个best practice。

Swift Concurrency原理窥探

了解了四种Task的使用方式后,不知道你们有没有和我一样的疑问❓那就是async/await这种机制是如何实现线程挂起和恢复的?详细点说就是:当async函数执行完毕后,如何回到之前的位置,继续执行其后的方法?

上图是一个简单的异步样例:在view渲染完毕后,触发一个异步函数,获取结果然后刷新页面。testAsync函数会跑在一个工作线程上,但await之后的内容是跑在主线程上的。这就保证了我们能以一种看似同步的方式写异步代码。

但这又是怎么实现的呢?Apple没有开源Swift Concurrency的代码,所以我们无法直接获取它的实现方式;不过好在Kotlin是开源的,而且也支持协程,我们可以参考kotlin的实现方式,来推测下Swift Concurrency的实现。

总的来讲,Kotlin协程的实现方式是:使用匿名状态机,调度器(Dispatcher)和Continuation实现协程的挂起和恢复。

我们来看一个简单的例子:

test函数启动了一个协程,运行该协程的线程可以是线程池中任意一个线程。在协程体中,test0和test2都是普通函数,也就意味着执行他们的线程是一样的;test1则不同,该函数是一个可挂起函数,和Swift的async函数类似,不同之处在于该函数有指定的调度器: Dispachers.IO,意味着该函数也不会在主线程运行。那在test1执行完毕后,系统是如何切回一开始分配的线程池中的线程呢?

让我们来看下反编译这段代码的结果:

我截取了部分代码(有代码重复), 可以看到invokeSuspend中有switch,将函数分为了两个状态: label 0 和 label 1, 当label为一,也就是运行函数test1时,invokeSuspend返回一个挂起函数;当test1结束运行时,最外层的continuation会调用resume方法,并且其中的调度器会决定切回到哪个线程,最终跳出switch,执行test2和test0。这里的continuation其实是kotlin对协程体的封装。

上面的switch其实可以看做是一个状态机,配合continuation和线程调度器,最终实现了函数的挂起和恢复,以及线程切换。完全的代码控制,不涉及传统的线程上下文切换,从而降低系统资源消耗。

Swift Concurrency实现原理个人认为是类似的,我们来看一个例子:

这个是Swift中Continuation的用法,主要是作为一个新旧代码的桥梁来使用。乍一看和kotlin的Continuation是两回事,但仔细思考下:withCheckedThrowingContinuation也是要suspend当前Task,最后resume;也就是说这个Continuation中,很可能也持有和Kotlin类似的线程调度器,不过在Swift中应该叫做executor,例如MainActor。

以上是Swift Concurrency大致原理。了解了原理后,我们才能写出准确,强壮的代码,避免性能问题,充分发挥协程优势。