Swift concurrency: Behind the scenes
Swift & UI 高级 26m

Swift 并发:幕后机制

Swift concurrency: Behind the scenes

2021年6月9日

在 Apple 官方观看视频

一句话判断

这场 Session 解开了 Swift 并发模型的底层实现——Continuation、cooperative thread pool、actor isolation 的工作原理都在这里,看完你才能真正理解为什么 async 函数不会阻塞线程。

这场 Session 讲了什么

Session 从底层实现的角度讲解 Swift 并发模型。不是教你怎么用 async/await,而是解释编译器和运行时怎么协作让 async/await 工作。

核心概念包括:Swift 的并发线程池(Cooperative Thread Pool)是一个固定大小的线程池,线程数量等于 CPU 核心数。async 函数在 suspend point 处”挂起”时,线程不会被阻塞而是被释放回线程池供其他任务使用。当异步操作完成后,任务被重新调度到某个线程上继续执行。

还详细讲解了 Continuation 的实现——async 函数的局部变量在 suspend 时被保存到堆上的 Continuation 结构中,resume 时恢复。Actor 的隔离通过编译时检查和运行时的 serial executor 实现。Task 的取消通过 Cooperative Cancellation 实现——系统不会强制取消你的任务,你需要定期检查 Task.isCancelled

值得深挖的点

为什么线程不会耗尽

GCD 的模型是”一个队列上的任务会阻塞一个线程”。如果你有 100 个并发网络请求,在等待响应时 100 个线程都被阻塞。Swift 的并发模型通过 suspend/resume 机制避免了这个问题——所有等待中的 async 函数不占用线程,它们的状态保存在堆上的 Continuation 中。线程池中永远只有 CPU 核心数个线程在活跃工作。这就是为什么 Swift 并发可以轻松处理上万个并发任务而不会耗尽线程。

Actor 的 Serial Executor

Actor 保证内部状态不被并发访问。实现方式是每个 Actor 有一个关联的 Serial Executor——所有对 Actor 方法的调用都被排队执行。当你 await actor.doSomething() 时,如果 Actor 正在忙,你的任务会挂起并加入等待队列。Actor 完成当前任务后,从队列中取下一个执行。这就是为什么 Actor 方法的调用总是需要 await——编译器不知道调用时 Actor 是否空闲。

代码片段

理解 suspend point 的行为

func fetchUserData() async -> User {
    // 此处线程 A 正在执行
    let data = await networkClient.request("/user")
    // <- suspend point:线程 A 被释放
    
    // resume:可能在线程 B 上继续执行
    // data 变量和函数上下文从 Continuation 恢复
    let user = try JSONDecoder().decode(User.self, from: data)
    return user
}

// 同时启动多个任务
func loadDashboard() async {
    // 三个任务并发,但只使用 CPU 核心数个线程
    async let user = fetchUserData()
    async let feed = fetchFeed()
    async let config = fetchConfig()
    
    // 三个任务都在等待网络,0 个线程被阻塞
    let (userData, feedData, configData) = try await (user, feed, config)
}

Cooperative Cancellation 的处理

func processItems(_ items: [Item]) async throws -> [Result] {
    var results: [Result] = []
    
    for item in items {
        // 检查是否被取消
        if Task.isCancelled {
            // 清理并抛出取消错误
            throw CancellationError()
        }
        
        // 另一种方式:在耗时操作前检查
        try Task.checkCancellation()
        
        let result = try await processItem(item)
        results.append(result)
    }
    
    return results
}

// 外部取消任务
let task = Task {
    try await processItems(bigArrayOfItems)
}

// 5 秒后取消
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    task.cancel()
}

Actor 的 Serial Executor 行为

actor DataProcessor {
    var processedCount = 0
    
    // 每个方法调用都被 serial executor 排队
    func process(_ item: Item) -> Result {
        processedCount += 1  // 安全:同一时刻只有一个方法在执行
        return item.process()
    }
    
    func getCount() -> Int {
        processedCount
    }
}

// 调用者视角
let processor = DataProcessor()

// 这两个调用可能并行发起
async let result1 = processor.process(item1)  // 如果 processor 忙,这里会等待
async let result2 = processor.process(item2)  // 排在 result1 后面

// 但 processor 内部是串行执行的
let (r1, r2) = try await (result1, result2)

最佳实践

性能调优: 不要在 async 函数中做长时间同步计算——这会阻塞协作线程池中的一个线程,影响全局调度。把 CPU 密集型工作放到 Task.detached 中(独立的执行上下文),或者用 withCheckedContinuation 桥接到 GCD 的全局队列。

取消处理: 在长时间运行的 async 循环中定期检查 Task.isCancelled。在每次循环迭代的开头调用 try Task.checkCancellation() 是最简单的模式。

还有什么值得关注

  • @MainActor 是一个全局 Actor,它的 serial executor 就是主线程的 run loop。
  • Task 的优先级(priority)会影响线程池的调度顺序,但不会保证实时性。
  • nonisolated 关键字可以让 Actor 的某个方法脱离 serial executor 执行。
WWDC 2021