从警告到编译错误:一次 Swift 6 严格并发迁移实录

举报
yd_278798845 发表于 2026/11/01 18:49:49 2026/11/01
【摘要】 引言:升级之后,项目里全是错误打开 Swift 6 语言模式的那一刻,很多人的第一反应是:这项目还能不能要了?几百条 Sendable 相关的红字错误,散落在网络层、缓存层、通知回调里。最省事的做法是到处加 @unchecked Sendable,让编译器闭嘴。但这相当于把编译器能替你发现的问题,留到了线上崩溃时才暴露。这篇文章记录我在实际项目里迁移时踩到的四个坑,以及一份可以直接照着走的清...

引言:升级之后,项目里全是错误

打开 Swift 6 语言模式的那一刻,很多人的第一反应是:这项目还能不能要了?

几百条 Sendable 相关的红字错误,散落在网络层、缓存层、通知回调里。最省事的做法是到处加 @unchecked Sendable,让编译器闭嘴。但这相当于把编译器能替你发现的问题,留到了线上崩溃时才暴露。

这篇文章记录我在实际项目里迁移时踩到的四个坑,以及一份可以直接照着走的清单。


一、先搞清 Swift 6 在检查什么

严格并发检查只关心一件事:非 Sendable 的值,有没有跨越隔离域被共享。

  • Sendable 表示"这个值可以安全地跨并发域传递"。值类型(struct/enum)在成员都可 Sendable 时自动满足;引用类型默认不满足。
  • 隔离域由 actor 或全局 actor(最典型就是 @MainActor)划定。跨域访问必须 await。
  • 全局变量必须是 let + Sendable,否则直接报错。

理解这两句话,大部分报错都能解释清楚。


二、坑一:actor 是可重入的,它不负责原子性

很多人把 actor 当成"自动加锁的对象",于是写出这样的缓存:

code.swift

actor ImageCache {    private var storage: [URL: Data] = [:]     func data(for url: URL) async throws -> Data {        if let cached = storage[url] { return cached }        let (data, _) = try await URLSession.shared.data(from: url)        storage[url] = data        return data    }}

这段代码没有数据竞争,编译器不会再报错。但它有一个逻辑上的竞态:两个协程同时请求同一张图时,都会先检查缓存,都没有命中,然后下载两遍。

原因在于 actor 的语义:它保证同一时刻只有一段同步代码在执行,却不保证一次方法调用整体是原子的。遇到 await 时,执行权就会交还给调用方,其他任务可以进来插队。

修复方式是让同一 URL 的请求在内部去重:

code.swift

actor ImageCache {    private var storage: [URL: Data] = [:]    private var inFlight: [URL: Task<Data, Error>] = [:]     func data(for url: URL) async throws -> Data {        if let cached = storage[url] { return cached }        if let task = inFlight[url] { return try await task.value }         let task = Task { () throws -> Data in            try await URLSession.shared.data(from: url).0        }        inFlight[url] = task         do {            let data = try await task.value            storage[url] = data            inFlight[url] = nil            return data        } catch {            inFlight[url] = nil            throw error        }    }}

结论:actor 保证的是互斥访问,不保证业务上的原子性。凡是在 await 前后依赖状态不变的地方,都要自己再想一遍。


三、坑二:把 @MainActor 当成常用的隔离域

一旦遇到隔离相关的报错,最省事的做法就是给所有类加上 @MainActor。这确实能让项目编译通过,但代价是网络、解析、磁盘 IO 全部挤到主线程。

正确的思路是按数据归属划分隔离域,而不是按"能不能编译"来划分:

code.swift

// 只在 UI 状态需要共享的地方用 MainActor@MainActorfinal class FeedViewModel {    private(set) var items: [Item] = []    private let service = FeedService() // actor,处理网络与解析     func refresh() async throws {        items = try await service.loadFeed()    }} actor FeedService {    func loadFeed() async throws -> [Item] {        let (data, _) = try await URLSession.shared.data(from: feedURL)        return try JSONDecoder().decode([Item].self, from: data) // 解析不占用主线程    }}

