查看源代码 线程进度
问题
了解线程何时完成对数据结构的访问
当多个线程访问同一数据结构时,通常需要知道所有线程何时完成了它们的访问。例如,为了知道何时可以安全地释放数据结构。实现此目的的一个简单方法是对数据结构的所有访问进行引用计数。这种方法的问题在于,引用计数器所在的缓存行需要在所有相关处理器之间进行通信。这种通信可能非常昂贵,并且如果频繁访问引用计数器,则会难以扩展。也就是说,我们希望使用除引用计数之外的其他方法来跟踪线程。
了解内存修改是否被一致地观察到
不同的硬件架构有不同的内存模型。一些架构允许非常激进的内存访问重排序,而另一些架构只重排序一些特定的情况。然而,所有现代硬件的共同点是,都会发生某种类型的重排序。当使用锁来保护多个线程进行的所有内存访问时,这种重排序将不可见。锁定原语将确保内存访问被排序。然而,当使用无锁算法时,必须考虑到硬件进行的这种重排序。
硬件内存屏障或内存栅栏是可以用来强制内存访问顺序的指令。不同的硬件架构提供不同的内存屏障。无锁算法需要使用内存屏障,以确保内存访问不会以导致算法崩溃的方式重新排序。内存屏障也是昂贵的指令,因此通常希望最小化这些指令的使用。
用于解决这些问题的功能
Erlang 虚拟机中的“线程进度”功能用于解决这些问题。“线程进度”这个名称的选择是因为我们希望使用它来确定一组线程中的所有线程何时取得了进展,从而使两个特定事件在它们所有线程中都发生过。
我们感兴趣的线程集合称为受管理线程。受管理线程是我们获取任何信息的唯一线程。这些线程必须频繁报告进度。并非系统中的所有线程都能够频繁报告进度。此类线程不允许在受管理线程集中,并称为未管理线程。未管理线程的一个示例是异步线程池中的线程。异步线程可能会被阻塞很长时间,因此无法频繁报告进度。目前,只有调度器线程和其他几个线程是受管理线程。
线程进度事件
系统中的任何线程都可以使用线程进度功能来确定以下事件是否在所有受管理线程中至少发生过一次
- 线程已从其他代码返回到线程进度功能中已知的状态,该状态独立于任何其他代码。
- 线程已执行完整的内存屏障。
当然,这些事件需要与其他内存操作有序发生。确定此操作的过程首先是启动线程进度操作。启动线程进度操作的线程在此之后轮询操作的完成情况。这两个事件必须在启动线程进度操作之后,以及在每个受管理线程中完成操作之前至少发生一次。这是通过内存通信排序的,这使得可以在线程进度操作完成后得出关于内存状态的结论。让我们将从启动到完成的进度称为“线程进度”。
假设线程进度功能是高效的,那么与使用首先想到的方法相比,许多算法都可以简化并提高效率。下面是一些示例。
通过能够确定上面的第一个事件何时发生,我们可以轻松知道所有受管理线程何时完成了对数据结构的访问。这可以通过以下方式确定。我们有一个使用数据结构 D
的一些功能 F
的实现。对 D
的引用总是在访问 D
之前查找的,并且在离开实现 F
的代码之前,总是会删除对 D
的引用。如果我们删除查找 D
的可能性,然后等待第一个事件发生在所有受管理线程中,那么没有受管理线程可以拥有对数据结构 D
的任何引用。例如,可以通过使用引用计数来实现这一点,但是包含引用计数器的缓存行在这种情况下会在每次访问 D
时在所有访问 D
的处理器之间来回传递。
通过能够确定第二个事件何时发生,可以很容易地对内存进行复杂的修改,这些修改需要被其他线程一致地看到,而无需诉诸锁定。通过进行修改,然后发出一个完整的内存屏障,然后等待第二个事件在所有受管理线程中发生,然后发布修改,我们知道所有读取此内存的受管理线程将获得修改的一致视图。读取此内存的受管理线程根本不必发出任何额外的内存屏障。
线程进度功能的实现
对实现的要求
为了能够确定所有受管理线程何时达到我们感兴趣的状态,我们需要在所有涉及的线程之间进行通信。当然,我们希望最小化这种通信。
我们还希望线程能够相对快速地确定何时取得了线程进度。也就是说,我们需要在通信开销和完成操作的时间之间取得某种平衡。
API
我将在此处仅介绍 API 中最重要的功能。
-
ErtsThrPrgrVal erts_thr_progress_later(void)
- 操作的启动。返回的线程进度值可用于测试操作的完成情况。 -
int erts_thr_progress_has_reached(ErtsThrPrgrVal val)
- 当我们达到作为参数传递的线程进度值时,返回一个非零值。也就是说,当返回非零值时,操作已完成。
当线程调用 my_val = erts_thr_progress_later()
并等待 erts_thr_progress_has_reached(my_val)
返回非零值时,它知道已取得线程进度。
在等待 erts_thr_progress_has_reached()
返回非零值时,我们通常不希望阻塞等待,而是希望继续处理其他内容。如果我们没有其他要处理的内容,我们通常希望阻塞等待,直到我们达到我们正在等待的线程进度值。为了能够做到这一点,我们提供了在达到特定线程进度值时唤醒线程的功能
-
void erts_thr_progress_wakeup(ErtsSchedulerData *esdp, ErtsThrPrgrVal val)
- 请求唤醒。当线程进度达到 val 时,将唤醒调用线程。
受管理线程需要通过调用以下函数频繁更新其线程进度
-
int erts_thr_progress_update(ErtsSchedulerData *esdp)
- 更新线程进度。如果返回非零值,则必须在不持有任何锁的情况下调用erts_thr_progress_leader_update()
。 -
int erts_thr_progress_leader_update(ErtsSchedulerData *esdp)
- 领导者更新线程进度。
未管理线程可能会延迟线程进度的进行
-
ErtsThrPrgrDelayHandle erts_thr_progress_unmanaged_delay(void)
- 延迟线程进度。 -
void erts_thr_progress_unmanaged_continue(ErtsThrPrgrDelayHandle handle)
- 让线程进度继续。
调度器线程可以调度一个操作,以便在取得线程进度时由调度器本身执行
void erts_schedule_thr_prgr_later_op(void (*funcp)(void *), void *argp, ErtsThrPrgrLaterOp *memp)
- 调度对funcp
的调用。自从调用erts_schedule_thr_prgr_later_op()
以来取得线程进度时,将执行调用(*funcp)(argp)
。
实现
为了确定事件何时发生,我们使用一个全局计数器,当所有受管理线程都调用了 erts_thr_progress_update()
(或 erts_thr_progress_leader_update()
) 时,该计数器会递增。这可以天真地使用“线程确认”计数器来实现。然而,这将导致通信爆炸,其中所有涉及的处理器需要在每次更新时相互通信。
与其在全局位置确认,不如每个线程都在自己的缓存行中确认它接受全局计数器的递增。这些确认缓存行按顺序位于一个数组中,并且每个确认缓存行仅由一个线程写入。其中一个受管理线程始终具有领导者责任。此责任可能会在线程之间跳转,但是只要系统中存在一些活动,其中始终有一个线程将具有领导者责任。具有领导者责任的线程将调用 erts_thr_progress_leader_update()
,它将检查所有其他线程是否在递增全局计数器之前确认了全局计数器的递增。领导者线程是唯一读取确认缓存行的线程。
通过这种方式,我们将获得信息从领导者线程流向所有其他受管理线程,然后再从其他线程流回领导者线程的通信模式。这是因为只有领导者线程才会写入全局计数器,而所有其他线程只会读取它,并且因为每个确认缓存行只会由一个特定的线程写入,并且仅由领导者线程读取。当每个受管理线程分布在不同的处理器上时,处理器之间的通信将反映线程之间的这种通信模式。
从 erts_thr_progress_later()
返回的值等于此线程最新确认的值加二。全局值可能是最新确认的值或最新确认的值减一。为了确保所有其他受管理线程在达到从 erts_thr_progress_later()
返回的值之前,实际上至少调用一次 erts_thr_progress_update()
,全局计数器加一是不够的。这是因为在我们调用 erts_thr_progress_later()
时,所有其他线程可能已经确认了当前全局值加一。但是,他们保证此时没有确认全局值加二。
上述描述的实现或多或少地最大限度地减少了在我们可以递增全局计数器之前所需的通信。然而,系统中由于线程进度功能而产生的通信量还取决于托管线程调用 erts_thr_progress_update()
的频率。如今,每个调度器线程或多或少地在每次 Erlang 进程被调度出时调用 erts_thr_progress_update()
。进一步减少由于线程进度功能而产生的通信的一种方法是,仅在每次 Erlang 进程被调度出时每隔一次、两次或三次,甚至更低的频率调用 erts_thr_progress_update()
。然而,通过降低线程进度更新的频率,所有依赖于线程进度功能的操作也会花费更长的时间。
非托管线程延迟线程进度
为了实现非托管线程的线程进度延迟,我们使用了两个引用计数器。一个是 current
,另一个是 waiting
。当一个非托管线程想要延迟线程进度时,它会递增 current
并获得一个指向它递增的引用计数器的句柄。当它稍后想要启用线程进度的继续时,它会使用该句柄来递减它之前递增的引用计数器。
当 leader 线程即将递增全局线程进度计数器时,它会验证 waiting
计数器是否为零。如果不是零,则不允许 leader 递增全局计数器,并且需要等待才能这样做。当它为零时,它会在增加全局计数器之前交换 waiting
和 current
计数器。从现在开始,新的 waiting
计数器将减少,以便它最终将达到零,从而使其有可能在下次递增全局计数器。如果我们只使用一个引用计数器,它可能会因不同的非托管线程而永远保持在零以上。
当非托管线程递增 current
计数器时,它不会阻止全局计数器的下一次递增,而是阻止之后的那次递增。这已经足够了,因为全局计数器需要递增两次才能完成线程进度。不阻止第一次递增也是可取的,因为在全局计数器被延迟递增之前,延迟被撤回的可能性增加了。也就是说,该操作将尽可能少地造成中断。
然而,这种非托管线程延迟线程进度的特性最好尽可能少地使用,因为大量使用会导致引用计数器缓存行的争用。然而,该功能在通常仅在托管线程中执行,但有时在一些不常见的情况下可能在其他线程中执行的代码中非常有用。
开销
无论使用多少次该功能,线程进度功能造成的开销或多或少是固定的,前提是使用相同数量的调度器。如今,已经有很多功能使用了它,我们计划更多地使用它。当重写 ERTS 内部功能的旧实现以使用线程进度功能时,这意味着要删除旧实现中的通信。否则,重写旧实现以使用线程进度功能就毫无意义。由于线程进度开销或多或少是固定的,因此重写将导致系统中总通信量的减少。
一个例子
ETS 表的主要结构最初是使用引用计数进行管理的。很久以前,我们就替换了这种策略,因为引用计数器导致每次访问表时都会发生争用。使用的解决方案是在每个调度器上调度“确认删除”作业,以便知道何时可以安全地释放已删除表的表结构。这些确认删除作业需要被分配。也就是说,我们需要分配和释放与调度器数量相同的块,才能释放一个块。当然,这是一个相当昂贵的操作,但我们只需要在删除表时执行此操作一次。更重要的是要摆脱表上每次操作都存在的引用计数器上的争用。
在引入线程进度功能后,我们可以删除实现“确认删除”作业的代码,然后只调度一个线程进度稍后操作来释放结构。除了大大简化代码外,我们在四核机器上执行的 mnesia tpcb 基准测试中,每秒处理的事务数增加了 10% 以上。