XiaoboTalk

并发编程向协程的进化 In Swift 6

前言

现代编程语言解决了线程爆炸,但也带来了类型爆炸问题
为了行文顺畅,需要先解释清楚并发和并行:
并行:指真正的同时执行,例如 6核心CPU(A19 芯片为例),最大并行数就是 6,6个任务跑在6个核心上,可以同时执行,所以并行的单词为: parallel (para-前缀有平行的意思)
并发:严格来讲并行属于并发的理想情况。通常所说的并发是指,时间片快速在不同线程之间切换(例如 10us 切换一次),而带给用户在同时执行的假象。而非正在的并行。并发的英文单词为 concurrency (协同发生)。
因各个版本的 Swift 语言表现差异较大,本篇统一在 Swift 6 环境下展开。Swift 语言新的并发模型由以下新特性支持:
  1. TaskTask 是 Swift 并发模型的 “最小执行单元”,Task 封装的任务,可以丢到任何空闲线程执行
  1. await / async 函数挂起,异步等待点,本质上是依赖 Task 做任务挂起
  1. Actor: 状态隔离+Task 调度(Task 如何串行排队),一个 Actor 内部开启新 Task,Task 内部会脱离当前 Actor 执行域
┌─────────────────────────────────┐ │ Actor(状态隔离+Task 调度) │ 👈 最高层:封装 Task 实现安全并发 └───────────────────┬─────────────┘ │ ┌───────────────────▼─────────────┐ │ async/await(Task 异步语法糖) │ 👈 中间层:简化 Task 的异步编写 └───────────────────┬─────────────┘ │ ┌───────────────────▼─────────────┐ │ Task(并发最小执行单元) │ 👈 最底层:所有并发的基础 └─────────────────────────────────┘
这些技术的底层实现也全面采用了现代式并发编程的模型:协程,其特点是:

1、线程数量 ≈ CPU 核心数

  • Swift 并发调度器会尽量维持合适的线程池大小,通常和 CPU 核心数相关。
  • 并不是每个 Task 都创建一个线程,而是 Task 在少量线程上调度执行。

2、线程长期运行,挂起的是任务 Task

  • Task 内部用 协程(continuation) 挂起和恢复。
  • 线程本身不会被阻塞挂起的是函数执行状态stack frame 和寄存器上下文)。
  • 当 Task 挂起时,线程可以跑别的任务,提高 CPU 利用率
过去,并发编程习惯以线程作为挂起和恢复的单位,由于线程经常被挂起,新任务到来,不得不创建新线程,这导致经常发生线程数量远大于 CPU 核心数的问题 (线程爆炸),线程爆炸主要带来的性能问题:
  • 线程上下文成本开销变大 (需要记录线程上下文、开关中断、trap到内核态、内核态到用户态切换)
  • CPU 的时间片变短(被过多的线程数量切割),无法完整执行一个有效任务,任务频繁切换

3、Task 协程与线程不阻塞

过去的并发编程模型重线程,线程基本等价于任务,线程会被阻塞挂起和恢复:
notion image
这种设计随着协程提出,逐渐被各个主流编程语言慢慢遗忘,Swift Rust Go JS 都逐渐使用携程来设计并发编程模型。
协程的核心是脱离线程,线程退居幕后,基本不阻塞,一直运行;协程以 Task 任务(函数)为单位进行挂起和恢复,被挂起或者恢复的函数及上下文被保留在 Heap 堆上,任务被挂起后,线程继续执行其他任务,不阻塞 CPU 的高效执行。堆上的挂起任务就绪后,再次恢复该函数的执行,底层的线程池也很好控制,不会出现线程爆炸问题。
Await 作为 Task 的语法糖,被标记为函数挂起点,同时 await 的顺序就是任务的依赖顺序:
notion image
notion image
一句话:协程(coroutine)建立在可暂停的函数执行体(continuation)之上。