边界要清楚:UI 状态在主 actor 上,数据加工在 actor 里,两者之间用 await 传递 Sendable 的数组。

另外要注意:如果某个 nonisolated async 函数内部有较重的 CPU 计算,在 Swift 6.2 起可以用 @concurrent 标记它,让它真正跑在全局并发执行器上,而不是调用方所在的 actor 上。


四、坑三:Task.detached 不会继承调用方的隔离域

进入后台线程时,很多人第一反应是 Task.detached,然后就会撞上这类报错:

code.swift

@MainActorfinal class FeedViewModel {    private(set) var items: [Item] = []     func refresh() {        Task.detached {            let fetched = await self.fetch()            self.items = fetched // 错误:Main actor-isolated property 不能在 nonisolated 上下文修改        }    }}

Task.detached 不会继承任何调用方的上下文:不继承 actor 隔离、不继承优先级、不继承 task-local 值。它适合那些完全独立、与该调用方没有任何关系的任务;不应当把它当作一种通用的后台写法。

大部分场景用 Task {} 就够了:

code.swift

func refresh() {    Task { // 继承 @MainActor 隔离,写回 items 不需要任何额外操作        let fetched = await fetch()        items = fetched    }}

如果确实想让耗时逻辑离开主 actor,正确的做法是把它交给另一个隔离域(比如前面提到的 actor),再通过 await 把结果带回来,而不是用一个 detached 任务硬把隔离打断。


五、坑四:把 @unchecked Sendable 当成例外手段

编译器的报错有时确实让人心烦,但只要加上 @unchecked,错误就消失了。但 @unchecked 的意思是"我手动保证线程安全",编译器从此不再检查。如果代码里根本没有加锁,那它就只是个谎言。

如果确实需要用引用类型跨域共享,就要把约定写清楚,并且真正落实:

code.swift

final class Counter: @unchecked Sendable {    private let lock = NSLock()    private var _value = 0     var value: Int {        lock.withLock { _value }    }     func increment() {        lock.withLock { _value += 1 }    }}

规则只有一条:所有可变状态必须被同一把锁保护,且内部引用不得逃逸出去。满足不了这两点,就别用 @unchecked,改成 actor 或者值类型。

全局变量是同一类问题的另一种表现:

code.swift

let formatter = DateFormatter()           // 错误:全局作用域中非 Sendable 的 let@MainActor let formatter = DateFormatter() // 方案一:交由主 actor 隔离nonisolated(unsafe) let formatter = DateFormatter() // 方案二:自己保证只读/线程安全 // 更推荐:换成天然 Sendable 的值类型let style = Date.FormatStyle(date: .numeric, time: .shortened)

六、迁移清单

  1. 先别急着切到 Swift 6。在 Swift 5 模式下把 Strict Concurrency Checking 调成 Complete(-strict-concurrency=complete),先把警告清零。
  2. 从边界推进:网络回调、通知、数据库、缓存,这些是数据跨域最频繁的地方,也是问题最集中的地方。
  3. 优先消除共享可变状态,让值类型和 actor 承担状态管理;加锁只是最后一步。
  4. 每次使用 @unchecked Sendable 或 nonisolated(unsafe) 都要写明理由,并在 PR 里重点 review。
  5. 用 Thread Sanitizer 加压力测试保底:编译器能证明的东西有限,并发 bug 最终还是要在运行时验证。

结语

Swift 6 的严格并发并不是故意让开发者为难。它只是把过去要靠线上崩溃才能发现的问题,提前到了编译期。迁移过程中真正花时间的地方,在于重新想清楚哪些状态该由谁持有、在哪个隔离域里被修改,改语法本身反倒花不了多少时间。想明白这件事,报错自然就消失了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。