Swift 并发编程:async 与 await
下载图片、读取文件、请求接口,这些操作都要等待外部设备或网络,耗时远高于 CPU 计算。
如果让主线程一直等,界面就会卡住。
Swift 用 async / await(SE-0296,Swift 5.5 引入)把「等待」变成语言级别的语法,让异步代码读起来和同步代码一样线性。
并发要解决什么问题
传统的并发写法是回调嵌套:请求 A 的回调里发起请求 B,B 的回调里再处理结果。
这种代码层级深、错误处理分散,还容易漏掉某条分支。
Swift 的并发模型做三件事:用 async 标记异步函数,用 await 在调用点等待,用 Task 管理任务的生命周期。
| 概念 | 写法 | 作用 |
|---|---|---|
| 异步函数 | func f() async -> T | 声明这个函数可能挂起等待 |
| 等待 | let x = await f() | 挂起当前任务,结果就绪后继续 |
| 任务 | Task { ... } | 创建一个独立的并发执行单元 |
| 任务组 | withTaskGroup | 动态并发执行一批同类型的任务 |
| 执行器隔离 | @MainActor | 保证代码在指定线程上执行,如主线程 |
await 不是「阻塞」,而是「挂起」。当前任务让出执行权,线程可以去跑别的工作,等结果回来再恢复。这是它和 sleep 阻塞线程的本质区别。
async 函数与 await
把一个函数标记为 async,就表示它的执行过程中可能暂停。
调用它的地方必须写 await,这是在语法上明确标出「这里会等待」。
定义一个异步函数
下面的例子模拟两次网络请求,用 Task.sleep 代表等待耗时。
实例
// async 标记异步函数,函数体内可以挂起等待
func fetchPage(_ name: String) async -> String {
try? await Task.sleep(for: .milliseconds(20))
return "\(name) 内容"
}
func loadAll() async {
// await 表示在这里挂起,等结果回来再继续
let home = await fetchPage("RUNOOB 首页")
let doc = await fetchPage("Swift 教程")
print(home)
print(doc)
}
await loadAll()
运行结果:
RUNOOB 首页 内容 Swift 教程 内容
两次请求是串行的:第二次要等第一次返回后才开始。
如果它们之间没有依赖关系,可以并发执行,后面的 async let 会讲到。
在脚本、Playground 和 main.swift 的顶层代码里可以直接写 await。在普通函数里用 await,该函数本身必须标记为 async。
await 是挂起点
执行到 await 时,当前任务会挂起,把线程让给其他任务。
结果就绪后,任务从这一行继续往下执行。
因为中间可能穿插了其他任务,所以不要在 await 前后假设「状态没有变过」,这一点在 actor 那篇会进一步展开。
async throws:既能等待也能失败
异步函数同样可以抛出错误,写法是把两个关键字并排放在参数列表之后。
实例
enum FetchError: Error {
case badStatus(Int)
}
// async throws:既能挂起,也能抛错
func fetchScore(_ id: Int) async throws -> Int {
try await Task.sleep(for: .milliseconds(10))
guard id > 0 else { throw FetchError.badStatus(id) }
return id * 10
}
do {
let score = try await fetchScore(7)
print("分数:\(score)")
let bad = try await fetchScore(-1)
print(bad)
} catch {
print("出错:\(error)")
}
运行结果:
分数:70 出错:badStatus(-1)
注意 try 和 await 的书写顺序是 try await。
在 do-catch 里,try await 抛出的错误和同步函数一样,由 catch 处理。
Task 与结构化并发
只有 async 函数还不够,总得有人「发起」第一次异步调用。
Swift 提供两种方式:Task 创建独立任务,async let 和 TaskGroup 组织结构化并发。
Task:独立任务
Task { } 创建一个新的并发任务,创建后立即开始执行。
它的返回值通过 task.value 取,取的时候要 await。
实例
func fetchPage(_ name: String) async -> String {
try? await Task.sleep(for: .milliseconds(20))
return "\(name) 内容"
}
// Task 创建一个独立的并发任务,立即开始执行
let t1 = Task { await fetchPage("任务甲") }
let t2 = Task { await fetchPage("任务乙") }
// 按固定顺序等待结果,输出顺序就是确定的
print(await t1.value)
print(await t2.value)
运行结果:
任务甲 内容 任务乙 内容
两个任务确实是并发跑的,但这里按 t1、t2 的顺序取值,所以输出顺序稳定。
Task 创建的任务不受当前作用域约束,即使外层函数已经返回,任务仍可能继续运行,这类任务被称为「非结构化任务」。能用结构化并发时就优先用它。
async let:并发执行固定数量的任务
当几个异步调用的数量固定、彼此独立时,async let(SE-0317,Swift 5.5 引入)最简洁。
它让表达式在后台立即开始求值,直到你真正需要结果时才等待。
实例
func fetchPage(_ name: String) async -> String {
try? await Task.sleep(for: .milliseconds(20))
return "\(name) 内容"
}
func loadPair() async {
// async let 让两个请求同时开始,不阻塞当前任务
async let first = fetchPage("async let 甲")
async let second = fetchPage("async let 乙")
// 在这里统一等待两个结果
let results = await (first, second)
print(results.0)
print(results.1)
}
await loadPair()
运行结果:
async let 甲 内容 async let 乙 内容
两个请求同时发起,总耗时接近一个请求而不是两个。
TaskGroup:并发数量在运行时决定
如果任务数量取决于运行时的数据,比如要并发下载一个数组里的所有地址,就用任务组。
结构化并发(SE-0304,Swift 5.5 引入)保证所有子任务在离开作用域前都会被等待,不会泄漏。
实例
// 任务组用于「数量在运行时才知道」的并发
func loadNumbers() async -> [Int] {
await withTaskGroup(of: Int.self) { group in
for i in 1...4 {
group.addTask { i * 100 }
}
var results: [Int] = []
for await value in group {
results.append(value)
}
// 任务完成顺序不保证,排序后输出才稳定
return results.sorted()
}
}
print(await loadNumbers())
运行结果:
[100, 200, 300, 400]
任务组返回结果的顺序是「完成顺序」,不是「加入顺序」。需要固定顺序时,要么像示例一样排序,要么在任务里带上索引再重新排列。
取消与超时
异步任务一旦发起,就需要一种机制告诉它「不用继续了」,例如用户离开页面、请求已经超时。
Swift 的取消是「协作式」的:调用 cancel() 只是把任务标记为已取消,具体怎么响应由任务自己决定。
任务内可以用 Task.isCancelled 查询状态,或者用 Task.checkCancellation() 在已取消时直接抛错。
实例
func step(_ n: Int) async throws -> Int {
// 主动检查取消状态,已取消则抛出 CancellationError
try Task.checkCancellation()
try await Task.sleep(for: .seconds(1))
return n * n
}
let job = Task { try await step(4) }
job.cancel() // 立刻请求取消
do {
print(try await job.value)
} catch is CancellationError {
print("任务被取消")
}
运行结果:
任务被取消
很多标准库 API 自带取消检查,例如 Task.sleep 被取消时会抛出 CancellationError,不用你手动判断。
如果自己的循环里没有 await,就要手动检查,否则取消信号永远不会被响应。
实例
func countUp() async -> Int {
var total = 0
for i in 1...1_000_000 {
if Task.isCancelled { break } // 收到取消信号就停止
total += i
}
return total
}
超时本身没有语言内置的写法,常见做法是让业务任务和一个计时任务赛跑,谁先完成就用谁的结果。
实例
enum FetchError: Error {
case timedOut
}
// 超时控制:让业务任务和计时任务赛跑,谁先完成用谁的结果
func withTimeout<T: Sendable>(seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: .seconds(seconds))
throw FetchError.timedOut
}
let result = try await group.next()!
group.cancelAll() // 取消还没结束的任务
return result
}
}
do {
let fast = try await withTimeout(seconds: 1) {
try await Task.sleep(for: .milliseconds(10))
return "RUNOOB 快速返回"
}
print(fast)
let slow = try await withTimeout(seconds: 0.1) {
try await Task.sleep(for: .seconds(2))
return "永远不会返回"
}
print(slow)
} catch {
print("超时:\(error)")
}
运行结果:
RUNOOB 快速返回 超时:timedOut
| API | 作用 | 典型场景 |
|---|---|---|
| task.cancel() | 请求取消任务,不会强制中断 | 用户离开页面、页面关闭 |
| Task.isCancelled | 查询当前任务是否已取消 | 长循环里手动判断 |
| Task.checkCancellation() | 已取消则抛出 CancellationError | 步骤开始前检查 |
| group.cancelAll() | 取消任务组中所有子任务 | 超时、拿到足够结果后提前结束 |
@MainActor 与 UI 更新
UIKit 和 SwiftUI 要求界面更新必须发生在主线程。
Swift 用全局 actor @MainActor(SE-0316,Swift 5.5 引入)来表达「这段代码属于主线程」。
把函数或类型标记为 @MainActor 后,从其他上下文调用它必须 await,编译器会自动完成线程切换。
实例
@MainActor
func updateUI(text: String) {
print("主线程更新界面:\(text)")
}
@MainActor
final class ViewModel {
var title: String = "加载中"
func refresh() {
title = "RUNOOB 已加载"
print(title)
}
}
func demoMainActor() async {
await updateUI(text: "RUNOOB 首页加载完成")
let model = await ViewModel()
await model.refresh()
}
await demoMainActor()
运行结果:
主线程更新界面:RUNOOB 首页加载完成 RUNOOB 已加载
把整个 ViewModel 标成 @MainActor,它的属性和方法就都归主线程管理,不会出现后台线程改界面导致的崩溃。
不要在 @MainActor 类型里做耗时计算。标记成主线程不等于「可以随便卡住主线程」,耗时的部分应该放到 Task 里执行,再回到主线程更新状态。
常见问题
下面整理异步编程入门阶段最常遇到的几个问题。
await 会阻塞线程吗
不会。它只是挂起当前任务,线程会被释放去执行其他任务。
真正阻塞线程的是 Thread.sleep 或同步的耗时计算,那些写法在异步上下文里应当避免。
什么代码会真正跑在别的线程上
标记 async 本身不保证切换线程,它只是允许挂起。
具体在哪个线程执行由执行器决定;@MainActor 明确要求主线程,其余情况通常由并发线程池承担。
Task 和 async let 怎么选
需要脱离当前作用域、生命周期独立,用 Task。
只是想在当前函数里并发几个固定调用,用 async let;数量不确定时用 TaskGroup。
为什么并发任务里的输出顺序每次都不一样
因为任务的完成顺序由调度器决定,不受代码书写顺序约束。
需要稳定顺序时,要么串行 await,要么收集结果后排序,或者在任务中携带序号再重排。
TaskGroup 里的任务能直接改外部变量吗
不能。任务闭包是可发送的,捕获外部可变变量会被编译器拒绝。
正确做法是让每个任务返回结果,在主任务里收集。
async 函数可以直接当普通函数调用吗
不能。必须用 await,而且调用点本身要处在异步上下文里。
同步代码想发起异步调用,只能通过 Task 这类入口。
