第30章 并发(一):async/await、Task 与结构化并发

第三十章:并发(一):async/await、Task 与结构化并发

异步不是“多开几个线程”这么简单,而是让程序在等待网络、磁盘或计时器时,能去做别的事。Swift 用 async/await 把这种等待写进代码结构,又用结构化并发保证子任务不会像断线风筝一样失控。

30.1 async 与 await

异步函数在参数列表后写 async,调用它要写 await:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func fetchName() async -> String {
    "Nova"
}

@main
struct App {
    static func main() async {
        print(await fetchName())
        // prints: Nova
    }
}

await 是一个可能的暂停点。函数暂停时不会阻塞线程,等结果准备好后再继续。暂停不代表“换了一条线程”,也不代表一定并发执行。

本章及后面两章的并发示例都用 @main 作为入口。它必须放进 SwiftPM 可执行目标的源文件里运行(原因见 3.6);拿单个 .swift 文件当脚本跑,会报 'main' attribute cannot be used in a module that contains top-level code。想快速验证,也可以把 @main struct App { ... } 换成顶层 let name = await fetchName() 这种写法。

会抛错的异步函数写 async throws:

1
2
3
4
5
6
enum LoadError: Error { case empty }

func load(_ text: String) async throws -> Int {
    guard !text.isEmpty else { throw LoadError.empty }
    return text.count
}

30.2 async let:并行等待多个独立结果

几个互不依赖的异步任务,用 async let 同时开工,再一起收结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func fetchUser() async throws -> String {
    try await Task.sleep(for: .milliseconds(10))
    return "Mia"
}

func fetchScore() async throws -> Int {
    try await Task.sleep(for: .milliseconds(10))
    return 95
}

@main
struct App {
    static func main() async throws {
        async let user = fetchUser()
        async let score = fetchScore()
        let result = try await (user, score)
        print(result.0, result.1)
        // prints: Mia 95
    }
}

两个子任务先启动,随后在 tuple 处一起等待。不要为了并行而并行:只有任务彼此独立时,async let 才有价值。

先花两句话把 Task.sleep(for: .milliseconds(10)) 拆开,因为这套写法后面会反复出现:

  • Task.sleep 是"睡一会儿",它会抛错——任务在这期间被取消就抛 CancellationError,所以前面必须写 try。
  • for: 接收的是 Duration,Swift 5.7 起标准库自带的"时长"类型。.milliseconds(10) 是隐式成员写法(第 15B.13 节),完整写法是 Duration.milliseconds(10)。
1
2
3
4
5
6
7
let pause: Duration = .milliseconds(10)
print(pause + .seconds(1), type(of: pause))
// prints: 1.01 seconds Duration

try await Task.sleep(nanoseconds: 10_000_000)   // 老写法:10 毫秒,要自己数零
print("醒了")
// prints: 醒了

老 API 用纳秒整数,10_000_000 到底是 10 毫秒还是 100 毫秒得数一遍;Duration 版本把单位写在明面上,还能直接相加相乘——这就是它值得多打几个字的原因。

30.3 Task:从同步世界进入异步世界

同步函数不能直接写 await,可以创建任务:

1
2
3
4
5
6
7
8
@main
struct App {
    static func main() async {
        let task = Task { await fetchName() }
        print(await task.value)
        // prints: Nova
    }
}

Task { ... } 继承当前上下文的一部分(例如 actor 隔离),适合从同步入口启动异步工作。Task.detached { ... } 完全不继承当前上下文,通常只在底层库或明确需要独立任务时使用。

1
2
3
let detached = Task.detached { 21 * 2 }
print(await detached.value)
// prints: 42

30.4 任务取消:协作式,不是强杀

任务取消是协作式的。调用 cancel() 只是设置取消标记,任务内部必须检查:

1
2
3
4
5
6
7
8
let task = Task { () throws -> String in
    try Task.checkCancellation()
    return "完成"
}

task.cancel()
print(task.isCancelled)
// prints: true

Task.checkCancellation() 在已取消时抛出 CancellationError。长时间运行的循环也可以主动检查:

1
2
3
4
5
6
func countUp() async throws {
    for value in 0..<100 {
        try Task.checkCancellation()
        if value == 3 { print("到达 3") }
    }
}

取消不是"立刻死亡",它是沿着任务树传递的一个标记,所以取消后的等待通常以 CancellationError 的形式被你接住:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let sleeper = Task {
    try await Task.sleep(for: .seconds(10))
}
sleeper.cancel()
do {
    try await sleeper.value
} catch {
    print(error is CancellationError)
    // prints: true
}

这里也解释了 Task.cancel() 为什么不需要 try:它只负责请求取消,把标记挂上去;什么时候响应,由被取消的任务在下一个暂停点或检查点自己决定。

