2 minute read

Swift Concurrency 里,actor 很容易和 @MainActor 被混成一件事。表面上看,它们都在处理“并发访问”问题;但如果把两者等同起来,后面几乎一定会写出边界模糊的代码。

我现在更愿意把它们拆成两句话来记:

  • actor 解决的是可变状态的隔离问题
  • @MainActor 解决的是某段代码必须受主执行器串行保护的问题

这两个概念有关,但不是一回事。把这层关系理顺之后,很多 Swift 并发代码会顺很多:什么时候该用 actor,什么时候该用 @MainActor,什么时候两者都不用,判断会清楚很多。

先别急着谈主线程,先看 actor 到底解决了什么

actor 被引入,不是为了替代 DispatchQueue.main.async,也不是为了让异步代码“自动更安全”。它更核心的目标,其实是避免共享可变状态在并发环境下被同时读写。

最典型的问题是这种代码:

final class Counter {
    var value = 0

    func increment() {
        value += 1
    }
}

如果多个任务同时调用 increment()value += 1 这类读-改-写操作就可能发生数据竞争。问题不在于这段代码写得短,而在于它默认允许多个执行上下文同时碰同一份可变状态。

把它改成 actor 之后,语义就变了:

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

这时 value 不再是“谁都可以直接摸”的共享状态,而是 actor 隔离的内部状态。外部要访问它,必须通过 actor 的隔离边界。也正因为这个边界存在,跨 actor 调用通常需要 await

let counter = Counter()

await counter.increment()
let value = await counter.currentValue()

这里的重点不是“加了 await”,而是 Swift 把“访问这份状态”这件事变成了一次受控制的跨隔离域调用。你不能再像访问普通对象一样,随手在任意线程、任意任务里直接读写它的内部可变数据。

这其实就是 actor 最重要的价值:不是帮你切线程,而是帮你把“谁有权修改状态”这件事变成编译器可检查的规则。

actor 保证没有数据竞争,但不保证没有业务层面的竞争

理解 actor 时,另一个很容易踩的坑是:它确实能避免数据竞争,但它不等于“从此一切并发问题都消失”。

原因在于 actor 是可重入的。也就是说,一个 actor 方法在执行过程中如果遇到 await 挂起,actor 可以去处理别的消息,之后再回来继续执行原来的逻辑。

这意味着下面这种代码虽然没有底层数据竞争,但仍然可能出现业务上的时序问题:

actor ImageCache {
    private var storage: [URL: Data] = [:]

    func data(for url: URL) async throws -> Data {
        if let cached = storage[url] {
            return cached
        }

        let downloaded = try await download(from: url)
        storage[url] = downloaded
        return downloaded
    }
}

看起来没问题,但如果两个任务几乎同时请求同一个 url,它们都可能在第一次检查缓存时发现“还没有”,然后分别发起下载。这里不会产生内存层面的竞争,因为 storage 仍然被 actor 隔离;但逻辑上会重复下载。

所以 actor 带来的不是“自动正确”,而是一个更强的基础前提:共享状态的访问顺序是受控的,剩下的业务一致性还得你自己设计。比如要不要去重请求、要不要在 actor 内维护 in-flight task、要不要做状态机,这些都还是建模问题,不是关键字能替你决定的。

这一点特别重要。很多人第一次用 actor,会下意识把它理解成“线程安全类”。这只说对了一半。更准确的说法是:actor 提供的是隔离模型,不是万用并发药。

@MainActor 说的是隔离域,不只是“主线程”

接着再看 @MainActor

很多文章会把它简单解释成“保证代码在主线程执行”。这个说法不算完全错,但不够精确。更准确一点,@MainActor 是一个全局 actor,它表示某段代码属于主 actor 隔离域,访问它需要经过主 actor。

看一个常见例子:

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var username: String = ""

    func updateName(_ newName: String) {
        username = newName
    }
}

这段代码的含义不是“这个类里每一行代码都神秘地绑定到了某条固定线程”,而是:这个类型及其隔离成员都受 MainActor 保护。谁想访问这些成员,就得满足主 actor 的隔离规则。

在 Swift Concurrency 的异步上下文里,这通常会表现为一次 hop 到主 actor 执行,因此很多时候看起来就像“自动切回主线程”:

func loadProfile() async {
    let name = await service.fetchName()
    await viewModel.updateName(name)
}

这里的 await viewModel.updateName(name),本质上不是普通意义上的 GCD dispatch,而是跨隔离域调用主 actor 隔离的方法。

这个区别看起来像术语问题,实际上很关键,因为它直接关系到一个常见误解:

@MainActor 并不等于“无论你从哪里调用,都一定立刻帮你切到主线程”。

如果调用点本身不在 Swift 并发模型正确感知的上下文里,比如某些旧式同步回调、遗留 API、手工线程切换场景,你不能把 @MainActor 当成一个无条件的运行时跳板。它首先是隔离规则,其次才在合适的异步上下文里表现为调度行为。

这也是为什么有些代码明明标了 @MainActor,但你仍然会遇到编译器警告,或者需要显式写出:

await MainActor.run {
    viewModel.updateName(name)
}

MainActor.run 的意义是:我现在明确要求这段闭包在主 actor 上执行。它和“给某个声明加 @MainActor”解决的问题并不完全一样。前者更像是一次显式 hop,后者是给 API 建立隔离契约。

什么时候用 actor,什么时候用 @MainActor

如果只讲定义,这两个概念不难;真正容易混乱的是工程里怎么落。

我现在更倾向于这样分:

如果一个类型的核心职责是持有并保护共享可变状态,比如缓存、令牌存储、下载去重器、会话状态、内存索引,这类对象更适合建模成 actor

如果一个类型的核心职责是驱动界面、维护 UI 可观察状态、响应用户交互,那它通常更适合标成 @MainActor。比如 ViewModel、部分 ObservableObject、只能在 UI 主隔离域里更新的状态容器。

一个很自然的组合是:

  • 底层状态和并发访问控制,交给 actor
  • 上层界面状态和渲染相关更新,交给 @MainActor

例如:

actor UserService {
    func fetchUser(id: String) async throws -> User {
        // 网络请求、缓存协调、并发去重
    }
}

@MainActor
final class UserViewModel: ObservableObject {
    @Published private(set) var user: User?
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func reload(id: String) async {
        do {
            user = try await service.fetchUser(id: id)
        } catch {
            user = nil
        }
    }
}

这个分层里,UserService 关心的是并发访问时的数据一致性,UserViewModel 关心的是界面状态更新必须落在主 actor。两者职责很清楚,也不需要把所有东西都粗暴地塞进 @MainActor

还有一个经常被忽略的点:不是所有成员都必须被隔离

Swift 在 actor 模型里还给了一个很实用的出口:nonisolated

当某个成员并不依赖 actor 的可变状态,或者某个协议要求同步访问,但实现本身不需要碰隔离数据时,可以把它标成 nonisolated,避免无意义的 await 和隔离限制。

这类设计的重点不是“少写几个关键字”,而是把 API 的并发语义写准确:哪些成员真正依赖隔离状态,哪些成员只是普通计算或常量暴露。隔离越精确,后续维护成本越低。

当然,前提也很明确:一旦用了 nonisolated,就不能再偷偷访问 actor 隔离的可变状态。这个边界如果写乱了,等于主动绕开模型给你的保护。

真正值得记住的结论

如果把整件事压缩成一句话,那就是:

actor 隔离的是状态,@MainActor 隔离的是执行上下文;它们都和并发有关,但解决的是不同层面的约束。

所以在 Swift 并发代码里,最好不要一上来就问“这里要不要切主线程”,而是先问两个更本质的问题:

  • 这段代码有没有共享可变状态需要隔离?
  • 这段状态变化是不是必须发生在主 actor?

第一个问题更接近 actor,第二个问题更接近 @MainActor

当你先按这个方式建模,再去写 await、写 MainActor.run、写 ViewModel 和 Service 的边界,代码会自然很多。反过来,如果一开始就把 actor 理解成“高级锁”,把 @MainActor 理解成“新版 DispatchQueue.main.async”,那后面大概率只是把旧问题换了一套新语法。

参考

  • SwiftLee: MainActor usage in Swift explained to dispatch to the main thread
  • SwiftLee: Actors in Swift: how to use and prevent data races
  • Swift Evolution: SE-0306 Actors
  • Swift Evolution: SE-0316 Global Actors