返回开发者新闻

在 Cinder 中实现 Python 延迟导入

2022年6月15日发布者:Germán Méndez Bravo

Python 被人们广泛认为是对开发者最为友好的语言之一,由于代码无需编译,因而能够实现快速反馈循环。但是,在 Instagram Server 上大规模使用 Python 时,我们在进行本地开发的过程中发现了严重的可用性问题: 每次更改任何 Python 文件, 开发者都需要等待服务器缓慢地重新加载(平均约 50 秒!),以观察更改产生的影响。

Meta 通过创建“延迟导入”解决了这一问题。“延迟导入”是 Python 运行时功能,可为延迟加载提供透明而稳健的机制。使用此技术后,我们的重新加载成本减少了约 70%,开发者每天可节省数百小时的时间,更快进行循环访问。

一杯凉透的咖啡

一日之计在于晨。您醒来后,为自己泡一杯热咖啡,打开笔记本电脑,开始一天的高效工作。对于白天需要完成的工作,您有许多妙策。您开始执行变基,服务器在您喝咖啡期间重新加载。新的一天开始了!

与往常一样,您编辑了一些文件,因此服务器需要重新加载。重新启动需要一些时间,一切都很顺利。这时,缺陷触发了一个错误,跟其他晦涩难懂的问题一样,您对这个错误一无所知,或者根本不清楚原因何在。您需要添加一些日志记录,于是修改了回溯中列出的一个文件……10 秒、20 秒、60 秒……服务器仍在重新加载……然后突然崩溃!日志行中出现语法错误!您修复了错误,然后保存文件……服务器开始一次又一次地重新加载……2 分钟过后,您才可以查看日志。1 个小时后,您终于修复了这个错误, 罪魁祸首原来是 您两天前删除的一行 import 代码,在获取和变基最新代码后, 这行代码触发了一个模糊的 导入循环。

到现在,一上午已经过去几个小时,而今天还有很多工作要完成,您感到心烦意乱。最糟糕的是, 咖啡已经凉透了

明白了吧,在全天的工作中,您随时可能需要花时间等待服务器重新加载,并且 其他人 也是一样。很快,时间积少成多,几分钟变成几小时,几小时变成几天,全部浪费掉了

服务器重新加载缓慢

启动 Instagram Server,我们 在加载模块方面 投入了大量时间。通常情况下,各个模块彼此密切相关,因此在导入各项内容时很难停止导入多米诺效应

2021 年年末,服务器重新加载大约需要 25 秒。经过 持续多年的 努力,这一时间不断缩减。如果我们未能注意继续优化,重新加载时间很快就会增加,2021 年还曾创下新高。在 2021 年年末,最严重时,部分服务器的重新加载时间长达 1.5 分钟。在此期间,工程师完全可能会分神去做其他有意思的事情,而忘记当下的工作。

为什么服务器会这么慢?

代码库太复杂

导致重新加载速度缓慢的主要原因是 Instagram 的代码库越来越复杂,再加上我们有海量的模块引用了大量内容。

Joshua Lear 用整整一天时间准备了下面这张图,您可以从中 窥见 Instagram Server 代码依赖项关系图的复杂程度。运行经过修改的依赖项可视化脚本 3 小时后,Lear 得到了一个 “大黑球”。 起初,他认为依赖项分析器有漏洞,但最终发现 Instagram Server 的依赖项关系图就是一个巨大的圆。

重建后的 Instagram 依赖项关系图(艺术性诠释),作者:Joshua Lear

Instagram 代码库依赖项关系图的确就是一个不美观的 巨大网格,内部紧密连接。单纯只是启动服务器就会自动触发加载大量模块(约 28,000 个),而且大部分启动时间基本上都是用于导入模块、创建 Python 函数及类对象。下面这张较为美观的依赖项关系图最初由 Benjamin Woodruff 提供,后经过更新以反映当前状态:

真实的 Instagram 依赖项关系图,2022 年 1 月

那这有什么问题?只要在热路径中找出较大的依赖项并将其从代码中移除,对吗?不尽然。

