Erlang的非阻塞代码加载

这是Erlang的运行时Erts的内部文档,重点介绍了Erts是如何加载代码的

原文: Non-blocking code loading

介绍

在OTP R16之前,是使用单线程模式进行代码加载操作,并且在进行Erlang代码加载的时候,VM中所有的其它操作都会停止。对于VM启动时首次加载模块,这并不是个问题,但是对正在执行的VM升级模块或者增加新的代码时,这对可用性就是非常严重的问题了。这个问题随着CPU的内核数量增加而愈加严重,因为等待所有调度器停止的时间,以及暂停时正在执行的任务的数量都会增加。

在OTP R16中,模块的加载不在会造成VM阻塞。在整个加载的过程中,Erlang中的Erlang进程,会不受打扰的持续执行。代码加载将由一个正常的Erlang进程执行,该进程和其它进程一样被调度。当加载过程完成了,加载操作会使用一个原子性操作使所有的进程都能看到新的代码。运行在SMP上的Erlang系统在加载/升级模块时,非阻塞代码加载将提高实时特性。

加载阶段

一个模块的加载分为两个阶段; 准备阶段和完成阶段。 准备阶段包含了读取BEAM文件和所有将代码加载到ERTS中的所有准备工作,这些准备工作可以在不影响当前执行中的代码的情况下轻松完成。完成阶段将使已加载(和准备好的)代码可以被正在执行的代码访问。完成阶段也会将模块的旧版本(已经被替换的或者被删除的)设置为不可访问。

准备阶段的设计,准许多个“加载器”进程并行的准备不同的模块,而完成阶段每次只准许一个加载进程执行。如果有第二个加载器试图执行完成阶段,它会被挂起直到第一个加载器执行完完成阶段。这只会阻塞这个Erlang进程,调度器可以在第二个加载进程被挂起的时候去调度其它的进程(细节可以在erts_try_seize_code_write_permissionerts_release_code_write_permission中找到)。

并行准备多个模块的特性并没有被启用,因为几乎所有的代码都是被code_server进程序列化加载的。但是在BIF上,已经做好了相应的准备。

1
2
  erlang:prepare_loading(Module, Code) -> LoaderState
  erlang:finish_loading([LoaderState])

这里的设想是,prepare_loading可以并行的被调用去加载不同的模块并且为每个模块返回“magic binary",其中包含所有准备好的模块的内部状态。函数finish_loading将获取包含这些状态列表,并且一次性完成所有状态的加载。

当前,我们依然在使用旧的BIF,erlang:load_module,它现在在Erlang中通过顺序的调用前面的两个函数来实现。由于我们并没使用多模块加载功能,因此函数finish_loading仅限于接受包含一个模块的状态的列表。

完成序列

在执行阶段,代码可以通过多个数据结构进行访问。这些代码结构分别是:

  • 导出表。包含所有导出函数。
  • 模块表。包含所有已经加载的模块。
  • “beam_caches”。标记catch执行的跳转目标。
  • “beam_ranges”。将代码地址映射到源文件中的函数和行。

这些结构中最常用的是导出表,在执行时,每次代码要执行外部调用时都会访问该表去查找被调用者的地址。出于性能考虑,我们希望在访问这些结构的时候,不要有任何的线程同步开销。早期,我们是通过紧急中断来解决的。在我们要改变这些代码时,我们会停止掉整个VM,剩下的时间我们都认为这是只读的。(译注:可以参考我之前的文章Linux的线程和信号中介绍的让JVM停机的方法)。

在R16中,我们的解决方案是对数据进行 复制 。我们有一组可以被正在执行的代码访问的主代码访问数据。当新的代码被加载时,主代码访问数据会被复制,并且会对副本进行更新,更新会包含新加载的模块,然后会将更新后的副本设置为主代码访问数据。这个主代码访问数据使用全局的原子量the_active_code_index进行标识。因此可以通过单个原子写操作进行切换。 当正在运行的代码需要访问主代码访问数据的时候,就必须读取这个原子量,例如,每次需要调用外部函数就需要进行一次原子量读取。并且,这种额外原子读取的性能损失非常小,因为它可以在没有任何内存屏障的情况下完成(如下所述)。并且通过这个方案,我们还可以获得事务性加载代码这一特性。正在执行的代码,永远不会看到被部分加载的模块。

完成阶段是有BIF,erlang:finish_loading按照以下顺序完成的:

  1. 获取独占代码写入权限(如果需要会暂停进程直到我们得到该权限)。
  2. 制作所有的主代码访问数据的完整副本。这个副本被成为暂存区,使用全局的原子变量the_staging_code_index进行标识。
  3. 更新临时区域中的所有数据以包含新准备的模块。
  4. 调度一个线程进度事件。这是未来所有的调度器都暂停并执行一个完整内存屏障的时候。
  5. 暂停加载进程。
  6. 在完成线程进度后,将暂存区的the_staging_code_index提交为the_active_code_index
  7. 释放代码写入权限,允许其他进程更新代码。
  8. 恢复加载进程,让它从erlang:finish_loading中返回。

线程进度

在4–6步中等待线程进度是必要的,以便进程在正常执行期间无需付出使用内存屏障这种巨大的性能代价,就能读取the_active_code_index原子量。当我们在步骤6中将新的值写入the_active_code_index时,我们可以明确的知道,当the_active_code_index可以再次被访问后,所有的调度器都将看到一个被更新的且一致的全新主代码访问数据。

如果在读取the_active_code_index时完全没有使用内存屏障将会有一个有趣的结果。不同的进程可能会在不同的时间点看到新的代码,这完全取决于CPU核心何时会去刷新硬件缓存。这听起来很不安全,但是实际上对Erlang来说并不很重要。我们必须保证的唯一特性是查看新代码的能力必须随着进程通信而传播。在收到由新代码触发的消息后,必须保证接收进程也能看到新代码。这是可以被保证的,因为所有类型的进程通信都涉及内存屏障,以便接收方能正确的阅读发送方所写的内容。 这个隐式的内存屏障也使接收进程读取到了the_active_code_index的新值,从而可以看到全新的代码。这不仅适用于Erlang消息,而且适用于各种进程间通信(TCP、ETS、进程名称注册、跟踪、驱动程序、NIF等)。

代码索引重用

为了优化步骤2中的复制操作,代码访问数据会被重用。在当前的解决方案中,我们有三组代码访问数据,由代码索引0、1和2标识。这些索引以循环方式使用。因此不必为每个加载操作初始化所有访问结构的全新副本,我们只需要更新自上两个代码加载操作以来发生的更改。 我们可以只使用两个代码索引(0和1),但这将需要在完成序列中的第2步之前增加一轮线程进度等待。因为在我们知道所有的调度器已经不在使用某个代码索引作为主索引时,我们才可以重用该代码索引作为暂存区。有了三代代码索引,步骤4–6的线程进度等待就可以保证我们能正确的重用索引。线程进度将等待所有正在运行的调度器至少重新调度一次。在第二轮的线程进度之后,当前正在执行的代码在读取代码访问数据时,不会读取到the_active_code_index中旧值。

这是在内存消耗和代码加载延迟之间权衡后的设计方案。

一致的代码视图

某些BIF可能需要获得一个当前代码的一致性快照。因此,只读取the_active_code_index一次,并在整个BIF执行期间只使用读取到的值进行代码访问。如果BIF执行时,发生了加载操作,二次读取the_active_code_index可能会导致读取到不同的值,从而导致不一致的代码视图。