为什么并发在 Swift 里变了天
在 Swift 5.5 之前,写并发要么靠 GCD 闭包层层嵌套,要么靠手动加锁。回调一多就形成"金字塔",锁一用错就数据竞争。Swift 现代并发从语言层面换掉了这套:用 async/await 写线性代码,用 Actor 自动隔离共享状态,数据竞争从"靠人小心"变成"编译器帮忙盯着"。
现代并发的目标是:让你写出看起来像顺序执行的代码,同时在 await 处自动让出线程;让共享数据被 Actor 保护,少写锁、少出错。
这篇文章沿着"语法 → 并发单元 → 状态隔离 → 实战"的顺序,把这套新模型走一遍。
async/await:把异步写成同步
用 async 标记一个可异步执行的函数,调用处用 await 等待结果。代码从上到下线性书写,读起来和同步函数一样,但每个 await 都是一个挂起点——在那里线程可以去干别的事。
struct User: Decodable { let id: Int; let name: String }
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://example.com/users/\(id)")!
let (data, resp) = try await URLSession.shared.data(from: url)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
}
关键认知:await 不会阻塞线程,只是挂起当前任务。因此可以放心在主线程附近写,不用担心卡死界面。但要记住,await 之后不能假设共享状态没变——别的任务可能在这期间改过它。
Task 与结构化并发
要在同步上下文里启动异步工作,用 Task { }。而要并发执行多个任务并等它们全部完成,用 async let 或 TaskGroup。结构化并发的含义是:父任务会等所有子任务结束,子任务抛出的错误会沿层级传播,不会"发出去就没人管"。
func loadProfile(id: Int) async throws -> (User, [Post]) {
async let user = fetchUser(id: id) // 两个请求并发进行
async let posts = fetchPosts(id: id)
return try await (user, posts) // 同时等待两者完成
}
- async let声明即并发,await 处才等待结果,适合数量固定的多个任务。
- TaskGroup需要动态数量、限制最大并发时用。
- 父子同生共死父任务取消,子任务一并取消。
- 避免孤立 Task零散 Task{} 难以管理生命周期,优先在结构化作用域里发起。
Actor:隔离共享可变状态
并发出问题,多半是多个线程同时改同一块数据。Actor 的解法是:把这块数据和它的方法封进一个 Actor,Actor 内部同一时刻只允许一个任务访问,外部访问要 await 排队。锁的工作交给了运行时。
actor Counter {
private var value = 0
func next() -> Int {
value += 1
return value
}
}
let c = Counter()
let a = await c.next() // 跨 Actor 访问必须 await
let b = await c.next()
把可变的计数器、缓存池、会话状态放进 Actor,就不用自己加锁了。值类型 struct 天然不共享,跨线程传递安全;真正需要共享的可变对象,才需要 Actor。
@MainActor 与界面更新
界面只能在主线程更新。Swift 用 @MainActor 标注"这段代码必须跑在主线程"。在 SwiftUI 里,视图的 body 默认就在 MainActor 上;网络请求放后台,拿到结果后再回到主线程刷新界面。
实践上推荐这样分工:ViewModel 在后台做网络与计算,把结果交给一个 @MainActor 的属性或方法更新界面。不要在后台线程直接改 SwiftUI 状态,否则会偶发不刷新甚至崩溃。
错误处理与任务取消
async 函数可以 throws,错误沿调用链传播,用 do/catch 收口。取消是协作式的:任务不会被硬杀,而是在合适的 await 点检查 Task.isCancelled,主动清理后退出。
两个常见失误:一是用 try? 把错误静默吞掉,调用方永远不知道失败;二是长循环里从不检查取消,任务取消了还在空转。涉及大量次循环或 IO 时,记得在循环里 try Task.checkCancellation()。
实战:并发拉取与超时控制
把前面的点串成一个带超时的并发加载:先并发拉用户和帖子,整体设一个超时。
func load(id: Int) async -> LoadState {
do {
let profile = try await withThrowingTaskGroup(of: Void.self) { g in
g.addTask { _ = try await fetchUser(id: id) }
g.addTask { _ = try await fetchPosts(id: id) }
try await g.waitForAll()
return LoadState.ready
}
return profile
} catch is CancellationError {
return LoadState.cancelled
} catch {
return LoadState.failed(error.localizedDescription)
}
}
这套写法把"并发、错误、取消"都照顾到了:并发拉数据用 TaskGroup,错误分类成加载状态,取消单独识别。界面只面对 ready / failed / cancelled 三种结果,逻辑清爽很多。下一步可以结合下一篇 Core Data,把并发拉下来的数据安全地持久化下来。