こんにちは,開発本部ソフトウェア開発統括部バックエンド開発部の柳浦です.普段はGo言語を主に利用してタクシーアプリ『GO』のバックエンドアプリケーションの開発に従事しています.仕事以外では個人的に様々なプログラミング言語に触れているのですが,業務では扱わない言語から業務に応用できる着想を得ることもしばしばあります.この記事ではその一例として,Zigというプログラミング言語のAPIにおけるメモリアロケーションの設計を見て考えさせられた,設計における戦略について書こうと思います.
Zigにおけるメモリアロケーション
Zigというプログラミング言語は,Bun1やTigerBeetle,Ghosttyなどが採用したことで近年注目を集めているように感じます.Zigは様々な特徴を持ちますが,その中でも特徴的なものに,動的なメモリアロケーションをする際に,アロケータを明示的に指定するというものがあります.本稿では,Zigがアロケーションをどう位置づけたか,その設計判断について見ていこうと思います.一言で表すと,Zigはアロケーションを実装詳細から契約へと昇格させたのだということです.
アロケーションが見えない世界
次のGoで書かれた関数のシグネチャを見てください.
func process(data []byte) Result
この関数はメモリ領域を確保するのか.確保するならどこに,どのような戦略で確保するのか,シグネチャからは何もわかりません.エスケープ解析の結果はビルドオプションを指定(-gcflags=-m)しなければ見えず,依存ライブラリのバージョンアップで挙動が変わる事もあります.呼び出し側がメモリ消費に上限をかけたい,あるいはテストでアロケーション回数を検証したいと思っても,何の手がかりも与えてくれません.
これは「アロケーションは関数の実装詳細である」という暗黙の前提に立った設計です.Goに限らず,多くの言語が共有している前提でもあります.しかしZigはこの前提を反転させました.
アロケーションをシグネチャに昇格させるという転換
同じ関数をZigで書くとこうなります.
fn process(allocator: std.mem.Allocator, data: []const u8) !Result
allocator が引数になり,戻り値の!は失敗しうることを示します.シグネチャを読んだだけで,三つのことが宣言されています.
- この関数はメモリアロケーションをする可能性がある
- どのアロケータを使うかは呼び出し側が決める
- メモリ確保に失敗しうる(OOMが発生したらエラーとして返ってくる)
ここで重要なのは,「Zigにはアロケータがある」という事実ではありません.それはC言語にもあるし,Goにもあります.重要なのはアロケーションを「いつ,どこで,どう失敗しうるか」のレベルでシグネチャに昇格したことです.ここから,この戦略を選択したことによる特性を挙げていきます.
ライフタイムは呼び出し側の選択になる
同じprocessが,三つの異なるメモリポリシーで動きます.
// 1. リクエスト単位のアリーナ:処理後に一括開放 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const r1 = try process(arena.allocator(), data); // 2. 固定バッファ:ヒープに触れない var buf: [4096]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); const r2 = try process(fba.allocator(), data); // 3. デバッグアロケータ(旧 GeneralPurposeAllocator):リーク検出付き var gpa: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa.deinit(); const r3 = try process(gpa.allocator(), data);
processの実装は何一つ変わらないが,呼び出し側はアリーナで「処理後に全部破棄する」と決められるし,固定バッファで「ヒープに触れさせない」とも決められます.
これは本質的には,ライフタイム戦略を関数に内在する性質から呼び出し側のポリシーに移動しているということです.短命オブジェクトをまとめて解放できるという,しばしば語られるアリーナの利点は,この観点の一例に過ぎません.重要なのは,関数が自分の使うメモリのライフタイムに関する判断権を持たないことで,そのおかげで,同じ実装が文脈に応じて再利用できるということです.
Goにも実験的なarenaパッケージ(GOEXPERIMENT=arenas)があります.a := arena.NewArena()でアリーナを作り,arena.New[T](a)で値を確保し,a.Free()で一括解放します.ただしこの提案は2023年1月にAPI設計上の懸念から無期限留保され,本番利用は推奨されていません.その後継として,Memory Regions提案が進行しています.
注目すべきは,Goが躓いているのはアリーナという機能そのものではなく,アロケーション戦略の抽象化の方だという点です.arena.New[T](a)はnew(T)の代替ではなく別の関数であり,既存の関数を「GC上で動かす」,「アリーナ上で動かす」,「固定バッファ上で動かす」と切り替えるには関数本体を書き換える必要があります.Zigが提供したのはアリーナというデータ型ではなくアロケータというインタフェースです.この一段の抽象の差が,呼び出し側で戦略を選べるか否かを峻別しています.アリーナを追加することと,アロケーションをAPIに昇格させることは全く別の問題だということがわかります.
計装と制約が値として合成できる
Zigのアロケータはインタフェース,つまり値なので,他のアロケータをラップできます.
// 概念例:上限付き+トラッキング付きアロケータ var tracking = TrackingAllocator.init(backing.allocator()); var limited = LimitingAllocator.init(tracking.allocator(), .{ .max_bytes = 1 << 20 }); const result = try process(limited.allocator(), data); std.debug.print("allocated: {} bytes\n", .{tracking.total_bytes});
「この呼び出しは最大1MBまで」,「この呼び出しのアロケーション総量を記録する」といった制約や計装を,関数本体に手を入れずに合成で実現できます.アロケータ自体が値だからこそ可能な芸当です.
Goにおける同様の関心事は,多くがランタイムレベルにあります.pprofのヒーププロファイルはコールスタック単位で確保元を特定できるので,粒度そのものは問題になりません.差が出るのは位置づけの方です.pprof はサンプリングによる事後の観測であり,「この呼び出しが何を確保したか」を後から眺めるための道具です.一方でZigのアロケータは呼び出しの前に渡す値なので,「この呼び出しだけ上限1MB」「このコードパスの確保だけ記録する」といった制約や計装を,その場のスコープに閉じた合成として書けます.Goでメモリ上限を設けるなら debug.SetMemoryLimit(GOMEMLIMIT)がありますが,これはプロセス全体に効くソフトリミットであり,特定の呼び出し箇所だけに適用する仕組みではありません.観測はできても,呼び出し単位でポリシーを強制・差し替えする口がランタイム側にしかない,というのが本質的な違いです.
メモリの振る舞いがテスト可能になる
リソース消費を契約として書けるのなら,テストも書けます.
test "process は 4KB を超えない" { var buf: [4096]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&buf); _ = try process(fba.allocator(), test_data); // 4KB を超えれば process 内で error.OutOfMemory が返り,テストは落ちる } test "process は OOM を伝播する" { var failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 3 }, ); try std.testing.expectError( error.OutOfMemory, process(failing.allocator(), test_data), ); }
ここで検証しているのは,「4KBを超えない」,「OOM時にちゃんとエラーを返す」といった,値の正しさとは別の,リソース消費の正しさに関する契約です.Zigではこのような作用を単体テストとして書き下せます.つまり,ベンチマークで観測するのではなく,契約として検証できるということです.
Goでtesting.B.ReportAllocsを使うこともできますが,あれは観測してレポートするものであり,契約として検証するものではなく,方向性がそもそも違います.
トレードオフ
ここまで良い点ばかり挙げてきましたが,もちろん代償もあります.
アロケータによってAPIが煩雑になる
アロケータを取る関数を内部で呼ぶ関数も,アロケータを持ち回ることになります.これがプロジェクト全体に伝播します.結果として小さなヘルパ関数のシグネチャまで軒並み太ります.
この代償が見かけ以上に重いことは,Goの事例が示しています.留保されたarena提案の後継として議論されているMemory Regions提案は,設計目標の筆頭に「APIを変更せずに使えること(既存の関数にアロケータ引数を足さなくてよい)」を掲げています.Goは一度arenaという形で明示的にアロケーション先を渡す方式を試し,標準ライブラリの膨大な関数がarena引数を取らねばならないという大規模なAPI変更の必要性を,採用見送りの決定打の一つと判断しました.加えて,arenaへの明示的な割り当てがエスケープ解析によるスタック割り当て(状況次第ではarenaより効果的な最適化)を打ち消す点も問題視されています.Regionsがgoroutineローカルな暗黙のスコープという正反対の設計を選んだのは,この反省からだと考えます(2026年5月時点ではまだ提案段階です).
ここには設計判断の分岐が見えます.Zigは「コストを払ってでもシグネチャに明示する」側を,Go(のRegions)は「シグネチャに出すコストは許容しがたいので暗黙のスコープに退避させる」側を選びました.これは脚注で触れたKokaの明示的エフェクトとOCaml 5の動的スコープの対比と同じ構図であり,能力をシグネチャに昇格させる手法には必ずこの「明示のコスト」が伴うことを示しています.
UAF(Use-After-Free)を防ぐ設計ではない
アロケータの規約はメモリの確保を契約化しますが,UAF(Use-After-Free)や二重解放は防げません.Rustの借用検査と混同してはいけません.DebugAllocatorのデバッグ機能がある程度補助してくれますが,型レベルで保証してくれるわけではありません.
OOMを回復可能エラーとして扱う実用性は限定的
Linuxのデフォルトのovercommit設定では,mallocは仮想アドレス空間を予約するだけで成功を返し,物理ページが割り当てられるのは初回アクセス時のページフォルトの段階です.本当にメモリが尽きると,後からカーネルのOOM killerがoom_scoreに基づいて選んだプロセス(必ずしもメモリを要求したプロセスとは限りません)にSIGKILLを送ります.SIGKILLは捕捉できないので,error.OutOfMemoryをどれだけ丁寧に伝播させても,現実のOOMは戻り値としては返ってこず,回復の機会すら与えられません.vm.overcommit_memory=2(OVERCOMMIT_NEVER)のように予約を厳密に会計させればアロケーション時点で失敗を返させることもでき,その場合は伝播に意味が出ます.とはいえ一般的なアプリケーションのデフォルト環境では,OOMを丁寧に伝播させる労力に見合うかは大いに議論の余地があると思います.
エコシステムの協調も必要
サードパーティライブラリがアロケータ規約を破って内部でmallocを呼んでしまえば,呼び出し側の上限もアリーナのライフタイム管理も無効化されてしまいます.言語仕様で強制できない以上,標準ライブラリと文化的合意によって支えられている,ある意味もろい設計でもあります.
アロケーションに限った話ではない
ここまでアロケータの話をしてきましたが,ここからより一般的な原則を抽出できます.
実装の詳細として隠されている能力(capability)を,型シグネチャに昇格させると何が起きるのか,いくつか例を挙げてみます.
- 時間:
time.Now()をグローバルに呼ぶ代わりにClockをパラメータとして渡すことで,テストで時刻を固定できるようになる. - ログ:ロガーを引数やcontext経由で渡すことで,呼び出し側でロガーを差し替えられる.
- 乱数:seedable RNGを引数で渡すことで,テストの再現性が確保できる.
- 並行ランタイム:tokioの
Runtimeをcontextとして渡す設計など.
これらはすべて同じ構造です.グローバル環境や暗黙の参照に頼っていた能力を,呼び出し側が選択して渡すものに変えることによって,差し替え可能性,テスト可能性,観測可能性が型として浮かび上がります2.これはcapability-basedな設計3,あるいは依存性注入の一種と捉えることもできるかと思います.
Zigのアロケータは,この原則がメモリという具体的な能力に適用された一例に過ぎません.Zigを書くつもりがなくても,自分の設計の手札として,実装の詳細として扱っているもののうち,本当は契約に昇格させるべき能力があるかどうかを検討することが可能です.
おまけ:他の言語ではどうなっているか
アロケーションをAPIに昇格させる試みは決してZig固有のものではありません.他の言語における2例を紹介しておきます.
Rust の allocator_api
Rust には std::alloc::Allocator トレイトと Vec<T, A: Allocator>のようなジェネリックなコレクションがあり,思想としてはZigと近いと思います.ただし2026年5月時点ではnightly onlyで,allocator working groupのロードマップを見ると,まだ安定化への道程は長そうです.一方でコミュニティはallocator-api2というワークアラウンドを広く受け入れており,需要はあるものの標準化が進んでいない状態だと言えます.
またZigのランタイムディスパッチとは異なり,Rustはジェネリクス(単相化)を選択したため,ゼロコスト抽象ではあるものの,すべてのコンテナに型パラメータを付与する必要があり,ZigにおけるAPIの煩雑さの増大とはまた別のノイズが表出します.
C++のstd::pmr
C++17で導入された Polymorphic Memory Resources は,std::pmr::memory_resourceという抽象基底クラスがあり,monotonic_buffer_resourceが本稿で扱ったアリーナに対応します.Zigによるアロケータのインタフェース化を継承と仮想関数で実現したものと考えて問題ないでしょう.
まとめ
Zigのアロケータから学ぶ価値は,アロケータの種類を覚えることだけではありません.本質はアロケーションを隠された実装の詳細から目に見える契約へと昇格させた,という設計判断にあり,ライフタイムの呼び出し側での制御や計装のコンポジション,メモリの振る舞いのテスタビリティなどは,すべてその自然な帰結として得られます.
トレードオフもあるため,すべての言語がこの設計を採るべきだとは思いません.それでも「実装詳細を契約に昇格させる」という設計手法そのものは,言語を選ばずに使えます.自分の書くコードのどこで同じ手法が適用できるか考えるための材料としてZigのアプローチは良い教材になると思います.
念のため書いておきますが,Zigは2026年5月時点でまだバージョン1未満であり,標準ライブラリのAPIも破壊的に変更される可能性があります.
- Rustで書き換えられたようです↩
- HaskellのモナドやKokaなどで扱う明示的なエフェクト(とハンドラ)はこの型への持ち上げそのものと言えるでしょう.一方でOCaml 5のEffect handlersは暗黙的な動的スコープに依拠したものとなっており,対照的な設計判断をとっています.↩
- こう書くとseL4やKeyKOSのcapabilityを想起するが,capability-based securityのような強制力(unforgeability等)はありません.↩