使用:counters和:atomics模块在Erlang中快速计数
摘要
这篇技术文章解释了如何使用Erlang的:counters和:atomics模块进行高性能计数和共享可变状态,从而突破标准的进程隔离模型。内容涵盖BEAM运行时中的原子操作,如add_get、exchange和compare-and-swap(比较并交换)。
暂无内容
查看缓存全文
缓存时间: 2026/05/11 21:54
# 使用 :counters 和 :atomics 在 Erlang 中快速计数
来源:https://andrealeopardi.com/posts/erlang-counters-and-atomics/
我刚参加完 ElixirConf EU 关于 Elixir 高级并发模式的培训回来。我爱死了 Erlang 的进程模型。但我也喜欢 OTP 团队在过去几年里发布的那些专注于*逃逸*该模型的功能。
许多语言似乎都是从可变、快速的数据结构开始,然后构建并发功能、线程隔离等。Erlang 则走了相反的方向。从并发原语、不可变数据、每进程内存开始。然后,引入逃逸机制,让进程能够窥探共享内存区域并(安全地!)修改它们。
ETS 是每个人都知道并在需要某种半可变共享内存空间时会使用的工具。然而,在最近的 OTP 版本中,我们的工具箱里增加了一些很棒的功能。我们将专注于*计数相关功能*:`:atomics` 和 `:counters`。
## `:atomics`
atomics 数组是一个堆外(off-heap)、共享、可变的 **N × 64 位整数**(有符号或无符号)块。这术语有点长。分解来看:
- **堆外**:它不生活在进程的堆中。它生活在 BEAM 中某个神奇的地方。
- **共享**:没有进程拥有它——BEAM 拥有它。
- **可变**:当你更新这个小数据结构时,你实际上改变了内存中的字节,而不是更新 Erlang/Elixir 数据结构(因为它们是不可变的,更新会复制它们)。
“数组”部分意味着每当你创建一个 "`:atomics` 数据结构”时,你正在创建一个包含 n 个整数的数组:
64 位整数数组的手绘草图
创建这样一个数据结构并随意操作它:
```elixir
ref = :atomics.new(n, [])
:atomics.add(ref, _index = 1, 23)
# 在不同的进程中添加... 反正它是共享的。
fn -> :atomics.add(ref, _index = 1, 19) end
|> Task.async()
|> Task.await()
:atomics.get(ref, 1)
#=> 42
```
```erlang
Ref = atomics:new(N, []),
atomics:add(Ref, 1, 23),
%% 在不同的进程中添加... 反正它是共享的。
{_, Ref} = spawn_monitor(fun() -> atomics:add(Ref, 1, 19) end),
receive
{'DOWN', Ref, process, _Pid, _Reason} ->
atomics:get(Ref, 1)
end.
%=> 42
```
Erlang 隐藏了底层数据的样子,这里只返回一个 `ref`。数组本身是一个相当底层的数据结构。例如,没有溢出检查:如果你使用无符号整数并尝试将其中一个数字设置为 2^64 + 1,它只会回绕到 1。没有特定的进程拥有这些数据。当数组的最后一个引用消失时,它会被垃圾回收。
`:atomics` 带来的是对这些整数的超快原子操作。这些操作几乎直接映射到 CPU 指令,所以当我说是快的时候,我是说*快*。
添加和获取数字并不是那么有趣。有趣的是像 `add_get/3` 这样的操作,它允许你原子地给给定数组索引处的数字增加值*并*获取更新后的值:
```elixir
:atomics.add_get(ref, _index = 1, 10)
#=> 52
```
```erlang
atomics:add_get(Ref, 1, 10).
%=> 52
```
*原子地*意味着在增加数字和获取它之间不会发生任何事情,因此不会出现竞态条件,即其他进程在调用者能够读回之前修改了数字。
你可以做的另一个原子操作是*交换*给定索引处的值并获取旧值:
```elixir
:atomics.exchange(ref, _index = 1, 0)
#=> 52
:atomics.get(ref, _index = 1)
#=> 0
```
```erlang
atomics:exchange(Ref, 1, 0).
%=> 52
atomics:get(Ref, 1).
%=> 0
```
最后但同样重要的是,一个众所周知的操作,通常用于并发环境中的同步:比较并交换(CAS) (https://en.wikipedia.org/wiki/Compare-and-swap)。你提供一个“预期值”和一个“期望值”,如果整数等于预期值,则将其设置为期望值——当然是原子性的。
```elixir
:atomics.compare_exchange(ref, _index = 1, _expected = 10, _desired = 42)
#=> 0 (旧值)
:atomics.compare_exchange(ref, _index = 1, _expected = 0, _desired = 42)
#=> :ok
:atomics.get(ref, _index = 1)
#=> 42
```
```erlang
atomics:compare_exchange(Ref, 1, 10, 42).
%=> 0 (旧值)
atomics:compare_exchange(Ref, 1, 0, 42).
%=> :ok
atomics:get(Ref, 1).
%=> 42
```
`:atomics` 数组还保证同一数组中*不同单元格*之间的操作顺序一致。假设一个写入者进程调用:
```elixir
:atomics.put(ref, _index = 1, 42)
:atomics.put(ref, _index = 2, 19)
```
```erlang
atomics:put(Ref, 1, 42),
atomics:put(Ref, 2, 19).
```
那么,读取者进程将**永远不会**看到第二个索引处的值却*没有*看到第一个索引处的值。这确实是关于可见性:看到某个操作的读取者保证能看到在此之前发生的所有操作。在 BEAM 世界里,这是理所当然的吧?好吧,像这样的属性是将这种数据结构从简单的整数集合转变为用于多线程和并发的强大同步原语的关键。
## `:counters`
`:atomics` 的亲密兄弟,API 表面更小,内存模型不同。`:counters` 仍然是独立内存空间中的 64 位整数数组,但整数只能是*有符号*的,并且没有原子原语(如 `exchange` 或 CAS)。
```elixir
ref = :counters.new(n, [:write_concurrency])
:counters.add(ref, _index = 1, 23)
:counters.add(ref, _index = 1, 19)
:counters.get(ref, 1)
#=> 42
```
```erlang
Ref = counters:new(N, [write_concurrency]),
counters:add(Ref, 1, 23),
counters:add(Ref, 1, 19),
counters:get(Ref, 1).
%=> 42
```
这里没有原子性的写后读方法(即我们通过 `:atomics.add_get/3` 获得的功能)。
现在到了有趣的部分,也是与 `:atomics` 的主要区别:底层数据结构。在这里,数组由**每个调度器一个整数**组成。这意味着在我机器上的单元素 `counters` 数组实际上是*十四个*整数,因为:
```elixir
System.schedulers_online()
#=> 14
```
```erlang
erlang:system_info(schedulers_online).
%=> 14
```
这种架构使得写入*极快*,因为同一个核心上没有竞争。每个调度器保持自己的整数数组,写入是本地的——它们只影响发生写入操作的那个调度器的整数。
:counters 数组架构的手绘草图,显示每个调度器的整数
复杂性转移到了读取端:`:counters.get/2` 基本上是将每个调度器的整数相加以获得总值。
:counters.get/2 操作的手绘草图,显示每个调度器整数的总和
使用 `:counters.put/3` 将计数器设置为特定值甚至更昂贵,因为该操作包括将每个调度器的整数重置为 `0`,然后将新值添加到其中一个调度器。
有了这个数据结构支持的计数器,读取不保证按顺序看到所有写入。并发读取者看不到计数器的一致快照。如果写入 A 发生在写入 B 之前,返回的总和可能反映两者、仅 A 或仅 B——取决于读取扫描时哪些每调度器单元格已落地。
并发读取者可能看到与发生的写入顺序不同的计数器的手绘草图
写入操作不会丢失,并且最终都会被看到,但并发读取者只能期望最终一致的结果。
## 基准测试
我运行了一些基准测试,以了解每种策略的性能,使用 ETS 作为基准。ETS 提供 `ets:update_counter/4`,可用于 `:counters` 和 `:atomics` 能做的某些事情。
在这个特定的基准测试中,我只是在循环中将计数器递增 `1`,并使用不同数量的并发写入者来查看并发扩展时情况如何变化。
随着并发写入者扩展,每种策略的吞吐量。两个轴都是对数。越高越好 (= 越快)。
规格以下是我用于运行基准测试的机器规格。
```
操作系统:macOS
CPU 信息:Apple M4 Pro
可用核心数:14
可用内存:24 GB
Elixir 1.19.2
Erlang 28.1
JIT 启用:true
```
有一个写入者时,所有解决方案表现相似。核心层面或 Erlang 进程之间没有竞争。
随着写入者扩展,事情变得更有趣。一旦我们超过机器上的核心数(记得在我的情况下是 14 个核心),ETS 性能显著下降。`atomics`(以及 `[:atomics]` 模式下的 `counters`)表现优于 ETS,随着写入扩展显示出一致的操作吞吐量。`counters` 不出所料地表现*非常好*。随着我们并行化写入,吞吐量上升,因为写入者不会为共享资源相互竞争。直到我们达到机器上的核心数,吞吐量随着我们添加写入者而**增加**——没有竞争,所以并行化有帮助。之后,性能趋于平稳,重要的是*不会*下降。
## 结论
我们探讨了 BEAM 上两个有趣的数据结构:`:atomics` 和 `:counters`。与我们行业的大多数事情一样,我把这句话留给你:**为工作选择合适的工具**。
当你需要*真正*的原子原语——比较并交换、交换、原子加并获取——以及将整数数组转变为实际同步原语的顺序一致性保证时,`:atomics` 是你想要的。对于一个现实世界的例子,看看 Broadway (https://elixir-broadway.org/),其限流功能是建立在 `:atomics` 之上的 (https://github.com/dashbitco/broadway/blob/d3a668c885342ae223b5ebcd9571efe4a0f18c41/lib/broadway/topology/rate_limiter.ex)。
当你写入多而读取少时,请使用 `[:write_concurrency]` 模式下的 `:counters`:命中计数器、请求计数器,任何你只想从许多进程增加数字而这些进程永远不会为同一缓存行竞争的地方。ETS 仍有其地位,但对于这种特定问题,这两个模块都能给你更好的数字和更紧密的语义。
我喜欢这一切的是背后的哲学。Erlang 设计了狭窄、范围明确的原语,在需要时作为进程模型的*逃逸机制*。这些数据结构与你将在任何执行相同操作的语言中找到的任何内容一样快。
---
资源:
- `counters` 文档 (https://www.erlang.org/doc/apps/erts/counters.html)
- `atomics` 文档 (https://www.erlang.org/doc/apps/erts/atomics.html)
- *去中心化 ETS 计数器以获得更好的可扩展性* (https://www.erlang.org/blog/scalable-ets-counters/) 作者 Kjell Winblad
相似文章
我尝试将BEAM风格的并发模型应用于代码智能体——结果令人惊讶
一项将BEAM风格并发(Erlang VM模型)应用于代码智能体的实验得到了令人惊讶的结果,暗示了在智能体协调和容错方面的潜在改进。
Ante:融合借用检查与引用计数的新方式
Ante 提出了一种新颖的方法,将借用检查和引用计数无缝结合,且不会引发运行时崩溃,使开发者能够在系统编程语言中同时使用这两种范式。
使用 iceoryx2 的 ByteAtomic 实现安全无锁原语
本文介绍 iceoryx2 的 ByteAtomic,这是一种按字节操作的原子包装器,可在 Rust 和 C++ 中实现序列锁等无锁原语时防止未定义行为。
Rust异步与ARM通用定时器
一篇技术博客文章,探讨了在ARM架构上使用ARM通用定时器进行Rust异步编程,比较了定时器外设,并讨论了Embassy和RTIC等框架。
理解C++20中的std::counting_semaphore和std::binary_semaphore
本文讲解C++20的std::counting_semaphore和std::binary_semaphore,涵盖其API、用于限制并发和线程间信号传递的用法,以及重要细节。