闲话 Swift 协程(7):GlobalActor 和异步函数的调度
我们已经知道可以使用 actor 来确保数据的线程安全,但对于数据的保护总是需要定义专门的 actor 实例是不是太麻烦了一些?
- 闲话 Swift 协程(0):前言
- 闲话 Swift 协程(1):Swift 协程长什么样?
- 闲话 Swift 协程(2):将回调改写成 async 函数
- 闲话 Swift 协程(3):在程序当中调用异步函数
- 闲话 Swift 协程(4):TaskGroup 与结构化并发
- 闲话 Swift 协程(5):Task 的取消
- 闲话 Swift 协程(6):Actor 和属性隔离
- 闲话 Swift 协程(7):GlobalActor 和异步函数的调度
- 闲话 Swift 协程(8):TaskLocal
- 闲话 Swift 协程(9):异步函数与其他语言的互调用
什么是 GlobalActor
前面我们为了保护特定的状态,就把这些状态包装到一个特定的 actor 实例当中,保护的方式就是将对于这些状态的访问调度到相应的 actor 的调度器当中串行执行。
那么问题来了,如果我有很多分散到不同类甚至不同模块的状态,希望统一调度,该怎么办?最典型的例子就是将 UI 操作调度到主线程,UI 本身就分散在不同的组件当中,对于 UI 的操作更是如此。为了应对这种场景,Swift 在提供了 actor 的基础上又进一步提供了 GlobalActor,旨在提供全局统一的执行调度。
GlobalActor 是一个协议,我们来看一下它的定义:
1 | public protocol GlobalActor { |
ActorType
是实现GlobalActor
协议的类型需要提供的全局唯一的 actor 的类型shared
是上述全局唯一的 actor 的实例sharedUnownedExecutor
是上述全局唯一的 actor 实例的调度器,它的值要求与shared.unownedExecutor
一致
所以只要确定了 shared
是谁,那么这个 GlobalActor
也就确定了。
此外,一个类型在实现 GlobalActor
时,我们还可以用 @globalActor
来修饰它,这样我们就可以用这个实现类去修饰需要使用该 GlobalActor
的实现类来隔离的函数或者属性了。这么说比较抽象,我们接下来看看官方目前提供的唯一一个 GlobalActor
实现是怎么定义的。
探索 MainActor
MainActor
是目前唯一一个 GlobalActor
的实现,它用来将对属性或者函数的访问隔离到主线程上执行。我们来看看它的定义:
1 | public final actor MainActor: GlobalActor { |
我们看到 MainActor
是一个 actor 类型,这对于 GlobalActor
协议来说不是必须的,我们完全可以定义一个 class 来实现 GlobalActor
,并且把一个 actor 类型关联到 GlobalActor
上即可。当然,如果条件允许,直接用 actor 类型来实现 GlobalActor
自然更方便一些。
我们在前面提到过,sharedUnownedExecutor
要与 shared.unownedExecutor
一致,这里很显然二者本质上都是 Builtin.buildMainActorExecutorRef()
。
此外,MainActor
被 @globalActor
修饰之后,自己就可以被用于修饰属性、函数或者类型,我们给出几个简单的 MainActor
的例子:
修饰属性:
1 | class State { |
修饰函数:
1 | func calledOnMain() { |
修饰闭包:
1 | func runOnMain(block: @escaping () -> Void) async { |
修饰类:
1 |
|
被 @MainActor
修饰的函数在调用时,如果当前不在主线程,则必须异步调度到主线程上执行;同样地,被修饰的属性在被其他线程访问时,也必须异步调度到主线程上处理。
被 @MainActor
修饰的类的构造器、属性、函数都需要调度到主线程上执行。需要注意的是,为了保证继承的一致性,被修饰的类需要满足或没有父类、或同样被 @MainActor
修饰、或父类是 NSObject
;被修饰的类的子类也将会隐式获得 @MainActor
上的状态隔离。
这里的异步访问逻辑实际上与 actor 类型的状态和函数的关系相同,即被 @MainActor
修饰的函数内部访问同样被 @MainActor
修饰的属性时则不需要异步执行,就好像它们都被定义到 MainActor
这个 actor 类型当中一样。
以上使用方法和细节同样适用于其他 GlobalActor
的实现。
自定义 GlobalActor 的实现
了解了 MainActor
的定义之后,我们就可以试着给出自定义的 GlobalActor
实现了,例如:
1 | actor MyActor: GlobalActor { |
其中自定义的调度器 MyExecutor
的定义如下:
1 | final class MyExecutor : SerialExecutor { |
大家可以简单阅读代码的注释来了解他们的作用。注意到 MyActor
也实现了 GlobalActor
协议,我们也使用 @globalActor
来修饰 MyActor
,这样我们就可以用 @MyActor
像 @MainActor
那样去修饰函数、属性和类,并让它调度到我们自己实现的调度器上了。
有关 MyActor
的使用示例,我们将在下一节进一步讨论。
注意 截至本文撰写时,Swift 的最新版本为 5.5.1。当前 Swift 协程对于自定义调度器的支持还在提案阶段,细节可参见:Custom Executors。
深入探讨 Actor 与协程的调度
Swift 的协程在执行调度问题上目前还比较含蓄,文档当中很少提及异步函数的执行以及异步函数返回时如何恢复。实际上,异步函数所在的调用位置会关联一个调度器,这个调度器要么来自于所在的 Task,要么来自于当前函数所属于的 actor 实例。
Swift 定义了两个默认的调度器,一个是并发的,一个是串行的;另外就是我们前面提到的,用于将异步函数调度到主线程上的主线程的调度器。
为了搞清楚 Swift 协程究竟是如何调度的,我们用 MainActor
和自定义的 MyActor
来调度我们的异步函数,看看有什么新发现。
在下面的例子当中,我们使用 @MainActor 修饰函数 calledOnMain:
1 | func calledOnMain() { |
接下来创建一个 Task 来调用它:
1 | Task { () -> Int in |
这里我们使用 log 这个定义的函数来打印输出,它与 print 的不同之处在于它会同时打印当前线程:
1 | [<NSThread: 0x6000015c41c0>{number = 2, name = (null)}] task start |
可以看到,calledOnMain 被调度到了 MainThread 上执行。task start 和 task end 执行所在的线程相同(当然也可以不同,但一定是相同的调度器所属的线程),这说明 calledOnMain 返回之后 Task 又被调度与之关联的调度器上执行。
@MainActor 也可以被用于修饰闭包的类型,例如:
1 | func runOnMain(block: @escaping () async -> Void) async { |
我们试着调用一下这个函数:
1 | Task { () -> Int in |
运行结果如下:
1 | [<NSThread: 0x600000ac8480>{number = 2, name = (null)}] task start |
这次只有 block 才会被调度到 MainThread 上,因为只有它被 @MainActor 修饰。
从这个例子当中我们其实还能推测出调度发生的位置,即:
- 异步函数开始执行
- 异步函数返回之处
实际上除此之外,Task 开始时也可能会发生一次调度。这些都是可能的调度位置,Swift 的运行时会根据实际情况判断调度前后是不是属于同一个调度器,以决定是不是真的需要发生调度。这些也能从我们待会儿的例子当中得到印证。
接下来我们使用 MyActor
依样画葫芦,完成类似的例子:
首先是函数的定义:
1 | func runOnMyExecutor(block: @escaping () async -> Void) async { |
然后调用它们:
1 | Task { () -> Int in |
运行结果如下:
1 | [<NSThread: 0x600003eb4040>{number = 2, name = (null)}] task start |
注意到 calledOnMyExecutor
调用时、runOnMyExecutor
当中的 block
执行时、block
当中的 sleep 之后恢复时分别执行了一次 enqueue
。大家有兴趣的话也可以在其中穿插一些需要调度到主线程的函数调用,看看实际的调度情况。
Task 与 actor 上下文
我们在前面介绍 Task 的构造时,讲到过可以使用 init
和 detached
两种方式来构造 Task 实例,前者会继承外部的上下文,包括 actor、TaskLocal 等,后者则不会。
下面的例子将会证明这其中有关 actor 的部分:
1 | Task { () -> Int in |
通过前面的介绍,我们已经知道 runOnMain 的参数 block 会被调度到 MainThread 上执行,那么其中的两个 Task
的日志输出理论上会有不同的表现:
1 | [<NSThread: 0x600001520180>{number = 2, name = (null)}] task start |
实际上也正是如此,task in runOnMain
打印到了 MainThread 上,而 detached task in runOnMain
因为通过 detached
创建的 Task
实例不会继承外部的 actor(以及其调度器),因此打印到了其他线程上(也就是默认的调度器上)。
Task 的两种不同的构造方式对于 TaskLocal 的继承情况同样如此,我们将在下一篇文章当中再给出对比示例。
小结
本文我们详细介绍了 GlobalActor 的设计初衷、实现方式以及使用方法,也探讨了 Swift 协程的调度细节,相信读者看到这里时,已经掌握了绝大多数 Swift 协程的相关知识。
下一篇文章我们将简单介绍一下 TaskLocal 的使用方法。
关于作者
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);移动客户端工程师,先后就职于腾讯地图、猿辅导、腾讯视频。
- GitHub:https://github.com/bennyhuo
- 博客:https://www.bennyhuo.com
- bilibili:霍丙乾 bennyhuo
- 微信公众号:霍丙乾 bennyhuo