main.c:5:39: warning: 'getpid' is deprecated: WASI lacks process identifiers; to enable emulation of the `getpid` function using a placeholder value, which doesn't reflect the host PID of the program, compile with -D_WASI_EMULATED_GETPID and link with -lwasi-emulated-getpid [-Wdeprecated-declarations]
printf("Hello, world! (PID: %d)\n", getpid());
^
/usr/share/wasi-sysroot/include/unistd.h:155:16: note: 'getpid' has been explicitly marked deprecated here
__attribute__((__deprecated__(
^
1 warning generated.
wasm-ld: error: /tmp/main-7a61a9.o: undefined symbol: getpid
clang-15: error: linker command failed with exit code 1 (use -v to see invocation)
PHP,这位在互联网世界叱咤风云的老将,驱动着全球超过 77% 的网站。但你是否想过,有一天 PHP 代码也能在浏览器前端自由奔跑,甚至在手机、嵌入式设备等更广阔的天地里施展拳脚?这并非天方夜谭,WebAssembly (Wasm) 的出现,为 PHP 开启了一扇通往新世界的大门。本文将带你踏上这场奇妙的代码漂流之旅,探索 PHP 编译到 WebAssembly 的奥秘,以及其中隐藏的挑战与机遇。
WebAssembly,简称 Wasm,是一种全新的二进制指令格式,它像一位技艺精湛的魔术师,将各种高级语言的代码转化为可以在任何支持 Wasm 虚拟机的环境中运行的“魔法指令”。最初,Wasm 在浏览器中崭露头角,凭借其卓越的性能和安全性,迅速赢得了开发者的青睐。如今,Wasm 的身影已经遍布服务器、移动设备、嵌入式系统等领域,成为了跨平台应用开发的新宠。
当我们谈论“编译 PHP 到 WebAssembly”时,我们并非要将 PHP 代码直接翻译成 Wasm 指令,而是要将 官方 PHP 解释器 编译成 WebAssembly。这就像是把一位经验丰富的翻译官“打包”到 Wasm 虚拟机中,让他随时待命,将 PHP 代码翻译成机器能够理解的语言。
这种做法的好处显而易见:只要有 Wasm 虚拟机的地方,就能运行 PHP 应用程序。无论是风光无限的浏览器,还是默默耕耘的服务器,甚至是资源有限的移动设备和嵌入式系统,PHP 都能找到自己的舞台。
就像咖啡有多种口味一样,WebAssembly 也有不同的“风味”,以适应不同的运行环境。这些“风味”通过目标三元组 (target triples) 来定义,例如
x86_64-linux-musl
和aarch64-apple-darwin
。在 WebAssembly 的世界里,主要有以下三种“风味”:wasm32-unknown-unknown
:这是一个“纯粹”的 Wasm 模块,没有任何环境假设。它就像一位与世隔绝的隐士,只依赖 WebAssembly 规范本身,与其他环境的交互只能通过导入和导出方法来实现。wasm32-unknown-emscripten
:这是 Emscripten 工具链的产物,主要面向浏览器或拥有 JavaScript 引擎的环境(如 Node.js)。它通常会生成一个 JavaScript 文件,用于加载 WebAssembly 模块,就像一位需要“翻译”才能与外界交流的学者。wasm32-wasi
:这个目标面向兼容 WASI (WebAssembly System Interface) 规范的 WebAssembly 运行时。WASI 就像一位经验丰富的“外交官”,为服务器端环境提供了一系列通用功能,如文件系统和套接字。Wasmtime、WasmEdge 和 Wazero 等都是支持 WASI 的 WebAssembly 运行时。wasm32-unknown-unknown
wasm32-unknown-emscripten
wasm32-wasi
在将 PHP 移植到 WebAssembly 的过程中,我们主要关注
wasm32-unknown-emscripten
和wasm32-wasi
这两种“风味”。前者让我们能够在浏览器和 JavaScript 引擎中运行 PHP 应用程序,而后者则让 PHP 能够在任何支持 WASI 的 WebAssembly 运行时中运行。有了 WebAssembly 的加持,PHP 的应用场景将得到极大的拓展:
PHP 是一门解释型语言,它通常会借助各种第三方库来实现特定的功能。在将 PHP 解释器编译到 WebAssembly 时,我们需要考虑如何处理这些第三方库。
一个常见的做法是将所有需要的扩展编译到相同的目标,并将解释器与这些库静态链接。这是因为在 WebAssembly 中,动态链接仍然是一个正在开发中的特性(被称为 Component Model),尚未成熟。
在构建 WebAssembly 版本的 PHP 时,一个好的策略是先禁用所有可选的扩展,以减少所需的依赖数量。然后,我们可以专注于构建核心解释器,并在之后逐步启用更多的扩展。
编译 PHP 解释器到 WebAssembly 并非易事,我们需要选择合适的工具链,并解决各种潜在的问题。
Emscripten 是一个强大的工具链,它可以将 C/C++ 代码编译成 WebAssembly 和 JavaScript 的组合,从而在现代 JavaScript 运行时(如 Web 浏览器和 Node.js)中运行。
Emscripten 通过 JavaScript 模块来处理 C 代码中的每一个系统调用 (syscall)。它模拟了一个 POSIX 环境,并处理内存分配、文件系统访问、网络等操作。虽然 Emscripten 并非完美,例如它不支持
fork()
系统调用,但它仍然是 WebAssembly 开发的绝佳基础。让我们来看一个简单的例子,展示如何使用 Emscripten 编译一个显示进程 PID 的 C 程序:
使用以下命令编译该程序:
这将生成
hello.wasm
和hello.js
两个文件。hello.wasm
是编译后的 WebAssembly 模块,而hello.js
是一个运行时,用于将 C 系统调用桥接到 JavaScript 函数调用。使用 Node.js 运行该程序:
你将会看到类似以下的输出:
Emscripten 通过 JavaScript 运行时,将 C 字符串转换为 JavaScript 字符串,并将其传递给
console.log()
。Emscripten 还注入了一个简单的getpid()
处理程序,它总是返回 42。当目标是
wasm32-wasi
时,使用 WASI SDK 并非强制要求,但它能极大地简化开发过程。wasi-libc
项目让我们能够在 guest 中使用 C 系统调用,这些调用会被映射到 host 运行时中实现的 WASI 系统调用。wasi-libc
并没有完全取代 POSIX 空间,但它提供了一个良好的起点。WASI SDK 包含了一个预配置的 clang 编译器,它可以轻松地使用
wasi-libc
C 库。让我们再次使用之前的 PID 示例:
使用以下命令编译该程序:
你可能会遇到以下错误:
wasi-libc
提示我们可以通过定义_WASI_EMULATED_GETPID
宏并链接wasi-emulated-getpid
库来模拟getpid
系统调用:现在,我们可以运行该程序:
你将会看到类似以下的输出:
在这个例子中,
getpid()
系统调用并没有离开 guest 环境,它被模拟并返回一个固定的数字。其他的系统调用可能会以不同的方式映射到 host,最终成为操作系统上的真实系统调用,这取决于 WASI 规范的 host 端实现。WASI 项目的设计目标是在保持现有程序功能的同时,在一个与底层操作系统隔离的环境中运行。
即使成功地编译了程序(在本例中是 PHP 解释器),仍然可能会在运行时遇到各种问题。
WebAssembly Language Runtimes
项目提供了编译为wasm32-wasi
的 PHP、Python 和 Ruby 的 WebAssembly 二进制文件。在尝试使用 PHP 解释器运行 WordPress 时,我们发现 SQLite 和 PHP 都存在一些问题。这些问题的根本原因是它们期望对齐的内存,而
mmap
在某种程度上提供了这种对齐。wasi-libc
的mmap
实现依赖于malloc
,后者会提供 16 字节的对齐。如果这种对齐不够,我们需要分配另一个页面(在 WebAssembly 中是 64 KiB),这将导致大量的填充和内存浪费。wasi-libc
中mmap
也能够将文件映射到内存中,但它的功能并不完整,也没有 POSIXmmap
那么多的保证。一个有效的选择是禁用
mmap
,并使用其他的替代方案。在 PHP 的例子中,Zend 解析器需要比malloc
提供的更大的对齐,因此我们不得不依赖aligned_alloc
来满足 Zend 解析器的需求。在本文评估的两种环境中,网络支持的处理方式截然不同。
在使用
wasm32-unknown-emscripten
时,WebAssembly 模块运行在 JavaScript 环境中,它可以访问网络功能,但需要进行一些调整。在使用wasm32-wasi
环境时,WebAssembly 模块运行在一个更加受限的环境中,它无法创建套接字。在 Emscripten 构建中,JavaScript 负责处理 C 代码请求原始 TCP 套接字的情况。Node.js 可以做到这一点,但 Web 浏览器不能。
因此,Emscripten 并不尝试打开 TCP 套接字,而是使用 WebSocket 模拟 POSIX TCP 套接字。WebSocket 客户端被包含在构建中,并将流量隧道到服务器端的端点,然后该端点打开请求的 TCP 套接字。即使在 Node.js 中也是如此,这样 Emscripten 就不必维护两个单独的代码分支。
Emscripten 提供了一个 WebSocket 代理服务器,它允许 Web 浏览器页面运行完整的 TCP 和 UDP 连接,充当接受传入连接的服务器,并执行主机名查找和反向查找。然而,不建议使用它,因为所有的系统调用都作为单独的 WebSocket 消息进行代理,这可能会非常慢。
这就是 WordPress Playground 对 JavaScript 模块应用自定义补丁的原因,该补丁仅代理连接到 MySQL 服务器所需的一小部分系统调用。特别是,它代理当选项为
SO_KEEPALIVE
或TCP_NODELAY
时的setsockopt()
调用。幸运的是,它与 Node.jsnet
模块支持的套接字选项完美对齐。另一个挑战是:C 套接字中的许多与套接字相关的操作是同步的,但它们由异步的 WebSocket 代理处理。这就是 Asyncify 发挥作用的地方。
WASI 环境与 Emscripten 环境的不同之处在于,它不允许 guest 模块打开出站连接,或在启动时创建新的套接字来接收入站连接。
在 WASI 中,host 代表 WebAssembly 模块创建一个套接字对,并在需要时将套接字文件描述符转发给 WebAssembly guest。从那时起,WebAssembly guest 能够正常地
accept()
连接。同样,WebAssembly guest 目前也无法打开出站连接。某些运行时提供了特定的解决方案,允许我们打开出站连接。WASI 社区也在开发 Sockets、HTTP 甚至 gRPC 提案,这些提案将丰富 WebAssembly 模块的网络功能。
如果一切顺利,我们现在有了一个能够同步运行 PHP 应用程序的解释器。也就是说,它从头到尾运行程序,并且不能在中间被打断。
然而,有些环境需要异步上下文。例如,Wasm 模块需要在浏览器中与异步 JavaScript 交互 (
wasm32-unknown-emscripten
)。Asyncify 可以对现有的 WebAssembly 模块进行instrumentation,使其执行可以被暂停和恢复。
它在
wasm32-wasi
环境中也很重要,因为一些 WebAssembly 提案(如 stack switching proposal)正在最终确定中。在这种环境中,asyncify 充当了一种 escape hatch,允许我们实现非线性程序控制流,例如setjmp/longjmp
模拟。这对于实现异常、纤程和类似的替代控制流非常有用。有了 asyncify,我们能够保存一些全局状态,我们可以使用这些状态来返回到现有的执行状态。
在 Emscripten 环境中,流控制由 JavaScript 模块处理,但 Asyncify 对于在 C 中使用的同步系统调用和 JavaScript 中可用的异步 API 之间进行接口至关重要。以网络为例。以下是 PHP 在其 MySQL 连接器中使用
setsockopt()
的方式:即使这段简短的代码是惯用的 C 代码,也没有直接的 JavaScript 处理它的方法。C 期望一个同步的返回值,但 JavaScript 只有在 WebSocket 代理回复后才能最终获得它。
那么,它是如何工作的呢?
Emscripten 将所有触发这种同步到异步委托的 C 函数转换为一个状态机,该状态机记住程序状态并展开调用堆栈。然后,JavaScript 事件循环继续。一旦结果准备就绪,WebAssembly 调用堆栈会逐个函数地展开,直到到达之前的展开点——从那里,同步执行继续,就像什么都没发生过一样。
这种方法的一个缺点是 Emscripten 需要知道每个触发异步行为的 C 函数。不仅如此,它还需要转换当调用发生时可能在调用堆栈上的每个函数。
Emscripten 可以自动检测这些函数,以方便开发人员,但使用动态调用的 C 程序可能会欺骗 Emscripten 进行过度贪婪的包装,这会显着降低整个程序的速度。这就是 WordPress Playground 选择手动列出所有这些函数的原因。权衡之处在于,即使缺少一个函数也会导致整个程序崩溃,因此 Playground 构建了开发人员工具来自动检测和修补这些情况。
将来,Asyncify 可能会被 JSPI 取代——JSPI 是一个正在制定中的新标准提案。JSPI 的工作原理是在单独的堆栈上执行开发人员指定的 WASM 导出,在进行异步调用时将该堆栈放在一边,并在以后恢复它。没有像 Asyncify 那样的重绕/展开。在撰写本文时,JSPI 是 V8 的 arm64 和 x64 构建中的一项实验性功能——因此在 Node.js 和基于 chromium 的浏览器中。
在其他 Asyncify 用例中,例如
setjmp/longjmp
的模拟,WebAssembly 提案(如 Stack Switching)将允许我们不依赖 Asyncify 作为实现它们的 escape hatch,而是直接依赖规范。正如我们所见,将程序移植到 WebAssembly 并非易事。像解释器这样的复杂程序可能非常棘手。程序表面会产生影响,并且解释程序往往具有丰富的标准库,这会增加依赖项的数量和要移植的第三方库的数量。
我们已经详细介绍了用于构建 WebAssembly 程序的各种选项和工具链,并且我们重点介绍了其中的两个:
然后,我们已经了解了如何承担编译解释器的任务,并为这两个工具链提供了有关该过程的大致步骤。我们还描述了根据目标编译解释器应遵循的关键策略。
然后,我们开始描述一些最重要的问题,以及如何克服它们。
总而言之,将 PHP 编译到 WebAssembly 是一项充满挑战但也充满机遇的工作。它为 PHP 带来了新的生命力,让 PHP 能够在更多的平台和环境中运行。虽然仍然存在一些问题需要解决,但 WebAssembly 的未来值得我们期待。
参考文献
Related searches: