在构建高并发 BEAM 应用时,计数器几乎是所有系统的基础设施:从限流令牌桶、请求频率统计,到分布式追踪的度量聚合,计数器无处不在。传统的实现方案通常依赖 ETS 写操作或进程状态 —— 前者面临进程 mailbox 序列化与写时复制的开销,后者则会将计数器值绑定到进程堆上,每一次 increment 都可能触发垃圾回收的扫描边界膨胀。这两类方案在每秒数万次增量操作的场景下,往往成为吞吐量的隐性瓶颈。
BEAM 运行时自 OTP 21 起引入的 :atomics 模块,以及更早存在的 :counters 模块,提供了另一条路径:直接利用底层硬件的原子指令(LOCK XADD、LOCK CMPXCHG 等),在完全不涉及进程堆的前提下完成 64 位整数的原子读写。这意味着计数器不再依附于任何 Erlang 进程,GC 无需感知其存在,且所有并发操作由 CPU 硬件保障原子性与有序性。以下从设计动机、API 语义、性能特征与工程决策四个维度,系统性地展开这一技术点的工程化实践。
问题建模:为什么 ETS 计数器会慢
在深入 :atomics 之前,有必要量化 ETS 计数器在高频写入场景下的真实开销。ETS 表虽然以 C 结构体形式存储在运行时全局堆中,但每一次 ets:update_counter/3 调用并非简单的内存写入 —— 它首先需要定位表槽位,随后在持锁状态下执行 CAS(Compare-and-Swap)循环,最后将结果回填到 ETS 内存块。如果同一个 ETS key 被大量并发进程同时修改,锁竞争会急剧放大延迟。更关键的是,ETS 属于 BEAM 堆外存储区,修改操作会触发进程堆向 ETS 堆的数据拷贝(写时复制语义的一部分),导致每次增量都可能在进程堆中分配临时数据结构。
一个常见的性能基线测试场景是:1000 个并发进程各自对同一个计数器执行 10000 次增量操作。使用 ETS 的 ets:update_counter/3 在现代多核服务器上通常能达到每秒 200 万至 500 万次操作的上限 —— 这对于大多数业务场景已经足够,但当系统需要处理每秒数千万次计数请求(如大规模 API 网关、游戏服务器或金融撮合引擎)时,ETS 的锁开销便成为不可忽视的制约因素。
:atomics 模块的设计目标正是将这一上限提升一到两个数量级。其核心思路是:既然计数器是独立于进程语义的值语义对象,就不应该用进程 mailbox 或 ETS 锁来模拟它,而应该直接映射到 CPU 提供的硬件原子操作上。
核心抽象:原子数组与操作语义
:atomics 模块将计数器组织为原子数组(atomic array)。调用 atomics:new(Arity, Opts) 创建一个包含 Arity 个原子槽位的数组,每个槽位是一个独立的 64 位整数,支持有符号或无符号两种解释方式。数组一旦创建,其中的每一个原子位置就成为一个全局可见、进程无关的计数器对象,进程退出不会影响其生命周期 —— 实际上,原子数组由 BEAM 运行时持有引用计数,当没有任何进程持有其引用时自动 GC。
原子数组支持的操作集合包括:put/3(覆写值)、get/2(读取当前值)、add/3 与 add_return/3(原子加法,后者返回结果)、sub/3 与 sub_return/3(原子减法)、exchange/3(原子替换并返回旧值)、compare_and_swap/4(条件更新,即 CAS)。其中最常用的是 add_return/3,对应硬件层面的 LOCK XADD 指令 —— 在单条 CPU 指令内完成增量并返回新值,不存在任何软件锁或中间状态。
一个需要特别注意的技术细节是溢出行为:有符号原子可以存储从 -(2^63) 到 (2^63)-1 的范围,无符号则可至 (2^64)-1。超出范围时会发生回绕(wrap-around),即最大值加 1 变为最小值。这一行为与大多数编程语言的整数溢出不同 —— 它不会抛出异常,而是静默回绕。对于速率计数器、访问频率等单调递增场景,溢出概率在实践中几乎可以忽略(每秒 10 亿次增量需要数十年才能溢出 64 位空间),但工程实现时仍应通过监控告警覆盖这一边界条件。
所有原子操作均满足 happens-before 顺序保证:如果操作 A 在操作 B 开始之前完成,那么 A 的效果必定对 B 可见。这意味着即使在高并发写入场景下,也不存在部分更新(partial update)或读到中间值的情况。
:counters 模块的定位差异
与 :atomics 同时存在的是 :counters 模块,两者在表面上功能相似,但存在关键差异。:counters 模块是 OTP 预加载模块,提供的操作更为丰富 —— 除了基本的原子增减,还包含 counters:get/2、counters:put/3、counters:add/3 等函数,且支持原子数组和单个计数器句柄两种使用模式。:counters 在内部实现上与 :atomics 共享相同的原子基础设施,但其 API 设计更贴近传统的 ETS 操作习惯。
从工程选型的角度:如果仅需要一组固定容量的原子计数器,且对吞吐量有极致要求,:atomics 的语义更为简洁明确;如果需要更灵活的动态容量管理或希望在单个计数器对象上执行更细粒度的操作,:counters 提供了更宽泛的 API 表面积。两者都不涉及进程堆分配,GC 压力均为零,这是它们相较于 ETS 的共同优势。
工程实践:容量规划与键映射
在实际业务系统中,计数器通常以字符串键(如 "api_rate:user_12345")标识,而原子数组以整数索引访问。因此,一个完整的原子计数器服务需要解决键到索引的映射问题。业界成熟的实践是结合 ETS 读写实现键映射层:初始化时创建一个固定容量的原子数组(例如 10000 个槽位),再维护一张 ETS 表将字符串键映射为数组索引。第一次为某个键执行增量时,在 ETS 中查找其对应索引;若不存在则分配一个新索引并记录映射。
这种模式的开销结构值得量化:键映射的 ETS 查询是读密集型操作,在高并发场景下 ETS 读操作的性能远优于写操作,且不涉及锁竞争(ETS 读通过无锁路径完成)。增量操作本身则完全在原子数组中完成,延迟恒定在数十纳秒级别。结合 CounterEx 库在 Elixir 生态中的 benchmark 数据,基于 :atomics 后端的实现可以稳定达到每秒 1000 万至 2000 万次增量操作,相比纯 ETS 实现提升约 5 到 10 倍。
容量规划是另一个必须正视的工程问题。由于原子数组在创建时即固定大小,超出容量后将无法创建新计数器(会返回 error 或 capacity_exceeded)。建议在系统设计阶段评估最大计数器数量,保留一定余量(如预估值的 120%)作为初始化容量。若业务逻辑允许对过期计数器执行清理,可以通过显式删除映射(ETS 记录删除)并标记对应原子槽位为 "已回收" 来实现复用 —— 但需注意,删除操作不会立即释放底层原子数组的空间,因为数组句柄本身是引用计数的唯一拥有者。
监控与可观测性
原子计数器虽然消除了 GC 压力,但并未消除监控需求。:atomics:info/1 函数返回原子数组的元数据,包括 size(槽位数)、max/min(取值范围)、memory(近似内存占用)。在生产环境中,建议将这些指标通过 Exometer 或 Telemetry 暴露到监控系统。特别需要关注的是计数器值的单调性 —— 对于速率限制类计数器,可以设置告警规则检测值的异常回跳(通常意味着溢出或重置)。
对于分布式系统中的跨节点聚合场景,需要额外注意::atomics 的原子性保证仅在单节点内部有效,跨节点同步仍需依赖分布式消息传递或 CRDT 数据结构。在设计多节点计数器聚合方案时,可以将每个节点的局部计数器置于原子数组中,再通过定时广播将当前值同步到协调节点进行合并。
选型决策树
面对新的计数器需求,建议按以下路径决策:若计数器集合是静态的、预先可知的,且吞吐量目标在每秒百万次增量以上,:atomics 是首选;若需要动态创建和销毁计数器,但写入频率适中(每秒数万次),ETS 仍是最省心的方案;若计数器数量极大但单个操作性能要求极高,可以考虑将 :atomics 作为热路径(高频增量),ETS 作为冷路径(持久化与聚合),两层结构各司其职。
实际工程中,将 :atomics 与 :counters 对立起来的场景并不多见 —— 更常见的模式是在同一个微服务体系内,根据不同子系统的需求组合使用。例如,API 网关的令牌桶使用 :atomics 实现零拷贝限流,游戏服务器的在线玩家计数使用 :counters 管理动态伸缩的房间容量,而持久化的业务指标则落入 ETS 和 DETS 的存储层。三者各有其最优应用场景,理解背后的实现机制是做出正确选型的前提。
资料来源
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。