PEP 776 – Emscripten 支持
- 作者:
- Hood Chatham <roberthoodchatham at gmail.com>
- 发起人:
- Łukasz Langa <lukasz at python.org>
- 讨论至:
- Discourse 帖子
- 状态:
- 草案
- 类型:
- 信息性
- 创建日期:
- 2025 年 3 月 18 日
- Python 版本:
- 3.14
- 发布历史:
- 2025 年 3 月 18 日, 2025 年 3 月 28 日
摘要
Emscripten 是一个完整的开源编译器工具链。它将 C/C++ 代码编译成 WebAssembly/JavaScript 可执行文件,用于 JavaScript 运行时,包括浏览器和 Node.js。Rust 语言也维护一个 Emscripten 目标。
本 PEP 正式确定了在 Python 3.14 中增加 Emscripten 支持的 Tier 3,该支持已于 2024 年 10 月 25 日获得指导委员会批准。目标是
- 描述 CPython Emscripten 运行时的当前状态
- 描述 Pyodide 运行时的当前状态
- 识别 Pyodide 运行时中待上游到 CPython Emscripten 运行时中的次要特性
此处识别的次要特性都可以在没有 PEP 的情况下实现。我们讨论了我们希望实现的更重要的运行时特性,但我们将其决策推迟到后续的 PEP 中。
动机
Web 浏览器是一个通用的计算平台,可在 Windows、macOS、Linux 和所有智能手机上使用。
Pyodide 项目自 2018 年以来一直支持 Emscripten Python。数十万学生通过 Pyodide 项目(如 Capytale 和 PyodideU)学习了 Python。Pyodide 也越来越多地被 Python 包用于提供交互式文档。这既证明了 Emscripten 平台的重要性,也证明了其成熟度。
Emscripten 和 WASI 也是唯一提供有意义的沙盒的受支持平台。
Emscripten 平台信息
“Pyodide” 与 “Emscripten Python”
为了本文件的目的,我们使用术语“Emscripten Python”来指代在 python/cpython
存储库中维护的 Emscripten Python,不包含任何下游附加功能。我们将 Emscripten Python 中存在的功能与 Pyodide 中存在的功能进行对比。
Pyodide 在 GitHub 上维护,并通过 jsDelivr、npm 和 GitHub 发布进行分发。
Emscripten Python 不分发,但可以按照开发指南中的说明构建
Emscripten 背景
Emscripten 由基于 LLVM 的 C 和 C++ 编译器和链接器组成,以及基于轻微修补的 musl libc 的运行时。
Emscripten 是一个基于 POSIX 的平台。它使用 WebAssembly 二进制格式和 WebAssembly 动态链接部分。
emcc
编译器是 clang
的封装。 emcc
链接器是 wasm-ld
的封装(也是 LLVM 工具链的一部分)。
Emscripten 对与 Linux 的可移植 C/C++ 代码源兼容性的支持相当全面,但存在一些预期的例外情况将予以阐明。CPython 已经支持编译到 Emscripten,它只需要对正常的 Linux 目标进行非常少量的修改。
POSIX 兼容性
Emscripten 是一个 POSIX 平台。然而,有些 POSIX API 存在但调用时总是失败,有些 POSIX API 根本不存在。特别是,网络 API 和阻塞 I/O 存在问题,并且不支持 fork()
。请参阅 Emscripten 可移植性指南。
Emscripten 可执行文件可以与线程支持一起链接,但它有几个限制
- 启用线程需要网站提供特殊的安全头,以表明接受存在 Spectre 风格信息泄露的可能性。这些头对于不熟悉 Web 平台的用户来说是可用性障碍。
- 如果一个可执行文件同时链接了线程和动态加载器,Emscripten 会打印一条警告,指出同时使用动态加载和 pthreads 是实验性的。这可能会导致性能问题或崩溃。这些问题可能需要 WebAssembly 标准工作来解决。
由于这些限制,Pyodide 标准化了 Python 的无 pthreads 构建。如果有足够的需求,以后可以添加无动态加载器的 pthreads 构建。
开发工具
Emscripten 开发工具在 Linux、Windows 和 macOS 上都得到了同样好的支持。上游工具包括
- Emscripten 软件开发工具包 (emsdk),可用于安装 Emscripten 编译器工具链 (emcc)。
- emcc 是一个 C 和 C++ 编译器、链接器,以及包含系统库头文件的 sysroot。系统库本身是根据请求的 ABI 动态生成的。
- Node.js 可以用作“模拟器”,从命令行运行 Emscripten 程序。这种模拟在 Linux 上表现最佳,macOS 次之。Node.js 是测试 Emscripten 程序最便捷的方式。
- 可以在任何 Web 浏览器中运行 Emscripten 程序。Selenium、Playwright 或 Puppeteer 等浏览器自动化工具可用于测试仅浏览器功能。
Pyodide 的工具
pyodide build
可用于交叉编译 Python 包以在 Emscripten 上运行。交叉编译在 Linux 上效果最佳,在 macOS 上有实验性支持,在 Windows 上完全不支持。pyodide venv
可以创建一个在 Pyodide 中运行的虚拟环境。pytest-pyodide
可以针对各种 JavaScript 运行时测试 Python 代码。
cibuildwheel 支持使用 pyodide build
构建用于 Emscripten 的 wheel。
短期内,Pyodide 的打包工具将保留在 Pyodide 存储库中。Pyodide 的打包工具长期应置于何处是一个悬而未决的问题。两个合理的选择是它继续在 pyodide
组织下,或者将其移至 GitHub 上的 pypa
组织。
Emscripten 应用程序生命周期
一个 Emscripten“二进制文件”由一对文件组成,一个 .mjs
文件和一个 .wasm
文件。 .wasm
文件包含所有已编译的 C/C++/Rust 代码。 .mjs
文件包含生命周期代码,用于设置运行时,定位 .wasm
文件,编译它,实例化它,调用 main()
函数,并在退出时关闭运行时。它还包括所有系统调用的实现,包括文件系统、动态加载器,以及将其他功能从 JavaScript 运行时暴露给 C 代码的任何逻辑。
.mjs
文件导出一个单独的 bootstrapEmscriptenExecutable()
JavaScript 函数,该函数引导运行时,调用 main()
函数,并返回一个可用于调用 C 函数的 API 对象。每次调用都会生成一个完整的独立运行时副本,并拥有自己独立的地址空间。
bootstrapEmscriptenExecutable()
接受大量运行时设置。完整列表在此处的 Emscripten 文档中描述。 其中最重要的是:
thisProgram
:argv[0]
的值。在 Python 中,这会进入sys.executable
。arguments
: 传递给main()
的字符串参数列表。preRun
: 在 JavaScript 运行时和文件系统引导完成后,但在调用main()
之前调用的回调列表。用于设置文件系统、环境变量和标准流。print
/printErr
: stdout 和 stderr 的初始处理程序。它们是行缓冲的,执行部分行的flush()
会强制添加一个额外的换行符。如果需要类 tty 行为,应在preRun()
钩子中替换标准流设备。onExit
: 运行时退出时调用的处理程序。instantiateWasm
: 用于实例化 WebAssembly 模块的回调。通过此函数覆盖 WebAssembly 实例化过程非常有用,当您有其他自定义异步启动操作或下载可以与 WebAssembly 编译并行执行时。实现此回调可以并行执行所有这些操作。
文件系统设置
标准库
为了使 Python 运行,它需要访问 Emscripten 文件系统中的标准库。有几种可能的方法:
- Emscripten 链接器有一个
--preload-file
标志,它会自动处理文件加载。有关其工作原理的信息可在此处获得。 这是最简单的方法,但 Pyodide 已不再使用它,因为它将文件嵌入到无法使用标准工具处理的自定义存档格式中。 - 对于 Node.js,使用 NODEFS 将包含文件的本地目录挂载到 Emscripten 文件系统中。这是最有效的选项,但仅限于 Node。它与 WASI 所做的工作密切相似。
- 将标准库放入一个 zip 存档并使用
ZipImporter
。使用未压缩的 zip 文件允许 Web 服务器和客户端对标准库本身应用更好的压缩。它还使用浏览器中更高效的本地解压缩算法,而不是效率较低的 WebAssembly 解压缩。这样做的缺点是内存占用更高,并且会破坏inspect
和各种测试,这些测试不期望标准库以这种方式打包。 - 将标准库放入未压缩的 tar 存档中,并将其挂载到由 tar 文件支持的 TARFS 只读文件系统。这在浏览器中可用的选项中具有最佳内存使用、运行时性能和传输大小。缺点是 Emscripten 本身不包含 TARFS,因此需要下游实现。
Pyodide 在每个运行时都使用 ZipImporter
方法。Python 在使用 node 运行时使用 NODEFS 方法,并在 Web 示例中使用 ZipImporter
方法。我们将继续采用这种方法。
ZipImporter
为引导问题提供了清晰的解决方案:Python 运行时能够解包各种存档格式,但 Python 运行时在标准库可用之前无法使用。由于 zipimport.py
是一个冻结模块,它避免了这些问题。所有其他方法都通过使用 JavaScript 设置标准库来解决引导问题。
第三方包
还需要使所有需要的包在 Emscripten 文件系统中可用。目前,Emscripten CPython 不支持包。Pyodide 对包使用两种不同的方法
- 在浏览器中,Pyodide 将 wheel 下载并解压到 MEMFS site-packages 目录中。然后它会预加载 wheel 中的所有动态库。每次运行时启动时,都会重新完成所有包的下载和安装工作。
- Pyodide 的
python
CLI 入口点会在引导 Python 之前将所有宿主文件系统挂载为 NODEFS 目录。这允许正常的虚拟环境机制工作。Pyodide 虚拟环境包含一个修补过的 pip 副本和一个自定义的pip.conf
,以便 pip 将安装 Pyodide wheel。在启动时,Pyodide 的python
CLI 将预加载 site-packages 目录中的所有 Emscripten 动态库。
控制台和交互式使用
stdin
默认总是返回 EOF
,而 stdout
和 stderr
默认分别调用 console.log
和 console.error
。可以向 bootstrapEmscriptenExecutable()
传递处理程序来配置标准流,但无论如何,I/O 设备都具有不理想的行缓冲行为,导致刷新时强制换行。为了在浏览器中实现行为良好的 TTY,需要移除默认的 I/O 设备,并在 preRun
钩子中替换它们。
使浏览器中的 stdin
正常工作带来了额外的挑战,因为不允许在浏览器主线程中阻塞用户输入。如果在带有共享内存头文件的 Web Worker 中运行 Emscripten,则可以使用共享内存和原子操作接收输入。 stdin
设备也可以使用实验性的 JavaScript Promise 集成 API,通过堆栈切换以更简单高效的方式阻塞。
Pyodide 替换了标准 I/O 设备,以修复行缓冲行为。当 Pyodide 在 Node.js 中运行时,stdin
、stdout
和 stderr
默认连接到 process.stdin
、process.stdout
和 process.stderr
,因此标准流开箱即用,表现为 tty。Pyodide 还确保 shutil.get_terminal_size
返回的结果与 process.stdout.rows
和 process.stdout.columns
一致。Pyodide 目前不支持堆栈切换 stdin
。
目前,Emscripten Python Node.js 运行器使用 Emscripten 提供的默认 I/O。Web 示例对 stdin
使用 Atomics
,并具有自定义的 stdout
和 stderr
处理程序,但它们表现出不理想的行缓冲行为。我们将从 Pyodide 上游标准流行为。
从长远来看,我们希望实现基于堆栈切换的 stdin
设备,但这超出了本 PEP 的范围。
陷阱和未捕获的异常
如果出现 WebAssembly 陷阱、未处理的 JavaScript 异常或未捕获的 WebAssembly throw 指令,我们认为 C 运行时状态已损坏。
与其他平台不同,当 libc 运行时发生陷阱或其他不可恢复的损坏时,没有操作系统来关闭可执行文件。我们需要提供自己的代码来打印回溯、转储内存或执行任何其他有助于调试崩溃的操作。如果我们暴露 JavaScript API,我们还必须确保在不可恢复的崩溃后禁用它,以防止下游用户观察到 Python 运行时处于不一致的状态。
为了检测致命错误,Pyodide 采用以下方法:所有从 WebAssembly 调用到 JavaScript 的可失败调用都使用 JavaScript try/catch 块封装。任何捕获到的 JavaScript 异常都会被转换为 Python 异常。这确保了在任何可恢复的 JavaScript 错误在任何 WebAssembly 帧中展开之前被捕获。所有 WebAssembly 的入口点也使用 JavaScript try/catch 块封装。在此捕获到的任何异常都已展开 WebAssembly 帧,因此被认为是致命错误(尽管有一个特殊情况来处理 exit()
)。这需要与 Python/JavaScript 外部函数接口进行基础集成。
当 Pyodide 运行时捕获到致命异常时,它会自省错误以确定它是来自陷阱、系统调用中的逻辑错误、没有 longjmp()
的 setjmp()
,还是 libcxxabi 调用 __cxa_throw()
(未捕获的 C++ 异常或 Rust panic)。我们尽可能提供信息丰富的错误消息。我们还会调用 _Py_DumpTraceback()
,以便除了 JS/WebAssembly 回溯之外,我们还可以显示 Python 回溯。它还会禁用 JavaScript API,以便进一步尝试调用 Python 会导致错误,指出运行时已致命失败。
通常,WebAssembly 符号会被剥离,因此 WebAssembly 帧不是很有用。使用 -g2
(或更高的调试设置)编译和链接可确保包含 WebAssembly 符号,并且它们将出现在回溯中。
由于 Emscripten Python 目前没有 JavaScript API 也没有外部函数接口,情况要简单得多。Python Node.js 运行器将对 bootstrapEmscriptenExecutable()
的调用封装在一个 try/catch 块中。如果捕获到异常,它会显示 JavaScript 异常并调用 _Py_DumpTraceback()
。然后它以代码 1 退出。我们将坚持这种方法,直到我们添加 JavaScript API 或外部函数接口,这超出了本 PEP 的范围。
规范
工作范围
将 Emscripten 添加为 Tier 3 平台仅需要支持从未打补丁的 CPython 源代码编译 Emscripten 兼容的构建。它不一定需要在 python.org 上有任何官方分发的 Emscripten 工件,尽管这些可能会在将来添加。短期内,它们将继续与 Pyodide 一起在下游分发。
Emscripten 将使用与其他 POSIX 平台相同的配置和 Makefile 系统构建,因此必须在 POSIX 平台上构建。Linux 和 macOS 都将受支持。
将提供一个 Python CLI 入口点,除其他外,可用于执行测试套件。
链接
仅支持静态链接 Python 解释器。我们在解释器中将 EM_JS 函数用于各种目的。可以动态链接包含 EM_JS
函数的目标文件,但它们的行为与静态构建中的行为显着不同。因此,这需要特殊的工作来支持。如果 Emscripten 中出现动态链接解释器的用例,我们可以评估支持它需要多少努力。
标准库
不支持的模块
请参阅 https://pyodide.org/en/stable/usage/wasm-constraints.html#removed-modules。
已删除的模块
以下模块已从标准库中移除,以减小下载大小,并且因为它们目前无法在 WebAssembly VM 中工作。
- curses
- dbm
- ensurepip
- fcntl
- grp
- idlelib
- msvcrt
- pwd
- resource
- syslog
- termios
- tkinter
- turtle
- turtledemo
- venv
- winreg
- winsound
已包含但无法工作的模块
以下模块可以导入,但无法使用
- 多进程
- threading
- sockets
以及任何需要这些模块的功能。
以下模块存在,但由于依赖于已删除的 termios 模块而无法导入
- pty
- tty
平台识别
sys.platform
将返回 "emscripten"
。尽管 Emscripten 试图与 Linux 兼容,但差异足够大,因此有必要使用不同的名称。这与 os.uname()
的返回值一致。
还有一个 sys._emscripten_info
,它包含 Emscripten 版本和运行时(浏览器中的 navigator.userAgent
或 Node.js 中的 "Node js" + process.version
)。
信号支持
WebAssembly 不原生支持信号。此外,在非 pthreads 构建中,WebAssembly 模块的地址空间不共享,因此任何能够看到中断的线程都无法在 Python 解释器运行代码时写入 eval breaker。为了解决这个问题,有两种可能的解决方案
- 如果在 Web Worker 中运行 Emscripten 并使用共享内存头文件提供服务,则可以使用 WebAssembly 地址空间之外的共享内存作为信号缓冲区。信号处理 UI 线程可以将所需的信号写入信号缓冲区。解释器可以定期检查 eval breaker 代码中此信号缓冲区的状态。与检查原生平台中的 eval breaker 相比,检查信号缓冲区速度较慢,因此我们每通过 eval breaker 50 次才检查一次。请参阅 Python/emscripten_signal.c
- 使用堆栈切换,我们可以偶尔切换堆栈并允许 JavaScript 事件循环运行,然后检查信号缓冲区的状态。这需要实验性的 JavaScript Promise Integration API,并且最好与 本文中描述的优化长任务的技术结合使用。
Emscripten Python 已经实现了基于共享内存的解决方案,并且 Pyodide 正在使用它。
最终,我们希望实现基于堆栈切换的信号,以便在 node 和浏览器的主线程以及没有共享内存头文件的网页中可以使用信号。我们还需要保留基于共享内存的方法,既是为了向后兼容,也是因为它在可能的情况下更高效。然而,这超出了本 PEP 的范围。
函数指针类型转换
C 标准的第 6.3.2.3 节,第 8 段规定
指向一种类型函数的指针可以转换为指向另一种类型函数的指针,然后再转换回来;结果应与原始指针比较相等。如果使用转换后的指针调用其类型与所指向类型不兼容的函数,则行为未定义。
然而,大多数平台行为相同:如果函数调用的参数过多,则忽略多余的参数;如果函数调用的参数过少,则多余的参数用垃圾数据填充。
另一方面,WebAssembly 规范定义了调用签名错误的函数会陷入陷阱(请参阅 call_indirect 执行中的第 18 步)。
Python 扩展模块通常会将函数转换为不同的签名并以不同的签名调用它。例如,许多 C 扩展将 METH_NOARGS
函数定义为接受 0 或 1 个参数。解释器会使用两个参数调用它,其中第一个是 Python 模块对象,第二个总是 NULL
。为了使这些扩展模块无需更改其源代码即可工作,我们需要特殊处理。
最初,我们通过调用 JavaScript 并让 JavaScript 调用函数指针来解决此问题。从 JavaScript 调用 WebAssembly 函数时,缺失的参数被视为零,多余的参数被忽略(参见此处的第 7 步)。这可行,但缺点是速度慢且破坏堆栈切换——无法通过 JavaScript 帧进行堆栈切换。
使用 wasm-gc ref.test 指令,我们可以查询函数指针的类型并手动修复参数列表。
wasm-gc 是 WebAssembly 运行时相对较新的功能,因此如果可能,我们尝试使用基于 wasm-gc 的函数指针转换跳板,否则回退到 JS 跳板。每个支持堆栈切换的 JavaScript 运行时也支持 wasm-gc,因此这确保了堆栈切换在每个支持它的平台运行时都有效。一个麻烦是 iOS 18 附带了损坏的 wasm-gc 实现,因此我们必须特殊处理它。
函数指针转换处理已在 cpython 中完全实现。Pyodide 使用与上游完全相同的代码。
CI 资源
Pyodide 可以在任何装有最新 Node.js 版本的 Linux 上构建和测试。Anaconda 已表示愿意提供物理硬件来运行 Emscripten 构建机器人,由 Russell Keith-Magee 维护。
CPython 目前不在 GitHub Actions 上测试 Tier 3 平台,但如果情况发生变化,其 Linux 运行器能够构建和测试 Emscripten Python。
PEP 11
PEP 11 将更新,以表明支持 Emscripten,特别是三元组 wasm32-unknown-emscripten_xx_xx_xx
。
Russell Keith-Magee 将担任这些 ABI 的初始核心团队联系人。
未来工作
改进打包生态系统中的交叉构建
Python 现在支持四个非自托管平台:iOS、Android、WASI 和 Emscripten。所有这些平台都需要通过交叉构建来构建包。目前,pyodide-build
允许为 Emscripten 构建大量的 Python 包,但这非常复杂。理想情况下,Python 打包生态系统应该有交叉构建的标准。这是一个长期而艰巨的项目,特别是因为打包系统复杂,并且在设计时就假定不会发生交叉编译。
Pyodide 运行时特性待上游化
这是 Pyodide 运行时特性的一部分,这些特性超出了本 PEP 和 Python 3.14 开发周期的范围,但我们希望将来将其上游化。
用于引导的 JavaScript API
目前我们不提供稳定的 Python 引导 API。相反,我们使用 用于 Node.js CLI 入口点的一组设置 和 用于浏览器演示的另一组设置。
Emscripten 可执行文件启动 API 复杂,存在许多可能损坏的配置。Pyodide 提供了一组比 Emscripten 更简单的选项。这为下游用户提供了很大的灵活性,同时允许我们维护少量经过测试的配置。它还减少了下游代码重复。
最终,我们希望将 Pyodide 的引导 API 上游化。短期内,为保持简单,我们将不支持 JavaScript API。
JavaScript 外部函数接口 (FFI)
由于 Emscripten 支持 POSIX,因此可以使用 os
模块完成大量任务。然而,JavaScript 运行时中的许多基本操作无法通过 POSIX API 实现。Pyodide 的方法是指定 JavaScript 对象模型与 Python 对象模型之间的映射,以及允许高级双向集成的调用约定。请参阅 Pyodide 文档。
Asyncio
大多数 JavaScript 原语是异步的。Python 运行的 JavaScript 线程已经有一个事件循环。实现一个 Python 事件循环,将所有实际工作推迟到 JavaScript 事件循环中并不太困难,在 Pyodide 中实现。
这在逻辑上取决于至少一些有限的 JavaScript FFI,因为在 JavaScript 事件循环上调度任务的唯一方法是通过调用 JavaScript。
不兼容的一个原因是,无法从 JavaScript 隔离区内控制事件循环的生命周期。这使得 asyncio.run()
和类似的东西无法工作。
使用堆栈切换,也可以从“同步”Python 帧创建协程。这些堆栈切换协程与普通 Python 协程在同一个事件循环上调度,并且是完全可重入的。这已在 Pyodide 中完全实现。
向后兼容性
添加新平台不会对 CPython 本身引入任何向后兼容性问题。但是,可能对 Pyodide 用户产生一些向后兼容性影响。Pyodide 有大量现有用户,因此在将 Pyodide 中的功能上游到 Python 时,务必注意尽量减少向后不兼容性。我们还需要一种方法来禁用部分上游的功能,以便 Pyodide 可以在下游用更完整版本替换它们。
安全隐患
添加新平台不会增加任何新的安全隐患。
Emscripten 和 WASI 也是唯一提供沙盒的受支持平台。如果用户希望执行不受信任的 Python 代码或不受信任的 Python 扩展模块,Emscripten 提供了一种安全的方式来完成此操作。
如何教授此内容
与本 PEP 相关的教育需求涉及两类开发人员。
首先,Web 开发人员需要了解如何构建 Python 并将其用于网站,以及他们自己的 Python 代码和任何支持包,以及如何在运行时使用它们。文档将以类似于现有 Windows 可嵌入包的形式涵盖此内容。短期内,我们将鼓励开发人员尽可能使用 Pyodide。
参考实现
Pyodide。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0776.rst