iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

V8 JavaScript engineで寒い冬を暖かく過ごしたい

はじめに

こんにちは!
株式会社iimonでエンジニアをしている「ひが」です!
本記事は iimonアドベントカレンダー5日目の記事です!

先日TSKaigi Hokuriku 2025で登壇してきました。
(唐突にすみません、、)
https://hokuriku.tskaigi.org/talks/18

人生初の登壇でめちゃくちゃ緊張しましたがなんとかやり切って一命を取りとめました。 資料は下記で公開しているので、興味のある方はぜひ見ていってください!(宣伝です笑)
「denoとtypescriptの関係について改めて考えてみる」
https://speakerdeck.com/higak9/denototypescriptnoguan-xi-nituitegai-metekao-etemiru

登壇の内容はDenoというJavaScriptのランタイムに関する内容でしたが、DenoにはV8 JavaScript engine(以下、v8)が使われています。Node.jsやChromeにも使われているエンジンですが、名前を聞く割にあまり触れてこなかったなぁー、、と振り返り、この機に少し遊んでみようと思い立ち記事にしてみました!
いよいよ冬に突入して寒くなってきましたので、やけどしない程度にv8に触れてみて、みなさまにも暖かみをご共有できると幸いです。
(逆に冷やしてしまったらすみません!!!🙇‍♂️)

v8の概略

まずはv8の概略について簡単にみていきましょう。
v8はGoogleが提供するオープンソースでハイパフォーマンスなJavaScriptエンジンです。
(一応WebAssemblyも対応してますが、今回は特に触れません)
v8.dev

C++で書かれていて、前述のとおりChromeやNode.js、Denoなどで使われています。 またECMAScriptも実装されていてWindowsMacLinuxなど主要なOS上で動かすことができます。
そんなv8は機能として

などを提供しています。
一方でDOMやWeb APIsなどは提供しておらず、そのあたりはChromeやNode.jsやDenoなどv8を埋め込む側が提供していたりします。
(後半にv8で遊ぶときにそのあたりも簡単に確認しましょう)

ざっくりv8の概略としては以上になりますが、もう少しJavaScriptエンジンの理解を深めるために概念的なお話をします。

JavaScriptの処理ってどうなっているの?

v8がJavaScriptソースコードコンパイルして実行するときに、どのような処理が流れているかを簡単にみていきましょう。 今回は下記の記事が比較的イメージを持ちやすく感じましたので、そちらを参考に確認していきます。
(ちょっとブラウザ寄りの説明ですが、大枠イメージをつけることを目的とします)
engineering.mercari.com
※ 下記の説明で出てくる画像も上記サイトより参照しています

スタック領域とヒープ領域

v8などのJavaScriptエンジンはスタック領域とヒープ領域という二つのメモリ領域を持っています。
・スタック領域
コンパイル時に必要なメモリのサイズが決まる値(プリミティブ型、オブジェクトや関数の参照など)に対して割り当てられる領域。 データの取り扱いはLIFO (Last In First Out: 後入れ先出し)方式で行われる(後で詳しくみます)
・ヒープ領域
実行時に必要なメモリ領域のサイズが決まる値(動的なデータ)に対して割り当てられる領域。 実行時に決まるデータ型としてはオブジェクトや関数、配列などがあり、スタックと異なりデータは構造化されていない(メモリサイズも可変)

下記は具体的なソースコードとスタック領域、ヒープ領域のメモリ割り当ての簡単なイメージです↓

図4: スタック領域とヒープ領域のメモリ割り当て

JavaScriptがブラウザでどのように動くのか

コールスタック

コールスタックはJavaScriptのランタイム(Node.jsなど)上で実行されている処理の履歴を格納する仕組みです。 どの関数が現在実行されていて、その関数の中でさらにどの関数が呼び出されたかをスタックのデータ構造に記録します。 コールスタックに関数を追加することをPush、逆に関数を取り出すことをPopと言い、v8エンジンではLIFO方式で処理されます。
下記のようなコードがあったときに

function b() {}
function a() {
 b();
}
a();

処理の流れとしてはaがbを呼び出し、bが処理を終了したらaも処理を終了すると感覚でわかるかと思いますが、その処理の流れをコールスタックを使ってイメージ図にしたものが下記になります。

図5: スタック構造の処理

JavaScriptがブラウザでどのように動くのか

タスクキューとマイクロタスクキュー

JavaScriptはシングルスレッドで処理が実行されるため、一つの処理を実行している間に次の処理が待機している状態となります。 処理の内容によって、タスク or マイクロタスクに分かれるのですが、とてもざっくり言うとタスクの集まりをタスクキュー、マイクロタスクの集まりをマイクロタスクキューと呼びます。タスクとマイクロタスクは深ぼっていくととても難しいので、ここでは簡単に例だけ挙げておきます。
タスクの例

  • script タグで読み込んだ JavaScript ファイル
  • setTimeoutのコールバック関数
  • UIイベント(クリックなど)のコールバック関数