一、逐渐淡化的 GCD

GCD 这个老牌且倍受好评的并发编程框架,正在逐渐被 Swift 的 async / await 这种新的类似于协程的并发编程模型替代,这是一种进步,App 应当尽量使用新的并发模型。
GCD 在使用上很简单,屏蔽了线程和锁的概念,交给开发者的是 同步/异步 + 队列 (串行队列 / 并发队列)。 GCD 最大的问题来自于它的底层设计,GCD 是线程贪婪的,对于一个 GCD 队列的一个队列,系统总是会迫切的给它分配线程资源,来尽快执行任务。即如果GCD队列在某个线程执行任务的时候,中途被阻塞 (例如 gcd sync),那么多前执行任务的线程会被挂起,但是这个线程也不能释放,也不会把线程分配给其他任务。如果此时队列有新的任务到来,系统则会尽可能的再创建一个线程来执行新的任务,当然这种情况发生在 GCD 使用并发队列的时候。
这种方式,是以线程为调度单位,来管理并发任务。过去 App 业务整体不复杂的时候,GCD 的问题并不会被暴漏的非常明显。然而随着航母级 App 越来越多,这种模式,会带来很明显的问题:线程爆炸,由于每个线程都需要自己独立的调用栈,以及保存线程现场的上下文数据,线程爆炸也会带来内存峰值。并且因为线程爆炸,还会带来线程切换开销的增加。

二、Task 新的底层并发基石

Task 是 Swift 并发模型的核心基石,本质是「由 Swift 运行时管理的异步执行单元」,负责:
  • 调度代码在全局并发池(或指定 Actor)执行;
  • 处理异步暂停 / 恢复(挂起时释放线程,恢复时重新调度);
  • 传递任务的结果 / 错误;
底层特征
  • Task 不依赖 async/await 或 Actor —— 你可以直接创建 Task 执行同步代码(但无意义);
  • 所有 async 函数的执行,最终都必须挂载到某个 Task 上(没有 Task,async 代码无法执行)。
示例:纯 Task 执行(无 async/await 也能跑)
// 最底层:直接创建 Task,无需 async/await let task = Task { print("Task 执行中:\(Thread.current)") // 后台线程 Thread.sleep(forTimeInterval: 1) // 模拟耗时操作 return "Task 完成" } // 等待 Task 结果(本质是 await,Swift 隐式处理) Task { let result = await task.value print(result) }

三、async / await (Task 挂起/恢复便捷语法)

async/await简化 Task 异步编程的便捷语法,本身不具备 “执行能力”,必须依附于 Task 存在:
  • async:标记函数 “需要在 Task 中执行,且可能暂停”;
  • await:标记 “此处可能暂停当前 Task,等待异步操作完成后恢复”;

追踪任务依赖:

多个 await 的依赖执行,不是靠 “多个 Task 之间的显式依赖”,而是靠「同一个 Task 内部按顺序暂停 / 恢复」实现的 ——Task 会严格按照代码中 await 的顺序,等待前一个异步操作完成后,再执行下一个,天然保证依赖关系。

四、Actor 隔离域

Actor 是 swift 提供的一个 isolate 数据隔离域,主要为了解决数据竞争问题,核心设计机制:

机制 1:状态隔离(Actor Isolation)—— 核心中的核心

  • 规则:Actor 的所有属性(状态)只能被 Actor 自身的方法访问,外部线程 / 任务必须通过异步调用await)访问,且同一时间只有一个任务能进入 Actor 的 “执行域”。
  • 底层逻辑:Actor 内部维护一个串行执行队列,所有对 Actor 状态的访问 / 修改都排队执行,天然避免多线程同时操作。
  • 编译期保障:Swift 编译器会强制检查 —— 如果外部试图直接访问 Actor 属性(而非 await 调用),会直接报错:
    • actor SafeCounter { var count = 0 func increment() { count += 1 } } let safeCounter = SafeCounter() safeCounter.count // ❌ 编译报错:Actor-isolated instance method 'increment()' cannot be called from outside of the actor safeCounter.increment() // ❌ 编译报错:同上 Task { await safeCounter.increment() // ✅ 必须 await,异步访问 let c = await safeCounter.count // ✅ 读取也需 await }

