巧妙使用 persistent_term

2019 年 9 月 9 日 · 作者:Lukas Larsson

这篇博客文章将介绍自发布以来我使用 persistent_term 的三种不同用途,并解释一下为什么它们与 persistent_term 配合得如此之好。

全局计数器 #

假设你想在你的系统中拥有一些全局计数器。例如,HTTP 请求被发起的次数。如果系统非常繁忙,该计数器每秒会被许多不同的进程递增很多次。在 OTP-22 之前,我所知道的获得最佳性能的最佳方法是使用条纹 ETS 表。即类似于下面的代码

incr(Counter) ->
  ets:update_counter(?MODULE,{Counter,erlang:system_info(scheduler_id)},1).

read(Counter) ->
  lists:sum(ets:select(?MODULE,[{{{Counter,'_'},'$1'},[],['$1']}])).

上面的代码将确保 ETS 表上的争用非常少,因为每个调度器都会在表中获得一个单独的槽来更新。这样做是以增加内存使用量为代价的,并且在读取值时你可能无法获得准确的值。

在 OTP-22 中,可以使用 counters 来实现相同的目标。Counters 通过使用 write_concurrency 选项内置了对条纹的支持,因此我们不必编写自己的实现。它们也比 ETS 表更快且使用更少的内存,因此有很多优势。

剩下的问题是如何找到计数器的引用。我们可以将其放入 ETS 中,然后在更新计数器时执行 ets:lookup_element/3

cnt_incr(Counter) ->
    counters:add(ets:lookup_element(?MODULE,Counter,2),1,1).

cnt_read(Counter) ->
    counters:get(ets:lookup_element(?MODULE,Counter,2),1).

这会导致大约 20% 的性能下降,所以这并不是我们真正想要的。但是,如果我们将计数器放置在 persistent_term 中,就像下面的代码一样,我们会获得大约 140% 的性能提升,这更符合我们想要的。

cnt_pt_incr(Counter) ->
    counters:add(persistent_term:get({?MODULE,Counter}),1,1).

cnt_pt_read(Counter) ->
    counters:get(persistent_term:get({?MODULE,Counter}),1).

产生如此巨大差异的原因是,当 counters 被放置到 persistent_term 中时,它们会作为字面量放置在那里,这意味着在每次递增时,我们不再需要复制 counters 引用。这有以下两个好处

1) 垃圾的数量将减少。在我的基准测试中,cnt_incr 生成的垃圾量为 6 个字,而 ets_incrcnt_pt_incr 都会创建 3 个字。

2) 无需修改引用计数。我的意思是,counters 引用被称为魔术引用或 NIF 资源。这些引用的工作方式与引用计数的二进制文件非常相似,即在发送到不同的进程时不会被复制。相反,只有引用计数在复制时会递增,然后在稍后由 GC 递减。这意味着对于 cnt_incr,我们实际上每次调用都会修改 3 个计数器。首先,当从 ETS 复制时,我们会递增计数器上的引用计数,然后更新实际计数器,最后递减引用计数器。如果我们使用 persistent_term,则该术语永远不会被复制,因此我们不必更新任何引用计数器,而只需更新实际计数器即可。

但是,将计数器放置在 persistent_term 中并非没有问题。为了删除或替换 persistent_term 中的计数器引用,我们必须进行全局 GC,这取决于系统,可能会非常非常耗费资源。

因此,此方法最好仅用于永远不会被删除的全局持久计数器。

你可以在 这里 找到以上所有示例的代码以及我运行的基准测试。

日志记录器级别检查 #

logger 中,有一个主要的日志记录级别,这是要生成的每个潜在日志消息要完成的第一个测试。此检查每秒可以执行多次,并且需要非常快。在编写本文时 (OTP-22),logger 使用 ETS 表来保存其所有配置,其中包括主要的日志记录级别。

这并不是很理想,因为从 ETS 表中查找意味着我们必须获取读锁以防止对该值的并行写入。获取这样的读锁并不是非常昂贵,但是当每秒执行数千次时,它会累积起来。

因此,在 此 PR 中,我使用了 persistent_term 作为主要日志记录级别的缓存。现在,当从热路径读取值时,logger 将改为使用 persistent_term。这会从热路径中删除所有锁,我们只需要在 persistent_term 哈希表中查找即可。

但是,如果我们需要更新主要的日志记录级别怎么办?我们不是会强制进行全局 GC 吗?不会,因为表示主要日志记录级别的小整数是立即数。这意味着该值适合一个机器字,并且始终完整地复制到调用进程。反过来,这意味着我们不必在替换该值时进行全局 GC。

执行此操作时,我们必须非常小心,以使该值不会成为堆值,因为更新的成本将会飙升。但是,它对 logger 非常有效,并且在不应进行日志记录时,将 ?LOG_INFO 调用的开销降低了约 65%。

大型常量数据 #

我们在 OTP 团队内部使用一个名为“票务工具”的内部工具。它基本上管理你在每个 Erlang/OTP 版本随附的发行说明中看到的 所有 OTP-XYZ 票证。它是一个 90 年代末或 00 年代初的古老工具,没有人真正想碰它。

其中一部分是一个服务器,其中包含大约 17000 个已创建的票证的缓存。在该服务器中,有一个单独的进程具有每个票证及其状态,以便加快在票证中的搜索速度。此进程的状态非常大,当它执行 GC 时,大约需要 10 秒钟才能完成。这意味着大约每 10 分钟,服务器就会冻结 10 秒钟,我们会体验到一段时间作为 Java 程序员的乐趣。

作为 VM 开发人员,我一直认为解决此问题的方法是为大型堆实现增量 GC 或至少是标记和清除 GC。但是,票务工具服务器的优先级从未高到足以让我花一两年时间重写 GC。

因此,两周前,我决定看一看,而是使用 persistent_term 将数据从堆移动到文字区域。之所以可以做到这一点,是因为我知道大多数票证仅被搜索而从未更改过,因此它们将永远保留在文字区域中,而确实被编辑的票证会移动到票证服务器的堆中。基本上,我的代码更改是这样的

handle_info(timeout, State) ->
  persistent_term:put(?MODULE,State),
  erlang:start_timer(60 * 60 * 1000, self(), timeout),
  {noreply,persistent_term:get(?MODULE)}.

这个小小的更改将整个 gen_server 状态放入文字区域,然后对其进行的任何更改都会将数据拉入堆中。这使 GC 暂停时间降至不明显,并且实现的时间比新的 GC 算法少得多。