Python 2 vs Python 3,究竟谁是性能之王?前段时间,Hackermoon 上一位叫 Anthony Shaw 的作者为我们做了一些测试,最终得出结论,虽然 Python 2 在加密和启动时间测试过程中,比 Python 3 的速度更胜一筹,但整体而言,Python 3 更快。
而这是否就意味着我们还是将项目代码迁移到 Python 3.0 的好?接下来,本文来自全球著名的桌面应用之一的 Dropbox 将分享他们要弃用 Python 2.0 的真实原因,以及如何将百万行的代码成功迁移至 Python 3。
Dropbox 是世界上流行的桌面应用之一,你可以安装在 Windows、macOS 和部分的 Linux 发行版上。但你可能不知道,这个应用大部分是用 Python 写的。实际上,Drew 给 Dropbox 写下的第一行代码就是用的 Windows 版 Python,用的是老牌的 pywin32 等库。
虽然我们靠着 Python 2 支撑了这么多年(我们用过的最新版本是 Python 2.7),但我们从 2015 年就开始向 Python 3 转换了。今天我们终于完成了转换,你现在再装 Dropbox 的话,那么它用的是 Dropbox 定制版本的 Python 3.5。本文将介绍这次史无前例的 Python 3 转换的计划、执行和发布过程。
为什么选择 Python 3?
Python 3 的接受度在 Python 社区一直是热门话题。现在虽然 Python 3 已经广为接受(http://py3readiness.org/),一些非常流行的项目如 Django 甚至完全放弃了 Python 2 的支持,但这个话题的热度依然存在。对于我们来说,影响我们决定进行转换的几个关键因素有:
引人入胜的新功能
Python 3 的创新十分迅速。除了一长列(http://whypy3.com/)正常的改进(如 str 和 bytes 的讨论),还有几个功能吸引了我们的眼球:
- 类型标注语法:我们的代码量非常大,所以类型标注对于开发的效率非常重要。在 Dropbox 我们很喜欢 MyPy(http://mypy-lang.org/),因此原生的类型标注支持对我们很有吸引力。
- 并行函数语法:许多功能都极度依赖线程和消息传递,我们采用的是 Actor 模式,使用了 Future 模块。而 asyncio 项目及其 async/await 语法有时能避免回调函数,从而获得更干净的代码。
过老的工具链
随着 Python 2 日久年深,最初适合部署的工具链也大部分过时了。由于这些因素,继续使用 Python 2 会带来一系列的维护负担:
- 过老的编译器和运行时使得我无法们升级一些重要更新。 例如,我们在 Windows 和 Linux 上使用 Qt,而最新版本的 Qt 包含了 Chromium(通过 QtWebEngine 实现),因此需要更现代的编译器。
- 我们与操作系统的集成越来越深,而无法使用新版本的工具链,导致使用新版 API 的成本增大。 例如,理论上 Python 2 依然需要 Visual Studio 2008 (http://stevedower.id.au/blog/building-for-python-3-5/)。但这个版本微软已经不再支持了,也与 Windows 10 SDK 不兼容。
冻结和脚本
当初,我们依靠“冻结”脚本为我们支持的每个平台创建原生应用程序。但是,我们并没有直接使用原生的工具链,如 macOS 的 Xcode,而是将创建各个平台上的二进制文件的任务交给其他程序去做,Windows 下是 py2exe,macOS 下是 py2app,Linux 下是 bbfreeze。这个完全面向 Python 的构建系统收到了 distutils 的启发,因为我们的应用最初只不过是个 Python 包,所以只需要一个类似于 setup.py 的脚本来构建。
随着时间的流逝,我们的代码量越来越大。现在,我们的开发已经不仅仅使用 Python 开发了。实际上,我们的代码现在由 Type/HTML、Rust 和Python 混合组成,某些平台上还用了 Objective-C 和 C++。为支持所有组件,setup.py 脚本(内部的名字为 build-all.py)越来越大,越来越难以管理。
导火索就是我们与各个操作系统集成的方式。首先,我们越来越多地引入高级的 OS 扩展,如 Smart Sync 的内核组件等,这些组件不能,通常也不会使用 Python 编写。其次,像微软和苹果等供应商对部署应用提出了新的需求,因此经常需要用到新的、更复杂的工具,这些工具经常是这些供应商独有的(比如代码签名等)。
例如在 macOS 上,10.10 版本引入了新的应用扩展以便与 Finder 进行集成,就是FinderSync(https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Finder.html)。它并不只是个 API,而是个完整的应用程序包(.appex),有自己的生存中崛起规则(即它由 OS 启动),而且对于进程间通信的要求更严格。换句话说,使用 Xcode 就很容易集成这些扩展,但 py2app 根本不支持它们。
因此,我们面临着两个问题:
- 由于我们使用 Python 2,因此无法使用新的工具链,所以集成新的 API 的代价更高(比如使用Windows 10的Windows Runtime)。
- 我们的冻结脚本使得部署原生代码的代价更高(例如在 macOS 上构建应用扩展)。
当我们计划转换成 Python 3 时,我们面临着两个选择:一是改进冻结脚本中的依赖,以支持 Python 3(从而支持现代编译器)和平台相关的功能(如应用程序扩展),二是不再使用以 Python 为中心的构建系统,完全放弃冻结脚本。我们选择了后者。
关于 pyinstaller 的一点:我们认真地思考过在项目早期使用 pyinstaller,但当时它不支持 Python 3,而且更重要的是,它和其他冻结脚本有类似的限制。不管怎样,这个项目本身很不错,我们只是觉得不适合我们而已。
嵌入 Python
为了解决构建和部署的问题,我们决定使用新的架构,在原生应用中嵌入 Python 运行时。我们不再将构建过程交给冻结脚本处理,而是使用各个平台自己的工具链(比如 Windows 下使用 Visutal Studio)来构建各种入口点。进一步,我们将 Python 代码抽象到一个库中,从而为多种语言“混合”的方式提供更直接的支持。
这样我们就可以直接使用各个平台的 IDE 和工具链了(例如可以直接添加原生的构建目标,如 macOS 上的 FinderSync),同时保留使用 Python 编写大部分应用程序逻辑的能力。
我们最后采用了下面的结构:
- 原生入口点:这些与各个平台的应用程序模型兼容。 其中包括应用程序扩展,如 Windows 下的 COM 组件和 macOS 下的应用程序扩展。
- 共享库可以使用多种语言编写(包括 Python)。
表面上,这个应用能够更接近平台的要求,而在各个库的背后,我们可以有更大的灵活性来选择自己喜欢的语言和工具。
这种架构能提高模块性,同时还带来一个关键的副作用:现在可以同时部署 Python 2 库和 Python 3 库了。联系到 Python 3 转换工作,我们的转换过程就需要两步:第一,给 Python 2 实现新的架构;第二,利用它将 Python 2 替换成 Python 3。
第一步:“解冻”
第一步就是停止使用冻结脚本。目前,bbfreeze 和 pywin32 都不支持 Python 3,所以我们别无选择。我们从 2016 年开始逐步进行这项改变。
首先,我们将配置 Python 运行时的工作抽象化,将 Python 的东西放到一个新的库中,名为 libdropbox_bootstrap。这个库会代替一些冻结脚本提供的功能。尽管我们不再需要这些脚本,但它们仍然提供了一些运行 Python 代码所需的最基本的东西:
打包代码以便在设备上执行
这样我们才能发布编译好的 Python 字节码,而不用发布 Python 源代码。由于以前的每个冻结脚本在各个平台上有各自的格式,我们利用这个机会引入了一种新的格式,用于在所有平台上打包代码使用:
- 所有 Python 模块的 Python 的字节码 .pyc 都放在单一的 zip 文档中(如 python-packages-35.zip)。
- 原生扩展. pyd / .so 由于是平台相关的原生动态链接库,他们必须安装在特定的位置,保证应用程序能毫无障碍地加载。 Windows 下,这些文件与入口点(即 Dropbox.exe)放在一起。
- 打包通过优秀的 modulegraph(作者是 py2app 和 PyObjC 的作者 Ronald Oussoren)实现。
隔离 Python 解释器
这样能阻止我们的应用程序在设备上运行其他的 Python 源代码。有意思的是,Python 3 使得这种嵌入变得容易得多了。例如,新的 Py_SetPath 函数(https://docs.python.org/3/c-api/init.html#c.Py_SetPath)能够让我们将代码隔离,不需要再像 Python 2 时代在冻结脚本中进行某种复杂的隔离操作了。为了在 Python 2 中支持这一功能,我们在定制版本的 Python 2 中向下移植了这一功能。
其次,我们使用了平台相关的入口点Dropbox.exe、Dropbox.app和dropboxd 来使用这个库。这些入口点都是用各自平台的“标准”工具编译的,即 Visual Studio、Xcode 和 make,没有使用 distutils。这样我们就可以去掉冻结脚本带来的大量修补工作了。例如,在 Windows 下,这一步大大简化,只需为 Dropbox.exe 配置 DEP/NX 即可,就能将应用程序装箱单和资源嵌入了。
关于 Windows 的一点说明:现在,继续使用 Visual Studio 2008 的代价已经非常高了。为了正确地转换,我们需要一个能同时支持 Python 2 和 Python 3 的版本,最终我们采用了 Visual Studio 2013。为支持它,我们进一步修改了定制版本的 Python 2,使之能正确在 Visual Studio 2013 下编译。这些修改的代价进一步证明,我们转换到 Python 3 的决定是正确的。
第二步:混合
成功地转换如此之大(包含大约 100 万行 Python 代码)、安装量如此之高(大约有几亿安装)的应用程序需要逐步进行。我们不能简单地在某次发布中“改变一个开关”来实现转换,特别是我们的发布过程要求每两个星期给所有用户发布一个新版本。因此,必须找到一种办法,将 Python 3 的部分转换发布给一小部分用户,以便检测并修改 Bug。
为达到这一点,我们决定实现用 Python 2 和 Python 3 同时编译 Dropbox。这要求做到以下两点:
- 能够同时发布 Python 2 和 Python 3 的“包”,包括字节码和扩展,两者必须能够并存。
- 在转换过程中强制使用混合的 Python 2 / 3 语法。
我们采用上一步引入的嵌入式设计来实现:将 Python 代码抽象到库和包中,就能很容易地引入另一个版本。这样入口点程序(即 Dropbox.exe)就可以在初始化的早期控制选择哪个 Python 版本了。
我们通过手动连接入口点程序到 libdropbox_bootstrap 来实现这一点。例如在 macOS 和 Linux 下,我们在 Python 版本确定之后使用 dlopen/dlsym 来加载。在 Windows 下,使用 LoadLibrary 和 GetProcAddress。
对 Python 解释器的选择必须在 Python 加载之前完成,因此为了使之更顺畅,我们实现了命令行参数 /py3 用于开发,和一个保存在硬盘上的永久设置,以便通过我们的功能切换系统Stormcrow(https://blogs.dropbox.com/tech/2017/03/introducing-stormcrow/)来控制。
有了这些,我们就能在启动 Dropbox 客户端时动态选择 Python 版本了。这样就可以在 CI 基础设施中设置额外的任务来针对 Python 3 运行单元测试和集成测试。我们还在提交队列中增加了自动检查,以防止提交会破坏 Python 3 支持的改动。
通过自动测试确保没问题之后,我们就开始将 Python 3 的改动推送给真正的用户。我们通过远程的功能开关来将新功能逐渐开放给用户。首先对 Dropbox 推送改动,这样我们就能找出并改正大部分主要的底层问题。然后将范围扩大到 Beta 用户,他们的 OS 版本问题更加芜杂。然后最终扩展到稳定版。7 个月之后,所有的 Dropbox 都已经在运行 Python 3 了。为了尽可能提高质量,我们要求所有与转换相关的 bug 必须进行深入调查并彻底修复,才能扩大推送的范围。
逐渐推送到 Beta 版
逐渐推送到稳定版
到了版本 52 时,这个过程终于完成了。我们可以完全从 Dropbox 的桌面客户端中删掉 Python 2 了。
写在最后
一篇文章很难完整概括我们将代码迁移至 Python 3.0 的完整过程,这其中还有许多可以讨论的东西。接下来,我们还会在以后的文章中讨论:
- 我们怎样在 Windows 和 macOS 上报告崩溃,并利用这些信息调试原生和 Python 代码;
- 怎样维护 Python 2 和 Python 3 混合代码,用到了哪些工具?
- 整个 Python 3 转换过程中最值得讨论的 Bug 和故事。
网友评论