作者:jojotov, iOS 开发,SwiftGG 翻译组成员,目前任职字节跳动,担任抖音直播客户端相关工作。
审阅:四娘,iOS 开发,老司机技能周报成员。目前上任于格隆汇,对 Swift 和编译器相关范畴感兴趣
本文根据 Session 10254 – Swift concurrency: Behind the scenes 整理
概览
在 WWDC 2021 中,Swift 迎来了一次重要的版别更新 —— Swift 5.5。Swift 的此次更新为 Swift 并发编程带来了非常大的改动,经过 async/await
(SE-0296)、Structured concurrency(SE-0304)以及 Actors (SE-0306),Swift 让开发者能够在更笼统的层面上思考并发场景的解决方法,一起确保了并发场景下的功用和安全性,防止了运用 GCD 等传统并发模型时或许呈现的多线程问题。
为了更好地了解 Swift 并发模型所解决的问题以及其背面的原理,本 Session 将经过一个开发新闻阅览 App 的比如,探求 Swift 并发模型的实现原理,以及运用 Swift 并发模型编码过程中,怎么取得更好的功用和效率。
假如你对 Swift 并发编程模型还不够了解,能够先查阅相关 Session:
- Session 10132 – Meet async/await in Swift
- Session 10133 – Protect mutable state with Swift actors
- Session 10134 – Explore structured concurrency in Swift
一个实践比如 —— 新闻信息流 App
在正式进入 Swift 并发编程的世界之前,首要假定一个或许的业务场景 —— 新闻信息流 App,包括拉取最新新闻并缓存本地、展现新闻标题和内容等功用。在这样一个场景中,咱们测验简略地结构一下全体架构,能够梳理出大约三个模块:UI 模块、数据库模块(用于缓存已拉取到的新闻)、网络模块(用于拉取最新的新闻)
其间,咱们会给 Database 模块分配一个 GCD 串行行列(Serial queue),以确保数据库在子线程精确更新,一起给 Networking 模块分配一个并行行列(Concurrent queue),用于一起建议多个新闻类别的恳求:
接下来,咱们会测验编写一个简略的功用:用户在手机屏幕上触发改写,恳求最新的新闻后缓存到本地,然后改写 UI 展现给用户:
- 根据需求拉取的新闻类别(
feedsToUpdate
),经过URLSession
建议同等数量的网络恳求 - 在网络恳求成功后,经过
databaseQueue.sync { ... }
往数据库串行行列参加一个同步使命,来确保数据库能够当即更新 - 数据库更新完后通知主线程改写 UI
至此,咱们现已开端编写完相关的代码,实现了一个新闻拉取、缓存并展现的功用,但上面简略的代码中,或许隐藏着一些难以察觉的功用问题,
运用 GCD 或许呈现的问题
根据 GCD 行列的原理,当咱们向一个异步行列参加使命时(调用 DispatchQueue.sync()
或许 DispatchQueue.asnyc()
),CPU 会创立若干个线程来履行这些使命,直到所有的 CPU 中心都被占用。假定咱们目前有两个 CPU 中心,假如其间一个 CPU 中心的线程被堵塞,这个 CPU 中心会创立一个新线程来测验继续履行其他使命。这儿的原因有二:
- 每个 CPU 中心都需求当时有一条活跃线程,以确保能够在恣意时刻履行恣意使命,以此来确保系统的功用。
- 当一条线程被堵塞时,它或许正在等候一些共享资源开释,而新创立的线程能够继续履行当时行列中的使命,然后协助开释这些共享资源。
结合咱们的新闻 App,咱们能够想象一下履行上文中的代码时 CPU 的状况(以 2 个 CPU 中心为例):
- CPU 创立两条线程来履行新闻信息流拉取
- 当其间一个拉取使命完结时,触发数据库存储的同步使命(
queue.sync()
),此刻当时线程被堵塞 - 由于还有其他未完结的新闻拉取使命,CPU 会创立更多新的线程来履行这些使命
- 新闻拉取使命履行完结,重复步骤 2;
由此可见,假如需求拉取的数据类别许多,且数据库存储的使命耗时较久,咱们或许会面对多个线程一起被堵塞的状况,因而 CPU 会不断创立新的线程来履行剩余未完结的新闻拉取使命,最终导致了线程爆破。
尽管线程爆破并不会直接影响运用的可用性,但当线程的数量非常多,远远超越了 CPU 中心数量的时候,咱们或许会面对一些功用问题:
- 内存占用升高
- 线程调度损耗
- 线程上下文切换损耗
关于怎么在运用 GCD 时尽量防止类似的问题,能够参阅过往的一些 Session:
- WWDC17 – Modernizing Grand Central Dispatch Usage
- WWDC15 – Building Responsive and Efficient Apps with GCD
运用 Swift 并发模型
在上述的比如中,咱们了解到运用 GCD 或许带来的线程爆破问题,那么假如运用 Swift 并发模型,咱们能够把线程问题优化到什么程度呢?答案是经过 Swift 的并发模型,咱们能够彻底防止线程数量过多的问题,理论上使得线程数坚持与 CPU 中心数量相同。
咱们能够看到,优化之后的线程数量下降为 2 个,取而代之的是 Continuation 的概念,这是 Swift 在线程之上笼统出的更高级的并发概念,与线程相比它具有明显的功用优势:没有上下文切换损耗,Continuation 之间的切换仅需一次函数调用的本钱。
合理运用 Swift 并发模型,能够把线程控制在与 CPU 中心数持平的数量,一起极大程度地下降了在多个使命切换时的损耗。
Swift 并发模型的规划理念,是为了确保在运转时控制线程的数量,在抱负状况下使线程数量不超越 CPU 中心数量,而 Swift 引进的结构化并发模型,例如async/await
、Task Group、Actors 等特性,都能够协助咱们完结此方针。
async/await
根据咱们的新闻 App,咱们测验运用 Swift 并发模型来改写新闻改写的逻辑。首要,符号 updateDatabase()
函数为 async
,并在调用处添加 await
标识:
如此以来,咱们运用 async/await
替代了本来的 DispatchQueue.sync
,到达了不堵塞线程的目的,而这部分工作,彻底是由 Swift 运转时来完结,关于开发者来说,咱们需求做的只是是调用方法的改动。
更多关于
async/await
的运用,请参阅 Session 10132 – Meet async/await in Swift
为了了解 async/await
是怎么工作的,首要咱们先回忆一下同步函数的调用方法:
- 每个线程中都有其各自的函数栈
- 当在某个线程调用一个函数时,会将此函数帧压入栈中,函数帧保存着函数的必要信息和局部变量等
- 当函数调用完结并回来时,此函数帧会从栈中弹出
那么,async
符号的异步函数是怎么调用的呢?这儿咱们会经过一个用于更新数据库的 updateDatabase()
函数来详细探求一下异步函数在仓库中的状况:
异步函数在调用时,会一起在栈和堆中各添加一个函数帧,栈中保存只在函数内部运用的局部变量等内容,而一些异步相关的内容,例如
await
符号的地方(称为挂起点 suspension point),则保存在堆中。
-
调用
updateDatabase()
函数时,其内部调用了add()
函数,此刻函数栈中会压入add()
函数帧(这儿只考虑从updateDatabase()
开端的函数栈,忽略之前的调用),而add()
函数中一些局部变量,例如(id, article)
都会一起保存在栈帧中 -
add()
函数内部调用了另一个异步函数database.save()
,并运用await
符号,因而这儿会形成一个挂起点(suspension point),这部分相关的信息不会保存到栈帧中,而是保存在堆中(例如newArticles
变量,在挂起点之前界说,且在挂起点之后也需求运用) -
在
database.save()
调用时,线程当时函数栈中的add()
会被直接替换为save()
。由于异步函数所需求的相关信息,现已在堆中保存,因而咱们不需求像平时调用一个同步函数时相同直接压入栈,而是能够直接替换当时栈顶的栈帧 -
假定
save()
函数中由于数据库资源未开释,需求暂时挂起等候,此刻会形成一个连续点(Continuation),这部分信息相同会保存在堆上,因而当时线程能够继续履行其他使命,确保线程不会堵塞 -
假定在一段时刻后,数据库资源可用,此刻
save()
能够继续履行,此刻save()
会被替换到某个线程的函数栈栈顶(不必定是本来的线程,或许是恣意可用的线程) -
最终,
save()
履行完结,此刻栈顶会被替换为之前的add()
函数,并继续履行后续的同步函数
至此,一个 async/await
的调用过程结束,咱们能够从其调用过程中的仓库状况看到,Swift 引进了连续点(Continuation)的概念,来确保线程的可继续运用,防止了由于线程堵塞导致的线程数量膨胀和线程上下文切换带来的额定开销,然后到达 Swift 并发模型的极致方针:线程数量同等于 CPU 中心数量。
Task group
Task 和 Task group 是 Swift 并发模型中引进的另一个笼统概念,Task 能够包括一系列的异步操作,例如一段包括 async/await
的代码,Task group 能够包括一系列的 Task。
在一个 Task 中,咱们会包括两个部分:挂起点(Suspension point)和连续点(Continuation),这两部分的标识是 await
要害字,await
所符号的方位,会被 Swift 编译器判定为一个潜在的挂起点,而 await
后面的部分,必定会在挂起点履行结束后才行(由运转时决议),因而这部分会被称为连续点。
相同,在一个 Task group 中,或许会包括多个子 Task,而每个 Task 必定会在上一个 Task 履行完结后才会履行。
Task 中的依靠联系和 Task 之间的依靠联系,都是由代码显式界说的,因而 Swift 能够在编译期判别出这些依靠联系并严厉履行。
更多关于 Task 以及 Task group 的内容,请参阅:
- Session 10134 – Explore structured concurrency in Swift(《【WWDC21 10132/10133/10134】认识 Swift 中的异步与并发》)
- The Swift Programming Language: Concurrency
- Task Group | Apple Developer Document
运转时约好
Swift 并发模型的最终方针是为了确保线程数量不超越 CPU 中心数量,为了达成此方针,苹果的工程师和其他开发者都需求遵从必定的准则:确保线程可继续履行,不被堵塞。这个准则被称为运转时约好(Runtime Contract)。
在 Swift 和操作系统的更新中,苹果供给了一个新的线程池:协作式线程池(Cooperative thread pool),并作为 Swift 并发模型的默许线程调度器。经过协作式线程池,Swift 在运转时就能够确保线程不会被堵塞,且防止了线程爆破时呈现的功用问题,然后到达线程数里不超越 CPU 中心数量的方针。
之前的 WWDC 中,WWDC17 – Modernizing Grand Central Dispatch Usage 和 WWDC16 – Concurrent Programming With GCD in Swift 3 都曾评论过怎么改善 GCD 的运用,然后尽量防止多线程功用问题,这些评论都建议开发者需求恪守必定的规则来运用 GCD,例如在系统的每个子模块中最多运用一个 GCD 串行行列。
在 Swift 并发模型中,这些约好和标准都下沉到了 Runtime 层面(Swift 运转时默许确保了线程数量的约束),也就是说,当咱们运用 Swift 结构化并发模型中的言语特性进行开发时,咱们无需在代码层面上过多地重视多线程功用问题。
怎么运用 Swift 并发模型
与运用 GCD 进行并发编程相比,Swift 供给的并发模型在功用、开发效率和代码可维护性都有非常大的提升,但这并不代表咱们在开发过程中能够彻底不加思索地去运用并发模型进行编程。
接下来咱们会围绕几个部分,评论开发过程中,怎么更好地运用 Swift 并发模型:
- 并发编程中的功用问题
-
await
导致原子性被损坏 - 遵从运转时约好
功用
前面咱们说到了并发编程相关的损耗,例如额定的内存占用和运转时逻辑,尽管 Swift 并发模型在功用上有较大优化,但依然会存在内存损耗和运转时效率损耗,因而咱们在考虑是否需求引进并发编程时,有必要优先考量功用上的收益是否远大于损耗。
举一个简略的比如,上述代码完结了一次 UserDefault 的存储,关于一个如此简略的行为,假如咱们加上 async/await
使其变为一次并发操作,其带来的收益或许并不会大于引进的并发损耗,由于 UserDefault 的存储损耗非常小,引进并发编程所带来的额定损耗会彻底抵消,甚至超越本来同步操作的功用损耗。因而,Profile your code 十分重要!
await
导致原子性被损坏
运用 await
特性会损坏代码的原子性,是另外一个需求咱们额定留意的问题。在之前对 async/await
的原理评论中咱们了解到,参加了 await
的异步函数,并不能确保与调用它的函数在同一线程履行,相同,在 await
回来后的代码也无法确保在同一线程履行,因而咱们需求在任何参加了 await
的地方防止以下行为:
- 在
await
前加锁 - 在
await
前后拜访线程私有数据
注:上述说到的行为都是根据
await
前后的代码不确保在同一线程履行,一起需求遵从线程可继续履行的准则,因而能够了解为咱们有必要防止在运用await
时添加任何或许堵塞线程的行为。
遵从运转时约好
在运用 Swift 并发模型进行编码时,咱们需求时刻确保咱们的代码不会损坏 Swift 并发模型的运转时约好,即确保线程的可继续履行。
-
绝对安全类型:
await
、Actors 和 Task group 等 Swift 结构化并发模型特性。由于运用这些类型时,咱们的代码直接显式界说了其依靠联系,所以在 Swift 能够在编译期得到这些依靠联系,并在运转时能给合理地调度线程,因而咱们能够放心运用这些类型。 -
需求当心运用的安全类型:
os_unfair_lock
、NSLock
。在 Swift 并发模型中运用锁也是安全的,但由于编译器并不支撑对运用锁的代码做特别处理,因而咱们在运用时需求进行充分的考量。这儿咱们区分同步和异步两种场景:同步场景下,运用锁是绝对安全的,由于在同步场景下,持有锁的线程,必定会继续履行使命并开释锁,因而并不违法 Swift 并发模型的运转时约好;异步场景下,假如持有锁的线程只会堵塞比较短的时刻,那这种场景下也能够认为此线程是可继续履行使命的。 -
不安全类型:
DispatchSemaphore
、pthread_cond
、NSCondition
以及pthread_rw_lock
等。运用这些并发类型时,其依靠联系并不会在代码中显式声明,而是在代码履行时才能够确认,因而 Swift 运转时无法判别在这些场景中,应该怎么调度线程,因而运用这些不安全类型,并不能确保线程可继续履行使命。例如,在上图的代码中,咱们无法确认信号量会在哪个线程被开释,因而这种类型的代码违反了 Swift 并发模型的运转时约好,无法确保线程能够继续履行使命不被堵塞。
在 debug 模式下时添加环境变量
LIBDISPATCH_COOPERATIVE_POOL_STRICT=1
能够敞开强制运用协作式线程池,假如代码运转中呈现不彻底类型和 Swift 并发模型一起运用的状况,会当即触发semaphore_waite_trap
。
运用 Actors 进行同步操作
Actors 是 Swift 并发模型中新增的言语特性,与 Class 相同,Actor 也是根本类型,并且为引用类型。Actors 最重要的一个特性是,任何 Actors 类型中的可变状况,在同一时刻只运转一个使命(Swift 结构化并发模型中的 Task 概念)拜访,也就是说 Actors 本身不允许并发拜访,防止了资源竞赛(Data Races)的多线程问题。
在拜访 Actors 的可变状况时,咱们需求添加
await
要害字,由于任何拜访 Actors 中可变状况的操作,都有或许形成一个挂起点(suspension point)。
Actors 中的互斥
由于拜访 Actors 中可变状况的操作本身是互斥的,因而咱们能够了解 Actors 是一个自带互斥锁的根本类型。
为了更好地了解 Actors 的优势及原理,咱们首要对比 GCD 串行行列、锁和 Actors 在存在竞赛(Under contention)和非竞赛(No Contention)场景下的好坏:
- 运用锁或许 GCD 串行行列的同步操作时,在存在竞赛的场景下(调用
queue.sync
的线程有正在履行的使命)会堵塞当时线程。 - 运用 GCD 串行行列的异步操作时,尽管在存在竞赛的场景下不会堵塞当时线程,但在非竞赛的场景下(调用
queue.async
的线程有没有使命需求履行),也会创立新的线程来进行异步操作,以此确保当时线程能够继续履行其他使命。 - 在协作式线程池的协助下,运用 Actors 能够确保既不会创立剩余的线程,也不会在堵塞当时线程。
Actor hopping
接下来,咱们还是回到新闻信息流 App 的场景下,深入了解 Actors 是怎么运作的。在这个场景中,咱们先聚集于数据库模块和网络模块,之前的比如中,它们内部都有一个串行或并行的 GCD 行列,现在咱们需求把网络模块的异步行列替换为一系列 Actor(以新闻类别为维度区分,例如 Sports feed actor 和 Weather feed actor),并把数据库模块的同步行列替换为 Database Actor:
当咱们需求恳求最新的新闻信息流,并保存到数据库时,各类别的 feed actor 会首要开端工作,并在完结后直接与数据库 actor 进行交互,把信息流保存在数据库中。这种 actor 之间的交互产生在协作式线程池中,称之为 Actor hopping。
注:Actor hopping 能够了解为线程在不同 actor 之间跳动地履行使命,并由协作式线程池来完结调度。了解这个行为,能够协助咱们更好地了解 Swift 并发模型中的协作式线程池(Cooperative thread pool)是怎么让不同线程进行 “协作” 的。
那么,Actor hopping 背面究竟是怎么工作的呢?咱们假定第一个完结的使命是 Sports feed actor(S1),此刻 S1 会调用 database.save
来进行数据存储,假定此刻数据库处于空闲状况(非竞赛状况的场景),那么当时线程会直接跳到 Database actor 去履行使命(D1)。
这儿咱们需求留意两点:
- 线程并没有由于调用
database.save
这个同步使命而被堵塞- 调用另一个 actor 并不会创立新的线程,而是将当时正在履行的 sports feed actor 使命暂时挂起,创立一个新的使命来履行 database actor
接下来,假定 Database actor 运转了一段时刻,但还未完结当时的存储使命,此刻 Weather feed actor 的使命(W1)刚好履行结束,并相同调用了 database.sync
来进行本地存储,那么运转时会创立一个新的 Database actor 使命 D2,但由于同一个 actor 在同一时刻最多只能履行一个使命,D2 并不会当即履行,而是处于等候的状况。
相同,由于 Weather feed 需求等候 database.sync
操作完结,因而 W1 也会和 S1 相同暂时挂起,而当时履行 W1 使命的线程会跳到 Health feed actor 履行使命 H1。
Actor 的可重入性和优先级联系
假定在上面的比如中,咱们的程序继续运转了一段时刻,这时数据库存储使命 D1 履行结束,这条线程此刻会有三种挑选:
- 履行数据库使命 D2
- 履行 Sports feed actor 使命 S1
- 履行 Weather feed actor 使命 W1
这引进了另一个问题——线程需求以某种规则来决策此刻应挑选跳到哪一个使命去履行。理论上,咱们有必要要做必定的取舍,优先履行更为重要的使命,例如触及 UI 改写的使命,而一些后台使命则不需求当即履行。
在探求 Swift 并发模型怎么解决优先级问题之前,咱们先回忆一下 GCD 在类似的场景下是怎么运作的。假定咱们有一个串行行列 databaseQueue ,并参加两种的使命——触及 UI 的高优先级使命 fetchLatestForDisplay()
以及低优先级的后台备份使命 backUpToiCloud()
。
在咱们参加必定数量的使命后,能够看到行列中的现在有两个 UI 相关的高优先级使命 A 和 B,一起还有 7 个低优先级的后台备份使命 1-7。
当 A 使命履行完结后,由于 GCD 串行行列严厉遵从 FIFO,因而下一个履行的使命是后台使命 1,再下一个是后台使命 2……此刻,下一个高优先级使命 B 只能等候后台使命 1 到后台使命 5 悉数履行完结后才能够开端——咱们一般称这种状况为 优先级反转(Priority Inversion)。
回到 Swift 并发模型中,Actor 是怎么解决优先级的问题呢?咱们继续回忆新闻信息流获取的比如,假定某个线程当时正在履行 Database actor 的一个 database.save
使命 D1,在履行到某个节点时,Database actor 需求等候某些资源开释,因而被暂时挂起,而当时线程则跳到 Sports feed actor 履行使命 S1。
线程继续运转了一段时刻后,S1 使命履行完结,此刻 S1 相同也调用了 database.save
进行本地存储,然后触发了 Database actor 的一个新使命 D2,尽管 Database actor 当时还有一个暂时挂起的使命 D1(假定 D1 所需的资源还未开释),但它依然能够创立新的使命 D2 并在当时线程当即履行。
这儿便触及到了 Actors 的一个重要特性——可重入性(Reentrancy),一个可重入的 Actor 即使有暂时挂起的旧的使命,它依然能够创立并履行其他新使命,而不会一向等候挂起的使命完结。
注:这儿的要害点是有暂时挂起的旧的使命,并不代表同一个 Actor 能够一起并行地履行多个使命。Actors 的可重入性意味着 Actors 不会像 GCD 串行行列相同严厉遵从 FIFO,而是能够先完结一个较晚参加的使命(例如上文的 D2),并无需等候较早参加的使命完结(例如上文的 D1)。
在了解了 Actors 的可重入性之后,咱们结合刚刚 GCD 行列优先级反转的问题,假如运用 Actors 替代 GCD 串行行列,那么在高优先级使命 A 履行结束之后,Database actor 能够直接挑选当时使命行列中的下一个高优先级使命 B,而不是依照 FIFO 履行使命 1-5。
注:关于 Actors 的可重入性和优先级问题,SE-0306 和 SE-0304 中有详细的评论。
Actors 的可重入性,更多地是出于对功用和安全性的考虑,以及提高线程利用率,在运用 Actors 时,咱们有必要要考虑到可重入性带来的不确认因素,详细的比如能够参阅 SE-0306,其间说到了可重入功能够减小死锁产生的或许性,并对非可重入的 Actor 或许产生死锁的场景作了详细的论述。
Main actor
最终,咱们还需求了解一个特别的 Actor——Main Actor(能够了解为 GCD 中的主行列)。当咱们履行完一个异步操作并需求改写 UI 时,咱们便触及到与 Main actor 相关的 Actor hopping 操作,在这种状况下,咱们需求额定留意其带来的上下文切换损耗,由于 Main actor 的使命必定会由主线程来履行,因而在产生 Actor hopping 时,极大或许会有线程上下文切换所带来的损耗。
在一些 for
循环语句中,假如触及类似的 Actor hopping,咱们需求留意循环次数带来的功用损害
在这种状况下,咱们需求对代码做必定的重构,来防止频频切换上下文带来的功用损害。
注:上面的代码中,尽管
updateUI()
操作需求等候database.loadArticle()
完结后才触发,但咱们需求了解这并不会堵塞主线程或许其他任何线程。
结语
在了解了背面的原理之后,咱们会发现 Swift 供给的并发模型,不只是是表面上更笼统的一个结构化并发编程模型,Swift 在编译层面和运转时层面,都对并发编程的功用、效率和开发体验做了很大程度的优化,例如协作式线程池的引进,以及新增的言语特性等。
不得不承认,社区为 Swift 带来了强大的活力,一起相关于 Objective-C,苹果的重心根本上已彻底倾向了 Swift。吸收了多种现代言语特性和优势的 Swift,野心注定不仅在于端运用开发范畴。
本 Session 只是探究了 Swift 结构化并发模型的一部分背面原理,假如想要更全面地了解 Swift 的结构化并发模型,请参阅相关的 Session 和 Proposal:
- SE-0304: Structured concurrency
- SE-0306: Actors
- SE-0296: Async/await
- Session 10132 – Meet async/await in Swift
- Session 10133 – Protect mutable state with Swift actors
- Session 10134 – Explore structured concurrency in Swift
重视咱们
咱们是「老司机技能周报」,一个继续追求精品 iOS 内容的技能大众号。欢迎重视。
重视有礼,重视【老司机技能周报】,回复「2021」,免费收取 2017/2018/2019/2020 内参
支撑作者
在这儿给咱们引荐一下 《WWDC21 内参》 这个专栏(重视咱们就能够免费收取),一共有 102 篇关于 WWDC21 的内容,本文的内容也来历于此。假如对其余内容感兴趣,欢迎戳链接阅览更多 ~
WWDC 内参 系列是由老司机牵头组织的精品原创内容系列。 现已做了几年了,口碑一向不错。 主要是针对每年的 WWDC 的内容,做一次精选,并号召一群一线互联网的 iOS 开发者,结合自己的实践开发经历、苹果文档和视频内容做二次创作。