第5章 Swift 6 与并发编程
本章导读:App 要联网、要读文件、要解码大图,这些活儿一做就是几百毫秒,直接在主线程上做,界面就会卡帧。传统的解决办法是 GCD 闭包套闭包、delegate 层层回调,再加上一把
NSLock守着共享变量——能跑,但错误往往要等到线上偶现崩溃才暴露。Swift 6 把并发从"靠程序员自觉"升级成了"编译器替你把关":数据竞争(data race)不再是运行时偶发 bug,而是编译错误。本章讲清 async/await 的挂起与恢复、Task 与 TaskGroup 的结构化并发、actor 的隔离与重入、Sendable 与严格并发模型、@MainActor、AsyncSequence,并速览宏与非可拷贝类型。读完本章,你写出的网络层、缓存层、下载器在编译期就基本杜绝了线程安全问题,后续第 14 章的网络请求与第 30 章的 MovieLog 项目都建立在这一章的地基上。
5.1 为什么需要并发:主线程与 UI、后台执行、数据竞争
5.1.1 主线程为什么不能被阻塞
iOS 的 UI 渲染跑在主线程(main thread)上:用户触摸事件的分发、视图布局、CoreAnimation 的提交,全在这一根线程上顺序执行。屏幕 60 帧意味着每 16.7 毫秒就要交一帧,ProMotion 屏更是 120Hz、8.3 毫秒一帧。如果你在主线程上同步发起一次 HTTPS 请求,少则 100 毫秒、多则 2 秒,这段时间里所有触摸都石沉大海,动画掉帧,用户的直观感受就是"App 卡死了"。
所以第一定律是:所有可能耗时的操作——网络、磁盘、大文件解析、图片解码、数据库查询——都必须从主线程挪走,完成后再回到主线程更新 UI。
5.1.2 回调地狱与数据竞争
在 async/await 之前,苹果世界的标准做法是 completion handler:
// 旧式回调写法(仅用于对比,本书新代码不再这样写)
func fetchMovie(id: String, completion: @escaping (Result<Movie, Error>) -> Void) {
URLSession.shared.dataTask(with: url(for: id)) { data, _, error in
guard let data else { completion(.failure(error!)); return }
do {
let movie = try JSONDecoder().decode(Movie.self, from: data)
DispatchQueue.main.async {
completion(.success(movie))
}
} catch {
completion(.failure(error))
}
}.resume()
}
嵌套两三层就已经开始痛苦:错误要一路透传、取消要手动记录 token、并行请求要自己数完成回调。更要命的是共享状态——比如一个被多个网络回调读写的缓存字典:
// 危险:两个后台线程同时读写同一个字典
class NaiveCache {
private var store: [String: Data] = [:]
func set(_ data: Data, for key: String) { store[key] = data }
func get(_ key: String) -> Data? { store[key] }
}
Dictionary 是值类型,但这里它被类包裹成引用类型。两个并发队列同时执行 store[key] = data,写操作在底层是"读旧值—修改—写回"三步,一旦交错,字典的内部存储可能直接损坏,表现为偶发崩溃、坏内存、读回脏数据。这就是数据竞争。过去你能依赖的只有一把锁:
private let lock = NSLock()
func set(_ data: Data, for key: String) {
lock.lock(); store[key] = data; lock.unlock()
}
锁是正确的,但它全凭自觉。忘了加锁、在锁里 await、两个锁的顺序写反造成死锁——编译器一个字都不会提示。Swift 6 的答案是把"哪些数据能跨线程传递、哪些方法只能在哪个执行器上跑"变成类型系统的一部分,让这类问题在编译期现形。
注意:Swift 6 的并发模型不是 GCD 的替代品,而是建立在合作线程池(cooperative thread pool)之上的高层抽象。你不需要、也不应该再手动
DispatchQueue.global().async来写新代码;只有在跟老 UIKit/C 代码打交道时才偶尔碰到 GCD。
5.2 async / await:异步函数、调用与挂起
5.2.1 把回调改成 async
上面 fetchMovie 的异步版本,读起来像同步代码:
struct Movie: Codable, Sendable {
let imdbID: String
let title: String
let year: String
}
func fetchMovie(id: String) async throws -> Movie {
let (data, response) = try await URLSession.shared.data(from: url(for: id))
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw NetworkError.badStatus
}
return try JSONDecoder().decode(Movie.self, from: data)
}
三个语法点:
- 函数签名里加
async,表示"这个函数可能挂起";如果还会抛错,写成async throws。 - 调用任何异步函数必须在前面写
await,读作"在这里可能挂起"。 - 返回类型不变。调用方用
try await接住结果:
func showMovie(id: String) async {
do {
let movie = try await fetchMovie(id: id)
print(movie.title)
} catch {
print("加载失败:\(error)")
}
}
没有 completion、没有 DispatchQueue.main.async、没有层层嵌套。
5.2.2 挂起到底发生了什么
这是本章最容易被误解的地方,必须讲透。
await 不会阻塞线程。 普通函数调用时,CPU 一步步执行指令,当前线程被这条调用"占着"直到返回。而 await 标注的是一个挂起点(suspension point):执行到这里时,当前函数把自己的局部变量、寄存器状态打包成一个"延续"(continuation),然后让出当前线程去干别的活——线程回到合作线程池,可能立刻去跑另一个任务。等网络数据到达、文件读完,系统在某个线程上恢复这个函数,把打包的状态解回来,从 await 后面继续执行。
关键推论:
- 一个线程可以先后服务成千上万个任务,线程数远小于任务数。所以你可以同时发起几十上百个网络请求,而不是开几十条线程。
- 函数被挂起时,不会占用任何线程。这跟"开一条线程 sleep"完全不是一回事——那是真把系统资源占住了。
- 恢复时不一定在原来的线程上。
await之后的代码跑在哪,取决于这个函数的隔离域(后面讲 actor 和 @MainActor 时你会看到)。
func demo() async {
print("1 开始,线程:\(Thread.current)")
let data = try await fetchMovie(id: "tt0111161")
// 上面这行挂起期间,当前线程被释放;恢复后可能换了一条线程
print("2 恢复,线程:\(Thread.current)")
print(data.title)
}
把上面这段跑起来,你会看到第 1 行和第 2 行打印的线程对象往往不是同一个。这是特性,不是 bug。
提示:正因为挂起期间线程被释放,一个没有阻塞的 async 函数几乎不消耗任何资源。你可以在 SwiftUI 的
.task { }修饰符里放心地写长逻辑,视图消失时任务会被自动取消(见 5.3.4)。
5.3 结构化并发:Task、TaskGroup、async let、任务取消
异步函数本身不能"自己跑起来"——它必须被一个任务(Task)包裹。任务有两种创建方式:结构化的(async let、TaskGroup、视图的 .task)和非结构化的(Task { })。结构化并发(structured concurrency)的核心思想是:任务的生命周期跟它的父任务绑定,父任务不结束,子任务必须全部结束;哪怕子任务抛错或被取消,编译器也保证有人等它、有人清理它。
5.3.1 Task:从同步世界跨入异步世界
普通函数里不能写 await,必须有一个异步上下文。Task { } 就是那座桥:
final class SearchViewModel {
var results: [Movie] = []
func onSearchButtonTapped() {
// 按钮的 action 是同步的,但我们要在里面跑异步逻辑
Task {
let movies = try await MovieCatalog.shared.search(query: "乐高")
self.results = movies // ⚠️ 这一行在 Swift 6 下需要注意隔离,见 5.6
}
}
}
Task { } 创建的是非结构化任务:它不绑定到任何父任务,闭包里 await 之后才返回,你拿不拿到它的结果都行。它适合"事件触发—发起一次异步工作"这种场景,比如按钮点击、通知响应。但在能不用的地方尽量不用,因为它打破了"父子同生共死"的保证。
5.3.2 async let:并行发起多个调用
假设搜索详情页要同时拉取电影信息、演职人员、影评。串行写是三倍延迟:
// 慢:三次请求一个接一个
let movie = try await catalog.detail(id: id)
let cast = try await catalog.cast(id: id)
let reviews = try await catalog.reviews(id: id)
用 async let 让它们并行:
func loadDetail(id: String) async throws -> DetailPage {
async let movie = catalog.detail(id: id)
async let cast = catalog.cast(id: id)
async let reviews = catalog.reviews(id: id)
// 到这里三个请求已经在并行跑了
return try await DetailPage(
movie: movie, // 第一次 await 才真正等待这一项
cast: cast,
reviews: reviews
)
}
async let x = expr 的语义是:立刻开始并行执行 expr,但不等待结果;直到你写 await x 时才取值。三个请求的总耗时约等于最慢那一个,而不是三者之和。编译器保证:在当前作用域结束前,这些并行任务一定被等待或被取消——你忘了取结果,它也会替你等完,绝不会留下悬空任务。
5.3.3 TaskGroup:数量不定的并行
async let 适合固定个数的并行。个数由数据决定时(比如一次拉取 20 张海报),用 withTaskGroup 或 withThrowingTaskGroup:
func loadPosters(for ids: [String]) async throws -> [String: Data] {
try await withThrowingTaskGroup(of: (String, Data).self) { group in
for id in ids {
// 每个 id 起一个子任务
group.addTask {
let data = try await self.downloadPoster(id: id)
return (id, data)
}
}
var collected: [String: Data] = [:]
// 逐个收集完成的结果;任何一个子任务抛错,整个 group 抛出
for try await (id, data) in group {
collected[id] = data
}
return collected
}
}
注意三点:of: 指定每个子任务返回什么类型;group.addTask 里闭包的返回值会成为 for try await 拿到的元素;for try await 按完成顺序(而非发起顺序)吐结果。如果想限制并发数(比如最多同时下载 6 张),在 addTask 前用信号量风格的 actor 计数,或者直接把 ids 分批——本书第 14 章网络层会给出完整实现。
5.3.4 任务取消:协作式,不是强制打断
Swift 的取消是协作式的:父任务取消时,系统把"已取消"这个标志传播给所有子任务,但子任务该跑还在跑,需要自己在合适的点检查并停下。
func downloadPoster(id: String) async throws -> Data {
// 方式一:抛 CancellationError
try Task.checkCancellation()
let (data, _) = try await URLSession.shared.data(from: url(forPoster: id))
// 方式二:自己判断并提前返回
if Task.isCancelled { throw CancellationError() }
return data
}
Task.checkCancellation():如果当前任务已取消,直接抛出CancellationError;否则什么都不做。Task.isCancelled:只读标志,适合在循环里每跑几轮检查一次。- 大多数标准库异步 API(
URLSession.data、FileHandle的读、AsyncSequence的迭代)在取消时会自动停止并抛错,所以网络请求一般不用你手动管。
结构化并发的取消传播是自动的:async let 变量在作用域结束时如果没人取它,会被自动取消;TaskGroup 里父任务抛错,group 会把所有还在跑的子任务一并取消。非结构化的 Task { } 则要自己持有 Task 句柄:
private var loadTask: Task<Void, Never>?
func start() { loadTask = Task { await load() } }
func stop() { loadTask?.cancel() }
SwiftUI 的 .task { } 修饰符把这件事做全了:视图出现时启动任务,视图消失时自动 cancel。这就是为什么前面能放心地在里面写长逻辑。
提示:清理逻辑(关闭文件句柄、落盘埋点)在任务取消时也要跑完。Swift 6.4 起
defer块里可以直接await(SE-0493),并提供withTaskCancellationShield(SE-0504)保证清理不被取消打断:func process(at url: URL) async throws { let handle = try FileHandle(forReadingFrom: url) defer { await withTaskCancellationShield { try? handle.close() } } try await readContents(of: handle) }
5.4 actor:隔离、重入、nonisolated
5.4.1 actor:自带锁的引用类型
前面那个 NaiveCache 的问题,用 actor 写一遍就彻底解决了:
actor MovieCache {
private var store: [String: Data] = [:]
func set(_ data: Data, for key: String) {
store[key] = data // 只有 actor 自己能碰这个可变状态
}
func get(_ key: String) -> Data? {
store[key]
}
}
actor 是引用类型,行为上像一把自动上锁的类。它的核心规则是隔离(isolation):actor 的所有可变状态只能在 actor 自己的执行上下文里被访问。外部想读 store 或者调用 set,必须通过异步访问点——也就是在外面调用时写 await:
let cache = MovieCache()
await cache.set(posterData, for: "tt0111161")
let data = await cache.get("tt0111161")
await cache.set(...) 意味着"把这个调用派发到 actor 的串行执行队列上排队执行"。actor 一次只执行一个任务,永远不会有两个调用同时读写 store。数据竞争在语法结构上就不可能发生——你不需要写锁,也不会忘写锁。
注意:actor 内部的方法彼此直接调用时不需要 await,因为它们已经在 actor 的上下文里。只有跨出 actor 边界才需要
await。
5.4.2 重入:actor 不是互斥锁的全部真相
新手最容易踩的坑是把 actor 当成"方法级互斥"。考虑一个银行账户:
actor BankAccount {
var balance: Int = 100
func transfer(to target: BankAccount, amount: Int) async throws {
guard balance >= amount else { throw BankingError.insufficient }
// ↓ 这一行 await 会挂起当前函数
try await target.deposit(amount)
balance -= amount
}
func deposit(_ amount: Int) {
balance += amount
}
}
在 transfer 里,guard 检查余额后,执行到 await target.deposit 时挂起——这一瞬间,本 actor 的执行队列空出来了,别的调用(比如另一笔 transfer)可以进来跑。等 target.deposit 返回、本函数恢复执行 balance -= amount 时,balance 可能已经被别的调用改过了。这叫重入(reentrancy)。
actor 保证的是"同一时刻不会有两个操作在 actor 内部执行",但它不保证"一个异步方法从挂起到恢复之间状态不变"。正确的写法是把"检查—扣款"这个整体在挂起前完成,或者用不可变值传递:
func transfer(to target: BankAccount, amount: Int) async throws {
balance -= amount // 挂起前先扣,没有 await 就不会被插队
do {
try await target.deposit(amount)
} catch {
balance += amount // 对方失败则回滚
throw error
}
}
记住一条经验法则:在 actor 的方法里,凡是跨越 await 之后还要依赖的可变状态,都要当成"可能已被别人改过"来处理。
5.4.3 nonisolated:不访问状态的方法可以同步调用
如果一个方法不碰 actor 的存储属性,只是做纯计算,可以标 nonisolated,让外部不用 await 就能调:
actor MovieCache {
private var store: [String: Data] = [:]
nonisolated func suggestedFileName(for key: String) -> String {
"\(key).jpg" // 不访问 store,纯函数
}
}
Swift 6.1 起 nonisolated 还能直接标在整个类型或 extension 上,用来批量解除隔离(例如给 @MainActor 类型补 Equatable 实现时):
@MainActor struct Rating: Equatable {
let movieID: String
var value: Double
}
nonisolated extension Rating {
static func == (lhs: Rating, rhs: Rating) -> Bool {
lhs.movieID == rhs.movieID && lhs.value == rhs.value
}
}
警告:
nonisolated方法里访问 actor 的可变存储属性是编译错误。这个保护正是它的价值——你声明"我不碰状态",编译器替你守着。
5.5 Sendable 与 Swift 6 严格并发
5.5.1 数据竞争如何成为编译错误
Swift 5 时代,并发检查是可选警告(-strict-concurrency=complete)。Swift 6 语言模式(SWIFT_VERSION = 6)把它升级成编译错误。这不是靠运行时插桩,而是靠一套"跨并发域传递数据"的静态分析。
先看一个会被编译器直接拦下的例子:
final class Counter { var count = 0 }
func bad() async {
let c = Counter()
await Task.detached {
c.count += 1 // ❌ Swift 6 编译报错:c 不能跨并发域传递
}.value
}
报错的本质是:Counter 是可变引用类型,被扔进另一个任务(另一个并发域)后,两边都可能改它,编译器无法证明这是安全的。换成值类型结构体就没问题:
struct Counter: Sendable { let count: Int } // 不可变的值类型,自动 Sendable
5.5.2 Sendable:"可以安全跨并发域"的标记
Sendable 是一个空协议,它不要求实现任何方法,只表达一个承诺:这个类型的值可以从一个并发域传到另一个并发域,而不会产生数据竞争。 满足条件的类型自动满足 Sendable:
- 值类型且所有存储都 Sendable:
struct/enum的字段全是 Sendable,编译器自动推导。这就是为什么String、Int、Array<Movie>(如果 Movie 是 Sendable)能自由跨域。 - 不可变的引用类型:
let只有不可变存储的 class(所有人都只读,没有竞争)。 - actor:天然 Sendable,因为外部只能通过 await 访问它的隔离状态。
- @MainActor 标注的类型:都在主线程上,天然安全。
需要手动标 Sendable 的典型场景是网络 DTO:
struct Movie: Codable, Sendable {
let imdbID: String
let title: String
let year: String
let poster: URL?
}
把网络返回的模型标成 Sendable,它就能在 URLSession 的回调、actor、主线程 ViewModel 之间自由传递。本书约定:所有跨网络、跨线程传递的模型类型一律显式标注 Sendable——这一行字就是编译期的安全网。
如果你手里确实是一个需要跨域共享的可变类,正确做法是把它包进加锁的容器,而不是裸奔。Synchronization 包提供了现代原语:
import Synchronization
final class Counter: @unchecked Sendable {
private let lock = Mutex(0) // Mutex 来自 Synchronization
func increment() { lock.withLock { $0 += 1 } }
func current() -> Int { lock.withLock { $0 } }
}
@unchecked Sendable 是你对编译器说"我知道它是引用类型,但我已经用 Mutex/Atomic 把可变状态守死了,请放行"。这是逃生舱,用一次就要审一次——绝大多数时候你应该直接用 actor 或值类型,而不是它。
5.5.3 编译模式与迁移
一个 App 不是一夜之间变成 Swift 6 的。苹果支持逐模块迁移:把 target 的 Swift Language Version 从 5 切到 6,该模块就开启严格并发;没切的老模块继续以 Swift 5 语言模式混编。迁移节奏建议是:
- 先在 Swift 5 模式下把
-strict-concurrency=complete打开,把所有警告当错误清一遍。 - 给网络模型、DTO 补
Sendable;把共享可变类改成 actor 或值类型。 - 最后把 target 切到 6。
Xcode 27 里的 Instruments 新增了 Swift Executors 乐器,能可视化合作线程池、Main Actor、自定义执行器的轨迹;调试时在 LLDB 里敲 language swift task tree 能直接打印当前任务树——排查"这个任务到底为什么没并发起来"时非常好用。
提示:Swift 6.2 引入了一个面向 UI 代码的选项
-default-isolation MainActor,让模块里的代码默认跑在主线程上,少写很多@MainActor;以及@concurrent属性显式声明"这个函数可以并行"。本书示例仍显式标注隔离,不依赖默认隔离模式,这样代码在任何工程配置下都成立。
5.6 @MainActor 与主线程隔离
更新 UI 必须在主线程。Swift 用 @MainActor 把这条规则也编译期化。
5.6.1 给类型或函数贴标
把 ViewModel 标成 @MainActor,它的所有方法和属性就都只能在主线程上访问:
@MainActor
final class SearchViewModel: ObservableObject {
@Published var results: [Movie] = []
@Published var isLoading = false
func onSearch(query: String) async {
isLoading = true
// 调用非 MainActor 的方法时要 await
let movies = try? await MovieCatalog.shared.search(query: query)
// 回到这里时已经在主线程上
results = movies ?? []
isLoading = false
}
}
这就是 5.3.1 那个例子里"直接写 self.results = movies"为什么需要解释:非 MainActor 的异步方法返回时,代码跑在后台线程上,直接碰 ViewModel 的属性会被编译器拒绝。而当 ViewModel 本身是 @MainActor 时,try? await ... 这一行挂起后恢复,编译器知道你回到了主线程,于是 results = ... 合法。
SwiftUI 的 View 协议的 body 本身就被假定在主线程上求值。所以在视图里写 .task { await ... },await 返回后直接更新 @State 是安全的。
5.6.2 什么时候用什么
- ViewModel、SwiftUI 视图状态:
@MainActor,或依赖 SwiftUI 自带的主线程上下文。 - 网络层、数据缓存、下载器:普通类型或 actor,不要标
@MainActor——那会把所有网络调用塞回主线程,等于自废武功。 - 从后台回调主线程:不需要再写
DispatchQueue.main.async。在@MainActor函数里await完一个后台操作,代码自动回到主线程。
注意:老代码里满屏的
DispatchQueue.main.async { ... }在新代码里基本消失了。这是好事——它从"手工搬运"变成了"类型系统自动路由",你不再会忘记、也不会搬错。
5.7 AsyncSequence 与异步序列
数组是"已经在那里的一串值"。有些值是一个一个到达的:实时的搜索输入、文件的字节流、服务器推送的消息。AsyncSequence(异步序列)就是这种东西的协议抽象,用法跟普通 Sequence 几乎一样,只是迭代时要 await:
func watchSearchField(_ stream: AsyncStream<String>) async {
for try await query in stream {
// 每来一个新关键词就重新搜索
let movies = try await MovieCatalog.shared.search(query: query)
print(movies.count)
}
}
常见的现成 AsyncSequence:
URLSession.bytes(from:):逐字节读网络流,边下边处理。FileHandle.bytes:逐行读大文件,不用一次性读进内存。NotificationCenter的现代 APInotifications(named:):把通知转成异步序列。- Swift 6.2 起
Observation框架的Observations:把@Observable对象的状态变更作为异步序列吐出来(第 9 章会用到)。
手动构造一个 AsyncSequence 用 AsyncStream:
let ticker = AsyncStream<Date> { continuation in
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
continuation.yield(Date()) // 每来一个值就 yield
}
continuation.finish()
}
}
for await tick in ticker {
print(tick) // 每秒打一个时间
}
for await 循环在序列结束或任务被取消时退出。这比 Timer + RunLoop 的写法干净得多,也天然支持取消。
5.8 宏与非可拷贝类型速览
并发不是 Swift 6 语言层的全部。这一节把两个会在后面章节反复遇到的东西交代清楚,细节留到用到时再展开。
5.8.1 宏:编译期替你生成代码
Swift 的宏(macro)在编译期展开成代码,你已经在不知不觉中用过了:SwiftData 的 @Model、Observation 的 @Observable、Swift Testing 的 @Test/#expect 全是宏。宏分两类:
- 附看宏(attached macro):写在声明上面,比如
@Observable给类生成观察逻辑、@Model生成持久化样板。 - 独立宏(freestanding macro):以
#开头,比如#Predicate { $0.title.contains(q) }、#expect(x == 1)。
Swift 6 新增了 @attached(body) 宏,能合成或改写整个函数体;还有调试宏 @DebugDescription,自定义 LLDB 里变量的显示格式。宏的实现依赖 swift-syntax,Xcode 26 起预构建 swift-syntax,编译宏目标快了很多。你日常写业务代码不需要自己造宏,但要能看懂"这个 @Observable 展开后到底干了什么"——第 9、13 章会拆开讲。
5.8.2 非可拷贝类型:值也有"所有权"
默认所有 Swift 类型都是可拷贝的(Copyable):把一个变量赋给另一个变量,值就复制了一份(引用类型则复制引用)。Swift 6 把非可拷贝类型(noncopyable,~Copyable)接入了泛型系统——可以声明"这个值不许被复制,只能被移动或消费":
protocol Resource: ~Copyable {
consuming func close() // close 会消费掉这个值
}
struct FileHandle: Resource, ~Copyable {
consuming func close() { /* 关闭句柄 */ }
}
这种类型的价值在于表达独占资源(文件句柄、内存缓冲区、跨语言的 move-only 类型),从语言层面杜绝"两个变量同时操作同一份资源"。Swift 6.4 又补了 UniqueBox、UniqueArray、Ref、MutableRef 等免引用计数的独占容器,以及 LLDB 对 ~Copyable 字段的调试支持。日常 App 开发很少直接写 ~Copyable,但它是 Swift 继续往"系统级安全语言"走的方向,知道有这回事即可。
5.9 常见问题与排错
1. 报错:"expression is 'async' but is not marked with 'await'"
你在同步上下文里调了异步函数。要么给当前函数加 async,要么用 Task { await ... } 包一层。注意按钮 action 闭包可以直接声明成 async,iOS 15+ 起 SwiftUI 支持异步 action。
2. 报错:"sending 'self' risks causing data races"
你把一个非 Sendable 的类实例传进了异步闭包。检查这个类是不是可以改成 actor、值类型,或者补上 Sendable(并确认它的可变状态都守住了)。最常见的根源是 ViewModel 没标 @MainActor 就被后台闭包捕获。
3. 网络请求发出去但结果没回来 / 任务静默取消
十有八九是结构化任务被父任务取消了。SwiftUI 里用 .task(id:) 时,id 一变旧任务立刻取消;视图消失也会取消。在异步函数里加 try Task.checkCancellation() 并打日志,确认是不是被取消的。调试时用 LLDB 的 language swift task tree 看任务树。
4. actor 方法里 await 之后读到的状态是错的
重入问题(见 5.4.2)。检查你的方法:在 await 之前做的假设,在 await 之后还成立吗?把"检查—提交"挪到挂起之前,或者重新读一次最新状态再决策。
5. @MainActor 类型的方法被非主线程调用,编译报错 "crossing main actor boundary"
你在普通 actor 里直接调了 @MainActor 的方法。加 await:编译器会把调用派发到主线程。不要用 DispatchQueue.main.async 绕过——那只是把编译错误藏起来,运行时还是会出问题。
本章小结
await是挂起点,不是阻塞:挂起时线程被释放回合作线程池,恢复时可能换了线程。- 异步函数必须用
async/async throws声明,调用方必须写await(或try await)。 - 结构化并发(
async let、withThrowingTaskGroup、.task)保证子任务一定被父任务等待或取消;非结构化的Task { }用于事件触发场景,要自己持有句柄。 - 并行用
async let(固定个数)或TaskGroup(个数不定),总耗时约等于最慢的那个,而非之和。 - 取消是协作式的:
Task.checkCancellation()、Task.isCancelled、标准库 API 自动响应;清理逻辑用 Swift 6.4 的defer+withTaskCancellationShield兜底。 - actor 把可变状态锁在自己的上下文里,跨边界访问必须
await;它防并发交错,但不防重入——跨await后要重新假设状态已变。 Sendable是"可安全跨并发域"的编译期承诺:值类型字段全 Sendable 自动满足,所有网络 DTO 应显式标注。- Swift 6 语言模式把数据竞争从警告变成编译错误;迁移按模块逐一切换,先清
-strict-concurrency=complete警告。 @MainActor让 UI 状态的主线程隔离成为编译期保证,新代码不再手写DispatchQueue.main.async。AsyncSequence把"逐个到达的值"抽象成可for await迭代的序列;宏与~Copyable是 Swift 6 语言层的另外两块拼图,后续章节会反复用到。
练习
1.(阅读)把 5.1.2 里的 NaiveCache 分别用(a)actor、(b)Mutex 重写,对比两版在调用方代码上的差异:哪些地方需要加 await,哪些不需要。(完成标准:两版都能在 Swift 6 严格并发模式下编译通过,并能说出 actor 版为什么调用方必须 await。)
2.(动手)写一个 ImageLoader actor,提供 func load(url: URL) async throws -> Data,内部用 NSCache(包成 actor 安全的写法)做内存缓存:命中则直接返回,未命中才走 URLSession。注意缓存字典是 actor 的隔离状态。(完成标准:同一 URL 连续调用两次,第二次不发起网络请求;用 Instruments 的 Swift Concurrency 乐器观察没有数据竞争警告。)
3.(动手)用 async let 把"电影详情 + 演职人员 + 影评"三个请求并行化,再故意在其中一个请求里 try await Task.sleep(for: .seconds: 2) 模拟慢请求,用秒表验证总耗时接近 2 秒而非 4 秒。(完成标准:打印三次请求的开始与结束时间,证明它们确实并行。)
4.(思考)在 BankAccount.transfer 里制造一次重入:并发发起两笔超额转账,观察 balance 是否会变成负数。用 5.4.2 的修正方案修复。(完成标准:无论并发多少笔,余额都不会低于 0,并能解释为什么"先扣款再 await"能挡住重入。)
5.(选做)用 AsyncStream 写一个"关键词输入防抖"序列:外部不断 yield 字符串,内部 300ms 内没有新输入才把最后一个词吐给下游搜索。(完成标准:快速连打 5 个字符,下游只收到最后一次的关键词。)