深度解析塑造未来的技术文章。

Pyodide:借助 WebAssembly 在浏览器中运行真正的 Python

Pyodide 将 CPython 编译为 WebAssembly,让你直接在浏览器中运行 Python,并支持 NumPy 和 pandas。本文介绍其原理与适用场景。

一条蟒蛇盘绕在形似浏览器窗口的玻璃箱里,旁边放着科学实验箱

自从 2010 年代初以来,在浏览器里跑 Python 就一直是个美好的愿望,但每次尝试都碰到同一个问题:Python 与 CPython 的 C 运行时耦合太深,很难直接移植到 Web 环境。Transcrypt、Brython 和 Skulpt 都试图用 JavaScript 重新实现 Python 来解决这个问题。它们处理简单脚本没问题,但一旦需要 NumPy、pandas 或其他 C 扩展库(而这些恰恰是 Python 真正有用的地方),就力不从心了。

Pyodide 走了另一条路:把真正的 CPython 解释器编译成 WebAssembly。这不是重新实现,而是用 Emscripten 把 CPython 的源码直接编译,使其能在浏览器中运行。这意味着语言兼容性是完整的,包括 C 扩展。你可以直接 import numpy,而且它真的能用,因为编译出的 Wasm 二进制文件里包含了 NumPy 实际的 C 代码。

Pyodide 底层原理揭秘

Pyodide 的构建流程是取 CPython 源码树,然后用 Emscripten 编译。Emscripten 是一套面向 WebAssembly 而非原生机器码的编译工具链。最终产物是一个实现 Python 解释器的 .wasm 二进制文件,外加一层 JavaScript 胶水代码,用来把解释器和浏览器环境连接起来。

难点在于 C 扩展。以 NumPy 为例,它包含超过 10 万行 C 和 Fortran 代码。Pyodide 会预先编译一批常用包(NumPy、pandas、scikit-learn、matplotlib、scipy),做成可按需加载的 Wasm 模块。当你调用 import numpy 时,Pyodide 会下载预编译好的 NumPy Wasm 模块,将其链接进正在运行的解释器,然后完成初始化。

<!-- Minimal Pyodide example -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.27.0/full/pyodide.js"></script>
<script>
async function main() {
// Load the Python interpreter (~10MB download)
const pyodide = await loadPyodide();
// Run Python code directly
pyodide.runPython(`
import sys
print(f"Python {sys.version} running in the browser!")
`);
// Load packages on demand
await pyodide.loadPackage('numpy');
// Use NumPy — the real NumPy, compiled to Wasm
const result = pyodide.runPython(`
import numpy as np
arr = np.random.randn(1000)
f"Mean: {arr.mean():.4f}, Std: {arr.std():.4f}"
`);
console.log(result);  // "Mean: 0.0123, Std: 1.0045"
}
main();
</script>

JavaScript 与 Python 的桥接

Pyodide 最强的特性之一,是 Python 与 JavaScript 之间无缝的互操作。Python 对象会自动代理到 JavaScript 中,反之亦然。你可以在 Python 里调用 JavaScript 函数、操作 DOM,两种语言之间传递数据时也无需手动序列化。

