用于提高可扩展性的去中心化 ETS 计数器
共享的 Erlang 术语存储 (ETS) 表通常是存储频繁从多个 Erlang 进程更新和读取的数据的绝佳场所。ETS 为 Erlang 进程提供键值存储。当 write_concurrency 选项被激活时,ETS 表在内部使用细粒度锁定。因此,多个进程在 ETS 表中插入和删除不同项的场景应该可以随着所用核心数量的增加而很好地扩展。但是,在实践中,这种场景的可扩展性尚未臻于完美。这篇博客文章将探讨 decentralized_counters
选项如何使我们朝着完美的可扩展性迈进一步。
ETS 表选项 decentralized_counters
(在 Erlang/OTP 22 中为 ordered_set
表引入,在 Erlang/OTP 23 中为其他表类型引入)已使可扩展性大大提高。启用了 decentralized_counters
的表使用去中心化计数器而不是中心化计数器来跟踪表中的项数和内存消耗。不幸的是,启用了 decentralized_counters
的表在获取表大小和内存使用情况时操作会很慢(ets:info(Table, size)
和 ets:info(Table, memory)
),因此,是否开启 decentralized_counters
取决于您的用例。这篇博客文章将让您更好地理解何时应该激活 decentralized_counters
选项以及去中心化计数器是如何工作的。
使用去中心化 ETS 计数器的可扩展性 #
下图显示了当进程在具有 64 个硬件线程的机器上对 set
类型的 ETS 表执行插入 (ets:insert/2
) 和删除 (ets:delete/2
) 操作时所达到的吞吐量(每秒操作数),无论 decentralized_counters
选项是激活还是停用。bag
和 duplicate_bag
表类型的可扩展性行为类似,因为它们的实现基于相同的哈希表。
下图显示了相同基准测试的结果,但使用 ordered_set
类型的表
有兴趣的读者可以在 decentralized_counters
的基准测试网站上找到有关该基准测试的更多信息。上面的基准测试结果表明,当激活 decentralized_counter
选项时,set
和 ordered_set
表的可扩展性都得到了显著提升。ordered_set
类型比 set
类型获得了更显著的可扩展性改进。set 类型的表对于哈希表桶具有固定数量的锁。ordered_set
表类型使用一个 争用自适应搜索树 实现,该搜索树根据检测到的争用程度动态更改锁定粒度。这种实现上的差异解释了 set
和 ordered_set
之间可扩展性的差异。有兴趣的读者可以在 之前的博客文章中找到有关 ordered_set
实现的详细信息。
还值得注意的是,运行基准测试的 Erlang VM 已使用配置选项“./configure --with-ets-write-concurrency-locks=256
”编译。配置选项 --with-ets-write-concurrency-locks=256
将基于哈希的 ETS 表的锁数量从当前默认的 64 更改为 256(256 是当前可以为该配置选项设置的最大值)。更改基于哈希的表的实现,以便可以设置每个表实例的锁数量,或者自动调整锁粒度,这似乎是一个很好的未来改进,但这并不是这篇博客文章的主题。
中心化计数器由一个单一的内存字组成,该内存字通过原子指令递增和递减。中心化计数器的问题在于,多个核心对计数器的修改是串行化的。这个问题被放大,因为多个核心频繁修改单个内存字会在 缓存一致性 系统中导致大量昂贵的流量。但是,从中心化计数器读取效率很高,因为读取器只需要读取单个内存字。
在为 ETS 设计去中心化计数器时,我们尝试优化更新性能和可扩展性,因为大多数应用程序相对较少需要获取 ETS 表的大小。但是,由于可能有应用程序频繁调用 ets:info(Table, size)
和 ets:info(Table, memory)
,我们选择使去中心化计数器成为可选的。
另一个可能值得记住的事情是,使用去中心化计数器的基于哈希的表往往比没有去中心化计数器的相应表使用稍微更多的哈希表桶。原因是,当去中心化计数器激活时,调整大小的决策基于对表中项数的估计,而不是精确的计数,并且调整大小的启发式方法比减少更积极地触发桶数的增加。
实现 #
现在,您将了解 ETS 中的去中心化计数器是如何工作的。去中心化计数器实现导出了一个 API,可以轻松地在去中心化计数器和中心化计数器之间切换。ETS 使用它来支持中心化和去中心化计数器的使用。去中心化计数器的数据结构如下图所示。当 is_decentralized = false
时,计数器字段表示当前计数,而不是指向缓存行填充计数器数组的指针。
当 is_decentralized = true
时,更新(递增或递减)计数器的进程会跟随指向计数器数组的指针,并递增数组中当前调度程序映射到的槽的计数器(取调度程序标识符模数组中的槽数,以获得适当的槽)。更新不需要做任何其他事情,因此它们非常高效,并且可以随着核心数量的增加而完美扩展,只要槽数与调度程序一样多即可。可以使用 +dcg
选项配置计数器数组中槽的最大数量。
为了实现 ets:info(Table, size)
和 ets:info(Table, memory)
操作,还需要读取当前的计数器值。读取当前的计数器值可以通过对计数器数组中的值求和来实现。但是,如果此求和操作与对计数器数组的更新同时进行,我们可能会得到奇怪的结果。例如,我们可能会最终处于 ets:info(Table, size)
返回负数的情况,这并不是我们想要的。另一方面,我们希望使计数器更新尽可能快,因此使用锁来保护计数器数组中的计数器不是一个好的解决方案。我们选择了一种解决方案,该解决方案允许读取器换出整个计数器数组,并等待(使用 Erlang VM 的线程进度系统)直到在计算总和之前无法在换出的数组中进行任何更新。以下示例说明了这种方法
-
[步骤 1]
一个线程将要读取计数器值。
-
[步骤 2]
读取器首先创建一个新的计数器数组。
-
[步骤 3]
指向旧计数器数组的指针被更改为指向新的计数器数组,并且
snapshot_ongoing
字段设置为true
。只有当旧计数器数组中的snapshot_onging
字段设置为false
时,才能进行此更改。 -
[步骤 4]
现在,读取器必须等待所有其他将更新旧数组中计数器的线程完成其更新。如前所述,这可以使用 Erlang VM 的线程进度系统来完成。之后,读取器可以安全地计算旧计数器数组中计数器的总和(总和为 1406)。计算的总和也会提供给请求计数的进程,以便它可以继续执行。
-
[步骤 5]
读取操作尚未完成,即使我们已成功计算出计数。必须将从旧数组计算的总和添加到新数组中,以避免丢失任何内容。
-
[步骤 6]
最后,新计数器数组中的
snapshot_ongoing
字段设置为false
,以便其他读取操作可以换出新的计数器数组。
现在,您应该对 ETS 的去中心化计数器如何工作有了基本的了解。如果您对实现的详细信息感兴趣,也欢迎您查看 erl_flxctr.c 和 erl_flxctr.h 中的源代码。
正如您可以想象的那样,读取去中心化计数器的值,例如使用 ets:info(Table, size)
,与中心化计数器相比非常慢。幸运的是,读取去中心化计数器值所花费的大部分时间都在等待线程进度系统报告可以安全读取换出的数组,并且读取操作在此期间不会阻塞任何调度程序,也不会消耗任何 CPU 时间。另一方面,去中心化计数器可以以非常高效且可扩展的方式进行更新,因此,如果您很少需要获取共享 ETS 表的大小和内存消耗,则最好使用去中心化计数器。
结论性意见 #
这篇博客文章介绍了 ETS 表的去中心化计数器选项的实现。具有去中心化计数器的 ETS 表比具有中心化计数器的 ETS 表具有更好的核心数量可扩展性。但是,由于去中心化计数器使 ets:info(Table, size)
和 ets:info(Table, memory)
非常慢,因此如果需要频繁执行这两个操作中的任何一个,则不应使用它们。