多对一并行信号发送优化

2021年11月05日 · 作者:Kjell Winblad

这篇博文讨论了最近合并到主分支的并行信号发送优化(计划包含在 Erlang/OTP 25 中)。当多个进程在多核机器上同时向单个进程发送信号时,此优化提高了信号发送吞吐量。目前,只有当接收进程配置了 {message_queue_data, off_heap} 设置时,此优化才处于活动状态。下图给出了在极端情况下优化可以带来的可伸缩性改进的概念(x 轴表示发送信号的 Erlang 进程数,y 轴表示吞吐量)

alt text

这篇博文旨在帮助您了解 Erlang 中如何在单个节点上实现信号发送,以及新的优化如何产生上图中所示的令人印象深刻的可伸缩性改进。让我们从简要介绍什么是 Erlang 信号开始。

Erlang 信号 #

Erlang 系统中所有并发执行的实体(进程、端口等)都使用异步信号进行通信。最常见的信号是通常使用 bang (!) 运算符在进程之间发送的普通消息。由于 Erlang 以成为并发编程语言而自豪,因此,当然,在不同实体之间有效地发送信号至关重要。现在让我们讨论一下 Erlang 程序员在信号发送顺序方面得到哪些保证,这将有助于了解新的优化是如何工作的。

信号顺序保证 #

信号顺序保证在 Erlang 文档中是这样描述的

“唯一给出的信号顺序保证如下:如果一个实体向同一目标实体发送多个信号,则顺序会得到保留;也就是说,如果 AB 发送信号 S1,然后向 B 发送信号 S2,则保证 S1 不会在 S2 之后到达。”

此保证意味着,如果多个进程向单个进程发送信号,则来自同一进程的所有信号都将按照发送顺序在接收进程中接收。但是,对于来自两个不同进程的两个信号,则没有顺序保证。不应将信号发送视为瞬时。信号发送后到到达目标之间可能存在任意延迟,但从 AB 的所有信号都沿着同一路径传播,并且不能相互越过。

该保证经过精心设计,旨在允许高效的实现并允许未来的优化。但是,正如我们在下一节中将看到的那样,在本文中提出的优化之前,该实现并未利用对在同一节点上运行的进程之间发送的信号的宽松顺序保证。

优化之前的单节点进程到进程实现 #

从概念上讲,在优化之前,Erlang VM 将 Erlang 进程的数据结构组织成如下图所示

alt text

当然,这是 Erlang 进程结构的极端简化,但这足以进行我们的解释。当进程激活了 {message_queue_data, off_heap} 设置时,将执行以下算法来发送信号

  1. 分配一个包含信号数据的新链表节点
  2. 在接收进程中获取 OuterSignalQueueLock
  3. 将新节点插入到 OuterSignalQueue 的末尾
  4. 释放 OuterSignalQueueLock

当接收进程的 InnerSignalQueue 中没有信号时,或者想要检查外部队列中是否有更多信号时,将执行以下算法

  1. 获取 OuterSignalQueueLock
  2. OuterSignalQueue 追加到 InnerSignalQueue 的末尾
  3. 释放 OuterSignalQueueLock

当接收进程配置为 {message_queue_data, on_heap} 时,信号发送的工作方式与本文的主要主题不太相关。但是,了解 {message_queue_data, on_heap} 的工作原理也将使您理解为什么当进程配置为 {message_queue_data, on_heap}(这是默认设置)时,不会启用并行信号队列优化,因此,这是将信号发送到此类进程的算法

  1. 尝试使用 try_lock 调用来获取 MainProcessLock
    • 如果 try_lock 调用成功
      1. 在进程的主堆区域上为信号数据分配空间,并将信号数据复制到该空间
      2. 分配一个包含指向进程堆分配的信号数据的指针的链表节点
      3. 获取 OuterSignalQueueLock
      4. 将链表节点插入到 OuterSignalQueue 的末尾
      5. 释放 OuterSignalQueueLock
      6. 释放 MainProcessLock
    • 否则
      1. 分配一个包含信号数据的新链表节点
      2. 获取 OuterSignalQueueLock
      3. 将新节点插入到 OuterSignalQueue 的末尾
      4. 释放 OuterSignalQueueLock

