无状态 Actors
摘要
对 Swift 中无状态 Actors 的技术探讨,讨论其用途、权衡以及与使用并发函数的结构体的比较,包括网络客户端和后台 Actors 等示例。
暂无内容
查看缓存全文
缓存时间: 2026/05/30 19:28
# 无状态 Actor
来源:https://www.massicotte.org/stateless-actors/
2026年5月29日
最近有人问了我一个有趣的问题:如果 Actor 的目的是保护可变状态,那么无状态的 Actor 是否毫无意义?
起初我觉得答案很简单。Actor 的存在是为了在状态周围定义一个小小的保护气泡。它们将数据与任何不安全访问"隔离"开来。一个无事可隔离的 Actor 听起来很奇怪。
这样的安排能发挥作用吗?
**注**
我写了另一篇关于 [Actor](https://www.massicotte.org/actors) 的文章,可能也值得一读。
## 轻松的非 MainActor 类型
我时不时会遇到一种"NetworkClient"风格的类型。它包含处理某些网络 API 的方法。这类类型常常被设计为 Actor。
```
actor NetworkClient {
func loadCart() async throws -> [Product] {
let (data, _) = try await URLSession.shared.data(for: cartRequest)
return try JSONDecoder().decode([Product].self, from: data)
}
}
```
这个特定的 `NetworkClient` 是一个 Actor,没有状态。但作为 Actor 给它带来了两个优势。首先,Actor 类型是 `Sendable` 的。这意味着我们可以轻松地传递这个类型,而无需过多思考。
其次,这个 `loadCart` 方法*绝不*会在主线程上运行。默认的 Actor 类型会在共享线程池上执行它们同步的工作。这里的 JSON 解码可能会很耗时。确保这项工作永远不会发生在主线程上是很棒的。
可能有点牵强,但第三个可信的理由是,这个类型可能只是**尚未**拥有状态。我们最终可能会添加缓存或认证,而这些都需要一个地方来存放。
(有人指出,提前预测状态可能存放在哪里本身可能是有问题的。我大致同意,所以在这里要格外小心。)
我认为这是可以的,**前提**是你是刻意这样做的。只要你理解了权衡,那就去做。但这里确实存在权衡。最值得注意的是,Actor 类型很难甚至不可能与协议一起使用。而且,它们还要求**同时**确保方法的输入和输出可以安全地传入/传出 Actor。它们会迫使你需要更多 `Sendable` 类型。
现在,对比一下:
```
struct NetworkClient: Sendable {
@concurrent
func loadCart() async throws -> [Product] {
let (data, _) = try await URLSession.shared.data(for: cartRequest)
return try JSONDecoder().decode([Product].self, from: data)
}
}
```
这个类型有一些优点。首先,它更容易与协议一起使用,因为你不需要处理隔离不匹配的问题。
第二个问题是一个人为的限制。Actor 串行地执行同步代码块。这意味着无论你向这个 `NetworkClient` Actor 抛出多少个任务,它一次只能解码一个 JSON 响应。而使用 `@concurrent` 函数,我们不再有这个限制。
我绝对不是说结构体在这里更好。但这里存在严重的权衡,值得思考。
**注**
查看这篇[文章](https://www.massicotte.org/synchronous-work)以获取更多关于如何管理耗时工作的信息。
## 后台 Actor
这是一个有趣的类型!
```
@globalActor
actor BackgroundActor {
static let shared = BackgroundActor()
}
```
然后,你可以在所有支持全局 Actor 注解的地方使用它。
```
Task { @BackgroundActor in
// 肯定不再是 @MainActor
}
```
我理解。我们习惯了看到单一的全局 `@MainActor`。这给了我们一种熟悉的方式来定义非主线程的工作。但这样的类型有两个严重的缺点。
就像我们上面的 `NetworkClient` Actor 一样,这个 `BackgroundActor` 串行地执行同步工作。它一次只能运行一个后台任务。对于这样的工具来说,这不是一个理想的特性。
另一个问题是全局 Actor 与类型系统的集成非常紧密。当你添加一个全局 Actor 时,你是在强制编译器保证工作在该 Actor 上执行。这可能会产生病毒式传播的效果,这是很多人在使用 `MainActor` 时注意到的。反过来也可能如此。如果你后来改变主意,**移除**一个全局 Actor 也可能很痛苦。
我理解这些动机。但我真心认为,花点时间学习语言中现有用于控制隔离的构造会对你有最大的帮助。无论如何,我也不确定这是否真的可以省略,所以这似乎是一项不错的投资。
## 自定义执行器的 Actor
我不知怎么忘记了这一点,但多亏了 [Gwendal](https://hachyderm.io/@groue) 没让我蒙混过关!
这不是你每天都会用到的东西,但那些纯粹为了将 Swift 的并发系统与另一个已有系统适配而存在的 Actor 非常重要。这是[自定义执行器](https://developer.apple.com/documentation/swift/serialexecutor)的主要用例之一。
这是一个强大的工具,并且与 Dispatch Queue 一起使用时出奇地简单。我见过它被用来更好地与 [AVFoundation](https://developer.apple.com/documentation/avfoundation) 集成。但这种方法也可能适用于任何其他基于队列的系统。
请允许我从[迁移指南](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/incrementaladoption#Integrating-DispatchSerialQueue-with-Actors)中摘取一个例子:
```
actor LandingSite {
private let queue = DispatchSerialQueue(label: "something")
nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
func acceptTransport(_ transport: PersonalTransportation) {
// 这个函数将在 queue 上运行
}
}
```
另一个例子——一个我相当尴尬自己没想起来的例子——就是 `MainActor`!这个 Actor 没有直接的**属性**,但它确实管理着状态——整个 UI。所以它有点介于两者之间,但考虑起来仍然非常有趣。
## 文件系统
好吧,这个很有趣。文件系统**绝对**是一种状态形式。但它是在程序之外的状态,编译器完全不可见。在这种情况下,我认为"无状态"的 Actor 是有意义的。状态不一定非得是实例属性。
假设你有某种磁盘上的缓存,被系统中许多不同的部分使用。并发访问可能会破坏涉及的文件/目录。Actor 提供的串行化给了你一种防止这种情况的方法。这不是理想的方案,因为编译器无法检查你的工作。要正确实现,确实需要手动封装所有内容,但无论如何你可能都想这样做。
这里可能出现的一个问题是阻塞操作。当你读取/写入磁盘时,你是在同步执行。这意味着这个 Actor 会占用并发运行时的一个线程。这些线程是**有限**的资源,大概等于每个优先级级别的 CPU 核心数。这与 GCD 截然不同,GCD 在没有可用线程时会愉快地创建大量(虽然最终仍然有限)的线程。
我的观点是,通常你不需要担心这个问题。当然,一个线程被占用了。但它被占用**做什么**通常不是什么重要的细节。只要工作满足运行时对"向前推进"的要求,你应该没问题。
然而,如果你担心(或者确实知道)这会有问题,将你的阻塞工作移出并发线程池是有意义的,而且通常很容易做到。GCD 仍然可用,你不应该害怕使用它。
(我忘了 [Jaim](https://bsky.app/profile/jaimzuber.com) 也[写过](https://jaimzuber.com/swift-concurrency/empty-global-actors)关于这个的内容,并且讲得相当详细。值得一看。)
## Actor 的第一条规则
Actor 有一种被过度使用的倾向。它们是非常有用的工具,我更喜欢它们而不是锁或队列。但就像任何同步原语一样,你应该能够清晰地说明为什么它是必要的。这就是 Actor 的第一条规则。
我认为**总的来说**,是的。一个没有状态的 Actor 是奇怪的东西。它可能代表一种误解。它可能让设计变得更复杂。但我认为它们肯定也可以是有意义的。
你知道我为并发和 Swift 6 迁移提供咨询服务吗?如果你觉得我能帮上忙,请[联系我](https://www.massicotte.org/consulting)。
相似文章
大家是如何处理长时间运行的代理的状态的?无状态沙盒正在吞噬我的夜晚
一位开发者讨论了在使用沙盒环境下的长时间运行编码代理中状态持久化的挑战,详细说明了高昂的恢复开销,并寻求社区解决方案来实现无需自定义检查点层的持久状态处理。
@TeachTheMachine: 状态化与无状态化智能体设计:可扩展智能体系统的权衡
本文探讨了状态化与无状态化智能体设计在可扩展人工智能系统中的权衡,并提供了使用 Groq API 和 Llama 3.1 8B Instant 模型的实现示例。
@_akhaliq: StateAct——在像素之前使用程序状态,用于长时域计算机使用代理 论文: https://huggingface.co/papers/2607.2…
StateAct 提出了一种代码优先的多代理系统,用于长时域计算机使用代理,该系统直接操作程序状态而非截图,在 OSWorld2.0 上实现了更高的成功率和更低的成本。
Show HN: 我用Swift构建了LangGraph
Swarm是一个用于构建代理工作流和多智能体系统的Swift框架,具备类型安全的工具调用、持久化检查点以及对多种LLM提供商的支持。
记住,不要重读:用于令牌高效自主实验的有状态ReAct智能体
本文提出使用LangGraph中的有状态ReAct智能体取代无状态自动研究模式,将每次迭代的令牌成本从O(n)降低至O(1),在超参数调优和代码优化基准测试中实现了52-90%的令牌减少。