Actor 与有状态服务
Task 适合无状态计算:函数执行完,结果进入对象存储,函数本身不保留任何状态。但有些场景需要一个长期存活的对象来维护状态,比如推理服务加载一次模型,后续请求复用;参数服务器持续更新权重;游戏环境需要持续推进。
这种场景用 Task 不太自然,Actor 就是 Ray 对这个问题的解答。
Actor 的基本用法
python
@ray.remote
class Counter:
def __init__(self):
self.value = 0
def incr(self):
self.value += 1
return self.value
counter = Counter.remote()
refs = [counter.incr.remote() for _ in range(3)]
print(ray.get(refs)) # [1, 2, 3]Counter.remote() 创建远程实例。counter.incr.remote() 提交 Actor 方法调用。
Actor 的执行模型
默认情况下,同一个 Actor 的方法调用按顺序执行。可以把 Actor 看成一个带状态的 Worker 加一个消息队列。
这种模型牺牲了单个 Actor 内部的并行性,换来状态一致性。
Actor 适合什么
| 场景 | 为什么用 Actor |
|---|---|
| 模型推理服务 | 模型加载一次,多个请求复用 |
| 参数服务器 | 状态持续更新 |
| 数据库/外部服务连接 | 连接对象不可序列化,放在 Actor 内 |
| 强化学习环境 | 环境状态需要持续推进 |
| 缓存或计数器 | 状态由 Actor 串行维护 |
Actor 和 Object Store 的区别
ray.put 更适合不可变大对象:
python
weights_ref = ray.put(weights)Actor 更适合可变状态:
python
model_server = ModelServer.remote(weights_path)
prediction_ref = model_server.predict.remote(batch)如果每次更新都生成新对象,可以用 Object Store。如果对象要被原地维护,并且方法调用需要顺序语义,用 Actor。
Actor 的常见问题
Actor 很方便,但也容易变成瓶颈。最常见的问题是“所有请求都打到一个 Actor”。Actor 方法默认串行执行,队列一长,延迟就上去了。
其他常见问题:
- Actor 方法里做了长时间阻塞(比如调外部 API),后续方法全部排队等待。
- Actor 持有大对象,重启代价高。
- 忘记声明 GPU 资源,Actor 被调度到没有 GPU 的节点。
解决思路:
- 用多个 Actor 做分片,分散请求。
- 读多写少的状态拆出来用
ray.put。 - 用并发 Actor(
max_concurrency)或 async Actor。 - 显式声明资源,确保调度到正确的节点。
Supervisor 模式
工程里常用一个 Actor 管理多个子 Actor:
Supervisor 可以负责:
- 路由请求。
- 重启失败子 Actor。
- 聚合状态。
- 控制并发。
Mini Ray 的 Actor 实现
Mini Ray 用一个独立线程承载 Actor 实例:
RemoteActorClass.remote()调用Runtime.create_actor()。- 运行时创建
queue.Queue()作为 Actor mailbox。 - Actor 线程初始化类实例。
- 每个方法调用放入队列。
- Actor 线程逐个取出消息并执行。
- 方法返回值写入
ObjectStore。
Mini Ray 的实现虽然简化了(用线程代替进程,用字典代替共享内存),但保留了 Actor 的核心设计:一个长期存活的执行单元,通过消息队列串行处理方法调用。
小练习
把 Counter 改造成 KVStore:
python
@ray.remote
class KVStore:
def __init__(self):
self.data = {}
def put(self, key, value):
self.data[key] = value
def get(self, key):
return self.data.get(key)再思考:如果有 100 万个 key,所有请求都打到一个 Actor 会有什么问题?你会怎么分片?