使用fork(2)实现Python测试迭代速度提升10倍

本文详细介绍了Benchling工程团队如何利用Unix的fork(2)系统调用,通过创建进程快照和分层加载模块的策略,将Python测试迭代时间从10秒缩短到1秒,显著提升了开发效率。

10倍提升Python测试迭代速度:使用fork(2)

在Benchling的Build团队,我们某天发现自己处于这样的境地:使用了146个包,这些包又引入了128个传递依赖,总共274个包。我们还花费大量时间等待SQLAlchemy模型初始化。结果是我们的测试工具需要10秒钟来设置。在修改代码后,你会启动测试运行器,等待几秒钟,alt+tab切换到浏览器,分心几分钟,然后发现代码中有拼写错误。

importlib.reload()的局限性

最明显的解决方案是使用标准库中的importlib.reload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import importlib
import sys
import test_harness_stuff  # 耗时10秒
import tests

def rerun_tests(changed_path):
    for mod in sys.modules.values():
        if mod.__file__ == changed_path:
            importlib.reload(mod)
            tests.run_tests()
            break

if __name__ == '__main__':
    setup_file_watcher(rerun_tests)
    tests.run_tests()

这种方法在更改的文件是测试文件(或依赖树中的任何其他叶节点)时效果尚可。但对于几乎任何复杂度的项目,这并不可行。

更好的解决方案:zeus和fork()

如果我们只是不加载你要更改的代码,直到你更改它之后呢?这样我们就不需要进行手术了!

fork()通过复制调用进程来创建一个新进程。子进程和父进程在独立的内存空间中运行。在fork()时,两个内存空间具有相同的内容。由一个进程执行的内存写入不会影响另一个进程。

因此,我们可以使用fork()来快照父进程,导入一些将要更改的代码(应用程序/测试),然后在以后回滚到快照。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import os
import sys
import test_harness_stuff  # 耗时10秒

def run_tests():
    pid = os.fork()
    if pid == 0:  # 子进程
        import tests
        tests.run_tests()
        sys.exit()
    else:  # 父进程
        os.waitpid(pid, 0)

if __name__ == '__main__':
    setup_file_watcher(run_tests)
    run_tests()

类似这样的方法将我们的测试迭代时间从10秒加速到1秒,这是一个改变工作流程的速度改进。

我们的进程树

zeus实际上有一个多级进程树,当文件更改时,它会识别哪个级别导入了它,并终止该进程及其所有祖先。在Benchling我们也这样做:我们根据开发人员处理它们的频率以及它们在依赖树中的位置将模块划分为多个层级,然后在fork后导入每个层级。

额外收获:通过不进行垃圾回收节省内存

一旦开始在os.fork()之后运行Python代码,你就会遇到Instagram面临的相同内存使用问题。

写时复制

Linux内核不会将所有内存从父进程复制到子进程。相反,它为子进程创建新的页表,指向父进程的内存,并将它们都标记为只读。当子进程尝试写入任何内存时,会触发页面错误。

gc_refs

Python的垃圾收集器需要知道哪些对象可以安全释放。为此,每个对象在其头部都有一个gc_refs字段,每当被引用时就会递增。

gc.freeze()

Instagram对这个问题的解决方案是调用gc.freeze()。这告诉解释器所有现有对象应被视为不符合垃圾回收条件,未来的访问不应递增引用计数器。

实现这个非常简单:就在fork()之前调用gc.freeze()!运行典型测试时,我们看到唯一集合大小减少了160 MiB。

不要调用gc.collect()!

现在你可能会想在冻结和fork之前调用gc.collect()。这听起来会节省内存,但这是个坏主意。

通用适用性

我们在这里描述的方法解决了一个我们认为很多人面临的问题——如果你积累了足够的依赖项,你可能会有缓慢的启动/重新加载时间。它在任何具有fork的系统上都能工作(除了没有WSL的Windows之外的所有系统)。

不过有一些注意事项:

  • 你需要能够fork然后继续执行代码
  • 你的语言需要能够在运行时动态加载模块
  • 如果你希望这适用于你的Web服务器,需要做更多工作

此外,只有在根据依赖树中的位置和编辑频率分离模块后,才能实现这些好处。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计