更新履歴
- : 公開
- : fflush() の前に改行の出力が必要だった理由と正しい実装について追記
はじめに
Emscripten を用いて PHP の処理系 を WebAssembly にコンパイルした。機能をある程度絞ることで、思ったよりも簡単に実現できたので、備忘録として記しておく。
なお、この記事では Emscripten や WebAssembly とは何か知っていることを前提とする。
バージョン情報
この記事中で使用するソフトウェア等のバージョンを記載する。
- Ubuntu 22.04 on WSL2
- Docker version 24.0.6
- Emscripten 3.1.46
- Node.js 20.7.0
- PHP 8.2.10
なお、Docker から下は Docker 上で導入するので、ホストマシンにはインストールしなくてよい。
本記事のゴール
先にこの記事のゴールを示しておく。これから示す手順のとおりに進めると、次のようなコードが動くようになる。このコードはこのあと使うので、index.mjs の名前で保存しておくこと。
import { readFile } from 'node:fs/promises';
import PHPWasm from './php-wasm.mjs'
const code = await readFile('/dev/stdin', { encoding: 'utf-8' });
const { ccall } = await PHPWasm();
const result = ccall(
'php_wasm_run',
'number', ['string'],
[code],
);
console.log(`exit code: ${result}`);
標準入力から与えたコードを WebAssembly にコンパイルされた PHP 処理系の上で実行している。このような php-wasm.mjs (とそこから呼び出される php-wasm.wasm) を作成する。
ビルド
C のエントリポイントを書く
先ほどのコードでも使っていたエントリポイントである php_wasm_run を用意する。
#include <stdio.h>
#include <emscripten.h>
#include <Zend/zend_execute.h>
#include <sapi/embed/php_embed.h>
int EMSCRIPTEN_KEEPALIVE php_wasm_run(const char* code) {
zend_result result;
int argc = 1;
char* argv[] = { "php.wasm", NULL };
PHP_EMBED_START_BLOCK(argc, argv);
result = zend_eval_string_ex(code, NULL, "php.wasm code", 1);
PHP_EMBED_END_BLOCK();
fprintf(stdout, "\n");
fflush(stdout);
fprintf(stderr, "\n");
fflush(stderr);
return result == SUCCESS ? 0 : 1;
}
ほとんどはただの PHP の公開 API を使ったコードだが、Emscripten 向けの注意点が 2点ある。
まずは EMSCRIPTEN_KEEPALIVE について。これは Emscripten が用意している特殊なマクロである。このマクロが付与されている関数は、どこからも使用されていなくともコンパイル後の WebAssembly バイナリから削除されない。もしこれを付け忘れると、未使用の関数とみなされ削除される。
次に、コードを評価したあとに呼んでいる標準出力と標準エラー出力に対する改行の出力について。出力バッファから出力させるためだけなら改行を出力させなくとも fflush() だけで事足りると考えたのだが、ないと動かなかったので追加した。これにより、PHP コードの出力の後ろに余分な改行が追加されてしまう。改行を出力せずともバッファを消費させる手段をご存知のかたはご教示願いたい。
fflush() の前に改行の出力が必要だった理由が判明したので追記する。これは、index.mjs で標準出力・標準エラー出力へ出力する方法を指定せず、デフォルトの実装に任せているため。Emscripten のデフォルト実装では、改行コードを出力するまで出力内容がバッファリングされ、fflush() が機能しない。
デフォルトの出力方法は index.mjs の中で PHPWasm() を呼ぶとき、stdout・stderr というオプションを渡せば変更できる。
const { ccall } = await PHPWasm({
stdout: (c) => {
if (c === null) {
// flush the standard output.
} else {
// output c to the standard output.
}
},
});
c は null か 1バイト符号つき整数を取り、null が flush 要求を意味する。
記事末尾のリポジトリはすでにこの変更を適用済み。stdout や stderr の完全なサンプルはそちらを参照のこと。
WebAssembly にコンパイルする
それでは WebAssembly にコンパイルしていこう。ここからは Dockerfile 上のコマンドとして操作を示す。
まずは Emscripten 公式が提供している Docker イメージ を使って、PHP 処理系と先ほど示した C 言語のソースコードを WebAssembly にコンパイルする。
FROM emscripten/emsdk:3.1.46 AS wasm-builder
次に、 php/php-src から PHP 処理系のソースコードを取得し、ビルドに必要な apt パッケージを取ってくる。有効にする拡張を増やしたいなら、ここでインストールするパッケージも増やすことになるだろう。
RUN git clone --depth=1 --branch=php-8.2.10 https://github.com/php/php-src
RUN apt-get update && \
apt-get install -y --no-install-recommends \
autoconf \
bison \
pkg-config \
re2c \
&& \
:
続けて、Emscripten のツールチェインを用いて PHP 処理系をビルドする。
RUN cd php-src && \
./buildconf --force && \
emconfigure ./configure \
--disable-all \
--disable-mbregex \
--disable-fiber-asm \
--disable-cli \
--disable-cgi \
--disable-phpdbg \
--enable-embed=static \
--enable-mbstring \
--without-iconv \
--without-libxml \
--without-pcre-jit \
--without-pdo-sqlite \
--without-sqlite3 \
&& \
EMCC_CFLAGS='-s ERROR_ON_UNDEFINED_SYMBOLS=0' emmake make -j$(nproc) && \
mv libs/libphp.a .. && \
make clean && \
git clean -fd && \
:
ここまでと比べると少し複雑なので、それぞれ詳しく見ていこう。
まず、buildconf は PHP 処理系をビルドするときに (Emscripten とは関係なく) 使うツールである。このツールの最も重要な仕事は、configure の生成である。
次に configure するわけだが、ここで emconfigure を使う。これを使うことで、Emscripten が上手く諸々のツールチェインを WebAssembly のビルド向けに調整しながら configure してくれる。
configure の後ろに指定してあるフラグは、通常の PHP 処理系のビルドで使う configure と同じなので、詳しくはそちらの cofigure --help を参照していただきたい。ほとんどは、機能の無効化のために指定している (依存するライブラリを減らし、ビルドをより簡単にするため)。
通常の C のビルドなら、configure の次は make するところだが、ここでも emmake を使う。役割はほとんど emconfigure と同様である。指定してある EMCC_CFLAGS という環境変数は、Emscripten の C コンパイラへのフラグで、ここでは ERROR_ON_UNDEFINED_SYMBOLS を無効化している。これにより、コンパイル中に出現した解決できなかったシンボルを無視するようになる (代わりに、そのシンボルを呼ぼうとしたタイミングで実行時エラーになる)。すべての依存を完全に解決するのは面倒なので、あまり使わない機能については無視してもよいだろう。
ここまでを実行すると libs/libphp.a が生成される。これは後で使うので移動させている。
さて、PHP 処理系をライブラリ化できたので、次に先ほど載せた C のソースコードをビルドしていこう。Dockerfile と同じ場所に php-wasm.c という名前で保存し、次のようにする。
COPY php-wasm.c /src/
RUN cd php-src && \
emcc \
-c \
-o php-wasm.o \
-I . \
-I TSRM \
-I Zend \
-I main \
../php-wasm.c \
&& \
mv php-wasm.o .. && \
make clean && \
git clean -fd && \
:
emcc は cc (C コンパイラ/リンカ) の Emscripten 版で、-c は「コンパイル」の意。-o や -I は普通の C コンパイラと同様、出力ファイルの指定とインクルードパスの指定である。
libphp.a と php-wasm.o が手に入ったので、これらをリンクして WebAssembly のバイナリとそのラッパである JavaScript ファイルを生成する。これにも emcc コマンドを使う。
RUN emcc \
-s ENVIRONMENT=node \
-s ERROR_ON_UNDEFINED_SYMBOLS=0 \
-s EXPORTED_RUNTIME_METHODS='["ccall"]' \
-s EXPORT_ES6=1 \
-s INITIAL_MEMORY=16777216 \
-s INVOKE_RUN=0 \
-s MODULARIZE=1 \
-o php-wasm.js \
php-wasm.o \
libphp.a \
;
それぞれのフラグについて解説する。
-s ENVIRONMENT=node は、生成する WebAssembly/JavaScript の実行環境を指定する。今回は node を指定しているので、Node.js 向けのファイルが生成される。
-s ERROR_ON_UNDEFINED_SYMBOLS=0 についてはすでに述べたので省略する。
-s EXPORTED_RUNTIME_METHODS='["ccall"]' は、生成される JavaScript から公開される API である。すでに index.mjs で使用しているが、ccall('関数名', '返り値の型', ['仮引数の型', ...], ['実引数', ...]) のように使う。
-s EXPORT_ES6=1 は、JavaScript コードを ECMAScript 6 に準拠した module として生成する。これを指定することで、require() ではなく import できる JavaScript を生成させられる。
-s INITIAL_MEMORY=16777216 は呼んで字のごとく。用途に合わせて適当に決めてほしい。
-s INVOKE_RUN=0 は、module をロードしたときに勝手に main() を呼ぶかどうか (だと思う)。今回は php_wasm_run() しか使うつもりがないので切っている。
-s MODULARIZE=1 は、実質的にほぼ必須のオプションであり、1 を指定することで「WebAssembly module をインスタンス化する関数」をエクスポートするような JavaScript ファイルを生成するようになる。これを指定しないと、生成物の JavaScript ファイルを読み込むと WebAssembly module が即座にインスタンス化されてしまい、起動のタイミングを制御できない。
ここまで実行すると、php-wasm.js と php-wasm.wasm が作られる。では、ここからはこれらの実行環境を作っていこう。
といっても、Node.js はビルトインで WebAssembly をサポートしているので、ほとんどやることはない。先ほど掲載した JavaScript のコードは、Dockerfile と同じディレクトリに index.mjs で配置すること。
FROM node:20.7
WORKDIR /app
COPY --from=wasm-builder /src/php-wasm.js /app/php-wasm.mjs
COPY --from=wasm-builder /src/php-wasm.wasm /app/php-wasm.wasm
COPY index.mjs /app/
ENTRYPOINT ["node", "index.mjs"]
実行
Dockerfile、php-wasm.c、index.mjs を用意したら、Docker コンテナをビルドして実行する。
$ docker build -t php-wasm .
$ echo 'echo "Hello, World!", PHP_EOL;' | docker run --rm -i php-wasm
Hello, World!
exit code: 0
まとめ
ここまでをまとめた Git リポジトリ を用意した。簡単にコンパイルできるので、興味があれば試してみてほしい。