用 Swift Actor 替换 DispatchQueue 做持久化,三个坑差点让我退回老路
前段时间在做一个 iOS 离线优先的数据层,原来的写法是老套路:一个 final class,内部持有 DispatchQueue(label: "...", attributes: .concurrent),读操作用 sync 加 barrier,写操作用 async(flags: .barrier)。跑了好几年,稳定但啰嗦。
Swift 5.5 已经发布挺久了,趁重构机会把整个持久层换成 actor。结果遇到了三个让我愣了很久的坑,在这里一一拆解。
现象
迁移刚开始编译就爆了一堆错误,修完编译再跑,发现 init 里的数据没有正确加载;最后在一次"优化"中用了 nonisolated,数据竞争悄悄回来了——Instruments Thread Sanitizer 报了 race,而我以为 actor 已经帮我兜底了。
根因
坑 1:把 init 写成 async,然后陷入调用地狱
最初我想在 init 里做文件读取,文件 I/O 可能慢,于是自然地写成了 async init:
// ❌ 看起来合理,实际上是麻烦的开始
actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
private var cache: [String: T]
private let fileURL: URL
init(directory: URL, filename: String) async {
self.fileURL = directory.appendingPathComponent(filename)
self.cache = await Self.loadAsync(from: fileURL) // 编译报错:self 还未初始化
}
}编译器直接拒绝:async init 里不能在所有属性初始化完成之前 await——Swift 要求 init 必须先把所有存储属性赋值,再才能进入 actor 的隔离上下文。你不能在"对象还没完全存在"的阶段就开始挂起它。
而且,即使绕过了这条规则,async init 还会传染给所有创建点:每次 let repo = LocalRepository(...) 都必须 await,整个调用链 async 传染,DI 容器、SwiftUI @StateObject 都开始报错。
Swift 规定存储属性必须在 init 第一阶段全部赋值完毕,async init 打破了这个假设。即使语法上能编译过,await LocalRepository() 会向上传染每一个创建点,SwiftUI 的 @StateObject 和普通属性声明都无法兼容。
正解:init 里做同步文件读取,用 try? Data(contentsOf:) 直接阻塞式加载。本地小文件(几 KB 到几百 KB)的同步读取耗时可忽略不计;真正需要延迟加载的大文件,应该把加载逻辑拆成一个单独的 async func load() 在外部显式调用:
actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
private var cache: [String: T]
private let fileURL: URL
init(directory: URL = .documentsDirectory, filename: String = "data.json") {
self.fileURL = directory.appendingPathComponent(filename)
// 同步加载,init 阶段 actor 隔离尚未激活,可以直接调用 static 方法
self.cache = Self.loadSynchronously(from: fileURL)
}
private static func loadSynchronously(from url: URL) -> [String: T] {
guard let data = try? Data(contentsOf: url),
let items = try? JSONDecoder().decode([T].self, from: data) else {
return [:]
}
return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
}
}坑 2:忘记 actor 方法调用自动变 await,导致存量代码大面积报错
原来 class 版的读取:
// 旧代码,同步调用
let item = repository.find(by: id)换成 actor 之后编译器立刻报错:expression is 'async' but is not marked with 'await'。actor 对外部调用者来说,所有方法默认都是异步的——哪怕方法体内部没有任何 await,跨 actor 边界就必须挂起。
这条规则本身不是坑,但存量代码改造量很大。更危险的是下一个坑。
坑 3:用 nonisolated 绕过 actor 隔离,数据竞争悄悄回来了
改存量代码改到头皮发麻,我在某个"只读"方法上加了 nonisolated,想跳过 await:
// ❌ 危险操作
nonisolated func find(by id: String) -> T? {
cache[id] // 编译器会拒绝这行:访问了 actor 隔离属性
}编译器其实会拒绝这种写法——nonisolated 方法不能访问 actor 的隔离存储属性,所以这行代码本身编译不过。但如果你在 nonisolated 方法里操作的是外部传入的参数或 nonisolated 属性(比如 let 常量),编译器就不报错了,而你以为"都在 actor 里,应该是安全的"——其实 nonisolated 方法可以被任何线程并发调用,不受 actor 串行执行的保护。
nonisolated 的语义是"这个方法不需要 actor 的保护"。它可以被任意线程同时调用,与 actor 队列完全解耦。用它来"省掉 await"是在主动撕掉 actor 提供的安全保证,数据竞争会悄无声息地回来,且 Instruments 不一定能在第一时间抓到。
不要试图绕过 await。actor 的代价是调用方需要 async 上下文,但换来的是编译器级别的线程安全——比运行时 Lock 和 TSAN 报警都更早暴露问题。把 nonisolated 只用于真正不接触共享状态的纯函数(比如格式化、计算属性派生值)。
// 正确姿势:所有公开 API 保持 actor 隔离
public func find(by id: String) -> T? {
cache[id] // actor 内部调用,安全
}
// 调用方
let item = await repository.find(by: id) // ✅ 明确异步边界一句话外卖
Swift actor 的线程安全是编译器强制的,
async init和nonisolated是两扇通向老问题的后门——前者传染调用链,后者悄悄撤销保护,都不要碰。