V8とC++とNode.jsの関係性をC++にV8を組み込んで理解した
1. はじめに
この記事は、C++・JavaScriptエンジンのV8・Node.jsの関係性および内部構造を探求したものです。
JavaScriptエンジンのV8を理解しようとV8についてWikipediaを読んでいると、よくわからないと思った箇所があった。
C++で書かれたアプリケーションの一部として動作させることもできる。
C++アプリケーションの中でV8を動かせるってどういう意味?
じゃあ、実際にC++アプリケーションにV8を組み込んでみよう!
ChatGPTとお話ししながら、進めた。
2. まず…V8とは何か?
JavaScriptエンジン。
JavaScriptエンジンとは?
JavaScriptのソースコードを実行するために、CPUに命令をする
V8の役割
メモリ管理
参照されなくなった変数やクラスなどを解放していく。
JavaScriptコンパイル
JavaScriptのソースコードを実行するために解析し、機械語に変換する
なぜC++アプリに組み込めるのか?
V8はCPUを動かすための機械語にはしてるが、OSとの直接やりとりはC++拡張が必要
3. 実践:C++アプリケーションにV8を組み込む
V8をビルドする方法
-
必要なツールをインストール:
brew install git python@3 cmake ninja llvm pkg-config
パッケージ 役割 python@3
depot_toolsやV8のスクリプトがPython 3で書かれているため。 cmake
V8内部で一部CMakeを使用するビルド処理があるため。CMake ninja
V8のビルドシステムである gn
がninja
を使ってビルドを実行する。超高速ビルドツール。Chrome, Androidの一部、LLVMなどCMakeのバックエンドによって使われている。Ninjallvm
コンパイル時・リンク時・実行時などの時点でプログラムを最適化するよう設計されたコンパイラ基盤。LLVM pkg-config
ライブラリの場所やバージョンを取得するための共通インターフェース。ビルド時のヘッダーやライブラリの検索に使う。 -
depot_tools
をcloneし、パスを通す
https://chromium.googlesource.com/chromium/tools/depot_tools.git -
fetch v8
でV8のソースコードを取得 -
M1/M2 (arm64) Macの場合、V8をarm64ターゲットでビルド:
bash gn gen out.gn/arm64.release --args='target_cpu="arm64" is_component_build=false is_debug=false v8_monolithic=true v8_use_external_startup_data=false' ninja -C out.gn/arm64.release v8_monolith
main.cppを自作してHello Worldを表示
-
~/v8work/hello_v8/main.cpp
を作成 -
v8.h
、libplatform.h
をincludeして、V8エンジンを初期化 - JavaScriptコード
"Hello, world from V8!"
を実行し、C++標準出力に出力 - 重要ポイント:
-
std::unique_ptr<Platform>
でV8プラットフォームを管理
-
#include <iostream> //C++の標準出力
#include <libplatform/libplatform.h> //V8のプラットフォーム
#include <v8.h> //V8エンジンのメインAPI: V8本体のクラスや関数たちを使うため
//毎回 v8::Isolate, v8::Context と書くのはめんどいので、Isolate, Context だけで書けるようにする。
using namespace v8;
int main (int argc, char* argv[]) {
// V8エンジンを正しく動かすための初期設定
V8::InitializeICUDefaultLocation(argv[0]); // ICU(国際化ライブラリ)の初期化
V8::InitializeExternalStartupData(argv[0]); // V8の初期化
std::unique_ptr<Platform> platform = platform::NewDefaultPlatform(); //プラットフォーム固有の情報のsetup
V8::InitializePlatform(platform.get()); // V8のプラットフォームの初期化
V8::Initialize(); // V8の初期化
// V8の"世界" (Isolate)を作る
// Isolateは「ひとつの独立したV8の実行環境」JavaScriptコードは必ずIsolateの中で動く
Isolate::CreateParams create_params;
create_params.array_buffer_allocator = ArrayBuffer::Allocator::NewDefaultAllocator(); //メモリの確保
Isolate* isolate = Isolate::New(create_params);
{
Isolate::Scope isolate_scope(isolate); //このスコープ内では、このisolateを使うと宣言
HandleScope handle_scope(isolate); // V8の内部オブジェクトを管理する (メモリリーク防止)
// V8の"世界" (Context)を作る: JavaScriptの変数・関数が存在する世界を作る
Local<Context> context = Context::New(isolate);
//実行したいJSコードを書き込む
Local<String> source = String::NewFromUtf8Literal(isolate, "'Hello, world from V8!'", NewStringType::kNormal);
//JSコードをコンパイル
Local<Script> script = Script::Compile(context, source).ToLocalChecked(); //コンパイルした結果を取得
//コンパイルしたJSコードを実行
Local<Value> result = script->Run(context).ToLocalChecked();
//結果をC++標準出力に表示する
String::Utf8Value utf8(isolate, result);
std::cout << *utf8 << std::endl;
}
isolate->Dispose(); // Isolateの解放
V8::Dispose();
delete create_params.array_buffer_allocator; //メモリの解放
return 0;
}
Isolateという分離した空間を作る
JavaScriptエンジンのV8には、Isolateと呼ばれる機能を用いることでプロセス内に複数の独立したオブジェクト空間およびイベントループを持つ空間を定義できるようになっています。そして分離された空間はそれぞれマルチスレッドで並列して実行されます。
V8のDocumentでは?
Isolate represents an isolated instance of the V8 engine. V8 isolates have completely separate states. Objects from one isolate must not be used in other isolates. When V8 is initialized a default isolate is implicitly created and entered. The embedder can create additional isolates and use them in parallel in multiple threads. An isolate can be entered by at most one thread at any given time. The Locker/Unlocker API must be used to synchronize.
IsolateはV8エンジンの分離されたインスタンスを表す。V8は完全に分離された状態を保持し、あるIsolateのオブジェクトはほかのIsolateから使われることはない。(V8エンジンの)組み込み元は、複数のIsolateを作成し、それらはマルチスレッドによって並列に利用できる。
つまり?
Isolateとは、完全に独立したV8の実行環境。複数のIsolateを作成すると、それぞれが互いに影響を与えることなく、別々のJavaScriptコードを実行できる。これは各Isolateが独自のメモリ空間、オブジェクト、イベントループを持ち、互いに完全に分離されているから。この仕組みにより、外部ネットワークや他のシステムと連携せずとも、単一のプロセス内で複数の独立したJavaScript実行環境(サンドボックス)を実現できる。
なんでそれがいい?
- 一つのisolateがクラッシュしても他のisolateの処理には影響を与えない
- 新しい機能を入れたいときに新しくisolateを作成でき、他の処理には影響を与えない
- 個々のIsolateが必要なときだけリソースを使用し、終了時に解放できる。→ メモリを効率よく使える
ビルド・リンク・実行までの手順
-
main.cpp
をコンパイルするコマンド:bash g++ main.cpp \ -I../v8/include \ -L../v8/out.gn/arm64.release/obj \ -lv8_monolith -pthread -std=c++20 \ -o hello_v8
-
std=c++20
必須(V8がC++20以上前提) -
I
(インクルードパス)とL
(ライブラリパス)を正しく指定項目 役割 例 目的 includeパス(-I) C++コードがV8のAPI(関数・クラス)を使えるようにするため -I/path/to/v8/include
#include <v8.h>
を解決するためlibパス(-L と -l) コンパイル後にV8の実体(コード本体)とリンクするため -L/path/to/v8/lib
-lv8_monolith
実行できるプログラムを作るため 項目 簡単なイメージ -I
ソースを書くための地図を渡す -L
+-l
実際に実行するためのエンジンを組み込む -
コンパイル成功後、実行:
./hello_v8
結果…
コンパイルに失敗…
icu関係のエラーが出て、まだ解決策が見つかってない😭
icu_74::UnifiedCache::_putIfAbsentAndGet(icu_74::CacheKeyBase const&, icu_74::SharedObject const*&, UErrorCode&) const in libv8_monolith.a[1207](unifiedcache.o)
icu_74::UnifiedCache::_put(UHashElement const*, icu_74::SharedObject const*, UErrorCode) const in libv8_monolith.a[1207](unifiedcache.o)
"std::__Cr::condition_variable::wait(std::__Cr::unique_lock<std::__Cr::mutex>&)", referenced from:
icu_74::umtx_initImplPreInit(icu_7::UInitOnce&) in libv8_monolith.a[1205](umutex.o)
icu_74::UnifiedCache::_poll(icu_74::CacheKeyBase const&, icu_74::SharedObject const*&, UErrorCode&) const in libv8_monolith.a[1207](unifiedcache.o)
原因は、libv8_monolith.aが壊れているから?
libv8_monolith.aは、静的ライブラリは、コンパイル済みのオブジェクトファイルをアーカイブしたもの。monolithの意味は、「V8 の全機能を単一のライブラリファイルにまとめている」ということ
AIによると
このライブラリ内の ICU (International Components for Unicode) 関連のコンポーネントが C++ の標準ライブラリの一部の機能(
condition_variable
)を参照していますが、それが見つからないという問題が発生しています。V8 は国際化サポートのために ICU を使用しており、その ICU コンポーネントが libv8_monolith.a に含まれていることがエラーメッセージから分かります。
4. Node.jsの正体
このサイトの図がわかりやすかった
C++でV8に拡張機能を作った。
- Node.jsは、C++でV8に拡張機能を加えることで、サーバー向けのJavaScriptランタイム(=Node.jsランタイム)を構築した。
- 図にある「Bindings」は、Node.jsランタイムの一部であり、C++で書かれた機能拡張(ファイル操作、ネットワーク操作など)をV8エンジンに接続する「橋渡しの役割」を果たしている。
- また、V8単体ではカバーしていないI/O処理、タイマー管理、ソケット通信などは、Node.jsランタイムの中のlibuv(C言語で実装されたライブラリ)が受け持っている。
- 厳密には「拡張機能+イベントループ管理(libuv)+モジュールシステム」など、ランタイムにはいろんな要素が含まれている
Node.jsの役割と、V8単体との違い
V8だけだと
- V8はただJSを実行するだけ
- でも「ファイル読む」とか「サーバー立てる」はできない
Node.jsランタイムがあると:
- ファイルも読める!
- サーバーも立てられる!
- DBにもアクセスできる!
- APIサーバーも作れる!
役割 | どんな感じ? |
---|---|
V8 | JavaScriptを超高速に動かすエンジン(コンパイラ+実行機) |
Node.jsランタイム(C++で作った拡張) | ファイル操作・ネット通信・非同期I/O・サーバー起動などを可能にする部品群 |
その両方を合体 | JavaScriptだけで「アプリ」「APIサーバー」「CLIツール」まで全部作れる |
5. Node.jsの内部構造に迫る:イベントループとlibuv
イベントループとは何か?
非同期イベントを順番に処理する仕組み。 V8のisolateの1つの空間には、一つのイベントループがある。
Node.jsのイベントループは、ざっくり以下のステージをぐるぐる回ってる:
フェーズ名 | 役割 |
---|---|
timersフェーズ | setTimeout, setInterval のコールバック実行 |
pending callbacksフェーズ | システム内部の遅延コールバック実行 |
idle | ほぼ何も起きない。「何もすることがないときの待機フェーズ」。将来の機能拡張のためにスペースをとっておく感じ |
prepareフェーズ | pollフェーズの準備。必要なら内部で仕込み処理をする |
pollフェーズ | I/Oイベント(ファイル読み書き、ソケット通信)をOSから受け取る場所。I/O非同期処理はここで実行される |
checkフェーズ |
setImmediate() のコールバック実行 |
close callbacksフェーズ | ソケットクローズ時のコールバック実行 |
libuvの役割(非同期I/Oとクロスプラットフォーム対応)
-
クロスプラットフォーム抽象化:
- 異なるOSのI/O APIの違いを吸収します(WindowsのIOCPやUnix系のepoll/kqueue/event portsなど)
- これにより開発者は単一のAPIでWindows、Linux、macOS、その他のUnixライクなシステムでコードを動かせます
-
イベントループの実装:
- Node.jsのイベントループの実体はlibuvが提供しています
- 非同期操作をスケジューリングし、適切なタイミングでコールバックを実行する仕組みです
-
非同期I/O操作:
- ファイル操作: 読み書き、監視、削除など
- ネットワーク操作: TCP/UDP通信、DNSリゾルバなど
- パイプとプロセス間通信
- 非同期シグナル処理
-
スレッドプール:
- ブロッキングI/O操作をバックグラウンドスレッドで実行する仕組み
- ファイルI/Oなど、OSレベルで非同期APIが提供されていない操作を効率的に行えます
ん〜。難しい…。以下の記事を読んで理解を深めよう。
6. まとめ
V8
JavaScriptの解析し、CPUを動かすための機械語に変換する
C++
C++にV8を組み込むことによって、解析した機械語をOSと実際にやり取りができるようになる。
C++を使って、V8に拡張機能を加えることができる。
Node.js
Node.jsは、C++でV8に拡張機能を加えることで、サーバー向けのJavaScriptランタイム(=Node.jsランタイム)を構築した。具体的には、拡張機能(ファイル操作、ネットワーク操作など)・イベントループ管理(libuv)+モジュールシステムを加えた。
7. 今後の学習
Node.jsの内部構造についてもっと理解する
- libuvについて理解する
- イベントループについて理解を深める
Discussion