【ITニュース解説】Node.js 26.9 turns node:ffi on by default at 37 nanoseconds a call
2026年09月18日に「Dev.to」が公開したITニュース「Node.js 26.9 turns node:ffi on by default at 37 nanoseconds a call」について初心者にもわかりやすく解説しています。
ITニュース概要
Node.js 26.9でC言語ライブラリを直接呼び出せる`node:ffi`がデフォルト有効化された。手軽に利用できるが、シンプルな呼び出しではパフォーマンスに注意が必要。特に大量データの処理に有効だが、返り値の型不一致やポインタの範囲外アクセスはプロセスをクラッシュさせる危険があるため、安全性に留意し活用すべきだ。
ITニュース解説
Node.jsのバージョン26.9.0がリリースされ、その中で特に注目すべき変更点として、これまで実験的な機能であったnode:ffiモジュールが、デフォルトで有効になったことが挙げられる。この変更により、Node.jsアプリケーションからC言語などで書かれたネイティブなライブラリ関数を、コンパイル済みのN-APIアドオンを作成することなく、直接呼び出すことが可能になった。以前のバージョン26.8.2までは、--experimental-ffiというフラグを明示的に指定しなければ利用できなかったが、26.9.0からはこのフラグなしで動作するようになる。これは、C言語の機能をNode.jsで手軽に利用できる選択肢が、よりアクセスしやすくなったことを意味する。もしnode:ffiモジュールの利用を避けたい場合は、--no-experimental-ffiフラグを渡すことで、この機能を無効にできる。この変更は単なるドキュメントの更新ではなく、実際にモジュールの有効化に関する動作が変わっているため、CI/CD環境などでNode.jsのバージョンを固定している場合には注意が必要となる。
node:ffiモジュールの導入は、Node.jsでネイティブコードを呼び出す際の既存の選択肢、すなわちC言語でN-APIアドオンを作成する方法や、コンパイル済みの外部バイナリをシェル経由で呼び出す方法に対する新たな選択肢を提供する。この新しいモジュールの性能を評価するため、筆者は簡単な整数の加算処理(add_i32)を題材に、純粋なJavaScriptでの実行、N-APIアドオンでの実行、そしてnode:ffiでの実行の速度を比較している。その結果、500万回の呼び出しにかかる1回あたりの平均時間は、純粋なJavaScriptが約2.2〜3.0ナノ秒、N-APIアドオンが約34.1〜35.8ナノ秒であったのに対し、node:ffiは約37.5〜38.1ナノ秒となった。このデータから、node:ffiはN-APIアドオンと比べて約7〜8%程度遅いことがわかる。また、どちらのネイティブ呼び出しも、純粋なJavaScriptでの同等の処理と比較すると、約15倍のコストがかかる。この性能差は、JavaScriptとネイティブコードの間で引数を変換・受け渡し(マーシャリング)するオーバーヘッドに起因する。このマーシャリングコストは、C言語でコードを書くか、単にシグネチャを宣言するかにかかわらず発生する。この結果は、node:ffiが既存のネイティブアドオンをパフォーマンス面で上回るものではないことを示している。node:ffiの真の利点は、パフォーマンスではなく、node-gypのようなビルドツールやコンパイラツールチェーンの準備が不要であること、そしてNode ABIのバージョンごとに再ビルドが不要であるといった、開発とデプロイの「利便性」にあると言える。
簡単な加算のような処理では、node:ffiのパフォーマンスはネイティブアドオンにわずかに劣り、純粋なJavaScriptに比べるとオーバーヘッドが大きいことがわかった。しかし、これは呼び出し回数が多く、個々の処理が軽い場合に顕著になる「呼び出しオーバーヘッド」が支配的な状況である。node:ffiが真価を発揮するのは、少量の呼び出しで大量のデータを処理する場面である。具体的には、1000万個のfloat64値をポインター経由で合計する処理をテストしたところ、純粋なJavaScriptのループでは約16.0〜18.4ミリ秒かかったのに対し、node:ffiを使ってFloat64Arrayのバッファへのポインターを渡す方法では約13.8〜14.0ミリ秒で完了した。このケースでは、node:ffiが15〜20%高速であることが示されている。これは、1回の呼び出しで1000万個の要素という大きなデータペイロードを渡すことで、呼び出しごとの固定オーバーヘッドが償却されるためである。つまり、node:ffiは、多くの細かい呼び出しよりも、一括でのバッファ操作や大量のデータ移動に適している。一方で、C言語で実装されたfib(75)のような単一の中程度の計算処理では、node:ffiによる呼び出しとJavaScriptでの同等のループ処理の速度はほぼ同じであった。V8エンジンのJITコンパイラがこのようなシンプルなループを非常に高速にコンパイルできるため、単発の計算ではネイティブコードを呼び出すメリットはほとんどない。node:ffiの固定的な呼び出しごとのコストは、呼び出し回数やデータ量が十分に大きい場合にのみペイできるものと言える。
node:ffiのドキュメントには「unsafe(安全ではない)」という記述があり、誤ったシグネチャ(関数定義)はプロセスをクラッシュさせる可能性があると警告している。筆者はこの警告を検証するため、いくつかの誤った引数を渡すテストを行った。例えば、uint64型の引数が期待される場所に通常のNumberを渡すと、「Argument 1 must be a uint64」というTypeErrorが発生し、BigIntを渡す必要があることが明確に示される。また、2つの引数を期待する関数に1つしか引数を渡さなかった場合も、「Invalid argument count: expected 2, got 1」というTypeErrorが発生し、引数の数の不一致が捕捉される。これらはモジュールが自動的に引数の形状をチェックしているため、直ちにクラッシュには至らない。しかし、問題となるのは戻り値の型が間違っている場合である。例えば、int64を返す関数をint32を返すものとして宣言して呼び出すと、エラーや警告なしに、静かに切り捨てられた(truncated)誤った数値が返された。これは、コードレビューでも見逃されやすく、深刻なバグにつながる可能性がある。さらに危険なのは、ポインターの長さが正しくない場合である。10要素のバッファを関数に渡し、実際には1000万要素あると誤って宣言した場合、プロセスはSegmentation fault (core dumped)というメッセージと共に終了した。これはドキュメントが警告する「クラッシュ」の典型的な例であり、バッファサイズと宣言された長さの不一致という、比較的一般的な状況で発生しうる。したがって、node:ffiは引数のカウントと基本的なスカラー型は検証するが、ポインターの境界チェックや戻り値の型の正確性は保証しないため、開発者はこれらを厳密に管理する必要がある。
Node.jsの新しいパーミッションモデルにおいて、FFI(Foreign Function Interface)は独自のセキュリティゲートとして扱われる。--permissionフラグを使用して実行時に権限を制限している場合、node:ffiを利用するには明示的に--allow-ffiフラグを付与する必要がある。この際、Node.jsは「--allow-ffiフラグは極度の注意を払って使用しなければならない。これはパーミッションモデルを無効にする可能性がある」という警告を発する。これは、ネイティブコードが一度ロードされると、Node.jsのランタイムによって設定された他のパーミッション制限に縛られないため、セキュリティモデル全体を危うくする可能性があることを文字通りに示唆している。ライブラリのハンドル管理に関しては、一度閉じたライブラリを再度使用しようとすると、プロセスがクラッシュすることなく、「Library is closed」というエラーが返される。また、新しいusingブロック構文を利用することで、スコープの終了時に明示的なclose()呼び出しなしにハンドルが自動的に閉じられるため、クリーンなリソース管理が可能である。node:ffiの呼び出しは、Workerスレッドからも特別な設定なしで動作する。複数のWorkerスレッドを用いて並行して呼び出しを行うことで、スループットがWorkerの数に比例して向上し、グローバルロックによる直列化は発生しないことが確認されている。
今回の検証から得られる結論として、node:ffiは既存のネイティブアドオンからのパフォーマンス改善を目的とした書き換えには適していない。わずかな性能差は書き換えの手間を正当化するものではなく、単一の軽い計算ではJavaScriptのJITコンパイラと同等かそれ以下の性能となる場合もある。node:ffiが最も効果を発揮するのは、数百万単位の要素を持つバッファを一度の呼び出しでネイティブ関数に渡し、大量のデータを効率的に処理するような場面である。しかし、本番環境でnode:ffiを使用する際には、特にポインターの長さに関する厳密なテストが不可欠である。例えば、実際のアプリケーションで渡されるバッファサイズと関数が期待する長さが一致しているかを、厳しく検証する必要がある。もし誤った長さのuint64値が渡された場合、今回の検証でSegmentation faultが発生したように、プロセスが突然クラッシュする可能性があるため、node:ffiは引数として渡される値の整合性については警告を発しないことを理解し、開発者側で細心の注意を払うべきである。