返回博客

用 Swift Actor 替换 DispatchQueue 做持久化,三个坑差点让我退回老路

前段时间在做一个 iOS 离线优先的数据层,原来的写法是老套路:一个 final class,内部持有 DispatchQueue(label: "...", attributes: .concurrent),读操作用 syncbarrier,写操作用 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 都开始报错。

坑:async init 传染调用链,Swift 的对象生命周期不允许 init 挂起

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 隔离,等于主动放弃线程安全

nonisolated 的语义是"这个方法不需要 actor 的保护"。它可以被任意线程同时调用,与 actor 队列完全解耦。用它来"省掉 await"是在主动撕掉 actor 提供的安全保证,数据竞争会悄无声息地回来,且 Instruments 不一定能在第一时间抓到。

正解:认同 actor 边界,所有读写统一走 await

不要试图绕过 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 initnonisolated 是两扇通向老问题的后门——前者传染调用链,后者悄悄撤销保护,都不要碰。