usingはなぜ8年かかったのか — GCが閉じてくれないもの
usingはなぜ8年かかったのか — GCが閉じてくれないもの
JavaScript が20年以上リソース解放をプログラマの規律に委ねてきたのは設計の怠慢だったのか、それともガベージコレクション言語が払わざるを得なかった代償だったのか。
導入
2026年5月19日の TC39 会議で、Explicit Resource Management 提案が最後の条件をクリアした。議事録の結論欄はたった二行である。「All conditions met for Stage 4」。提案者の Ron Buckton はその場でこう述べている。この提案はしばらく一種の条件付き Stage 4 の状態にあったが、すべての条件を満たしたと考えており、まもなく前進する、と。議長は改めて正式に Stage 4 を要求する必要があるかという問いに対し、それは形式的な手続きにすぎないと線を引いた。条件付き前進は条件が満たされた時点で発効するのが前例だという理由である。
TC39 の完了提案リストを開くと、この提案に紐づく会議履歴の最も古い項目は2018年7月だ。8年かかった。その間に JavaScript は optional chaining を手に入れ、トップレベル await を手に入れ、Temporal が先に Stage 4 に上がった。ところが「ファイルを開いたら閉じろ」という、言語設計において最も古い部類に属する要求が構文になったのは2026年である。
今回言語に入るのは四つの塊だ。using と await using 宣言、Symbol.dispose と Symbol.asyncDispose というプロトコル、DisposableStack と AsyncDisposableStack というコンテナ、そして解放中に発生した例外を扱う SuppressedError。これを紹介した Qiita 記事のタイトルは率直である。「usingがStage4になったので、ようやくリソース解放忘れから解放されるよ」。本当に解放なのか。そしてそもそも、なぜこれほど長く足止めされていたのか。
何が入ったのか — スコープが終われば閉じる
構文そのものは一行で終わる。
function hoge(){
using handle = exampleResource();
// 何か長い処理
}
// スコープを抜けた時点で自動的にリソースが解放される
using で宣言した変数は、その変数が属するブロックが終わる瞬間に [Symbol.dispose]() メソッドが同期的に呼ばれる。ブロックでも関数本体でもモジュールのトップレベルでも同じだ。値が null や undefined なら黙って無視され、オブジェクトなのに [Symbol.dispose] が関数でなければ TypeError が投げられる。
本当の利得はリソースが二つ以上あるときに現れる。提案書が直接示した対比は残酷なほど明快だ。これまでの「正しい」コードはこうだった。
{ // ブロックで囲み a, b が外側スコープに漏れないようにする
const a = ...;
try {
const b = ...;
try {
...
}
finally {
b.close(); // b が a に依存しうるので b を先に閉じる
}
}
finally {
a.close(); // b.close() が投げても a は必ず閉じる
}
}
同じ保証を using は一行で表現する。
using a = ..., b = ...;
...
複数のリソースは宣言とは逆順に解放される。y が x に依存して作られたなら y が先に閉じられ、x が後になる。内部的にはスタックであり、解放は pop の順序だ。Node.js のファイルハンドルを二つ開く典型例において、この順序は偶然ではなく契約である。
{
using f1 = await fs.promises.open(s1, constants.O_RDONLY),
f2 = await fs.promises.open(s2, constants.O_WRONLY);
const buffer = Buffer.alloc(4092);
const { bytesRead } = await f1.read(buffer);
await f2.write(buffer, 0, bytesRead);
} // f2 が解放されたあと f1 が解放される
非同期の解放は await using が担う。データベーストランザクションのように閉じる動作自体が非同期なリソースのためのもので、[Symbol.asyncDispose] を呼び、なければ [Symbol.dispose] にフォールバックする。
コンテナ二つは、構文だけでは表現しきれない場合のためにある。DisposableStack は use(value) ですでに破棄可能なオブジェクトを積み、adopt(value, onDispose) でまだ Symbol.dispose を実装していない既存 API を包み、defer(onDispose) で Go の defer のような後始末コールバックだけを登録し、move() でスタックの中身をまるごと新しいスタックへ移す。提案書はこの設計が Python の contextlib.ExitStack に由来すると明記している。Buckton は会議でこのコンテナの存在理由を二つに要約した。リソースの集約と、現在 dispose 構文をサポートしていない既存 API との相互運用である。
解放中に例外が出たらどうなるか。それが SuppressedError だ。本体で投げられた例外を解放コードの例外が上書きしてしまう事故を防ぐため、新しい例外は抑制された例外を suppressed フィールドに抱えて上がってくる。例外が続けて発生すれば SuppressedError は入れ子になる。提案書は AggregateError を使わなかった理由まで書き残している。AggregateError は相関のない複数のエラーを平坦なリストとして持つのに対し、抑制関係は二つのエラーの直接的な対応であり、いびつに入れ子になるからだ。
構文には禁止区域もある。スクリプトのトップレベルでは使えず(モジュールのトップレベルは可能だ)、for-in ループの初期化部でも、switch の case や default 節の直下でも使えない。ブラウザ対応はまだ揃っていない。caniuse によれば Chrome と Edge は134から、Firefox は141から対応しており、Safari は正式リリースではなく Technology Preview にのみ入っている。グローバル対応率は73.39パーセントだ。MDN はこの機能に「広く使われているブラウザの一部で動作しないため Baseline ではない」という但し書きを付けている。TC39 の完了リストが記載する掲載予定年は2027年である。
GCはメモリを回収するがファイルハンドルは閉じない
ここからが本題だ。JavaScript はガベージコレクション言語である。誰も free() を呼ばない。ではなぜ close() は手で呼ばなければならないのか。
ガベージコレクタが回収するのはメモリだ。正確にはヒープに確保されたオブジェクトの記憶領域である。到達不能になったオブジェクトを探してその場所を返すことにかけて GC は有能だ。しかしファイルディスクリプタはヒープにない。カーネルのプロセスごとのテーブルにある。TCP ソケットはカーネルの接続状態にある。ミューテックスは他のスレッドが待っているロック状態にあり、データベーストランザクションはサーバ側のセッションに開いており、イベント購読は発行側のリスナ配列に残っている。JavaScript オブジェクトを GC が片付けても、カーネルの fd テーブルはそのままだ。提案書の用語定義はこの境界を正確に引いている。リソースとは、寿命の定まったオブジェクトであり、その寿命の終わりに寿命に敏感な操作が実行されるか、ファイルハンドルやソケットのようにガベージコレクトされない参照が閉じられるか解放されるべきもの、である。
問題はもう一つある。GC は「いつ」動くかを約束しない。仮に言語がすべてのオブジェクトにデストラクタのフックを提供したとしても、そのフックがいつ呼ばれるかはコレクタのスケジュール次第だ。ロックを GC に委ねれば、他のコードがロックを取得する時点がコレクタの気分に左右される。決定的でない解放は、多くのリソースにおいて解放しなかったのと実質的に同じである。Qiita 記事の著者が対照として挙げた PHP は参照カウントを使うため、参照がなくなった時点でリソースが片付く。代償は参照カウント自体のコストと循環参照の処理であり、それはエンジン設計の根本的な選択なので、構文一つで取り戻せる種類のものではない。
そのため JavaScript に残された唯一の答えが try/finally だった。そして提案書が並べた失敗のリストは、この答えがなぜ不十分だったかを示している。第一に、エコシステムの解放方法そのものがばらばらだ。イテレータは iterator.return()、WHATWG ストリームリーダは reader.releaseLock()、Node のファイルハンドルは handle.close()、Emscripten の C++ オブジェクトハンドルは Module._free(ptr) や obj.delete() である。名前が統一されなければ自動化も習慣化もされない。第二に、try なしで書いた解放呼び出しは例外一発で飛ばされる。第三に、リソースが二つあるとき手書きの finally ブロックは順序を間違えるか、先の解放が投げれば後の解放に到達できない。提案書のコメントはそのまま「b.close() が a に依存していたら問題だ」「a.close() が投げたら b には決して到達しない」と書いている。第四に、try/finally はリソースをブロックの外に漏らす。finally で閉じたハンドル変数はその後もスコープに生きており、すでに閉じたオブジェクトを使うコードを言語は止めてくれない。
他の言語はこの問題をそれぞれ違う値段で解いてきた。C++ の RAII はスコープを抜けるときにデストラクタを必ず、決定的に呼ぶ。最も強い保証だが代償も大きい。GC がないので所有権をプログラマが設計せねばならず、ムーブセマンティクスが必要で、デストラクタで例外を投げればプログラムは終わる。Rust の Drop は同じ決定的解放に所有権と借用検査を重ね、二重解放と解放後使用をコンパイル時に防ぐ。保証は最も強いが、その代わりプログラマはコンパイラと議論する術を学ぶことになる。C# は GC 言語でありながら IDisposable と using を導入して決定的解放の構文を載せ、C# 8 からはブロックを新たに作らない using 宣言まで備えた。ただし IDisposable を実装するか、using で包むかは規律の領域に残る。Python の with は __enter__ と __exit__ のプロトコルとブロック構文を併せて提供し、個数が動的なリソースのために ExitStack を標準ライブラリに置いた。Java の try-with-resources も同じ系譜であり、提案書はこの三つを先行事例として明記している。
JavaScript の using はこのスペクトラムの中で C# の位置に座る。解放のタイミングは決定的で構文に埋め込まれているが、どのオブジェクトが破棄可能かはライブラリの選択であり、using を使うかは呼び出し側の選択だ。コンパイラが「このハンドルを using で受け取っていない」と拒否してはくれない。Buckton が会議で使った表現は、まさにその半分の保証を指している。using と await using 宣言はリソースの RAII スタイルの初期化であり、リソースは宣言された地点で確保され、その宣言がスコープを外れるまで追跡されてから解放される、というものだ。
ではなぜ8年なのか。第一に、構文を後から差し込むコストである。using は既存コードでいくらでも変数名になりうるため文脈依存で解釈される必要があり、その調整が言語のあちこちと衝突した。最後の会議で Buckton が報告した整理作業がその痕跡だ。3月の会議で、case と default 節、そしてスクリプトのトップレベルで using 宣言を禁じる書き方が曖昧だったのを避けるため、ContainsUsing という抽象操作を導入したという。さらに興味深いのはその先だ。新設したこの操作が、末尾呼び出しの可否を判定するのに使われていた既存の操作と、書き方が違うだけで意味は完全に同一であることが判明し、二つは統合された。リソース解放が末尾呼び出し最適化にまで触れる。スコープを抜けるときに解放すべきものが残っていれば、その呼び出しは末尾呼び出しではないからだ。後から入る機能は言語の予想外の場所で代金を請求する。
第二に、プロトコルを遡って適用するコストだ。Symbol.dispose は新しいシンボルなので既存オブジェクトには存在しない。ストリームリーダも、ファイルハンドルも、サードパーティのコネクションプールも、それぞれがこのシンボルを実装するまで using の対象にならない。DisposableStack.adopt が存在する理由がまさにその空白である。第三に非同期だ。await using は単に解放を await するのではなく、その宣言を含むブロック・関数本体・モジュールから制御フローが抜けるたびに暗黙の非同期割り込み点を作る。提案書はこの帰結を隠さずコードで示している。
async function f() {
{
await using x = ...;
a();
} // ブロック終了、暗黙の await
b(); // a() の呼び出しとは別のマイクロタスク
}
await using を一行足すだけで、同じマイクロタスクで実行されていた二つの文が別々のマイクロタスクに分かれる。提案書は、こうした暗黙の割り込み点は構文上十分に示されるべきだと述べ、await using の存在自体がその目印になると判断した。8年のかなりの部分は、こうした判断を一つずつ確定させることに費やされた。
新しい構文が生む新しいミス
あらゆる新機能は新しいミスの型を連れてくる。using も例外ではない。
一つ目の落とし穴は、スコープを越えて生き残らねばならないリソースだ。コネクションを作って返すファクトリ関数の中では using は使えない。関数が終わる瞬間に作ったばかりのコネクションが閉じられてしまうからだ。ところが生成の途中で失敗すればすでに作ったものは片付けねばならず、成功すれば所有権を呼び出し側へ渡さねばならない。この条件付きの所有権移転こそが move() の存在理由である。提案書の例はコンストラクタの中で using stack = new DisposableStack() を作って部品を順に積み、最後の行で this.#disposables = stack.move() によってすべてをインスタンスフィールドへ移す。コメントが設計意図をそのまま語っている。生成が失敗していれば、その行に到達する前に stack が解放される、と。move() を忘れれば、コンストラクタが終わる瞬間に組み立てたばかりのオブジェクトが自らを分解する。try/finally の時代には存在しなかったミスだ。
二つ目は解放中の例外の扱いである。SuppressedError は情報を保存してくれるが、それはその構造を知っているコードに対してだけだ。既存の catch ブロックが error.message だけを出力しているなら、真の原因は error.suppressed の中に、場合によっては error.suppressed.suppressed の中に入ったまま静かに消える。ロギングとエラーレポートのコードがこの入れ子を解くよう更新されなければ、新しい構文は可観測性を下げる方向に働く。
三つ目は黙殺だ。null と undefined はオプショナルなリソースを支えるため意図的に無視される。便利だが、リソースを作れずに undefined が入ってきた場合と、きちんと作られて解放まで終えた場合がコード上で区別できない。失敗が声を上げずに正常経路を装う。
四つ目はエコシステムの空白期である。Qiita 記事の著者もこの点を楽観していない。優先度の高いところから徐々に対応されつつはあるものの、決して今すぐ全てのリソースで使えるというものではない、と釘を刺している。構文はすでに標準であり一部のランタイムは対応済みだが、あなたが実際に閉じねばならないそのコネクションプールが Symbol.dispose を実装しているかは別問題だ。その空白期のあいだ、コードベースには using を使う箇所と try/finally を使う箇所が混在し、混ざったコードはどちらの規律も一貫して保ちにくい。
実務者の判断は二段階に分かれる。導入時期の問題は比較的単純だ。Node 系のサーバコードならランタイムの対応を確認すればよく、ブラウザへ直接配信するコードなら Safari の正式リリースがまだないのでトランスパイルが前提になる。自分が使うトランスパイラとリンタがこの構文を扱えるかは各自で確かめる必要がある。
より重要なのは、既存コードを移す価値があるかだ。リソースが一つで finally が一行の箇所は、移しても得るものがほとんどない。移す価値が明確なのは三つの場所である。リソースが二つ以上あって入れ子の try/finally を要求する箇所、解放順序が依存関係のせいで重要な箇所、そして生成途中の失敗時の部分的な後始末を手で書いていたコンストラクタだ。この三箇所はまさに人が繰り返し間違えてきた場所であり、構文が実際に保証を肩代わりしてくれる場所でもある。楽観的に見れば5年以内に標準ライブラリと主要フレームワークが Symbol.dispose を標準装備し、using は const のように目立たない既定値になる。悲観的に見れば Symbol.dispose の実装が散発的に留まり、using は「使えるなら使う」選択肢のままで、二つの方式が混ざったコードがかえってレビュー負荷を増やす。現実的なシナリオはその中間だ。データベースドライバ、ファイルシステム、ロックライブラリのように解放ミスが即座に障害へつながる領域が先に移行し、残りは長く居座る。
結論
最初の問いに戻ろう。JavaScript が20年以上リソース解放を規律に委ねてきたのは怠慢だったのか、必然だったのか。答えはどちらでもない。それは選択であり、長く先送りされた請求書だった。ガベージコレクションはメモリという一種類の資源についてのみプログラマを解放し、言語は残りの資源について何の装置も与えないまま「うまく書け」と言い続けてきた。その間に iterator.return() と reader.releaseLock() と handle.close() がそれぞれの名前で散らばり、正しい入れ子の try/finally を手で書く者は稀だった。必然でなかった証拠は、C# がとうの昔に同じ問題を GC 言語の内側で解いていたという事実である。
同時に、using は解放ではない。言語はいま「解放すると宣言すれば必ず解放する」を保証するだけで、「解放すると宣言せよ」を強制はしない。その半分の保証は C# が20年近く生きてきた場所であり、その場所でもリソースリークは消えなかった。変わるのはミスの種類だ。finally を書き忘れるミスの代わりに move() を書き忘れるミスが、解放順序を間違えるミスの代わりに suppressed を覗かないミスが来る。
そこで残る問いはこうだ。言語が規律を構文へ昇格させるとき、我々は何を得て何を失うのか。そしてあなたのコードベースで using へ移す価値がある場所は、本当にこれまで事故が起きてきたその場所なのか。
出典:
- https://qiita.com/rana_kualu/items/83783c6dd3b4b51fa77f
- https://github.com/tc39/proposal-explicit-resource-management
- https://github.com/tc39/notes/blob/HEAD/meetings/2026-05/may-19.md#explicit-resource-management-stage-4-progress-update
- https://github.com/tc39/proposals/blob/main/finished-proposals.md
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/using
- https://caniuse.com/mdn-javascript_statements_using