查看源代码 延迟释放

问题

在多线程环境中处理内存分配的一种简单方法是使用全局锁来保护内存分配器,执行内存分配或释放的线程在整个操作过程中必须持有该锁。当然,这种解决方案的可扩展性非常差,因为它会导致严重的锁争用。对此方案的改进是使用多个线程特定的分配器实例。也就是说,每个线程都在其自己的分配器实例中进行分配,该实例由锁保护。在一般情况下,需要在线程之间传递对内存的引用。如果一个线程需要释放来自另一个线程的分配器实例的内存,则可能会发生锁冲突。在 Erlang VM 这样的系统中,频繁地进行内存分配/释放,并且内存引用也在线程之间传递,这种解决方案也会由于锁争用而导致可扩展性差。

用于解决此问题的功能

为了减少由于锁定分配器实例而引起的争用,我们引入了完全无锁的实例,这些实例与每个调度器线程绑定,并为其他线程引入了一个额外的锁定实例。系统中的调度器线程有望完成大部分工作。可能仍然需要其他线程,但不应执行任何主要和/或时间关键的工作。锁定分配器实例上出现的有限的争用或多或少可以忽略不计。

由于我们仍然需要在调度器线程之间传递对内存的引用,因此我们需要某种方式来管理它。属于一个调度器线程的分配器实例只能由该调度器线程操作。当其他线程需要释放来自外部分配器实例的内存时,它们仅将内存块传递到附加到原始分配器实例的包含释放作业的“消息框”。当调度器线程检测到此类释放作业时,它将执行实际的释放。

“消息框”是使用通过要释放的内存块的无锁单链表实现的。此列表中元素的顺序并不重要。新空闲块的插入将在列表末尾附近的某个位置进行。要求新块必须插入到末尾会在多个线程同时插入大量内存块时导致不必要的争用。

引用此单链表的数据结构覆盖了两个缓存行。一个缓存行包含有关列表头的信息,另一个缓存行包含有关列表尾的信息。这是为了减少此数据结构的缓存行乒乓效应。列表的头将仅由拥有分配器实例的线程操作,而尾将由其他插入释放作业的线程操作。

尾部

在数据结构的尾部,我们找到一个指向列表最后一个元素的指针,或者至少是指向列表末尾附近的某个位置的指针。在无争用的情况下,它将指向列表的末尾,但是当执行同时插入操作时,它将指向列表末尾附近的某个位置。

插入元素时,将尝试将指向新元素的指针写入由最后一个指针指向的元素的下一个指针中。这是使用原子比较和交换完成的,该原子比较和交换期望下一个指针为NULL。如果成功,则执行此操作的线程会将最后一个指针移动到指向新插入的元素。

如果上面描述的原子比较和交换失败,则最后一个指针没有指向最后一个元素。在这种情况下,我们需要将新元素插入到最后一个指针指向的元素与实际的最后一个元素之间的某个位置。如果这样做,当线程停止添加新元素时,最后一个指针最终将位于最后一个元素上。当尝试在末尾附近插入但未能成功时,插入线程有时会移动到下一个元素,有时会再次尝试相同的元素。这是为了在严重争用期间分散插入的元素。也就是说,我们尝试将内存的修改分散到不同的位置,而不是让所有线程继续尝试修改内存中的同一位置。

头部包含指向列表开头的指针(head.first)和指向其他线程可以引用的第一个块的指针(head.unref_end)。这些指针之间的块仅由数据结构的头部引用,该头部仅由拥有分配器实例的线程使用。当这两个指针不相等时,拥有分配器实例的线程会逐块释放,直到head.first到达head.unref_end

当然,我们需要定期将head.unref_end移近末尾,以便能够继续释放内存块。由于在链表中插入新元素的所有线程都将使用最后一个指针进入列表,因此我们可以使用此知识。如果我们调用erts_thr_progress_later()并等待直到我们达到该线程进度,我们知道在调用erts_thr_progress_later()时,没有托管线程可以引用到最后一个指针指向的元素。这是因为,所有托管线程必须至少离开一次实现此操作的代码,并且它们始终通过最后一个指针进入列表。tail.next字段包含有关下一个head.unref_end指针和需要达到的线程进度的信息,之后我们才能移动head.unref_end

不幸的是,不仅线程进度功能管理的线程可能会插入内存块。还需要考虑其他线程。其他线程不会像托管线程那样频繁地使用此功能,因此对它们使用效率较低的方案并不是什么大问题。为了处理非托管线程,我们使用两个引用计数器。当一个非托管线程进入此实现时,它会增加当前使用的引用计数器,而当它离开此实现时,它会减少相同的引用计数器。当消费者线程调用erts_thr_progress_later()以确定何时移动head.unref_end是安全的时,它还会交换非托管线程的引用计数器。先前的当前值表示从此时到此时的未完成引用。新的当前值表示此点之后的未来引用。当消费者线程检测到我们既达到了所需的线程进度,又达到了先前的当前引用计数器变为零时,移动head.unref_end是安全的。

使用两个引用计数器的原因是,我们需要知道引用计数器最终将达到零。如果我们仅使用一个引用计数器,则不同的非托管线程可能会永远将其保持在零以上。

空列表

如果没有新的内存块插入到列表中,则该列表最终应该为空。但是,指向该列表的所有指针都期望始终指向某些内容。这可以通过插入一个空的“标记”元素来解决,该元素仅在没有其他元素时才存在。也就是说,当列表为空时,它仅包含此“标记”元素。

争用

当非拥有分配器实例的线程连续插入元素时,拥有分配器实例的线程将能够在列表的头部几乎不受其他线程干扰地工作。在尾部,大量的同步插入可能会导致争用,但是我们通过在末尾附近分散新元素的插入来减少这种争用,而不是要求所有新元素都插入到末尾。

调度器和锁定的分配器实例

此外,供非调度器线程使用的锁定的分配器实例也像所有其他分配器实例一样,都有一个用于释放作业的消息框。原因是,其他线程可能会分配内存并将其传递给需要释放它的调度器。我们不希望调度器必须等待锁定此锁定实例。由于锁定实例也具有用于释放作业的消息框,因此调度器可以仅插入作业并避免锁定。

基准测试结果

当运行 ehb 基准测试时,会在调度器之间传递大量消息。所有消息传递都会以某种方式导致内存分配和释放。由于消息是在不同的调度器之间传递的,因此我们将在分配消息的分配器实例上发生争用。通过引入延迟释放功能,当在具有超线程的 Intel i7 四核处理器(使用 8 个调度器)的较新机器上运行时,我们获得了 25-45% 的加速,具体取决于基准测试的配置。