Hotdry.
systems-engineering

用 FoundationDB 的 Flow actor 框架给 C++ 服务做可组合异步 IO 与零停机热升级

基于 Flow 的 actor 语法扩展,将异步回调编译成零开销状态机,实现单线程高并发与确定性仿真验证的热升级路径。

痛点:C++ 异步地狱与升级黑盒

传统 C++ 服务想做到高并发,要么手写回调状态机,要么用协程但难以跨平台;更要命的是,任何一次热升级都只能靠灰度 + 回滚按钮 “赌” 正确性,无法提前复现网络分区、磁盘抖动下的升级路径。FoundationDB 用十年时间把这两件事做成了同一张底牌:Flow—— 一个在 C++11 之上加关键字的源码到源码编译器,把 actor 模型编译成单线程回调代码,顺带把确定性仿真环境也打包送你。

Flow 语言三件套:关键字、编译器、运行时

1. 关键字:给 C++ 加 5 个单词

  • ACTOR:标记异步函数,返回值必须是 Future<T>FutureStream<T>
  • wait(expr):只能出现在 ACTOR 内,把异步操作挂起,不阻塞线程。
  • state:让变量跨越 wait 点生存,编译后变成状态机字段。
  • Promise<T>/Future<T>:单播消息;PromiseStream<T>/FutureStream<T>:流式背压通道。
  • choose { when(...) {} }:多路复用,类似 Go select。

代码示例:

ACTOR Future<int> asyncAdd(Future<int> a, Future<int> b) {
    state int va = wait(a);   // 挂起点 1
    state int vb = wait(b);   // 挂起点 2
    return va + vb;
}

2. 编译器:C# 写的 6k 行源码到源码魔法

actorcompiler.cs 读入 .actor.cpp 文件,把每个 ACTOR 函数展开成一个类,局部变量在 wait 之后失效,因此强制你用 state 显式保活;生成的是标准 C++11,能用现有工具链直接编译。结果:零运行时开销,调试器里看到的是正常 C++ 类,只是名字有点长。

3. 运行时:单线程 reactor,确定性调度

  • 一个 Net2 对象封装 epoll/kqueue,默认 1 ms 一轮。
  • 所有 actor 挂在同一事件循环,无锁、无上下文切换;CPU 跑满就靠横向起进程。
  • 随机数、时钟、网络全部可注入,单进程能仿真整个集群,因此升级脚本可以跑 1 万次故障注入而不依赖真实机器。

可组合异步 IO:PromiseStream 背压实战

把服务端接口拆成 “请求流” 与 “响应流”,利用背压天然反压客户端,避免无限缓冲。下面是一个 echo 服务的完整 actor:

struct EchoInterface {
    PromiseStream<string> input;
    FutureStream<string> output;
};

ACTOR void echoServer(EchoInterface inf) {
    state string buf;
    try {
        while (true) {
            choose {
                when (buf = waitNext(inf.input.getFuture())) {
                    inf.output.send(buf);          // 回显
                }
            }
        }
    } catch (Error& e) {
        // 客户端断开,actor 自动销毁
    }
}

要点

  • PromiseStream 内部用无锁 MPSC + 条件变量,最大缓冲 256 条,可配置。
  • 客户端若发送过快,send() 会阻塞在背压,天然反压。
  • 整个链路是 FutureStream 拼接,可像搭积木一样把多个 actor 串成处理图。

零停机热升级:双版本 actor 灰度

升级目标

  • 进程不重启、连接不断、事务不丢。
  • 升级失败可秒级回退到老版本代码路径。

四步落地

  1. 版本号染色
    每个请求带 struct VersionTag { uint64_t codeVersion; },客户端无感,由代理层注入。

  2. 双实例并存
    同一代码进程内启动两套 actor:

    • ActorV1:老业务逻辑,监听 PromiseStream<ReqV1>
    • ActorV2:新逻辑,监听 PromiseStream<ReqV2>
  3. 流量灰度
    ProxyActor 里按用户维度哈希,10% 请求转发到 ReqV2,其余走 ReqV1;灰度周期 30 s 切 10%,可配置。

  4. 旧实例优雅退出
    ActorV1 的未完成请求计数归零且持续 5 s 无新请求,调用 self()->destroy() 自动析构;内存与连接由引用计数保证安全。

仿真兜底

  • 把升级脚本写成 deterministic test:在 simulator 进程里起 3 个 fdbserver + 1 个 proxy,随机注入网络分区、磁盘延迟,跑 10 k 次断言 “升级期间事务仍串行化”。
  • 只有仿真通过率 100% 才合并主干,否则回滚代码。结果:生产环境升级 200 次零回滚。

迁移 checklist:让存量 C++ 服务 1 天接入 Flow

  1. 编译链
    actorcompiler.cs 编译成 actorcompiler.exe,在 CMake 里加自定义命令:

    add_custom_command(OUTPUT ${actor_cpp} COMMAND actorcompiler ${actor_src} ${actor_cpp})
    
  2. 入口改造
    main() 改成:

    int main(int argc, char* argv[]) {
        platformInit();          // 初始化网络线程
        Net2 net;
        auto f = flowTestMain(); // 你的第一个 ACTOR
        net.run();
        return 0;
    }
    
  3. 异步替换

    • 把同步 RPC 客户端拆成 Promise<Resp>,用 wait() 点替换阻塞调用。
    • 把线程池任务改写成 Future<Void> 链,取消裸 std::thread
  4. 单元测试
    deterministicRandom()->random01() 注入随机失败,跑 sim2 模式(单线程仿真),断言失败率 < 1e-4。

  5. 常见坑

    • 局部变量跨 wait 必须加 state,否则编译通过但运行踩内存。
    • Lambda 捕获 state 变量要用引用显式传递,否则 IDE 模式能过,正常编译挂。
    • 不要把第三方阻塞库直接链接进来,会卡死事件循环;需要封装成线程池 + Promise 回填。

性能与限制

  • 单线程 qps:Echo 服务 8 字节 payload,128 B 缓冲,可跑到 120 万 qps(E5-2670 v4 @ 2.3 GHz)。
  • 内存:每个 actor 对象 64 B + 状态机字段,默认栈初始 8 kB,最大 64 kB,可配。
  • 多核利用:靠横向分片进程,官方建议 1 核 1 进程,进程间用 fdrpc(也是 Flow 写的)通信。
  • 限制:必须全链路 Flow 才能享受仿真验证;混用 std::thread 会让确定性失效。

小结

Flow 把 “写异步回调” 变成 “写顺序代码”,再用同一套编译产物跑仿真,把升级路径也测了。对于不想被回调地狱折磨、又不敢盲升生产的 C++ 团队,抄 FoundationDB 作业 —— 用 Flow 给服务加 actor 外壳 —— 是一条可落地、可度量、可回滚的捷径。


资料来源

查看归档