Swift Concurrency 初探
什么是协程
协程, 又名纤程,是一种协作式的任务调度模式,程序可以主动挂起或者恢复执行。简单来说就是我们自己可以控制程序/函数的挂起和恢复,而不阻塞当前的执行线程。从这里大家可以看出,协程是一种比线程还轻量级的调度单元,无需类似进程,线程的上下文切换,不消耗CPU资源。
线程是操作系统层面的概念,协程是语言层面的概念。
下面我们通过 生产者-消费者模型 来看看到底什么是协程:
1 | import time |
这里使用Python3的yield生成器实现了一个简单的生产-消费模型,简单来说就是生产一个,消费一个,直到循环结束。输出结果如下:
1 | [PRODUCER] Producing 1... |
可以看到,整个流程没有创建任何线程锁,由一个线程执行,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 | async let result1 = URLSession.shared.data(...) |
这种方法适用于有顺序依赖关系,而且个数一定的场景。例如上传图片,一般来说上传分两步,第一步上传图片本身资源;第二部上传图片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大致原理。了解了原理后,我们才能写出准确,强壮的代码,避免性能问题,充分发挥协程优势。