痛点: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 灰度
升级目标
- 进程不重启、连接不断、事务不丢。
- 升级失败可秒级回退到老版本代码路径。
四步落地
-
版本号染色
每个请求带struct VersionTag { uint64_t codeVersion; },客户端无感,由代理层注入。 -
双实例并存
同一代码进程内启动两套 actor:ActorV1:老业务逻辑,监听PromiseStream<ReqV1>。ActorV2:新逻辑,监听PromiseStream<ReqV2>。
-
流量灰度
在ProxyActor里按用户维度哈希,10% 请求转发到ReqV2,其余走ReqV1;灰度周期 30 s 切 10%,可配置。 -
旧实例优雅退出
当ActorV1的未完成请求计数归零且持续 5 s 无新请求,调用self()->destroy()自动析构;内存与连接由引用计数保证安全。
仿真兜底
- 把升级脚本写成 deterministic test:在
simulator进程里起 3 个 fdbserver + 1 个 proxy,随机注入网络分区、磁盘延迟,跑 10 k 次断言 “升级期间事务仍串行化”。 - 只有仿真通过率 100% 才合并主干,否则回滚代码。结果:生产环境升级 200 次零回滚。
迁移 checklist:让存量 C++ 服务 1 天接入 Flow
-
编译链
把actorcompiler.cs编译成actorcompiler.exe,在 CMake 里加自定义命令:add_custom_command(OUTPUT ${actor_cpp} COMMAND actorcompiler ${actor_src} ${actor_cpp}) -
入口改造
把main()改成:int main(int argc, char* argv[]) { platformInit(); // 初始化网络线程 Net2 net; auto f = flowTestMain(); // 你的第一个 ACTOR net.run(); return 0; } -
异步替换
- 把同步 RPC 客户端拆成
Promise<Resp>,用wait()点替换阻塞调用。 - 把线程池任务改写成
Future<Void>链,取消裸std::thread。
- 把同步 RPC 客户端拆成
-
单元测试
用deterministicRandom()->random01()注入随机失败,跑sim2模式(单线程仿真),断言失败率 < 1e-4。 -
常见坑
- 局部变量跨 wait 必须加
state,否则编译通过但运行踩内存。 - Lambda 捕获
state变量要用引用显式传递,否则 IDE 模式能过,正常编译挂。 - 不要把第三方阻塞库直接链接进来,会卡死事件循环;需要封装成线程池 +
Promise回填。
- 局部变量跨 wait 必须加
性能与限制
- 单线程 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 外壳 —— 是一条可落地、可度量、可回滚的捷径。
资料来源
- apple/foundationdb 主仓库
- 博客园《foundationdb 代码阅读 --Flow 语言》