为什么需要并发
主线程(Main / UI 线程)必须保持流畅:任何超过约 16ms 的阻塞都会让用户看到卡顿。网络、磁盘、解码等耗时操作必须离开主线程。
GCD(Grand Central Dispatch)用队列 + 闭包把工作派发到后台,再用回调回到主线程。它在十年里解决了"能并发"的问题,但存在回调嵌套深、取消困难、数据竞争靠纪律约束等痛点。Swift 并发是苹果在编译期解决这些问题的新答案。
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 是挂起,这也是它比老式同步等待高效的原因。
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 | 注册取消时的清理逻辑 |
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
)
}
async let 适合少量固定并发。数量动态或需收集结果集合时用 await withThrowingTaskGroup,可循环添加子任务并汇总,且保留结构化取消语义。
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 标注的类型/方法保证在主线程执行,是 UI 更新的安全边界。SwiftUI 的 @MainActor 属性包装器、UIKit 的视图更新都依赖它。把"必须主线程"写在类型上,比到处 DispatchQueue.main.async 可靠得多。
延续 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;多次调用会触发断言崩溃,漏调用会让调用方永久挂起。把所有出口(成功/失败/缺值)都覆盖到。
从闭包回调迁移
把历史 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 标注,迁移成本越低。
常见陷阱与 FAQ
常见原因是计算密集型工作没离开主线程。在 SwiftUI/UIKit 的 Task 里直接跑重计算,仍会在主 actor 上执行。把重活放进 Task.detached 或 await withTaskGroup,或标注计算函数为非 @MainActor。
在 actor 的方法/属性访问器内部可以直接访问(你已身处隔离域);但外部调用方必须 await。注意在 actor 内调用其他 actor 的方法也会跨隔离、需要 await。
async let 适合少量、已知、要收集结果到同一返回值的并发;withThrowingTaskGroup 适合数量动态、需汇总或需精细取消的场景。两者都比裸 Task { } 更"结构化",优先使用。