苹果应用开发实战五:Swift 6 与并发编程

0 阅读23分钟

第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 后面继续执行。

关键推论:

  1. 一个线程可以先后服务成千上万个任务,线程数远小于任务数。所以你可以同时发起几十上百个网络请求,而不是开几十条线程。
  2. 函数被挂起时,不会占用任何线程。这跟"开一条线程 sleep"完全不是一回事——那是真把系统资源占住了。
  3. 恢复时不一定在原来的线程上。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 语言模式混编。迁移节奏建议是:

  1. 先在 Swift 5 模式下把 -strict-concurrency=complete 打开,把所有警告当错误清一遍。
  2. 给网络模型、DTO 补 Sendable;把共享可变类改成 actor 或值类型。
  3. 最后把 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 的现代 API notifications(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 绕过——那只是把编译错误藏起来,运行时还是会出问题。

本章小结

  1. await 是挂起点,不是阻塞:挂起时线程被释放回合作线程池,恢复时可能换了线程。
  2. 异步函数必须用 async/async throws 声明,调用方必须写 await(或 try await)。
  3. 结构化并发(async let、withThrowingTaskGroup、.task)保证子任务一定被父任务等待或取消;非结构化的 Task { } 用于事件触发场景,要自己持有句柄。
  4. 并行用 async let(固定个数)或 TaskGroup(个数不定),总耗时约等于最慢的那个,而非之和。
  5. 取消是协作式的:Task.checkCancellation()、Task.isCancelled、标准库 API 自动响应;清理逻辑用 Swift 6.4 的 defer + withTaskCancellationShield 兜底。
  6. actor 把可变状态锁在自己的上下文里,跨边界访问必须 await;它防并发交错,但不防重入——跨 await 后要重新假设状态已变。
  7. Sendable 是"可安全跨并发域"的编译期承诺:值类型字段全 Sendable 自动满足,所有网络 DTO 应显式标注。
  8. Swift 6 语言模式把数据竞争从警告变成编译错误;迁移按模块逐一切换,先清 -strict-concurrency=complete 警告。
  9. @MainActor 让 UI 状态的主线程隔离成为编译期保证,新代码不再手写 DispatchQueue.main.async。
  10. 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 个字符,下游只收到最后一次的关键词。)