机制 2:重入安全(Reentrancy)—— 避免死锁的关键

  • 定义:当 Actor 正在执行一个异步方法(比如 func doAsync() async)且暂停等待(比如 await 其他操作)时,允许其他任务进入 Actor 执行队列,而非阻塞。
  • 为什么需要重入?假设 Actor 不支持重入,当一个异步方法 A 执行到 await 时,Actor 会被 “锁住”,如果此时另一个任务调用 Actor 的方法 B,会一直阻塞,直到 A 执行完 —— 这会导致死锁(比如 A 等待 B 的结果,B 等待 A 释放 Actor)。
  • 示例:Actor 重入的合理性(await 挂起点后,释放 actor 执行权)
    • actor ImageLoader { var cache: [String: UIImage] = [:] // 缓存正在下载的任务,避免重复下载 var pendingDownloads: [String: Task<UIImage?, Never>] = [:] func loadImage(url: String) async -> UIImage? { // 1. 先查缓存(Actor 执行域) if let img = cache[url] { return img } // 2. 检查是否已有下载任务,有则直接等待其结果 if let existingTask = pendingDownloads[url] { return await existingTask.value } // 3. 无则创建下载任务,加入 pendingDownloads let downloadTask = Task { await downloadImage(url: url) } pendingDownloads[url] = downloadTask // 4. 等待下载结果 let img = await downloadTask.value // 5. 清理 pendingDownloads + 更新缓存(串行安全) pendingDownloads.removeValue(forKey: url) if let img = img { cache[url] = img } return img } }
loadImage 执行到 await downloadImage 时,Actor 会释放执行权,允许其他任务(比如另一个 loadImage)进入 —— 但更新 cache 的操作仍会串行执行,保证安全。

机制 3:调度(Scheduling)—— 隐藏的执行逻辑

Actor 的任务调度由 Swift 运行时管理,无需手动控制:
  • 默认调度器:Actor 使用 GlobalActor(全局并发池)调度任务,优先级继承自调用方;
  • 自定义调度器:可通过 @MainActor 标记 Actor,使其所有方法都在主线程执行(UI 相关 Actor 常用):
    • @MainActor actor UIActor { var uiState: String = "" func updateUI(text: String) { // 自动在主线程执行 uiState = text } }
  • 调度规则:Actor 的任务队列是先入先出(FIFO),但异步暂停的任务会重新排队,保证最终执行的串行性。
恢复后的重新排队:
Actor 中执行到 await 被挂起的任务,在异步操作完成恢复后,不会直接继续执行,而是重新排到该 Actor 执行队列的队尾,等待前面的任务执行完毕后再串行执行。
类比银行排队。

五、Task / Await 在 Actor 上的心智模型区别

await 不换 Actor,Task {} 换执行域
也可以问自己一句:
“这里是 suspend(await)还是 spawn(Task)?”
Task 默认会断开当前 Actor :
@MainActor func foo() { Task { // ❌ 这里不保证在 MainActor } }
但 await 不会断开断开 Actor:
@MainActor func foo() async { await bar() // ✅ 这里仍然在 MainActor }
Swift 选择的策略是:
  • Task {} = 我明确要并发 / 脱离当前执行域
  • Actor 必须写出来,防止“隐式性能灾难”
Actor 必须显式写出来的意思是,如果明确指定了 Actor 域,Task 就会在特定的域执行:
Task { @MainActor in // ✅ 这里一定在 MainActor } // 或者 await MainActor.run { // ✅ 一定在 MainActor }
await 只是整将函数挂起,是一个函数挂起点。