Pyodide完全解説|ブラウザでPythonが動く仕組みをゼロから理解する【WebAssembly】
ブラウザの上でPythonが動く。
この一文だけ聞くと「ああ、なんかそういうやつね」で終わらせてしまう人が多いんですよね。
でも実際にどういう仕組みで動いているのかを理解すると、これはかなりやばい話だとわかるんです。
こんにちは、音楽家の朝比奈幸太郎です。
今日はPythonの話。
今さらPython?古い、AIの時代に?
いやいや、むしろ今日お話しするWebAssemblyの話は、これからの時代のお話です。
サーバーがいらない。
インストールがいらない。
HTMLファイル一枚を開くだけで、NumPyもpandasもMatplotlibも、フルのPython科学計算スタックがそのまま動く。
その中心にいるのがPyodideというプロジェクトであり、筆者も空音開発の開発では多用しています。
この記事では、Pyodideがどこから来て、どういう仕組みで動いていて、何ができて、何ができないのかを、できる限り隅々まで書いていきましょう。
Pyodideとは何か
一言で言えば、CPythonをWebAssemblyにコンパイルしたものといえます。
CPythonというのは私たちが普段「Python」と呼んでいる公式実装のことで、その実体はC言語で書かれたインタープリタプログラム。
このCのコードをEmscriptenというツールチェーンを使ってWebAssembly(WASM)バイトコードに変換する。
WebAssemblyはブラウザが直接実行できるバイナリフォーマットで、ChromeもFirefoxもSafariもEdgeも、すべてのモダンブラウザがネイティブサポートしている。
結果として何が起きるかというと、ブラウザのJavaScriptエンジンの中に、完全なPythonインタープリタがサンドボックスとして動くという状態が生まれます。
CPython (C言語)EmscriptenWebAssemblyブラウザ完全なPython実行環境
「完全な」というのが重要で、これはトランスパイルや互換レイヤーではありません。
本物のCPythonが動いているというのがポイント。
だからこそNumPyのようなC拡張を含む科学計算ライブラリも、同じEmscriptenでWASMにコンパイルして同梱することで、本物のNumPyをブラウザで動かすことができる。
これはすごい。
誕生の経緯:Mozillaの社内実験から世界へ
Pyodideは2018年、MozillaのエンジニアであるMichael Droettboom氏が社内プロジェクト「Iodide」のために作りました。
Iodideというのは、ブラウザだけでリテラシーサイエンスコンピューティングができるノートブック環境を目指した実験的プロジェクトだったんです。
データサイエンティストがローカル環境に依存せず、ブラウザだけで分析から可視化まで完結できる世界を作ろうとしていました。
そのためにはPythonをブラウザで動かす必要があった。
それがPyodideの出発点となります。
2021年、MozillaがIodideプロジェクトから撤退したことをきっかけに、Pyodideはエンジンだけが切り出されて完全独立したコミュニティ駆動のオープンソースプロジェクトとして再出発。
現在はQuansight Labs、Cloudflare、Chan Zuckerberg Initiative(CZI)などの支援を受けながら、独立したコアチームが開発を続けています。
ライセンスはMozilla Public License 2.0(MPL 2.0)。
まず動かしてみる:最小構成
理屈より先に動かした方が理解が早い。
HTMLファイルを一枚作って、以下のコードをそのまま貼り付けてブラウザで開いてみてほしい。
Copy<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.js"></script>
</head>
<body>
<script>
async function main() {
const pyodide = await loadPyodide();
const result = await pyodide.runPythonAsync(`
import sys
sys.version
`);
console.log(result); // Python 3.12.x が返ってくる
}
main();
</script>
</body>
</html>
ブラウザのコンソールを開くとPythonのバージョン文字列が表示される。
サーバーは一切使っていない。
これが動いた瞬間、何かが変わった感覚があるはずだ。
ただし初回はCDNから約6.4MBのダウンロードが走り、初期化に4〜5秒かかる。
これは現時点でのPyodideの最大の課題の一つで、公式ロードマップにも改善項目として明記されている。
2回目以降はブラウザキャッシュが効くので体感が大きく変わる。
パッケージ管理:micropipとloadPackage
Pyodideにはパッケージを取得する方法が2つある。
pyodide.loadPackage() は、PyodideがあらかじめWASMにコンパイル済みで同梱しているパッケージをロードする。
NumPy、pandas、SciPy、Matplotlib、scikit-learn、OpenCV、regex、PyYAML、lxmlなど、200本以上のパッケージが対象になっていて、これらはPyodideチームが事前にEmscriptenでビルドして配布しているものになります。
Copyawait pyodide.loadPackage("numpy");
await pyodide.loadPackage(["pandas", "matplotlib"]);
micropip は、PyPI上の純粋PythonパッケージをリアルタイムでインストールできるPyodide専用の軽量パッケージマネージャーだ。
C拡張を含まない純粋Pythonのwheelがあれば、ほぼどのパッケージでも動きます。
Copyimport micropip
await micropip.install("requests")
await micropip.install(["beautifulsoup4", "pydantic"])
重要な制約としては、C拡張が必要でPyodideの事前ビルドリストにないパッケージはインストールできないということ。
たとえばPillowは対応済みだがlightgbmのような特殊なライブラリはまだ使えない場合があります。
使いたいパッケージが対応しているかどうかは公式パッケージリストで確認するのが早い。
JavaScript↔Python FFI:二つの言語が溶け合う
Pyodideの一番面白い機能がここになります。
JavaScriptとPythonを自由に相互呼び出しできるFFI(外部関数インターフェース)が内蔵されていること。
PythonからDOMを直接操作する:
Copyfrom js import document, console, window
# HTML要素を作ってDOMに追加
div = document.createElement("div")
div.textContent = "Pythonから生成したDOM要素"
div.style.color = "royalblue"
document.body.appendChild(div)
# ブラウザのコンソールに出力
console.log("Pythonからこんにちは")
# alertを呼ぶ
window.alert("Pyodideが動いています")
サーバーもAPIも経由せずに、Pythonの文法でDOMを操作できます。
これを初めて動かしたときの衝撃は正直かなりありますよね。
JavaScriptからPython関数を呼ぶ:
Copy// Python側で関数を定義
await pyodide.runPythonAsync(`
def analyze_chord(root, chord_type):
# 和音の構成音を返す(音楽理論の処理をPythonで書ける)
intervals = {"major": [0,4,7], "minor": [0,3,7], "dim": [0,3,6]}
return [root + i for i in intervals[chord_type]]
`);
// JavaScript側から呼び出す
const analyze = pyodide.globals.get("analyze_chord");
const result = analyze(60, "major"); // MIDIノート番号で返ってくる
console.log(result.toJs()); // [60, 64, 67]
型変換の仕組みについて:
PythonとJavaScript間でオブジェクトが渡される際、PyodideはJsProxyというラッパークラスを使って型変換を管理しています。
to_js()とto_py()は明示的な変換関数です。
v0.29.0からデフォルトの挙動が変わり、Python辞書をto_js()で変換するとJavaScriptのMapではなくObjectとして返るようになりました。
これは長期間ユーザーから要望が上がっていた変更でした。
エラーハンドリングも双方向に対応。
JavaScript側で投げた例外をPythonのexceptで捕捉でき、その逆も成立するわけです。
非同期処理:ブラウザとPythonの最大の摩擦点
ブラウザ環境はシングルスレッドのイベントループで動いている。
PythonのコードがUIスレッドをブロックするとブラウザが固まる。
この問題への答えが非同期実行です。
runPythonAsync()はPythonのasyncioコルーチンをJavaScriptのPromiseとして実行する。
Copyconst result = await pyodide.runPythonAsync(`
import asyncio
async def heavy_computation():
await asyncio.sleep(0) # イベントループに制御を返す
result = sum(range(10_000_000))
return result
await heavy_computation()
`);
console.log(result);
v0.25.0からはJS Promise Integration(JSPI)という実験的機能が導入されました。
run_sync()関数を使うことで非同期処理を同期的に待機できる仕組みで、これは「Pythonの同期コードがブラウザの非同期APIを直接呼べない」という根本的な制約を崩す可能性がある技術として注目されています。
Copyfrom pyodide.ffi import run_sync
from js import fetch
# 同期コードの中でfetchを待機できる
response = run_sync(fetch("https://api.example.com/data"))
data = run_sync(response.json())
Web Workersとの連携:UIを止めないために
重い計算をメインスレッドで動かすとUIが固まる。解決策はWeb Workersだ。
Copy// worker.js
importScripts("https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.js");
let pyodide;
self.onmessage = async (event) => {
if (!pyodide) {
pyodide = await loadPyodide();
}
const { code, id } = event.data;
try {
const result = await pyodide.runPythonAsync(code);
self.postMessage({ id, result: result.toString(), success: true });
} catch (err) {
self.postMessage({ id, error: err.message, success: false });
}
};
Copy// main.js
const worker = new Worker("worker.js");
worker.postMessage({
id: 1,
code: `
import numpy as np
arr = np.random.randn(1000, 1000)
float(np.linalg.norm(arr))
`
});
worker.onmessage = (e) => {
console.log("計算結果:", e.data.result);
};
現時点での制約として、複数のWeb WorkerやメインスレッドでPyodideインスタンスを共有することはできません。
それぞれのWorkerが独立してPyodideを初期化する必要があります。
メモリ効率の問題として認識されており、改善がロードマップに上がっているようです。
パフォーマンス:正直に数字を出す
公式ロードマップに記載されている実測値として、PyodideはネイティブPythonの約3〜5倍遅いというのは正直なところ。
ネイティブCPython:Pyodide≈1:3∼5
ただし面白いのはC拡張の挙動で、C言語をコンパイルしたWASMはほぼネイティブ速度か最大2〜2.5倍遅い程度で動きます。
つまりPythonのAPIを持つがC実装のライブラリ(NumPy、SciPyなど)は非常に速いというわけ。
遅いのはPureなPythonのループ処理に限定されます。
実用上の意味として、NumPyで行列計算をするような用途ならパフォーマンスの問題はほぼ気にならないが、Pythonのforループを何百万回も回すような処理はブラウザ上では現実的ではない。
設計を工夫してNumPyに計算を任せる作りにすれば、多くのデータサイエンス用途では十分実用になります。
個人的人も速度に関して気になったことは筆者の環境ではまだありません。
同期I/Oの制約:避けられない現実
また、Pythonの膨大なライブラリエコシステムは同期I/Oを前提として設計されていますよね。
しかしブラウザ環境ではネットワークアクセスもファイルシステムアクセスもすべて非同期APIとなります。
ソケットも使えません。
これがPyodideの根本的な制約の一つで、たとえばrequestsライブラリは長い間Pyodideで動かなかったわけですが、v0.25.0からpyxhrという同期HTTPクライアントが追加され、requestsが部分的に使えるように。
しかし、すべてのネットワーク系ライブラリが動くわけではないのが注意です。
Copy# v0.25.0以降、requestsが部分的に使えるようになった
import requests
response = requests.get("https://api.example.com/data")
print(response.json())
完全な同期I/Oのサポートはロードマップ上の課題として現在も残っている。
他の「ブラウザPython」実装との比較
Brython / Transcrypt はPythonコードをJavaScriptにトランスパイルするアプローチです。WebAssemblyを使わない。
CPythonとの完全互換はなく、C拡張ライブラリが使えないため、科学計算用途では論外といっていい。
PyScript はPyodideを内部エンジンとして使うフレームワーク。
PyodideのFFIをラップしてHTMLフレンドリーなAPIを提供しています。
PyodideそのものというよりPyodideの上に乗るUI層として理解するのが正確です。
CPythonの公式WASMビルド はCPython本体にEmscriptenターゲットが追加されたもので、Pyodideとは別プロジェクト。
v0.28.0のリリースでPEP 776(Emscripten Runtime support)とPEP 783(Emscripten Packaging)によってABIの標準化が進んでおり、PythonコミュニティとPyodideの協調が深まっている。
RustとWebAssemblyの相性が最高な理由
WebAssemblyはあくまで「出力フォーマット」。
つまりどの言語からでもWASMを生成することは理論上できます。
C++でもGoでもAssemblyScriptでも問題ない。
ではなぜRustがWASMの文脈で特別扱いされるのか?
それには明確な理由があるので、最後にシェアしておきましょう。
まず、ガベージコレクターを持たないことが最初の理由となります。
JavaやGoのようなGCを持つ言語をWASMにコンパイルすると、GCのランタイムごとバイナリに持ち込むことになります。
これがファイルサイズを膨らませ、実行時のポーズを生みだす。
RustはそもそもGCを持たない言語設計になっているので、WASMに余計なランタイムを一切持ち込まずに済むわけです。
生成されるバイナリは最小限で、数KBから数百KB程度に収まることが多いんですね。
Pyodideの6.4MBと比べるとその差は歴然というわけです。
ツールチェーンの完成度も理由の一つ。
wasm-packとwasm-bindgenという2つのツールがRustのWASMエコシステムの中心に鎮座。
wasm-bindgenはRustとJavaScriptの間の型変換と相互呼び出しを自動生成してくれるツールで、Pyodideが手動で管理するFFIをほぼ自動化してくれます。
wasm-packはそのビルドとnpmへのパブリッシュをワンコマンドで完結させる。
Copyuse wasm_bindgen::prelude::*;
// この関数がそのままJavaScriptから呼べるようになる
#[wasm_bindgen]
pub fn analyze_chord(root: u8, chord_type: &str) -> Vec<u8> {
let intervals: Vec<u8> = match chord_type {
"major" => vec![0, 4, 7],
"minor" => vec![0, 3, 7],
"dim" => vec![0, 3, 6],
_ => vec![0],
};
intervals.iter().map(|i| root + i).collect()
}
Copy// JavaScript側からはこう呼ぶ
import init, { analyze_chord } from "./pkg/my_wasm.js";
await init();
const notes = analyze_chord(60, "major");
console.log(notes); // Uint8Array [60, 64, 67]
#[wasm_bindgen]というアトリビュートを一行書くだけで、Rustの関数がJavaScriptから直接呼べるようになる。
型変換のグルーコードはすべて自動生成される。
メモリ安全性とWASMの思想的一致も見逃せません。
WASMはサンドボックス化されたメモリモデルを持ち、ホスト環境(ブラウザ)のメモリに直接アクセスできない設計になっています。
Rustの所有権システムとボローチェッカーはコンパイル時にメモリの安全性を保証しており、この二つの設計思想は非常によく噛み合っていて、RustのコードをWASMに変換したとき、メモリ起因のバグがほぼ発生しません。
PythonとRustのWASMアプローチの根本的な違いを整理しておきましょう。
Pyodideは「CPython全体をWASMに持ち込む」という巨大なランタイムごと移植する戦略となります。だから初回ロードが重い代わりに、Pythonの資産をそのまま全部使えるわけです。
RustのWASMは「必要な処理だけをWASMに変換する」というミニマルな関数単位の移植が基本戦略で、バイナリは軽く速いが、Rustで書き直す工数がかかる。
Pyodide=Pythonランタイムごとブラウザに持ち込む(重いが資産をそのまま使える)Rust+WASM=必要な関数だけを軽くWASMに変換する(速くて軽いが書き直しが必要)
どちらが正解かではなく、何を優先するかで使い分けるのが正しい判断となります。
既存のPython資産を活かしたいならPyodide、パフォーマンスとバイナリサイズを最優先にするならRust+WASMという棲み分けになります。
開発したい対象によって考察していくべきであり、やはりPythonのライブラリの多さ(もちろんRustもCライブラリを使えるが)は魅力的なのであります。
ではでは。