マイクロタスクの例

  • Promiseのthen / catch / finally のコールバック関数
  • queueMicrotask のコールバック関数

イベントループ

イベントループは一連の処理の流れを制御する仕組みです。
とても簡単にいうと
1. コールスタックが空になるまで処理を実行
2. なくなったらキュー(タスク or マイクロタスク)から実行待ちの処理をコールスタックに追加
3. コールスタックが空になるまで処理を実行
...
みたいな形で無限ループします。
実はイベントループはシングルスレッドのJavaScriptで非同期処理を実現するためにとても重要な仕組みで、タスクやマイクロタスクの処理の優先順位などが結構肝だったりするのですが、そのあたりは後半にv8で遊びながら確認していきましょう。
イベントループのイメージ図は下記のようなものになります。

図7: より詳細な全体処理

JavaScriptがブラウザでどのように動くのか

※ 本記事では必要な部分だけピックアップしておりイメージ図の中には説明していない単語も含まれています、詳細を知りたい方は参考記事の方をご覧ください

一先ず概念的なお話はここまでになります。
(個人的にはちょっと暖まってきました)

v8で遊ぶ

概念的なお話も済みましたので、ここから実際にv8を動かして遊んでみようと思います!
今回はjsvu(JavaScript engine Version Updater)というGoogleChromeLabsが提供しているパッケージを利用します。 GitHub - GoogleChromeLabs/jsvu: JavaScript (engine) Version Updater

npmでグローバルインストールして

npm install -g jsvu

パスを通し(筆者の環境ではMacの.zshrc)

export PATH="${HOME}/.jsvu/bin:${PATH}"

jsvuのコマンドからv8を選択してインストールします

jsvu

余談ですが、jsvuはv8以外のJavaScriptエンジン(SpiderMonkeyFirefox)やJavaScriptCoresafari))も扱うことができます。
今回はv8で遊ぶのでv8を選択していますが、気になる方は別途遊んでみてください。
v8がインストールできたら、v8コマンドを実行することができるようになります。

v8

v8コマンドの実行後にはd8 が立ち上がり、v8の様々なオブジェクトを確認することができます。
※ d8はv8独自の開発者向けのシェルで、JavaScriptを実行したりv8に加えた変更をデバックできたりします
https://v8.dev/docs/d8

# 色々なオブジェクトについて見てみる
❯ v8
V8 version 10.3.125
d8> globalThis
[object global]
d8> Object.keys(globalThis)
["version", "print", "printErr", "write", "read", "readbuffer", "readline", "load", "setTimeout", "quit", "testRunner", "Realm", "performance", "Worker", "os", "d8", "arguments"]
d8> console
{debug: function debug() { [native code] }, error: function error() { [native code] }, info: function info() { [native code] }, log: function log() { [native code] }, warn: function warn() { [native code] }, dir: function dir() { [native code] }, dirxml: function dirxml() { [native code] }, table: function table() { [native code] }, trace: function trace() { [native code] }, group: function group() { [native code] }, groupCollapsed: function groupCollapsed() { [native code] }, groupEnd: function groupEnd() { [native code] }, clear: function clear() { [native code] }, count: function count() { [native code] }, countReset: function countReset() { [native code] }, assert: function assert() { [native code] }, profile: function profile() { [native code] }, profileEnd: function profileEnd() { [native code] }, time: function time() { [native code] }, timeLog: function timeLog() { [native code] }, timeEnd: function timeEnd() { [native code] }, timeStamp: function timeStamp() { [native code] }, context: function context() { [native code] }, [Symbol(Symbol.toStringTag)]: "Object"}
d8> setTimeout
function setTimeout() { [native code] }
d8> setInterval
(d8):1: ReferenceError: setInterval is not defined
setInterval
^
ReferenceError: setInterval is not defined
    at (d8):1:1
d8> queueMicrotask
(d8):1: ReferenceError: queueMicrotask is not defined
queueMicrotask
^
ReferenceError: queueMicrotask is not defined
    at (d8):1:1
d8> Promise
function Promise() { [native code] }

上記で実際にv8のオブジェクトを見てみると、globalThisやconsole系(infoやlogなど)、setTimeout、Promiseなどは定義されていることがわかりますが、setIntervalやqueueMicrotaskなどのWeb APIは定義されていないことがわかります。冒頭でも述べましたが、基本的にv8にはWeb APIは含まれておらず、それを埋め込む環境(ChromeやNode.jsやDenoなど)が提供しています。PromiseはECMAScriptのビルトインオブジェクトなのでv8にも存在していることがわかります。

ではここから実際にJavaScriptのソースを実行してみます。

v8 ファイル名.js

でjsファイルを実行することができます

// v8SimpleTask.js
console.log("1: main start !!!");
setTimeout(() => {
  console.log("3: timeout 5000ms");
  // 遅延時間を長くしてもこちらが先にタスクとして処理される
}, 5000);
setTimeout(() => {
  console.log("4: timeout 0 ms");
  // 遅延時間 0 ms
});
console.log("2: main end !!!");

