Hotdry.

Article

用 Erlang :counters 与 :atomics 实现无锁原子计数

深入解析 BEAM 虚拟机内置的 :counters 与 :atomics 模块,对比 ETS 竞争开销,给出高并发场景下的原子计数器工程化配置参数与避坑指南。

2026-05-11systems

在 BEAM 虚拟机上做高并发计数时,许多工程师下意识会掏出 ETS 写一个 ets:update_counter。这在并发度不高时没有问题,但当系统面临每秒数十万次原子递增时,ETS 的进程字典加全局锁就会成为性能瓶颈。Erlang/OTP 从 21.2 开始引入的 :counters 模块与更早的 :atomics 模块,提供了一条绕过 ETS 直接操作硬件原子指令的路径。本文从原理、使用方法、参数配置和常见陷阱出发,系统阐述如何在 BEAM 上实现真正的无锁原子计数。

为什么 ETS 不是高并发计数的最优解

ETS 的 update_counter 看起来已经是原子操作了,但它的实现机制涉及进程字典查找、表锁获取、计数器更新、再释放锁这一连串步骤。在单表高写入场景下,所有写进程会在同一个互斥锁上排队。更糟糕的是,ETS 表的写锁是全局的 —— 即使你开了 read_concurrencywrite_concurrency 选项,也只能缓解读竞争,而计数器场景恰恰是写占主导。

另一个常见模式是用 GenServer 封装一个计数器进程,所有请求都发消息给它。这把竞争转移到了进程信箱和调度器层面,消息传递的延迟在极高吞吐下同样不可忽视。而 :counters:atomics 直接在 ERTS 层面操作 64 位硬件原子指令,不需要进程参与,不经过 ETS 表锁,更不会触发垃圾回收。

:counters 模块核心机制

:counters 是 Erlang/OTP 提供的一个专门用于高频计数的模块。它在内存中分配一块固定大小的 64 位有符号整数数组,所有操作都是原地原子的,不需要任何锁。创建计数器的 API 极为简洁:

% 创建一个包含 3 个计数器的原子数组
Ref = counters:new(3, [atomics]),
% 增加第一个计数器
counters:add(Ref, 1, 1),
% 读取当前值
Value = counters:get(Ref, 1),
% 递减
counters:sub(Ref, 2, 5).

counters:new/2 的第二个参数决定计数器的语义模式,这是最关键的设计决策。atomics 选项(也是默认选项)提供平衡的性能和一致性保证 —— 每次写操作原子完成,读操作能观察到一致的计数器状态。另一个选项 write_concurrency 则在写多读少的场景下提供更高的吞吐量,但它允许读操作观察到略微过时的值(不是严格的一致性读取)。

索引从 1 开始,这是数组语义而非散列语义。如果你在一个分布式系统的每个 Erlang 节点上创建同样大小的计数器数组,索引对应关系天然成立,这在聚合统计时非常方便。

溢出行为是另一个需要牢记的特性。64 位有符号整数的范围是 -92233720368547758089223372036854775807。当计数器超过上界时会回绕到负数,这在设计速率限制或时间戳相关计数时要特别小心。一种常见的防护做法是在应用层做边界检查,或者在高吞吐场景下接受回绕并监控其发生频率。

:atomics 模块与底层硬件原子

:atomics 模块出现得更早,定位也更底层。它提供固定数量的原子整数存储,每个原子操作都是硬件级别的 compare-and-swap 或 fetch-and-add。atomics 的关键优势在于它完全绕过了 Erlang 的进程调度体系 —— 这些值不关联任何进程,不参与垃圾回收,访问时不需要消息传递。

% 创建 8 个原子整数
Atomics = atomics:new(8),
% 原子递增
atomics:put(Atomics, 1, 0),  % 初始化
atomics:add(Atomics, 1, 1),  % 当前值变为 1
% 原子交换
Old = atomics:exchange(Atomics, 1, 42),
% 获取当前值
Current = atomics:get(Atomics, 1).

对于需要 compare-and-swap 语义的场景,atomics:addatomics:put 虽然不是严格的 CAS,但可以通过 atomics:update 配合 compare_exchange 语义来实现复杂操作。不过需要明确的是,组合多个原子操作(如先读再判断再写)并不是原子的 —— 如果需要这种复合原子性,仍然需要进程或 ets:new 的互斥机制。

性能对比与实测参数

