前言

现代编程语言解决了线程爆炸,但也带来了类型爆炸问题

为了行文顺畅,需要先解释清楚并发和并行:

并行:指真正的同时执行,例如 6核心CPU(A19 芯片为例),最大并行数就是 6,6个任务跑在6个核心上,可以同时执行,所以并行的单词为: parallel (para-前缀有平行的意思)

因各个版本的 Swift 语言表现差异较大,本篇统一在 Swift 6 环境下展开。Swift 语言新的并发模型由以下新特性支持:

  1. Task:Task 是 Swift 并发模型的 “最小执行单元”,Task 封装的任务,可以丢到任何空闲线程执行
  2. await / async 函数挂起,异步等待点,本质上是依赖 Task 做任务挂起
  3. Actor: 状态隔离+Task 调度(Task 如何串行排队),一个 Actor 内部开启新 Task,Task 内部会脱离当前 Actor 执行域
plain text
┌─────────────────────────────────┐
│ 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 协程与线程不阻塞

过去的并发编程模型重线程,线程基本等价于任务,线程会被阻塞挂起和恢复:

这种设计随着协程提出,逐渐被各个主流编程语言慢慢遗忘,Swift Rust Go JS 都逐渐使用携程来设计并发编程模型。

协程的核心是脱离线程,线程退居幕后,基本不阻塞,一直运行;协程以 Task 任务(函数)为单位进行挂起和恢复,被挂起或者恢复的函数及上下文被保留在 Heap 堆上,任务被挂起后,线程继续执行其他任务,不阻塞 CPU 的高效执行。堆上的挂起任务就绪后,再次恢复该函数的执行,底层的线程池也很好控制,不会出现线程爆炸问题。

Await 作为 Task 的语法糖,被标记为函数挂起点,同时 await 的顺序就是任务的依赖顺序:

一句话:协程(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 也能跑)

swift
// 最底层:直接创建 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 调用),会直接报错:

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

  • 定义:当 Actor 正在执行一个异步方法(比如 func doAsync() async)且暂停等待(比如 await 其他操作)时,允许其他任务进入 Actor 执行队列,而非阻塞。
  • 为什么需要重入?假设 Actor 不支持重入,当一个异步方法 A 执行到 await 时,Actor 会被 “锁住”,如果此时另一个任务调用 Actor 的方法 B,会一直阻塞,直到 A 执行完 —— 这会导致死锁(比如 A 等待 B 的结果,B 等待 A 释放 Actor)。
  • 示例:Actor 重入的合理性(await 挂起点后,释放 actor 执行权)

当 loadImage 执行到 await downloadImage 时,Actor 会释放执行权,允许其他任务(比如另一个 loadImage)进入 —— 但更新 cache 的操作仍会串行执行,保证安全。

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

Actor 的任务调度由 Swift 运行时管理,无需手动控制:

  • 默认调度器:Actor 使用 GlobalActor(全局并发池)调度任务,优先级继承自调用方;
  • 自定义调度器:可通过 @MainActor 标记 Actor,使其所有方法都在主线程执行(UI 相关 Actor 常用):
  • 调度规则:Actor 的任务队列是先入先出(FIFO),但异步暂停的任务会重新排队,保证最终执行的串行性。

恢复后的重新排队:

Actor 中执行到 await 被挂起的任务,在异步操作完成恢复后,不会直接继续执行,而是重新排到该 Actor 执行队列的队尾,等待前面的任务执行完毕后再串行执行。

类比银行排队。

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

await 不换 Actor,Task {} 换执行域

也可以问自己一句:

“这里是 suspend(await)还是 spawn(Task)?”

Task 默认会断开当前 Actor :

swift
@MainActor
func foo() {
    Task {
        // ❌ 这里不保证在 MainActor
    }
}

但 await 不会断开断开 Actor:

swift
@MainActor
func foo() async {
    await bar()
    // ✅ 这里仍然在 MainActor
}

Swift 选择的策略是:

  • Task {} = 我明确要并发 / 脱离当前执行域
  • Actor 必须写出来,防止“隐式性能灾难”

Actor 必须显式写出来的意思是,如果明确指定了 Actor 域,Task 就会在特定的域执行:

swift
Task { @MainActor in
    // ✅ 这里一定在 MainActor
}

// 或者

await MainActor.run {
    // ✅ 一定在 MainActor
}

await 只是整将函数挂起,是一个函数挂起点。