{message_queue_data, off_heap} 相比,{message_queue_data, on_heap} 的优势在于信号数据直接复制到接收进程的主堆中(当 MainProcessLocktry_lock 调用成功时)。{message_queue_data, on_heap} 的缺点是发送者会在接收者的 MainProcessLock 上创建额外的争用。请注意,我们不能在将数据分配到接收者进程堆之后立即释放 MainProcessLock。如果在将信号插入到进程堆之前发生垃圾回收,则信号数据将丢失(持有 MainProcessLock 可防止发生垃圾回收)。因此,当多个进程在多核系统上同时向同一进程发送信号时,{message_queue_data, off_heap}{message_queue_data, on_heap} 提供更好的可伸缩性。

但是,即使 {message_queue_data, off_heap} 在旧实现中比 {message_queue_data, on_heap} 具有更好的扩展性,信号发送者仍然必须在短时间内获取 OuterSignalQueueLock。当存在足够的并行发送者时,此锁可能会成为可伸缩性瓶颈和争用热点。这就是为什么我们在上面的基准测试图中看到旧实现的扩展性非常差,甚至出现减速的原因。现在,我们准备好了解新的优化。

并行信号发送优化 #

该优化利用了上面讨论的 Erlang 的宽松信号顺序保证。只需保持来自同一实体的信号顺序,即可确保信号顺序保证成立。因此,不同的发送者之间无需相互同步!从理论上讲,信号发送可以完全并行化。但是,在实践中,只有一个执行线程处理传入信号,因此我们还必须记住,我们不希望减慢接收者的速度,并且理想情况下要使接收信号的速度更快。由于当启用 {message_queue_data, off_heap} 设置时,信号队列数据存储在进程主堆区域之外,因此垃圾回收器无需遍历整个信号队列,从而为信号队列中包含大量信号的进程提供更好的性能。因此,对于优化而言,当 OuterSignalQueueLock 未争用时,不添加不必要的开销也很重要,这样我们就不会过多地减慢现有 {message_queue_data, off_heap} 用例的速度。

优化实现的鸟瞰图和数据结构 #

我们决定采用一种设计,当 OuterSignalQueueLock 上的争用似乎很高时,按需启用并行信号发送优化,以尽可能避免不必要的开销。这是在未激活优化时(这是使用 {message_queue_data, off_heap} 创建进程时的初始状态)进程结构的概念视图

alt text

下图显示了启用并行信号发送优化时进程结构的概念视图。此图与上一图的唯一区别是 OuterSignalQueueBufferArray 字段现在指向一个包含带缓冲区的数组的结构。

alt text

当并行信号发送优化处于活动状态时,发送者不再需要获取 OuterSignalQueueLock。发送者通过一个简单的哈希函数映射到 OuterSignalQueueBufferArray 中的一个槽,该哈希函数应用于进程 ID(没有进程 ID 的发送者当前映射到同一个槽)。在发送者获取接收进程结构中的 OuterSignalQueueLock 之前,发送者会尝试在其 OuterSignalQueueBufferArray 中的槽中排队(如果存在)。如果排队尝试成功,则发送者可以继续,甚至无需触碰 OuterSignalQueueLock!来自同一发送者的信号顺序得以保持,因为同一发送者始终映射到缓冲区数组中的同一槽。现在,您可能已经了解了为什么通过新的优化,信号发送吞吐量可以大大提高,就像我们在前面介绍的基准测试图中看到的那样。本质上,OuterSignalQueueLock 上的争用分散到 OuterSignalQueueBufferArray 中的槽中。本节的其余小节介绍了实现的细节,如果您不想深入了解,可以跳过这些小节。

自适应激活外部信号队列缓冲区 #

如上图试图说明的那样,OuterSignalQueueLock 携带一个统计计数器。当该统计计数器达到某个阈值时,将通过在进程结构中安装 OuterSignalQueueBufferArray 来激活新的并行信号发送优化。锁的统计计数器以一种简单的方式更新。当线程尝试获取 OuterSignalQueueLock 并且锁已被占用时,计数器会增加,否则,计数器会减少,如下面的代码片段所示