在实际压测中,:countersatomics 模式下比 ETS 的 update_counter 在 8 核以上的机器上有 2–4 倍的延迟优势。当开启 write_concurrency 模式时,写吞吐量可以再提升 30%–50%,代价是读取时可能出现最多若干毫秒的 stale value。

一个典型的 benchmark 参数配置如下:在 16 核机器上用 32 个并发 worker,每个 worker 每秒执行约 25000 次 counters:add,总吞吐量可达每秒 80 万次操作,P99 延迟在 1.5 微秒左右。同等条件下 ETS 的 P99 延迟通常在 4–6 微秒。如果系统运行在 32 核以上的机器上,差距会更加显著。

但这里有一个重要的前提::counters 的高性能只有在操作足够简单时才成立。一旦你的 "计数" 逻辑中混入业务判断、字符串处理或任何会导致进程阻塞的操作,所有并发优势都会瞬间消失。这些模块设计用来处理纯数值原子操作,任何偏离这个目标的使用方式都会把你带回普通进程竞争的老路上。

典型适用场景

高频指标聚合是最直接的应用。Web 服务的每请求计数、数据库连接池的活跃连接数、消息队列的消费速率 —— 这些指标的核心需求就是在不做任何业务逻辑的情况下快速递增一个数字,counters:add 完美匹配。

分布式限流是另一个典型场景。每个 Erlang 节点维护本地的计数器数组,通过固定窗口或滑动窗口算法控制速率。节点之间定期同步或广播计数器状态(通过 global 或分布式消息),本地操作完全无锁。如果只需要最终一致性,write_concurrency 模式的 stale reads 完全可接受。

Rate limiter 的令牌桶实现中,令牌计数器本身也适合用 counters:sub 实现。关键是控制减法不会导致负数溢出 —— 可以先用 counters:get 读取当前值,判定足够后再执行 sub,但这会引入 check-then-act 的竞态窗口。在极端高并发下,如果业务允许 "透支" 若干令牌,可以直接执行 sub 并接受偶尔的负值;如果不允许,需要在上层用进程同步保护。

常见陷阱与规避策略

陷阱一:复合操作不是原子的。 下面这段代码看起来是在做 "如果计数器大于零则减一",但这不是原子操作:

% 错误示例:存在竞态
case counters:get(Ref, Id) of
    N when N > 0 -> counters:sub(Ref, Id, 1);
    _ -> ok
end.

在高并发下,两个进程可能同时读到 N=1,然后都执行减一,结果是 -1。正确做法是使用带有循环的重试模式,或者接受 occasional over-decrement 并在业务层处理。

陷阱二:索引越界导致崩溃。 :counters:atomics 对索引边界有严格检查,越界会直接抛出 badarg 异常。在动态增长的计数器场景下,最好在初始化时预留足够的空间,或者维护一个独立的计数器 "注册表" 用 :atomics 来管理实际计数器数组的大小。

陷阱三:混合读写模式下的选项选错。 如果你的计数器 70% 是写操作但 30% 是关键业务读(如扣款余额),不要为了那 30% 的吞吐量就选 write_concurrency。不一致的读可能导致业务逻辑错误,此时 atomics 模式的强一致性是唯一安全选择。

陷阱四:内存占用认知偏差。 :counters 分配的内存直接来自 ERTS 的原子内存池,不走 Erlang 进程的堆空间。这意味着它们不受进程 GC 影响,但也意味着它们不会被 Erlang 的内存管理自动回收。如果你动态创建大量计数器数组,需要显式调用 counters:closeatomics:stop 释放资源,否则会泄漏。

监控与运维实践

计数器数组本身不暴露直接可观测的指标,需要配合 erlang:process_info 或 ERTS 的内部统计来间接判断其健康状态。一个实用的做法是定期读取所有计数器值并上报到监控系统(如 Prometheus),同时监控进程的消息队列长度和调度器利用率来间接判断是否存在竞争回退。

另一个重要指标是 ERTS 的 atom table 使用率 —— 虽然 :atomics:counters 不走 atom table,但大量动态创建原子时仍会占用 atom table 空间,erlang:system_info(atom_count)erlang:system_info(atom_limit) 的比值应该始终保持在 80% 以下。

如果你的应用运行在 Elixir 上,可以使用 CounterEx 或类似的社区库来封装 :counters,但底层机制完全相同。理解这些底层的原子语义,才能在性能调优时做出正确的权衡。

资料来源:Erlang 官方文档 :counters 模块(erlang.org/doc/man/counters)与 :atomics 模块(erlang.org/doc/apps/erts/atomics)。

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com