Share
Embed
GoにおけるFFIのこれまでとこれから
Masaaki Goshima
September 10, 2026
Transcript
54 slides
-
自己紹介 • goccy [goss-ee] ( ごっしー ) • LayerX Inc.
( Platform Engineering / Enabling Group ) • OSS • ◦ Total Earned Stars: 10k+ ◦ go-json / go-yaml / bigquery-emulator etc. 最近の登壇内容 ◦ Go Conference mini in Sendai 2026 ▪ ◦ AI時代を見据えたコードカバレッジ計測ツールの開発 Go Conference 2025 ▪ GoでWebAssemblyを利用した実用的なプラグインシステムの構築方法 -
FFI (Foreign Function Interface) • 同一プロセス内で、異なるプログラミング言語で書かれた処理を 呼び出すための仕組み • ABI (
Application Binary Interface ) をあわせて呼び出す ◦ バイナリ間で関数を呼び出すための約束 • 実運用では、主にC言語で実装された機能を呼び出すために使われる • C ABI を理解できるかが重要 • 本発表では FFI を利用する主体となる言語をホスト(言語)と呼称 C ABI Go C -
メモリ管理の構造上の課題 ( 1/3 ) • ホストとC側でメモリ管理が別 ◦ ホストからC側のメモリ利用状況を認識する方法はない ◦ ホスト側で作ったオブジェクトがGCで解放されたとき、対応するC側の
オブジェクトが解放されないまま残る(メモリリーク)が起こりやすい 未確保のメモリ Goで確保したメモリ GC Go C C ABI Cで確保したメモリ -
型変換の実装難易度が高い • C言語の関数を呼び出して結果を受け取るには、関数の引数と返り値を ホスト言語とC言語の間で変換する必要がある • int ( 数値 ) や
char* ( 文字列 ) はまだ良いが、 構造体に対するポインタやコールバックが必要な関数ポインタは複雑 • 可変長引数APIへの対応が(基本的に)できない • C++ を対象にする場合はさらに難しくなる ◦ ホスト言語 ⇔ C ⇔ C++ のように C を経由して呼び出す ◦ ポインタのアップキャスト・ダウンキャストへの対応 ◦ スマートポインタへの対応 -
開発・デプロイが難しい • CライブラリをコンパイルするにはCコンパイラが必要 • 開発時 ◦ コンテナを利用する場合、コンテナ上にCコンパイラを用意してビルド するか、ホストマシン上でクロスコンパイルする必要がある ◦ ホスト言語のプロファイラ・デバッガがC側のコードに対して使えない
• デプロイ時 ◦ Cライブラリを動的リンクする場合、デプロイ先にライブラリや libc が必要 -
ポインタのライフサイクル管理 ( 1 / 2 ) • Cのオブジェクトに対応するGo側の struct を用意する
• runtime.SetFinalizer か runtime.AddCleanup (推奨) で finalizer を紐づける ◦ • AddCleanup は循環参照の心配がない runtime.KeepAlive で C関数の呼び出し終了まで、Cオブジェクトを 格納している Goの struct が解放されないようにできる -
ポインタのライフサイクル管理 ( 2 / 2 ) counter.go counter.c type Counter
struct { ptr *C.Counter } typedef struct { int n; } Counter; func NewCounter() *Counter { cnt := &Counter{ptr: C.counter_new()} runtime.AddCleanup(cnt, func(p *C.Counter) { C.counter_free(p) }, cnt.ptr) return cnt } Counter *counter_new(void) { return calloc(1, sizeof(Counter)); } func (c *Counter) Inc() { C.counter_inc(c.ptr) runtime.KeepAlive(c) } void counter_free(Counter *c) { free(c); } void counter_inc(Counter *c) { c->n++; } -
コールバック (トランポリン ) ( 1 / 2 ) • C
から Go の関数を直接呼び出すことはできない ◦ Goの関数をCの関数ポインタとして渡すことはできない • Go では //export directive でC側に関数を公開できる • runtime/cgo.NewHandle(any) ◦ Go の func をグローバルの map[uintptr]any 型の map に登録しつつ、 登録時の key の値を返すような API ◦ C側には map の key となる uintptr の値だけを渡し、 Go に戻ってきた時に map から func を取り出して利用 ◦ 使い終わったら Delete() で map から entry を削除 -
コールバック (トランポリン ) ( 2 / 2 ) /* #include
<stdint.h> extern int goOpCallback(int a, int b, uintptr_t h); static int calc(int a, int b, uintptr_t handle) { return goOpCallback(a, b, handle); } */ import "C" //export goOpCallback func goOpCallback(a C.int, b C.int, h C.uintptr_t) C.int { opFn := cgo.Handle(h).Value().(func(int, int) int) return C.int(opFn(int(a), int(b))) } func Calc(a, b int, opFn func(int, int) int) int { h := cgo.NewHandle(opFn) defer h.Delete() return int(C.calc(C.int(a), C.int(b), C.uintptr_t(h))) } Calc(1, 2, func(a, b int) int { return a + b }) // 3 Go: Calc C: calc Go: goOpCallback h := NewHandle(opFn) C.calc(a, b, h) goOpCallback(a, b, handle) Handle(h).Value() opFn(a, b) h.Delete() -
シングルバイナリにする方法 • cgo でも静的リンクすればシングルバイナリにできる • C/C++ ライブラリをGoモジュールに同梱し、go build 経由でコンパイルする ◦
• 必要な外部依存は C コンパイラだけ -ldflags '-linkmode external -extldflags "-static"' と -tags netgo,osusergo を使うことで libc まで含めた静的リンクが可能 # Linux/amd64 host -> Linux/arm64 static binary CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \ CC="zig cc -target aarch64-linux-musl" \ go build -tags netgo,osusergo \ -ldflags '-linkmode external -extldflags "-static"' ./cmd/app -
cgo より良い FFI は作れないのか • • メモリを安全に扱いたい ◦ ホストとCでメモリのアドレス空間を分離したい ◦
SIGSEGV が起きた時にホストまでクラッシュしないで欲しい Go のクロスコンパイルの恩恵を受けたい ◦ ビルド環境にCコンパイラやクロスコンパイラを用意したくない -
WebAssembly ( WASM ) への期待 • C/C++ から WASM へ変換するツールは
emscripten や wasi-sdk といった 成熟したものがある ◦ • WASM は Rust や Go などいろいろな言語から生成できる WASM Sandbox ◦ WASM モジュールは自分専用のメモリ(リニアメモリ)しか読み書きできない ◦ ファイルアクセスなどOSの機能は、ホストが許可したものだけ呼べる(WASI) WASM Sandbox C/C++ emscripten メモリ管理 (リニアメモリ ) wasi-sdk WASM システムコール 制御 ( WASI ) -
WASM Sandbox ( メモリ管理 ) • WASMのインスタンスごとにリニアメモリ([]byte)で管理 • ホストメモリとアドレス空間が完全に分離 •
メモリの範囲外アクセスはGoのエラーになる Go WASM Linear Memory -
WASM Sandbox ( システムコール制御 ) • WASM から発行されるシステムコールはホスト上ですべて制御できる ( WASI
) • 特定のファイルやサーバーにだけアクセスさせるなど、きめ細かい制御が可能 Go WASM Syscall Control Layer OS Resources fd_read /home/goccy/foo.go /etc/passwd -
WASM を使った FFI を Go で利用する流れ 1 C/C++/Rust などのライブラリを WASM
に変換 import _ “embed” 2 Go の embed package を使って WASM を埋め込む 3 WASM Runtimeを利用して WASM をコンパイル・実行 4 WASM ABI を利用して公開 APIにアクセスする // go:embed lib.wasm var wasm []byte var wasm []byte -
wazero • github.com/wazero/wazero • Pure Go で実装された WASM Runtime •
Interpreter mode と Compiler mode がある • ◦ Interpreter: 逐次実行 ( すぐ起動するがパフォーマンスが悪い ) ◦ Compiler: 最初にすべてコンパイルして機械語にしてから実行 実用的には Compiler mode で利用したいが、 コンパイル時に CPU/MEM リソースを大量に消費するため注意が必要 • コンパイルした結果をキャッシュファイルとして永続化することはできるため、二回目以 降はコンパイルをスキップする手段がある -
Go で FFI 用途で WASM を使ったライブラリの例 • klippa-app/go-pdfium ◦ •
• Google の C++ で書かれた PDFium の Go port ncruces/go-sqlite3 ◦ SQLite3 の Go port ◦ 今は WASM は使っていないが、過去のバージョンで使用 goccy/go-graphviz ◦ Graphviz の Go port ◦ go-graphviz が新しくなりました - Route54 -
WASM を使った FFI の課題 1. C/C++ プロジェクトから WASM を作ること自体が難しい 2.
WASM ABI を理解して、ホストとのブリッジを作成するのが難しい ホストと WASMはアドレス空間が異なる ため、文字列を渡すだけでも難しい cgo ホストで確保した文字列のアドレスと長さをCに教えればそのまま参照できる WASM ホストのアドレスをWASMから直接参照できないため、WASMのリニアメモリ 上に必要な領域を確保し、そこにホスト側からデータを書き込む必要がある FFIで使用する基本型は i32 i64 f32 f64 の 4つだけなので表現力に乏しい -
wasmify ( github.com/goccy/wasmify ) C/C++ ライブラリから FFI 用途のWASM を生成するステップを抽象化し、 •
WASMの生成とホストとのブリッジコードを冪等に生成するツール FFI用途のWASM: main を持つ実行ファイルではなく、ホストから関数を呼ぶための • ライブラリ形式の WASM AI Agent のハーネスとして利用することを想定 • ◦ 一部の複雑で非決定的な側面を AI に任せる設計 WASM の生成ステップを5段階に分類 • 1 configure 2 header analysis 3 build capture 4 bridge code generation 5 wasm build -
1. configure • C/C++のソースコードをビルドするために必要なツールチェーンと アーキテクチャを決定し、必要なソースコードを生成するフェーズ • GNU Autotools / Makefile
/ CMake / Bazel / Ninja などツールチェーンが多数あ り、実行方法も多岐にわたるため、プロジェクトごとの差が非常に大きい • wasmify はプロジェクトごとの差を AI に吸収してもらい、正規化した 設定情報を wasmify.json に残す • CI などで configure を実行するときは、wasmify.json をもとに実行することでAI な しで常に同じ状態を再現できる ( 冪等性 ) -
2. header analysis • C/C++ライブラリのヘッダファイルを clang parser を利用して解析 • wasmify.json
に利用する関数名を書くと、引数と返り値の型とその依存 型を 自動で見つけ、Protocol Buffers に定義 wasmify.json "bridge": { "ExportFunctions": [ "calc::compute" ] } calc.h namespace calc { enum Operation { ADD, SUB }; struct Result { double value; }; Result compute(Operation op, double a, double b); } calc.proto service Calc { rpc Compute(ComputeRequest) returns (ComputeResponse); } message ComputeRequest { Operation op = 1; double a = 2; double b = 3; } message ComputeResponse { Result result = 1; } enum Operation { ADD = 1; SUB = 2; } message Result { double value = 1; } -
3. build capture • まずは実行しているホスト向けにビルドを走らせる • このとき、CC などの環境変数を差し替え、wasmify 経由でビルドする •
ビルドに必要なコマンドと引数をすべてキャプチャする -
4. bridge code generation • header analysis で生成した Protocol Buffers
からブリッジコードを自動生成 • protoc プラグインをホスト言語ごとに書くことで複数言語をサポート ◦ • protoc-gen-wasmify-go が用意されており、Go コードを生成できる C 側のブリッジコードは wasmify 自身が生成する protoc-gen-wasmify-go protoc or Buf Bridge Code ( Go ) wasmify Bridge Code ( C ) -
5. wasm build • build capture ステップでキャプチャした build コマンドを最初から実行 •
compiler は wasi-sdk を利用し、WASM 向けの option も指定 • wasi-sdk は 32bit WASM のビルドしか対応していないが、64bit ビルドが欲しい場 合は、wasmify 側で 64bit 用のコードを生成して対応 wasmify wasi-sdk C/C++ Sources Bridge Code (C) -
wasmify によって得られたもの • wasmify.json によって再現性のあるビルドパイプラインを手に入れた • FFI のために必要な作業は、AI と共に wasmify.json
を作る作業 • CI 経由で WASM とブリッジコードを生成できるので、 GitHub Artifact Attestations と組み合わせて成果物の出所を保証しつつ、 ライブラリに組み込める • CGO_ENABLED=0 でビルドできる Pure Go ライブラリを簡単に作れる -
簡単に作れるようにはなったが ... • cgo で作った GoogleSQL ライブラリを書き直し ( goccy/go-googlesql )
、 Pure Go で動く bigquery-emulator を作ることができた • しかし、リリース翌日からパフォーマンスが低下 しているとの報告が相次ぐ ◦ bigquery-emulator#472 , bigquery-emulator#478 • WASM Sandbox によって安全なFFIを実現する方向性は間違っていないはず • パフォーマンス問題だけ解決する方法はないか v0.6.6 ( cgo ) v0.7.x ( wazero ) 0.6s 17.3s BigQuery emulator のパフォーマンスが wazero 版で約30分の1に -
Hint: RLBox • Firefoxに導入された、脆弱性を含む可能性のある既存の C/C++ ライブラリを、同一プ ロセス内で安全に実行するためのフレームワーク ( https://rlbox.dev )
• wasm2c ◦ C/C++ ==> WASM ==(wasm2c)==> C ◦ WASM由来のメモリやsyscallに対する制約を維持したまま、Cコードにする Firefox Trusted Code RLBox C Library Isolated Memory -
wasm2go • github.com/goccy/wasm2go • WASM を Go と Plan9 asm
に変換するツール • WASM Sandbox の性質とパフォーマンスを両立するために開発 • 変換時にできる限り最適化を行って効率的なコードを生成する ◦ 変換後のコードの世界には WASM 由来の制限がないため、 例えば 128bit 以上の SIMD 命令が利用できる • Go の Linker DCE ( Dead Code Elimination ) により、FFI で利用しない コードはリンク時に削除され、バイナリに混入しない • wazero で実行するより速く、実行時の消費リソースの心配がない -
Benchmark ( cgo vs wazero vs wasm2go ) Target: GoogleSQL
/ Env: Apple M5 (32 GB RAM), macOS 26.2 Go 1.26.2, native arm64 binaries (GOARCH=arm64), Apple clang 17.0.0 for cgo -
wasm2go の課題 1. インスタンスのメモリ使用量が多すぎる ◦ WASM 由来の構造的課題 2. 変換後のコード量が巨大すぎる ◦
wasm2go というアプローチが持つ課題 -
課題1: インスタンスのメモリ使用量が多すぎる • WASM はインスタンスごとにリニアメモリをもつ ( だから安全 ) • GoogleSQL
などの巨大なエンジンはデータセグメントだけで数十MB、 起動時の初期化などでさらに多くのメモリを確保 ( 〜30MB など普通 ) • 中身は全インスタンスで同一なのに、N個でN倍 • wazero でも同様の課題がある Linear Memory Instance A WASM Instance WASM Instance WASM Instance WASM Instance Instance A Instance A WASM Instance : 確保済みメモリ 30MB X 4 Instance = 120MB -
解決策: mmap ( MAP_PRIVATE ) で CoW • 初期化済みリニアメモリを一度ファイルに書き出す (
Image file ) • 各インスタンスは MAP_PRIVATE でそのファイルを自分のメモリとしてマップ • 読むだけのページは物理メモリを共有、書いたページだけ自分用にコピー (CoW) • 4 GiB コンテナで作れるインスタンス数が 100 程度から 3000 近くに増加 ◦ e.g.) 1 Instance あたり 30MB => 1.0MB Shared Memory ( Image file ) syscall.Mmap(fd, PROT_READ|PROT_WRITE, MAP_PRIVATE) Instance A Instance B : 書き込んだメモリ -
課題2: 変換後のコードが巨大すぎる • WASM から Go コードを生成したら 6 ~ 20倍のコード量になった
◦ • 3MBのWASMから 50MB弱の Go コードになる例もあった 巨大だと何が問題になるか ◦ OOM でコンパイルできない ◦ build や lint に時間がかかり過ぎる 3MB Go 50MB -
OOMでコンパイルできない問題 • 50MB の単一ファイルをコンパイルすると、一般的な環境では OOM になる • ファイルを分けても意味はない ◦ Go
は package 単位でまとめてコンパイルするので、ファイルを分けてもすべて のファイルを同時にコンパイルしようとして OOM • package を分けても意味はない ◦ package ごとに並行にコンパイルするので OOM -
解決策: 直列依存 • ひとつの package の中に OOM にならない程度のファイルを押し込み、package の 間に直列の依存関係を作って並行コンパイルを防ぐ
• ただし、直列に依存するということは、呼びたい関数が他の package にある場合に 循環参照になる ( 次のスライドで解決方法を説明 ) pkg0 pkg1 import _ “pkg0” pkg2 import _ “pkg1” pkg1 から pkg2 の関数を呼び出すと循環参照 -
go:linkname による循環参照回避 • 循環参照は go:linkname で安全に解決することができる • go:linkname は、参照したい関数をその関数が定義されている package
を import することなく利用することができるという性質を利用 • 同一ライブラリの中での linkname の利用なので安全に利用できる pkg1 pkg2 import _ “pkg1” //go:linkname foo github.com/goccy/mylib/pkg2.Foo func foo() func bar() { foo() } -
build や lint に時間がかかりすぎる問題 • 特に lint ( golangci-lint )
が問題 • 開発者の go.mod の推移的依存に wasm2go で変換したライブラリがあると、 golangci-lint 実行時に linter によっては依存先のすべての Go コードを 静的解析にするため、wasm2go で変換した Go コードが静的解析の対象に なり lint に時間がかかりすぎる事態になる • ライブラリ利用者すべてに wasm2go ライブラリを lint 対象から外してもらうのは非 現実的 -
解決策: asm を利用して静的解析をスキップ • golangci-lint を実行する環境は基本的に amd64 と arm64 なので、
その環境向けには Go ではなく Plan9 asm を生成する • asm は静的解析の対象外なので、どれだけ大きくても lint の時間は増えない ◦ • build の時間も短縮される amd64/arm64 以外の target (e.g. GOARCH=wasm ) の場合は Go コードが 使われるので、他アーキテクチャへのカバレッジも問題ない • つまり、Pure Go と amd64 と arm64 の 3種類のコードを生成する -
asm の方が速いは幻想 - ABI0 と ABIInternal • すべて asm で書いたからといって
Pure Go より速くなるわけではない • Go は関数を呼び出す時に ABI0 と ABIInternal という呼び出し規約が使われる ◦ ABI0: 引数はスタック経由 ( 遅い ) ◦ ABIInternal: 引数はレジスタ経由 ( 速い ) • asm で ABIInternal を使えるのは標準ライブラリだけ • Go コンパイラが生成する関数呼び出しは ABIInternal • 自分で asm を書くと Go ⇔ asm / asm ⇔ asm の全てが ABI0 になる • asm から他の関数を頻繁に呼び出すと遅くなるので、ホットパスでは自分で インライン化する努力が必要になる -
wasm2go の成果物 • go-googlesql : GoogleSQL in pure Go •
go-python : Python in pure Go • go-perl : Perl in pure Go • go-spidermonkey : SpiderMonkey in pure Go • go-llama : llama.cpp in pure Go -
Cross Language Binding • Go ABI を利用して Perl / Python
/ SpiderMonkey の資産を再利用できる • Perl や Python で書かれたライブラリの機能を Go から利用する • Perl で書かれた機能を SpiderMonkey から利用することもできる • 各言語で書かれた機能を組み合わせてシングルバイナリアプリが作れる Go ABI Perl Python Go Spider Monkey -
Agent Sandbox • Sheena ( github.com/goccy/sheena ) • Go 言語のための
Agent Sandbox • Agent がよく使う Bash コマンドをすべて Go で実装 • Code Interpreter として Python / SpiderMonkey をサポート • Access Control ◦ • Resource Control ◦ • FileSystem Access / Network Access / Subprocess 生成を制御可能 メモリ使用量や強制停止のサポート Linker DCE で利用する機能だけバイナリに含めることができる -
Serverless 基盤 • Go の HTTP Server でリクエストを受け取り、起動した Goroutine で
Interpreter を 作成してリクエストを流す • Goroutine で CPU を使い切ることができる • 同一プロセス上で大量のインスタンスを同時に起動できる • go-spidermonkey なら Next.js や Hono 製のサーバーアプリケーションが動く ◦ イベントドリブンの Node.js よりパフォーマンスが出る可能性も? -
FFIの構造的な課題の移り変わり • • • cgo ◦ クロスコンパイルが難しい ◦ 安全性が低い wasm
◦ パフォーマンスの悪化 ◦ 実行時のリソース ( CPU/MEM ) 使用量が多い wasm2go ◦ モジュールサイズの増大 ◦ ビルドキャッシュの増加とダウンロード時間の増加 -
wasm2go は最良の方法なのか • 自分がこれから作るものは基本的にすべて wasm2go で作る • すべてのケースで wasm2go が最良とは限らない
◦ 速度を追求したい用途で cgo を使う道は存在する ◦ WASM を埋め込む方がバイナリサイズの面では一番良い -
至高の FFI を考えてみてください The Go gopher was designed by Renée
French. Illustrations by avocadoneko.