循环导入

高度复杂的代码加上盘根错节的依赖项,这无异于一场灾难。通过重构来让依赖项保持简洁并尽量减少其数量,这似乎是个显而易见的解决方法,但是 最大的阻力 在于循环导入。只要开始尝试重构,导入循环就会从各个位置弹出。

导入循环不仅增加了重构的难度,而且曾造成若干次中断,即使是更改导入顺序也会在某些位置触发导入循环,只不过是出现时间早晚的问题而已。

一线曙光

过去,我们尝试过重构模块,以期中断导入循环,简化依赖项关系图。我们尝试了精心设计的解决方案,设置了成本高昂的子系统延迟,例如: Django Url通知Observer,甚至是 正则表达式。这种方法有一定效果,但是生成的解决方案较为脆弱。多年来,我们投入大量时间试图解决该问题,包括通过人工分析、重构及清理等方法,但最终却发现,由于代码及复杂程度持续增加,许多努力很快会变得徒劳。这个过程很艰难,也很脆弱,而且难以扩展。

我们需要的是普遍适用的稳健延迟方法。

延迟导入

二趾树懒(由 Geoff Gallice 通过 Creative Commons 提供)

我们需要更为 透明自动可靠持久的 延迟方法,而不是尝试通过人工方式 延迟, 例如采用 内部导入import_module()__import__()。预期计划很宏大,而且存在风险,但是我跃跃欲试,准备全身心投入到 CPython 中,开始实现 延迟导入 (在 Cinder 中)。

延迟导入改变了 Python 的导入机制,只会导入要使用的模块。其核心在于:每次单独的 导入 (例如 import foo)不会立即加载并执行模块,而是会创建 “延迟对象” 名称在被使用之前, 该名称将在内部 保留延迟对象实例。 其使用方式包括在导入模块之后的下一行中使用,或者是过了数小时后在深度调用堆栈中使用。

努力了几个星期后,我得出了一个原型。这个原型非常出色,不仅能够运作,而且很有前景,因此我丝毫没有意识到之后还要面临一场“艰难战斗”。困难部分在于要保证整个过程可靠,确保实现流程高效,还要做到在推出时不会导致太多问题。最终发现,更改 Python 语义(这个功能的做法)比起初预想的要复杂得多,而且在此过程中还发现并解决了许多意想不到的困难。

Python 的内部运行方式存在不少复杂的细节,而延迟导入的 延迟对象 意外从 C 语言领域渗入到了 Python。与 Carl Meyer 和 Dino Viehland 讨论之后,我决定重新设计机制,并将其中大部内内容移动至更深层次,即 Python 的核心—字典内部项。我很兴奋,但是修改已高度优化的字典实现可能会严重影响性能,所以我非常重视这个方面,在优化工作上花了大量时间。

最终,我实现了可靠且高效的版本运行。我在数万个 Instagram Server 模块中启用了延迟导入,并开始进行性能测试,了解此功能在生产过程中是否会存在性能差异(实际上不应该存在此差异)。确实,整体看起来基本没什么变化,我们没有发现任何清晰的信号可以表明此实现会对生产有负面影响,而我最终也获得了一个中性性能的编译版本。

成效

在 2022 年 1 月初,我们将其应用到了数千个生产和开发主机,都是没有出现重大问题,而我们可以通过以下关系图立即了解 Instagram Server 启动时间的差异:

我们加载的模块数量较之前少了约 12 倍,而且经测量,Instagram 开发服务器的 p50 重新加载时间减少了约 70%p90 重新加载时间减少了约 60%。与此同时,该功能基本上消除了我们每天都会看到的所有导入循环错误事件。在其他服务器和工具上,也能看到性能表现提升了 50% 至 70%,而且内存使用量减少了 20% 至 40%

您能从图中看出何时启用了延迟导入吗?

点击此处查看其他成效。

挑战

