iOS 开发手册IOS DEVELOPMENT

CHAPTER 02并发模型 · CONCURRENCY

结构化并发

从 GCD 闭包回调到 async/await 的任务树。理解 Swift 并发如何用编译期规则消灭数据竞争,并平滑迁移历史代码。

开始阅读 难度进阶 先修Swift 基础 约3小时
02.1

为什么需要并发

主线程(Main / UI 线程)必须保持流畅:任何超过约 16ms 的阻塞都会让用户看到卡顿。网络、磁盘、解码等耗时操作必须离开主线程。

老办法:GCD 与回调

GCD(Grand Central Dispatch)用队列 + 闭包把工作派发到后台,再用回调回到主线程。它在十年里解决了"能并发"的问题,但存在回调嵌套深、取消困难、数据竞争靠纪律约束等痛点。Swift 并发是苹果在编译期解决这些问题的新答案。

主线程(UI) 只做渲染与响应 后台任务 网络/磁盘/计算
耗时操作离开主线程,完成后把结果派回主线程更新 UI。Swift 并发让这条链路写法接近同步代码。
02.2

async/await 基础

async 标记"可能挂起"的函数,await 标记"此处可能让出线程"。编译器把异步代码编译成状态机,你写起来却像同步顺序代码。

func fetchUser(id: String) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// 调用:await 处函数可能挂起并让出线程,但不会阻塞线程
do {
    let user = try await fetchUser(id: "42")
    print(user.name)
} catch {
    print("失败:\(error)")
}
挂起 ≠ 阻塞

"挂起"(suspend)时线程被释放去做别的事,等结果就绪再从断点恢复;"阻塞"(block)则是线程原地干等。async/await 是挂起,这也是它比老式同步等待高效的原因。

02.3

Task 与任务树

并发代码必须运行在某个 Task 里。Task 创建子任务,形成任务树:父任务取消,所有子任务随之取消;父任务等待所有子任务完成。

// 在 SwiftUI/UIKit 中常用 Task 启动异步工作(自动绑定当前 actor)
Task {
    await loadData()
}

// 带优先级的独立任务(不与当前 actor 共享上下文)
Task(priority: .high) {
    await refresh()
}

// 可取消:持有句柄
let task = Task { try await longWork() }
task.cancel()   // 设置取消标志;函数内需检查 Task.isCancelled

// 检测取消并尽早退出
func longWork() async throws -> Int {
    for i in 0..<1_000_000 {
        try Task.checkCancellation()   // 已取消则抛 CancellationError
        // ... 工作
    }
    return 0
}
API语义
Task { }在当前 actor 上启动子任务,继承上下文
Task.detached { }完全独立的任务,不继承 actor / 优先级
Task.isCancelled / checkCancellation()检查与响应取消
withTaskCancellationHandler注册取消时的清理逻辑
02.4

async let 与结构化并发

async let 启动一个并发子任务并立即返回占位绑定,父作用域结束时自动等待它完成——这是"结构化"的体现,不会泄漏任务。

func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser(id: "42")     // 并发启动
    async let orders = fetchOrders()          // 并发启动
    async let balance = fetchBalance()        // 并发启动
    // 三个请求并行;函数返回前 await 它们全部完成
    return try await Dashboard(
        user: user,
        orders: orders,
        balance: balance
    )
}
与 Task.Group 的区别

async let 适合少量固定并发。数量动态或需收集结果集合时用 await withThrowingTaskGroup,可循环添加子任务并汇总,且保留结构化取消语义。

02.5

Actor 数据隔离

actor 是"自带串行队列的引用类型":它保护内部可变状态,外界只能通过 await 异步访问其属性/方法,编译器禁止跨 actor 直接访问,从类型系统消灭数据竞争。

actor BankAccount {
    private var balance: Double = 0
    func deposit(_ amount: Double) { balance += amount }
    func withdraw(_ amount: Double) -> Bool {
        guard balance >= amount else { return false }
        balance -= amount
        return true
    }
}

let account = BankAccount()
await account.deposit(100)          // 必须 await,进入 actor 隔离域
let ok = await account.withdraw(30)
MainActor 是特殊的 actor

@MainActor 标注的类型/方法保证在主线程执行,是 UI 更新的安全边界。SwiftUI 的 @MainActor 属性包装器、UIKit 的视图更新都依赖它。把"必须主线程"写在类型上,比到处 DispatchQueue.main.async 可靠得多。

02.6

延续 continuation

面对只提供回调的老 API,用 withCheckedThrowingContinuation 把它包装成 async 函数。checked 版本会在运行期检测"是否恰好 resume 一次",防止漏调用或重复调用。

func data(from url: URL) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        URLSession.shared.dataTask(with: url) { data, _, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let data {
                continuation.resume(returning: data)
            } else {
                continuation.resume(throwing: URLError(.badServerResponse))
            }
        }.resume()
    }
}
resume 恰好一次

必须保证每个代码路径都调用且仅调用一次 resume;多次调用会触发断言崩溃,漏调用会让调用方永久挂起。把所有出口(成功/失败/缺值)都覆盖到。

02.7

从闭包回调迁移

把历史 GCD 代码迁移到 Swift 并发,通常分三步:用 continuation 包一层 async、在主线程边界用 @MainActor 标注、用 async let 取代手动派发合并。

// 旧:回调嵌套
func oldLoad(completion: @escaping (Result) -> Void) {
    DispatchQueue.global().async {
        let user = fetchSync()
        DispatchQueue.main.async { completion(.success(user)) }
    }
}

// 新:async 封装 + 明确主线程边界
@MainActor
func newLoad() async throws -> User {
    return try await Task.detached(priority: .userInitiated) {
        try fetchSync()
    }.value
}
渐进式迁移

不必一次性重写。保留老回调 API,加一层 async 包装供新代码调用,旧调用点逐步替换。Swift 6 起默认开启完整并发检查,越早用 @MainActor / actor 标注,迁移成本越低。

02.8

常见陷阱与 FAQ

为什么我的 async 函数里还是卡 UI?

常见原因是计算密集型工作没离开主线程。在 SwiftUI/UIKit 的 Task 里直接跑重计算,仍会在主 actor 上执行。把重活放进 Task.detached 或 await withTaskGroup,或标注计算函数为非 @MainActor。

actor 里能直接读自己的属性吗?

在 actor 的方法/属性访问器内部可以直接访问(你已身处隔离域);但外部调用方必须 await。注意在 actor 内调用其他 actor 的方法也会跨隔离、需要 await。

async let 和 Task 该怎么选?

async let 适合少量、已知、要收集结果到同一返回值的并发;withThrowingTaskGroup 适合数量动态、需汇总或需精细取消的场景。两者都比裸 Task { } 更"结构化",优先使用。

02.9

实战练习与资源

延伸资源