// JavaScript calling Python
const pyodide = await loadPyodide();
// Define a Python function
pyodide.runPython(`
def analyze(data):
import statistics
return {
'mean': statistics.mean(data),
'median': statistics.median(data),
'stdev': statistics.stdev(data)
}
`);
// Call it from JavaScript with JS data
const analyze = pyodide.globals.get('analyze');
const result = analyze([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
console.log(result.toJs());  // {mean: 5.5, median: 5.5, stdev: 3.03}
// Python accessing the DOM
pyodide.runPython(`
from js import document
element = document.getElementById('output')
element.textContent = 'Updated from Python!'
`);

代理系统会自动处理类型转换:Python 的 dict 变成 JavaScript 对象,list 变成数组,数字变成 JavaScript 的 number。对于大规模数据传输,比如把一个百万元素的 NumPy 数组传给 JavaScript 可视化库,Pyodide 会使用共享内存缓冲区来避免复制,这对性能至关重要。

Pyodide 的适用场景

交互式计算环境。基于 Pyodide 构建的 JupyterLite 可以完全在浏览器中运行 Jupyter notebook。不需要服务器,Python 内核直接在本地的 Wasm 中运行。这对教育领域意义重大:学生无需安装任何东西就能运行 Python notebook,教师也不必为此配置服务器。

数据探索工具。允许用户上传数据并进行分析的 Web 应用,可以用 Pyodide 完全在客户端处理数据。数据不会离开浏览器,既解决了隐私问题,也省下了服务器成本。相比把数据发送到后端的 CSV 上传工具,一个在浏览器里用 pandas 做转换的版本部署更简单,也更私密。

带实时示例的文档。Python 库的文档可以嵌入真正能执行的交互式代码块。读者不再只看静态输出,而是能直接修改代码并立即看到结果。对于学习来说,这比静态示例高效得多。

原型开发与实验。习惯用 Python 思考的数据科学家可以直接在浏览器里原型化数据转换和可视化,然后把结果分享成一个 URL。不需要环境配置,不需要管理依赖,也不会有「在我机器上是好的」这类问题。

性能的现实

Pyodide 并不快。相比原生代码,WebAssembly 本身有额外开销,计算密集型任务通常慢 1.5 到 3 倍。再加上 Pyodide 运行的是编译成 Wasm 的 CPython(纯 Python 代码本身就比 C 慢约 100 倍),对于纯 Python 循环,Pyodide 大约比原生 CPython 慢 2 到 5 倍。

但关键在于:Pyodide 真正有用的场景,通常主要耗时在 C 扩展调用上,而不是纯 Python 代码。当你调用 np.dot(a, b) 时,实际计算是在编译后的 C 代码中完成的(现在是编译成 Wasm 的 C 代码)。这部分 C 代码的 Wasm 开销是 1.5 到 3 倍,对于交互式场景来说完全可以接受。一次原生 10 毫秒的 NumPy 矩阵乘法,在 Pyodide 中大约需要 20 毫秒。对于在 notebook 里点一下「运行」的用户来说,这点差异几乎感觉不到。

Performance comparison (approximate):
Native CPython    Pyodide (Wasm)
Pure Python loop:     1x                3-5x slower
NumPy operations:     1x                1.5-3x slower
Pandas groupby:       1x                2-3x slower
Startup time:         50ms              2-5 seconds
Package loading:      instant (pip)     2-10s (download + init)
Startup is the real cost. Once loaded, interactive
performance is adequate for most use cases.

启动成本才是更大的问题。Pyodide 核心运行时需要下载约 10MB,加载 NumPy 再增加 7MB,pandas 又增加 10MB。对于需要快速渲染的网页来说,5 秒的初始化延迟是致命的。因此,Pyodide 最适合那些用户本来就愿意等待环境加载的应用,比如 notebook、数据工具和交互式教程,而不是传统网页。

Pyodide 做不到的事

并非所有 Python 包都能在 Pyodide 中运行。依赖系统库的包,比如数据库驱动、GUI 库,以及调用操作系统特定 API 的代码,都无法编译为 Wasm。psycopg2 需要 PostgreSQL 客户端库,opencv 需要系统图形库。这些依赖在浏览器环境里根本不存在。

网络能力也很有限。Python 的 socket 模块在浏览器中无法使用,因为 Wasm 没有原始套接字访问权限。HTTP 请求要通过 Pyodide 的 JavaScript 桥接层调用浏览器的 fetch() API,因此会受 CORS 限制。你无法在 Pyodide 里运行 Flask 服务器(没有可以监听的套接字),也无法随意建立网络连接。

线程能力通过 Web Workers 得到了部分支持,但 Python 的 GIL(全局解释器锁)依然存在,而且线程模型与原生 CPython 也有所不同。对于 CPU 密集型的并行任务,使用 multiprocessing(每个 worker 拥有独立的 Wasm 实例)比使用线程效果更好。

Pyodide 与转译型 Python 的对比

值得把 Pyodide 的思路(将 CPython 编译为 Wasm)与另一条路线(将 Python 转译为 JavaScript)做个比较。Transcrypt 和 Brython 等项目会把 Python 语法转换成 JavaScript,生成的代码直接在浏览器的 JS 引擎中运行。

转译在运行时更快:生成的 JavaScript 以原生 JS 速度执行,而不是 Wasm 版 CPython 的速度。而且它没有启动成本,因为不需要下载运行时。但代价是兼容性:转译后的 Python 无法运行 C 扩展,与 CPython 在语义上存在一些细微差异(尤其是数值类型、字符串处理和边界情况),并且只支持标准库的一部分。

Pyodide 的优势在于它运行的是真正的 CPython。只要你的 Python 代码能在 CPython 中运行,它就能在 Pyodide 中运行(系统级依赖除外)。对于依赖 NumPy、pandas 和 scikit-learn 的数据科学工作流来说,这种兼容性是不可妥协的。而对于不需要 C 扩展的简单脚本,转译方案可能更合适。正如任何 Wasm 与原生 JS 的对比所表明的,选择哪种工具取决于具体的工作负载。

未来走向

Pyodide 仍在积极开发中,并持续改进。近期版本减小了核心运行时的体积,借助流式编译(浏览器在下载 Wasm 的同时就开始编译)改善了启动速度,并扩充了预编译包的范围。WebAssembly 生态也在走向成熟:WASI 提供了标准化的系统接口,组件模型则将让 Wasm 模块之间的互操作更加便捷。

更大的趋势是,浏览器正在成为通用运行时。借助 JavaScript、Wasm 以及 Pyodide 这类项目,你几乎可以在浏览器中直接运行任何语言编写的代码。这并不会取代服务端计算,而是通过把无需服务器的计算迁移到客户端来对其形成补充。对于 Python 庞大的数据科学生态来说,在浏览器中于客户端运行,打开了以前根本无法实现的应用场景。