在这个过程中,我遇到了许多困难,无法在此一一列举。在这些困难中,其中一些尤为复杂,但是都非常有趣,而且很有挑战性。我记得 CPython 中有一些漏洞(与 TypedDict 相关的 bpo-41249),所以我不得不移除一些代码库,并且修复大量测试。

在构建可与延迟导入兼容的代码库的过程中,当我们开始使用延迟导入后,较常遇到的问题包括:

  1. 与依赖 导入副作用的模块相关的问题
    • 在导入过程中,代码执行任何逻辑。
    • 依赖在父模块中设置为属性的子模块。
  2. 与动态 Python 路径相关的问题;尤其是从 sys.path 添加路径(并且在导入之后移除路径)。
  3. 所有错误都从导入时间推迟到了首次使用时间(包括 ModuleNotFoundError),这可能会导致调试操作变得复杂。
  4. 在应用类型批注时需要谨慎,否则可能会无意中导致延迟导入失败:
    • 模块应该使用 from __future__ import annotations.
    • 我们应该为 typing.TypeVar()typing.NewType() 采用字符串类型批注。
    • 将类型别名封装在 TYPE_CHECKING 条件块中。

如要了解更多综合性问题,请点击此处

亮点

虽然 延迟导入的概念 并不新颖,而且不难理解(即延迟加载模块,等到使用导入名称时再加载),但是我们并没有听说过其他直接在 CPython 内部项进行的任何低级别实现,也没有发现过去有人在这方面的工作可以媲美我们目前在 Cinder 中的实现。以下是这项功能的部分亮点:

  • 此功能提供自动稳定且基本透明 的解决方案,为 在 Python 中延迟导入模块的 常用范式提供支持。
  • 此功能的使用成本较低。我们可以全局 启用延迟导入,将其用作语言层级的功能,并让 Python 延迟加载使用的所有模块和软件包 (甚至是第三方和标准库软件包)。
  • 此功能非常高效
    我们在实时服务器中进行了一系列测试,而且在添加延迟导入修补程序(但未启用该功能)后,得到了性能中性的结果。
    我们还运行了开源 pyperformance3 次,并对比启用延迟导入与未添加该修补程序的情况,有以下重大发现:
  • 消除了导入循环。这并不是说启用延迟导入后就不能使用循环导入。在模块层级中仍然可以存在合理的循环依赖项,但是大部分循环将不具有伤害性,而且不会显示为导入错误。在 Instagram 用例中,我们将工程师每天会发现的循环导入错误由约 80 个减为 0 个。
  • 此功能真的很好用 (Just Works™)(大部分情况下)。

前景

  • 经过一段时间的调试和优化后,延迟导入毫无疑问会让我们在 内存使用量启动时间方面有所改善, 甚至有可能(有希望) 提升 Instagram 生产服务器的 性能表现。
  • 无须担心 循环导入,延迟导入将为代码库的现代化与品质优化提供全新途径。重构变得更加简单,之前无法实现的事情现在变得可行。
  • 此功能与外部第三方软件包和库兼容,所以延迟导入也适用于这些内容,因此更多应用程序可以使用这个功能。
  • 在上游应用延迟导入,从而将该功能应用于更广泛的 Python 生态系统!

致谢

本计划是一个大型项目,在此过程中许多工程师倾注了时间和精力,共同致力于实现这一伟大事业,若没有他们的支持,成功也就无从谈起。我在此诚挚感谢参与此项计划的所有人员,谢谢你们审核代码并提出相关建议。感谢你们协助推出这项功能,并将延迟导入的应用扩展至 Instagram 之外的领域。感谢你们为本博文提供意见、建议和点评。 Anirudh PadmaraoBen GreenBenjamin WoodruffCarl MeyerDino ViehlandItamar OstricherJacky ZhangJoshua LearKrys JurgowskiLisa RoachLoren Arthur、Miguel Gaiowski、Perry RandallXiaoya Xiang以及其他所有参与人员,谢谢你们!

如需了解有关 Meta Open Source 的更多信息,请访问我们的 Open Source 网站、订阅我们的 YouTube 频道,或在 TwitterFacebookLinkedIn 上关注我们。