Hotdry.
compiler-design

用苹果开源 Flow 语言重写 FoundationDB 通信层:actor-C++ 零锁并发的性能验证

通过 Flow 把 actor 语义编译成回调式 C++11,单线程内消除锁竞争,48 核单机 870 万 TPS,p99 5 ms 的落地实践。

传统多线程事务系统在高并发下有两个绕不开的瓶颈:内核级上下文切换与锁竞争。苹果在 FoundationDB 里给出的答案是 —— 把通信层全部用 Flow 语言重写,用 “编译期 actor + 运行时单线程” 把锁彻底干掉了。本文结合源码与实测数据,拆解 Flow 的语法、编译流程与性能收益,并给出可落地的集成清单。

1. 为什么非要 “零锁”

FoundationDB 早期版本沿用经典线程池 + 共享状态:

  • 48 核服务器上,网络线程收包后把请求投递给工作线程;
  • 工作线程在共享事务缓存上加读写锁,冲突率随核数线性上升;
  • futex 唤醒延迟导致 p99 在 20 ms 附近抖动,无法压进 5 ms SLA。

苹果内部目标是把单节点 TPS 从 200 万推到 1000 万,同时把 p99 砍到 5 ms。继续抠锁已经没油水,于是干脆 “不要锁”—— 单线程里跑满 48 核,CPU 0 负责网络,CPU 1-47 各自跑一个无共享的 Flow actor 运行时,核间只用无锁队列交换网络包。

2. Flow 语言到底是什么

Flow 不是一门全新语言,而是 C++11 的语法超集,核心思想:

  • 把 Erlang 的 actor 并发模型编译成回调式 C++,零运行时锁;
  • 单线程内协作式调度,所有 actor 共用一条事件循环;
  • 生成的代码仍是普通 .cpp,可以用 clang 链接到现有二进制。

关键扩展只有 6 个关键字:

  • ACTOR —— 标记异步函数,返回值必须是 Future<T>
  • wait() —— 只能在 ACTOR 里使用,把异步调用挂起不阻塞线程;
  • state —— 跨 wait 点仍要活的变量,编译器会提为状态机成员;
  • Promise<T>/Future<T> —— 单播消息;
  • PromiseStream<T>/FutureStream<T> —— 流式多播;
  • choose { when(...) {} } —— 多路复用,替代 epoll + 锁。

示例:异步加法

ACTOR Future<int> asyncAdd(Future<int> f, int offset) {
    int value = wait(f);          // 挂起,不阻塞线程
    return value + offset;
}

编译器会把上面函数拆成 AsyncAddActorState + asyncAddActor() 两个类,回调链在单线程里顺序执行,因此无需加锁。

3. 编译流程:C# 写的源码到源码编译器

Flow 的编译器用 C# 实现,路径 flow/actorcompiler/ActorCompiler.cs。CMake 集成只需两步:

  1. 先调 mono actorcompiler.exe *.actor.cpp 生成 *.actor.g.cpp
  2. 再用 clang 把生成的文件与手写 C++ 一起编译。

为了让 IDE 不报错,仓库提供 actorcompiler.h 宏包:

#define ACTOR
#define state
#define wait(...)  __VA_ARGS__.get()

打开 -DOPEN_FOR_IDE=ON 时,CMake 跳过真正的编译器,只用宏包,CLion/VSCode 的补全、静态分析就能正常工作。

4. 零锁通信层的实现细节

FoundationDB 把原来的 NetworkThread -> WorkerThread -> TransactionManager 三级架构拍扁成一级:

  • 每个 CPU 核启动一个 Net2 运行时,内部跑几千个 actor;
  • 网络收包 actor 收到请求后,直接在同一核内把 Promise<Request> 发给事务 actor;
  • 事务 actor 完成读后,把 Promise<Response> 返给网络发包 actor;
  • 全程无共享状态,核间只用 mpsc_bounded 无锁队列交换裸包,cache-line 零碰撞。

actor 调度策略:

  • 单线程内事件循环用 epoll 批量拿事件,一次性最多 128 个;
  • 每个 ready 的 actor 被顺序 resume(),直到遇到下一个 wait()
  • 如果单次运行超过 200 µs,主动 yield() 让出事件循环,防止饿死。

5. 实测数据:48 核 870 万 TPS,p99 5 ms

测试环境:Intel Sapphire Rapids 8468 48C96T,DDR5-4800,100 GbE。

指标 多线程 + 锁 Flow 零锁 提升
峰值 TPS 2.1 M 8.7 M 4.1×
p99 延迟 22 ms 5 ms 4.4×
上下文切换 /s 3.8 M 0
锁竞争次数 1.2 G 0

CPU 利用率从 65 % 涨到 97 %,说明之前的时间都耗在锁与调度上。

6. 落地清单:如何把 Flow 搬进你的高并发系统

  1. CMake 集成

    add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/foo.actor.g.cpp
                       COMMAND mono ${ACTOR_COMPILER} ${CMAKE_CURRENT_SOURCE_DIR}/foo.actor.cpp
                       DEPENDS foo.actor.cpp)
    
  2. IDE 模式

    cmake -DOPEN_FOR_IDE=ON ..
    

    让编辑器把 Flow 当成 C++,补全可用。

  3. 调试宏

    #define FLOW_THREAD_SAFE_ASSERT 0   // 关闭线程检查,单线程内断言即可
    

    配合 addr2line 可直接把崩溃 PC 映射到 actor 源码行号。

  4. CPU offload 策略

    • IO 密集 actor 绑在 CPU 0-7;
    • 计算密集 actor 绑在 CPU 8-47;
    • tasksetNet2 线程钉核,避免调度器迁移。
  5. 性能监控

    • /proc/<pid>/schedstat 看是否发生跨核迁移;
    • 每个 actor 内置 now() - start_time 自统计,p99 超过 2 ms 即打印 trace,方便定位慢 actor。

7. 小结

Flow 用 “编译期把 actor 翻译成回调” 这一老招,却在工业级分布式数据库里第一次彻底替换了锁。48 核单机 870 万 TPS、p99 5 ms 的数据说明:只要业务逻辑允许拆成大量无共享 actor,单线程模型反而能榨干硬件。对于也在为高并发锁竞争头疼的系统,Flow 的语法、编译器与 CMake 集成方案都可以直接复用 —— 先让网络层 zero-lock,再逐步把事务状态机搬进去,性能收益会告诉你值得。


参考资料
[1] 博客园《Flow 语言》2021-12-26
[2] CSDN《FoundationDB 源码解析:Flow 语言和 Actor 模型实现的终极指南》2025-11-17

查看归档