在Swift并发编程里,引用类型是最容易被忽视的竞争来源。值类型由于拷贝语义,跨任务传递时通常天然安全;但一个class实例的引用被多个任务同时持有时,任何对内部var属性的修改都可能产生不确定结果。Swift 5.5之后引入的Sendable标记和Actor执行体,正是针对这类问题的两层防线。前者告诉编译器哪些类型可以安全地跨越并发域,后者创建了一个串行化的状态容器。理解二者如何作用于引用类型,可以帮助在实际工程中把数据竞争挡在编译期。

Sendable协议如何约束引用类型
Sendable协议本身没有方法要求,它是一个标记协议,表示该类型的实例可以被安全地从一个并发域传递到另一个并发域。对于枚举和结构体,只要所有存储属性都是Sendable,编译器可以自动推导;但引用类型想要遵循Sendable,条件要严格得多。编译器允许class遵循Sendable的前提是:它必须是final class,并且所有存储属性都是不可变的let常量,而且这些属性的类型本身也必须是Sendable。这样设计的原因是,只有不可变引用类型才能保证多个任务同时读取同一实例时不会发生写入竞争。
例如下面这个UserProfile类就可以安全地标记为Sendable,因为它没有可变状态,任何任务拿到引用后只能读取,不能修改:
final class UserProfile: Sendable {
let userID: Int
let nickname: String
init(userID: Int, nickname: String) {
self.userID = userID
self.nickname = nickname
}
}
如果去掉final声明,或者把某个let改成var,编译器会立即报告错误,提示该class不符合Sendable要求。这种编译期检查非常有用,它防止了团队在后续维护中不小心给本应不可变的对象添加可变状态。需要注意的是,编译器对NSObject子类的检查并不完全可靠,如果一定要跨线程传递可变OC对象,就需要谨慎使用@unchecked Sendable,但这意味着开发者自己承担全部线程安全责任,应当尽量避免。
还有一类情况是引用类型内部虽然可变,但外部访问全部通过锁或串行队列保护。此时理论上它是线程安全的,但编译器无法自动证明,只能通过@unchecked Sendable手动承诺。不过这种写法绕过了编译器的安全网,一旦锁的粒度或使用方式出错,数据竞争仍然会发生。因此对于大多数业务模型,更好的做法是让类本身保持不可变,把可变状态交给Actor管理。
Actor如何为引用类型建立隔离边界
Actor是Swift并发模型中的引用类型,但它与普通class最大的区别在于:Actor内部的状态在运行时被串行访问。每个Actor实例拥有独立的执行上下文,对它的所有方法调用都必须通过异步机制进入,编译器会强制使用await。这样一来,即使Actor内部存储了一个可变的class实例,外部任务也不能直接触碰那个class的属性,只能调用Actor暴露的方法,由Actor在自己的串行上下文中完成读取或修改。
下面这个例子中,SessionManager是一个Actor,它内部持有一个可变引用类型SessionState。外部并发任务不能直接修改state,只能通过updateLastAccess这类Actor方法操作,从而避免了多线程同时写入:
final class SessionState {
var lastAccessTime: Date
var activeCount: Int
init(lastAccessTime: Date, activeCount: Int) {
self.lastAccessTime = lastAccessTime
self.activeCount = activeCount
}
}
actor SessionManager {
private let state: SessionState
init(initialState: SessionState) {
self.state = initialState
}
func recordAccess() {
state.lastAccessTime = Date()
state.activeCount += 1
}
func currentState() -> SessionState {
return state
}
}
注意currentState返回SessionState是一个类引用,这会把内部可变引用暴露给外部。如果调用方拿到引用后在其他线程修改它,隔离就被破坏了。因此更安全的做法是让SessionState遵循Sendable且不可变,或者让Actor返回一份值类型快照。这个细节说明,Actor的隔离边界并不是自动延伸到其内部持有的引用类型上的,必须配合Sendable设计才能形成完整防线。
另一个容易混淆的点是,Actor方法内部可以同步访问自己的状态,但外部调用必须await。这类似于把每个Actor实例看成一个串行队列,所有方法调用都排队执行。虽然它避免了低层次锁的显式使用,但也要注意不要在Actor方法中执行长耗时同步操作,否则会阻塞该Actor的串行上下文,影响后续任务排队。
Sendable类与Actor的组合策略
在实际架构中,把Sendable类和Actor结合起来,可以同时获得不可变数据的自由传递能力和可变状态的集中管理能力。一种常见模式是:用Sendable值类型或不可变引用类型作为跨任务传输的数据包,用Actor作为唯一可变状态的拥有者。例如,网络层先请求到一个DTO,这个DTO是Sendable的,可以安全地从后台任务传回主Actor;而写入缓存、更新列表等可变操作则全部交给一个DataStore Actor完成。
下面的代码展示了一个简单组合:FeedItem是不可变Sendable类,FeedStore Actor负责持有并修改数组。外部任务可以安全地传入FeedItem实例,但无法直接改动存储内容:
final class FeedItem: Sendable {
let id: UUID
let title: String
init(id: UUID, title: String) {
self.id = id
self.title = title
}
}
actor FeedStore {
private var items: [FeedItem] = []
func insert(_ item: FeedItem) {
items.append(item)
}
func allItems() -> [FeedItem] {
return items
}
}
FeedItem虽然是class,但因为final且只有let属性,它满足Sendable,可以被安全地传入Actor。而FeedStore内部的数组虽然可变,却被Actor隔离,外部无法直接操作。这样即使多个任务同时向同一个FeedStore插入数据,实际写入操作也会被串行化,不会出现数组底层存储被并发修改的问题。
另一种组合方式是把Actor方法中的参数和返回值都约束为Sendable。Swift编译器会检查跨Actor调用时传递的类型是否满足Sendable,如果不满足会直接报错。这个检查在Swift 6模式下会变得更严格,Swift 5语言模式下很多场景只是警告,但建议从现在开始就把公开API设计为Sendable友好的。特别是涉及闭包时,例如Task.detached中捕获的引用类型,如果捕获了非Sendable的class实例,就可能导致隐性竞争。
还需要注意@preconcurrency import和nonisolated等语法对兼容性的影响。例如某些系统框架的类在旧SDK中未标记Sendable,升级并发检查后可能产生大量警告。此时不应立刻大面积添加@unchecked Sendable,而应分析每个类型是否真的不可变或是否有Actor保护。只有通过组合策略把可变状态集中在少数Actor里,把跨任务传递的数据尽量设计成不可变Sendable类或值类型,才能让编译器检查真正发挥作用,而不是靠禁用并发检查来消除报错。
总体来看,Swift对引用类型并发安全的处理思路很清晰:Sendable负责回答类型能不能安全传递,Actor负责回答可变状态在哪里被修改。单靠其中任何一个都不完整,只有将不可变引用类型与Actor隔离结合起来,才能在保留class引用语义的同时有效预防数据竞争。