実行

v8 v8SimpleTask.js

結果

setTimeoutをしていない処理から順に実行されている点は通常の非同期処理のイメージが合うかと思います。
ただ5秒遅延時間をセットしている処理が0秒の遅延時間をセットしている処理より先に実行されてますね!?!

この結果からv8ではsetTimeoutにタイマー機能は実装されておらず、そのあたりもv8を利用している環境(ChromeやNode.jsやDenoなど)自身が提供しているということがわかります。

次にPromiseと絡めた処理内容をみてみましょう。

// v8EventLoop.js
// <- 1st Task
console.log("1: main start !!!");
setTimeout(() => {
  // 2nd Task
  console.log("4: timeout1 main start !!!");
  Promise.resolve("1st Promise")
    .then((value) => {
      console.log("6: timeout1 promise then1 !!!", value);
    })
    .then(() => {
      console.log("7: timeout1 promise then2 !!!");
    });
  setTimeout(() => {
    // 5th Task
    console.log("13: timeout4 start !!!");
    Promise.resolve("2nd Promise")
      .then((value) => {
        console.log("14: timeout4 promise then1 !!!", value);
      })
      .then(() => {
        console.log("15: timeout4 promise then2 !!!");
      });
  });
  console.log("5: timeout1 main end !!!");
});
setTimeout(() => {
  // 3rd Task
  console.log("8: timeout2 main start !!!");
  Promise.resolve("3rd Promise")
    .then((value) => {
      console.log("10: timeout2 promise then1 !!!", value);
    })
    .then(() => {
      console.log("11: timeout2 promise then2 !!!");
    });
    console.log("9: timeout2 main end !!!");
});
Promise.resolve()
  .then(() => {
    console.log("3: promise only")
    setTimeout(() => console.log("12: promise only timeout3")) // 4th Task
  });
console.log("2: main end !!!");
// 1st Task ->

実行

v8 v8EventLoop.js

結果

少しややこしいのですね。。笑
前述でも少しお話しましたが、setTimeoutはタスクキュー、Promiseはマイクロタスクキューに格納されます。
そしてv8のイベントループは
1. タスクキューから単一のタスクを取り出して実行
2. その時点でマイクロタスクキューに格納されているすべてのタスクを実行
3. 1. の処理に戻る
というループになっています。

v8のソースコード上だとこのあたりで実装されてそうですかね。
https://github.com/v8/v8/blob/main/src/libplatform/default-platform.cc

上のjsファイルの処理内容もsetTimeoutとPromiseをわざと入れ子にして処理順を見てみましたが、その実行結果からタスクとマイクロタスクの処理順の関係がわかるかと思います。この処理順(と各環境によるタイマーの実装など)によってシングルスレッドのJavaScriptでもcallbackなりPromiseなりasync / awaitなりで(比較的)使い勝手が良くなる非同期処理を実現されてるんですね。なんか、そこまで深く考えたことなかったですね!!
JavaScriptの非同期を本当に理解しようとしたら本当に大変そうですが、いつか挑戦したいですね、砕け散りそうですが。。)

まとめ

以上、簡単にv8で遊んでみました!
個人的にはだいぶ暖まりました!気持ち的に!

今回結構時間がなかったのでざっくりとした説明が点在しています。。
(言い訳です!笑、散らかった状態ですみません。。)
要所要所で深掘りポイントがたくさんありますので興味のある方はぜひ色々調べてみてください!

Appendix

もう少し詳細に踏み込んでみたい方は下記のMDNのサイトなど覗いてみると面白いかもです。
(実行コンテキストやイベントループの種類、タスクVSマイクロタスクの議論などあったりします)
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth

下記の記事もイベントループとTypeScriptの型から非同期処理を解説されていてイメージがつきやすく面白かったです。
https://zenn.dev/mizchi/articles/understanding-promise-by-ts-eventloop

下記の記事は今回の執筆でもだいぶ参考にさせていただきましたが、ほかにも詳細な内容が書かれているのでとてもおすすめです。
https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/e-epasync-v8-engine

そもそもJavaScriptのPromiseやasync / awaitなどの詳細が気になった方は下記のPromise本など見てみると勉強になるかと思います。 azu.github.io

さいごに

最後まで読んでくださりありがとうございました。

弊社では現在エンジニアを募集しています! この記事を読んで少しでも興味を持っていただけた方、ぜひカジュアルにお話ししましょう!!

iimon採用サイト / Wantedly

以上、アドベントカレンダー5日目の担当は毎日を必死に乗り切っている「ひが」でした!

明日のアドベントカレンダー担当は「おく」さんです!
普段からムードメイクが上手でいつもみんなに笑いを届けてくれます!
どんな記事を書いてくれるか楽しみですね!!

参考

v8.dev
engineering.mercari.com
github.com
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth
https://zenn.dev/mizchi/articles/understanding-promise-by-ts-eventloop
https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/e-epasync-v8-engine
azu.github.io