返回文章列表

Swift Concurrency 系列 05|Actor 是什么?它和传统线程安全写法有什么区别?

Actor 不是“Swift 版锁”,它真正改变的是共享状态该如何被组织

很多人第一次听到 Actor,都会下意识把它理解成“Swift 提供的一种线程安全工具”。

这个理解不完全错,但如果只停在这里,后面很容易写出“用了 Actor,结构还是乱”的代码。

因为 Actor 最重要的地方,不是又给你多了一把新锁,而是它改变了一个默认思路:

共享可变状态不应该先暴露给所有人,再想办法保护;更合理的做法是先把它隔离起来,再决定外部如何访问。

这听起来像是措辞上的区别,实际上是并发设计习惯上的分水岭。

一、并发里最危险的东西,往往不是异步调用本身,而是共享可变状态

多数并发 bug 的起点都不是“发了一个异步请求”,而是:

  • 两个任务同时读写同一份缓存
  • 一个任务还没写完,另一个任务已经基于旧值做了判断
  • 多个页面同时修改一份全局状态
  • 某个服务既负责刷新 token,又负责消费 token,还负责把结果广播出去

这些问题看上去形式很多,但根源常常是一样的:
共享可变状态没有明确边界。

而一旦状态边界不清,任何任务调度顺序变化都可能把 bug 放大出来。

二、传统线程安全方案的问题,不是不够强,而是不够集中

过去我们常用的方案有:

  • NSLock
  • 串行队列
  • 读写锁
  • “这个对象只能在某个 queue 上访问”的团队约定

这些方案都不是错,今天很多成熟系统仍然在稳定使用。
它们真正的问题不是机制弱,而是:

访问规则容易散。

一开始你可能还能记得:

  • 这个缓存必须加锁访问
  • 那个字典只能在串行队列里改
  • 某个状态只能在主线程读写

但随着调用点越来越多,这些规则就会慢慢失真。
并发 bug 最烦人的地方,往往不是你没有工具,而是你再也没法保证全团队一直严格遵守那些零散规则。

三、Actor 的核心思路不是“保护共享状态”,而是“先减少共享”

这是最值得反复强调的一点。

传统思路更像:

  1. 先有一份共享状态
  2. 再想办法通过锁、队列、约定把它保护起来

Actor 的思路则更接近:

  1. 这份状态本来就不应该被任意访问
  2. 我先把它放进隔离边界
  3. 外部只能通过受控接口和它交互

这带来的最大变化是:你开始被迫思考状态归属,而不是默认所有人都能直接摸到它。

例如一个 token 刷新协调器,如果只是一个全局对象加一堆锁,你的默认心智是“大家都在用这份状态,我得保护它”。
如果它是 Actor,你的默认心智会变成“这份状态归它管理,别人只能跟它要结果或发命令”。

这就是设计方式的变化。

四、为什么这种隔离式思路更适合复杂项目

因为复杂项目最怕的不是单个点写错,而是规则扩散。

一旦状态访问被 Actor 收口,很多问题会更早暴露:

  • 这份数据到底归谁管
  • 外部需要的是快照、派生值,还是某个命令式操作
  • 哪些操作必须串行语义,哪些只是只读查询

这些问题在旧模式里常常会被拖到很后面,因为大家默认“先共享,有问题再加锁”。
而 Actor 逼你更早做边界设计。

这也是为什么我更愿意把 Actor 看成并发设计工具,而不只是线程安全工具。

五、Actor 最适合放在哪里

不是所有对象都值得变成 Actor。
它更适合那些同时满足下面几个特征的角色:

  • 有共享可变状态
  • 会被多个任务并发访问
  • 一致性很重要
  • 访问方式需要被集中协调

典型例子包括:

  • 图片缓存协调器
  • Token 刷新协调器
  • 下载任务注册中心
  • 某类全局资源访问器
  • 需要串行消费事件的消息中心

这些对象的共性是:它们不是页面局部状态,而是跨任务共享、又很容易出并发问题的状态中心。

如果只是一个纯函数 service,或者一个只在单页面内部短暂使用的状态对象,未必需要 Actor。

六、Actor 和“给整个类都加个锁”到底有什么本质差别

表面看,它们都能做到“多个任务别把状态改坏”。
但工程体验差别很大。

给整个类加锁时,常见问题是:

  • 调用方还是能拿到很多内部细节
  • 锁的粒度容易失控
  • 某些方法里又去调用别的同步资源,形成复杂嵌套
  • 团队成员仍然可能绕开规则,直接访问本不该访问的状态

而 Actor 至少在语义层明确表达了:

  • 这份状态不是自由共享的
  • 跨隔离访问需要显式经过异步边界
  • 访问这份状态本身就是一种受约束的行为

也就是说,Actor 不只是“帮你加了保护”,而是让不安全的访问方式更难被随手写出来。

七、Actor 解决不了什么

这点必须讲清楚。
很多人学到 Actor 后,会产生一种错觉:以后线程安全都交给它了。

实际上 Actor 只解决一类问题:隔离共享状态

它解决不了:

  • 业务时序本身是否合理
  • 页面离开后旧任务是否该继续
  • 结果是否已经过期
  • 你的状态边界是不是本来就划错了

举个例子,假设你把搜索结果缓存做成了 Actor,但页面仍然允许旧请求覆盖新关键字结果,那 bug 一样还在。
因为这里的问题不是共享状态被并发写坏,而是“结果有效性”本来就没被建模。

所以 Actor 很重要,但它不是并发万灵药。

八、Actor 最容易被误用的两个方向

1. 什么都想塞进 Actor

一旦把 Actor 当成“只要用了就更安全”的标签,你就会开始过度包装:

  • 明明只在单线程上下文里用的状态,也包成 Actor
  • 明明更适合做纯函数处理的逻辑,也强行塞进 Actor
  • 最后整个系统到处都是异步访问边界,结构反而变重

Actor 不是越多越好,关键是放在真正需要隔离共享状态的地方。

2. 把 Actor 当“安全证明”

有些代码一加上 Actor,团队就会默认这块“已经线程安全了”。
但很多 bug 根本不是来自数据竞争,而是来自:

  • 错误的生命周期设计
  • 错误的状态流
  • 错误的任务关系

Actor 能保证“别乱并发摸我内部状态”,但不能保证“你的业务流程是对的”。

九、一个更实用的判断问题

如果你在犹豫某个对象要不要设计成 Actor,我建议先别问“它会不会线程不安全”,而是先问:

  1. 它内部是不是有重要的共享可变状态?
  2. 这个状态会不会被多个任务同时访问?
  3. 我是否希望外部只能通过受控接口和它交互?
  4. 如果不加隔离边界,团队以后是否很容易随手乱用它?

如果这些问题大部分答案都是“是”,那它就很可能适合 Actor。

十、结论:Actor 的真正价值,是把共享可变状态从默认暴露变成默认隔离

如果只用一句话总结,我会说:

Actor 真正改变的,不是“你现在多了一种锁”,而是“共享可变状态终于不再默认暴露给所有人”。

传统思路更像是:先共享,再补保护。
Actor 的思路则是:先隔离,再决定如何访问。

而在复杂并发系统里,这种思路变化往往比“多一种线程安全工具”更重要。