取消会沿着结构化任务树向下传播。父任务取消时,子任务也会被标记为取消,但子任务仍然需要在自己的暂停点或检查点响应。

30.5 任务组:动态数量的结构化并发

withTaskGroup 适合运行时才知道要启动多少个子任务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@main
struct App {
    static func main() async {
        let total = await withTaskGroup(of: Int.self, returning: Int.self) { group in
            for value in [1, 2, 3, 4] {
                group.addTask { value }
            }

            var sum = 0
            for await value in group {
                sum += value
            }
            return sum
        }

        print(total)
        // prints: 10
    }
}

任务组会等待所有子任务结束,并把结果按完成顺序交回来。需要抛出错误时使用 withThrowingTaskGroup;抛错后,尚未完成的任务会被取消。

两个关键动作记住就够了:group.addTask { ... } 往组里塞一个子任务,闭包的返回值就是它的结果类型;for await value in group 按"谁先完成谁先回来"的顺序取结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum FetchError: Error { case negative }

func sum(_ numbers: [Int]) async throws -> Int {
    try await withThrowingTaskGroup(of: Int.self) { group in
        for n in numbers {
            group.addTask {
                guard n >= 0 else { throw FetchError.negative }
                return n
            }
        }
        var total = 0
        for try await value in group { total += value }
        return total
    }
}

print(try await sum([1, 2, 3]))
// prints: 6
do {
    _ = try await sum([1, -2])
    print("不该走到这里")
} catch {
    print("抛错了:\(error)")
}
// prints: 抛错了:negative

两个细节:闭合大括号里的 try(try await withThrowingTaskGroup)和循环里的 for try await 都省不掉——错误要么在这里被抛出,要么在更外层被接住;一旦有子任务抛错,任务组会自动取消其余还没跑完的子任务,这就是"结构化"的含义。

30.6 优先级与任务局部值

任务有优先级提示:

1
2
3
4
5
let urgent = Task(priority: .userInitiated) {
    "重要但不要靠它解决一切"
}
print(await urgent.value)
// prints: 重要但不要靠它解决一切

优先级是调度建议,不是抢占式保证。不要用它替代正确的依赖管理。

任务局部值可以像环境变量一样沿着异步调用链传递:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
enum RequestContext {
    @TaskLocal static var requestID = "none"
}

@main
struct App {
    static func main() async {
        RequestContext.$requestID.withValue("abc") {
            print(RequestContext.requestID)
            // prints: abc
        }
    }
}

它比全局变量安全,因为值是绑定在当前任务链上的。

30.7 续体:桥接回调和 async/await

老 API 使用回调时,可以用 withCheckedContinuation 包装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func legacyValue() async -> Int {
    await withCheckedContinuation { continuation in
        continuation.resume(returning: 42)
    }
}

@main
struct App {
    static func main() async {
        print(await legacyValue())
        // prints: 42
    }
}

会失败的回调用 withCheckedThrowingContinuation。每个续体必须且只能恢复一次;恢复两次会触发运行时错误,永不恢复则调用者永远等下去。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
enum FetchError: Error { case negative }

func legacyDouble(_ value: Int, completion: @escaping (Result<Int, Error>) -> Void) {
    completion(value >= 0 ? .success(value * 2) : .failure(FetchError.negative))
}

func double(_ value: Int) async throws -> Int {
    try await withCheckedThrowingContinuation { continuation in
        legacyDouble(value) { result in
            continuation.resume(with: result)     // 一次且只一次
        }
    }
}

print(try await double(21))
// prints: 42

resume(with:) 接收一个 Result,成功就返回值、失败就抛出里面的错误——这正是把回调世界和 async 世界接起来最省力的写法。

30.8 本章小结

工具作用
async / await标记并等待异步操作
async let并行启动固定数量的子任务
Task从同步上下文启动异步任务
Task.detached不继承上下文的独立任务
cancel()请求取消,任务需协作响应
任务组动态启动、等待并收集子任务
任务优先级调度提示,不是绝对保证
续体把回调 API 桥接成 async/await

30.9 本章易错点速查

易错点正确理解
以为 await 一定会切换线程它只表示可能暂停,执行位置由隔离规则决定
并发执行所有任务只有独立工作才值得并发
调用 cancel() 后立刻认为任务已停止取消需要任务内部协作响应
在任务组里泄漏子任务结构化任务组会等待全部子任务
滥用 Task.detached它不继承上下文,常导致隔离和优先级问题
续体恢复多次或从不恢复会导致崩溃或永久等待

30.10 下章预告

下一章进入隔离域和 actor。你会知道为什么多个任务同时改一块状态会出问题,也会知道 Sendable、@MainActor、AsyncSequence 如何把数据竞争挡在编译期。

最后修改 September 19, 2026: 更新 (3489033b1)