用 Swift Actor 保护可变状态
Highlight
Actor 将类的可变状态自动隔离在串行执行域中,外部访问必须通过
await。编译器会强制检查这条规则——忘记await代码直接编译失败。一个原本需要手动加锁的计数器,用 actor 只需把class改成actor,线程安全由语言和运行时保证。
核心内容
数据竞争(Data Race)是并发编程中最隐蔽的 bug 之一:两个线程同时读写同一块可变内存,结果不可预测,复现困难,调试更是噩梦。传统解决方案是用锁(NSLock)或串行派发队列(DispatchQueue)保护共享状态,但容易遗漏、难以测试、还有死锁风险。
Swift 5.5 引入的 actor 类型在语言层面解决了这个问题。Actor 是一种特殊的引用类型,它的所有可变状态都被隔离在 actor 内部。外部代码访问 actor 的属性或方法时,必须通过 await 显式标记这是一个潜在的挂起点。编译器强制检查这条规则,将数据竞争从运行时错误转变为编译时错误。
详细内容
数据竞争问题
一个简单的计数器,用 class 实现时存在数据竞争(00:42):
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(counter.increment()) // data race!
}
Task.detached {
print(counter.increment()) // data race!
}
两个 Task 同时调用 increment,value = value + 1 不是原子操作,结果不可预测。
值类型的局限
值类型(struct)天然没有数据竞争,因为每次赋值都是拷贝(02:20):
var array1 = [1, 2]
var array2 = array1
array1.append(3)
array2.append(4)
print(array1) // [1, 2, 3]
print(array2) // [1, 2, 4]
但值类型无法表达共享可变状态。把 Counter 改成 struct(02:59),每个 Task 拿到的是独立拷贝,结果永远是 1:
struct Counter {
var value = 0
mutating func increment() -> Int {
value = value + 1
return value
}
}
Task.detached {
var counter = counter
print(counter.increment()) // always prints 1
}
Actor 隔离
将 class 改为 actor(05:23),编译器自动保证串行访问:
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
Task.detached {
print(await counter.increment())
}
Task.detached {
print(await counter.increment())
}
Actor 内部的方法调用是同步的(07:51)。resetSlowly 方法内部多次调用 increment,不需要 await:
extension Counter {
func resetSlowly(to newValue: Int) {
value = 0
for _ in 0..<newValue {
increment() // 同步调用,无需 await
}
assert(value == newValue)
}
}
挂起后的状态假设
await 会挂起当前执行,期间 actor 状态可能被其他任务修改。ImageDownloader 的缓存逻辑有一个经典 bug(09:02):
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
// Bug: await 期间 cache 可能已被其他任务修改
cache[url] = image
return image
}
}
await downloadImage 期间,另一个任务可能已经完成同一 URL 的下载并写入了缓存。解决方案(11:59):用 inProgress 状态防止重复下载:
actor ImageDownloader {
private enum CacheEntry {
case inProgress(Task<Image, Error>)
case ready(Image)
}
private var cache: [URL: CacheEntry] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
switch cached {
case .ready(let image):
return image
case .inProgress(let task):
return try await task.value // 等待已有任务完成
}
}
let task = Task { try await downloadImage(from: url) }
cache[url] = .inProgress(task)
do {
let image = try await task.value
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil
throw error
}
}
}
协议遵循与 Actor 隔离
静态方法天然在 actor 外部执行(13:30):
actor LibraryAccount {
let idNumber: Int
var booksOnLoan: [Book] = []
}
extension LibraryAccount: Equatable {
static func ==(lhs: LibraryAccount, rhs: LibraryAccount) -> Bool {
lhs.idNumber == rhs.idNumber // 静态方法,无需 await
}
}
实例方法可以用 nonisolated 标记为在 actor 外部执行(14:15),但只能访问 let 常量:
extension LibraryAccount: Hashable {
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(idNumber) // idNumber 是 let,安全
}
}
跨 Actor 传递数据
Actor 之间传递数据时,值类型(struct)是安全的,因为传递的是拷贝(17:15):
struct Book {
var title: String
var authors: [Author]
}
func visit(_ account: LibraryAccount) async {
guard var book = await account.selectRandomBook() else { return }
book.title = "\(book.title)!!!" // OK: 修改的是本地拷贝
}
引用类型(class)则存在风险(17:39)。从 actor 取出的 class 实例,外部修改会影响 actor 内部状态:
class Book {
var title: String
}
func visit(_ account: LibraryAccount) async {
guard var book = await account.selectRandomBook() else { return }
book.title = "\(book.title)!!!" // Not OK: 修改的是共享引用
}
Sendable 协议
Sendable 协议标记可以安全跨并发域传递的类型(20:08)。struct 自动满足 Sendable(如果所有属性都满足):
struct Book: Sendable {
var title: String
var authors: [Author]
}
泛型类型可以用条件遵循(20:43):
struct Pair<T, U> {
var first: T
var second: U
}
extension Pair: Sendable where T: Sendable, U: Sendable { }
Main Actor
UIKit 要求所有 UI 操作在主线程执行。传统做法是用 DispatchQueue.main.async(24:19):
func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
DispatchQueue.main.async {
checkedOut(booksOnLoan)
}
@MainActor 让编译器保证函数始终在主线执行(25:01):
@MainActor func checkedOut(_ booksOnLoan: [Book]) {
booksView.checkedOutBooks = booksOnLoan
}
await checkedOut(booksOnLoan) // Swift 确保在主线程执行
类也可以标记为 @MainActor(26:21),其所有方法隐式在主线程执行:
@MainActor class MyViewController: UIViewController {
func onPress(...) { ... } // 隐式 @MainActor
nonisolated func fetchLatestAndDisplay() async { ... } // 显式脱离主线程
}
核心启发
-
用 actor 替代手动锁:新项目直接用 actor 替代
NSLock和串行 DispatchQueue。老项目迁移时,先把最外层的状态管理对象改为 actor。 -
警惕
await后的状态变化:在 actor 方法中,await挂起后重新执行时,actor 状态可能已被修改。不要假设await前的判断仍然成立。 -
用
inProgress模式防止重复工作:对于耗时操作(如网络下载),用枚举状态标记”进行中”,让后续请求等待同一任务完成,而不是发起重复请求。 -
优先使用值类型跨 actor 传递数据:struct 的拷贝语义天然安全。如果必须用 class,确保其实现
Sendable或不会在 actor 外部被修改。 -
用
@MainActor替代DispatchQueue.main.async:@MainActor在编译期保证线程安全,比运行时的派发队列更可靠。ViewController、ViewModel 等与 UI 相关的类型可以直接标记为@MainActor。
关联 Session
- WWDC2021 10132 - Swift 中的 async/await — async/await 基础,理解 actor 的前提
- WWDC2021 10134 - 探索 Swift 结构化并发 — Task、TaskGroup 与 actor 的配合使用
- WWDC2021 10019 - SwiftUI 中的并发 —
@MainActor在 SwiftUI 中的实践 - WWDC2021 10216 - Swift 中的 ARC — 引用类型与并发安全的底层机制
评论
GitHub Issues · utterances