void erts_proc_sig_queue_lock(Process* proc)
{
    if (EBUSY == erts_proc_trylock(proc, ERTS_PROC_LOCK_MSGQ)) {
        erts_proc_lock(proc, ERTS_PROC_LOCK_MSGQ);
        proc->sig_inq_contention_counter += 1;
    } else if(proc->sig_inq_contention_counter > 0) {
        proc->sig_inq_contention_counter -= 1;
    }
}

外部信号队列缓冲区数组结构 #

目前,OuterSignalQueueBufferArray 中的槽数固定为 64。在当今存在的大多数实际应用中,64 个槽应该足以减少信号队列争用。很少有服务器具有 100 多个内核,并且典型的应用程序会花费大量时间来执行除发送信号之外的其他操作。使用 64 个槽还允许我们实现一个非常高效的原子可更新位集,其中包含有关哪些槽当前非空的信息(上图中的 NonEmptySlots 字段)。此位集使将缓冲区数组刷新到 OuterSignalQueue 的效率更高,因为只需访问和更新缓冲区数组中非空的槽即可执行刷新。

激活优化后的信号发送 #

当进程向另一个安装了 OuterSignalQueueBufferArray 的进程发送信号时执行的算法的伪代码如下所示

  1. 分配一个包含信号数据的新链表节点
  2. 使用哈希函数将发送者的进程 ID 映射到正确的槽 I
  3. 获取槽 ISlotLock
  4. 检查槽 IIsAlive 字段
    • 如果 IsAlive 字段的值为 true
      1. 如果缓冲区为空,则在 NonEmptySlots 字段中设置适当的位
      2. 将分配的信号节点插入到槽 IBufferQueue 的末尾
      3. 将槽 I 中的 NumberOfEnqueues 增加 1
      4. 释放槽 ISlotLock
      5. 信号已入队,线程可以继续执行下一个任务
    • 否则(OuterSignalQueueBufferArray 已停用)
      1. 释放槽 I 的锁
      2. 以与优化之前的信号发送算法相同的方式插入到 OuterSignalQueue

从外部信号队列缓冲区数组获取信号和停用优化 #

从外部信号队列获取信号的算法使用 OuterSignalQueueBufferArray 中的 NonEmptySlots 字段,因此它只需要检查那些保证为非空的槽位。从高层次来看,该例程的工作方式如下伪代码所示:

  1. 获取 OuterSignalQueueLock
  2. 对于缓冲区数组中的每个非空槽位
    1. 锁定该槽位
    2. 将该槽位中的信号追加到 OuterSignalQueue 的末尾
    3. 将该槽位的 NumberOfEnqueues 字段的值添加到 OuterSignalQueueBufferArray 中的 TotNumberOfEnqueues 字段
    4. 重置该槽位的 BufferQueueNumberOfEnqueues 字段
    5. 解锁该槽位
  3. OuterSignalQueueBufferArray 中的 NumberOfFlushes 字段的值增加 1
  4. 如果 NumberOfFlushes 字段的值已达到某个阈值 T
    • 计算最后 T 次刷新期间的每次刷新平均入队数 (EnqPerFlush),即 (TotNumberOfEnqueues / T)。
      • 如果 EnqPerFlush 低于某个阈值 Q
        • 停用并行信号发送优化
          1. 对于 OuterSignalQueueBufferArray 中的每个槽位
            1. 获取 SlotLock
            2. 将该槽位中的信号(如果有)追加到 OuterSignalQueue 的末尾
            3. 将该槽位的 IsAlive 字段设置为 false
            4. 释放 SlotLock
          2. 将进程结构中的 OuterSignalQueueBufferArray 字段设置为 NULL
          3. 调度释放缓冲区数组结构
      • 否则,如果平均值等于或高于阈值 Q
        • 将缓冲区数组结构中的 NumberOfFlushesTotNumberOfEnqueues 字段设置为 0
  5. OuterSignalQueue 追加到 InnerSignalQueue 的末尾
  6. 重置 OuterSignalQueue
  7. 释放 OuterSignalQueueLock

为了简单起见,上面的伪代码片段省略了很多细节。但是,如果您理解了它们,那么您就对 Erlang 中的信号发送工作原理、新优化的实现方式以及它是如何自动激活和停用的有了很好的理解。现在,让我们更深入地研究一下新实现的基准测试结果。

基准测试 #

创建了一个可配置的基准测试,以衡量信号发送进程和接收进程的性能。该基准测试允许 N 个 Erlang 进程在 T 秒的时间内向单个进程发送信号(具有可配置的类型和大小)。NT 都是可配置的变量。大小为 S 的信号的有效负载包含一个长度为 S 的列表,其中包含字大小(64 位)的项目。发送吞吐量是通过将发送的信号数量除以 T 来计算的。接收吞吐量是通过等待直到所有发送的信号都被接收,然后将发送的信号总数除以第一个信号被发送和最后一个信号被接收之间的时间来计算的。基准测试机器具有 32 个内核,每个内核有两个硬件线程(总共 64 个硬件线程)。您可以在 信号队列基准测试页面上找到详细的基准测试描述。

首先,让我们看一下下面非常小的消息(包含单个整数的列表)的结果。接收吞吐量的图与我们在这篇博文开头看到的相同。毫不奇怪,优化后发送消息的可扩展性要好得多。更令人惊讶的是,接收消息的性能也得到了显着提高。例如,对于 16 个进程,优化后的接收吞吐量提高了 520 倍!接收吞吐量的提高可以用以下事实来解释:在这种情况下,接收器必须更少地从外部信号队列中获取消息。优化后发送速度更快,因此接收器每次用完消息时都会从外部信号队列将更多消息带到内部。因此,发送器可以在需要再次从外部队列获取消息之前,处理来自内部队列的消息更长时间。我们不能期望接收器在超过一定点后有任何改进,因为只有一个硬件线程可以同时处理消息。

alt text

以下是较大消息(包含 100 个整数的列表)的结果。在这种具有较大消息大小的情况下,我们没有获得那么好的改进。对于较大的消息,基准测试花费更多的时间进行其他工作,而不是发送和接收消息。诸如内存系统的速度和内存分配之类的事情可能会成为限制因素。尽管如此,如下所示,我们在发送吞吐量和接收吞吐量方面都获得了相当不错的改进。

alt text

您可以在 基准测试页面上找到更大消息以及非消息信号的结果。真正的 Erlang 应用程序不仅仅进行消息和信号发送,因此此基准测试当然不能代表真实应用程序将获得的改进类型。但是,基准测试表明,我们已经将并行发送消息到单个进程成为问题的阈值提高了。也许新的优化开启了编写软件的新颖有趣的方式,而以前由于性能原因,这些方式是不切实际的。

可能的未来工作 #

用户可以使用 {message_queue_data, off_heap}{message_queue_data, on_heap} 配置进程。这种可配置性增加了 Erlang 程序员的负担,因为很难确定哪个对于特定进程更好。因此,拥有一个 {message_queue_data, auto} 选项也很有意义,该选项将自动检测即使在 on_heap 模式下的锁争用,并根据检测到的争用程度在 on_heapoff_heap 之间无缝切换。

如前所述,信号队列缓冲区数组中的 64 个槽位是一个良好的开端,但在服务器具有数千个内核时可能不够。使实现更具可扩展性的一种可能方法是使信号队列缓冲区数组可扩展。例如,可以在数组中的每个槽位使用争用检测锁。如果特定槽位中的争用很高,则可以通过创建指向带有缓冲区的子数组的链接来扩展此槽位,其中发送器可以使用另一个哈希函数(类似于 HAMT 数据结构的工作方式)。

结论 #

当多个进程并行向同一进程发送信号时,新的并行信号队列优化会影响配置为 {message_queue_data, off_heap} 的进程,从而产生更好的可扩展性。当争用较低时,该优化的开销非常低,因为它仅在其争用检测机制指示争用较高时才被激活。