ガベージコレクションのトレース
このガイドでは、ガベージコレクションのトレースの基本について説明します。
このガイドを読み終える頃には、以下のことができるようになります。
- Node.js アプリケーションでトレースを有効にする
- トレースを解釈する
- Node.js アプリケーションで潜在的なメモリ問題を特定する
ガベージコレクタの仕組みについて学ぶべきことはたくさんありますが、一つだけ覚えておくべきことは、GC が実行されている間、あなたのコードは実行されないということです。
ガベージコレクションがどのくらいの頻度で、どのくらいの時間実行されるのか、そしてその結果がどうなるのかを知りたいと思うかもしれません。
セットアップ
このガイドの提案のために、次のスクリプトを使用します。
// script.mjs
import from 'node:os';
let = 1_000_000;
const = new ();
function () {
const = {
: .(),
: .(),
: .(),
: .(),
};
.();
}
function () {
.(`Total: ${.} entries`);
}
// execution
(() => {
while ( > 0) {
();
..(`~~> ${} entries to record\r`);
--;
}
();
})();
ここではリークが明らかですが、実際のアプリケーションのコンテキストでリークの原因を見つけるのは面倒な場合があります。
ガベージコレクショントレース付きで実行する
--trace-gc フラグを使用すると、プロセスのコンソール出力でガベージコレクションのトレースを確認できます。
$ node --trace-gc script.mjs
注:この演習のソースコードは、Node.js Diagnostics リポジトリにあります。
次のような出力が得られるはずです。
[39067:0x158008000] 2297 ms: Scavenge 117.5 (135.8) -> 102.2 (135.8) MB, 0.8 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2375 ms: Scavenge 120.0 (138.3) -> 104.7 (138.3) MB, 0.9 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2453 ms: Scavenge 122.4 (140.8) -> 107.1 (140.8) MB, 0.7 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2531 ms: Scavenge 124.9 (143.3) -> 109.6 (143.3) MB, 0.7 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2610 ms: Scavenge 127.1 (145.5) -> 111.8 (145.5) MB, 0.7 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2688 ms: Scavenge 129.6 (148.0) -> 114.2 (148.0) MB, 0.8 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2766 ms: Scavenge 132.0 (150.5) -> 116.7 (150.5) MB, 1.1 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
Total: 1000000 entries
読みにくいですか? いくつかの概念を見直し、--trace-gc フラグの出力を説明する必要があるかもしれません。
--trace-gc によるトレースの調査
--trace-gc (または --trace_gc、どちらでも構いません) フラグは、すべてのガベージコレクションイベントをコンソールに出力します。各行の構成は次のように記述できます。
[13973:0x110008000] 44 ms: Scavenge 2.4 (3.2) -> 2.0 (4.2) MB, 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
| トークン値 | 解釈 |
|---|---|
| 13973 | 実行中のプロセスの PID |
| 0x110008000 | アイソレート (JS ヒープインスタンス) |
| 44 ms | プロセス開始からの時間 (ミリ秒) |
| Scavenge | GC の種類 / フェーズ |
| 2.4 | GC 前の使用ヒープ (MB) |
| (3.2) | GC 前の合計ヒープ (MB) |
| 2.0 | GC 後の使用ヒープ (MB) |
| (4.2) | GC 後の合計ヒープ (MB) |
| 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) | GC に費やされた時間 (ミリ秒) |
| allocation failure | GC の理由 |
ここでは 2 つのイベントにのみ焦点を当てます。
- Scavenge
- Mark-sweep
ヒープはスペースに分割されます。これらの中には、「new」スペースと呼ばれるスペースと、「old」スペースと呼ばれるスペースがあります。
👉 実際には、ヒープの構造は少し異なりますが、この記事ではより単純なバージョンで説明します。詳細については、Orinoco に関する Peter Marshall のこの講演をご覧になることをお勧めします。
Scavenge
Scavenge は、new スペースでガベージコレクションを実行するアルゴリズムの名前です。new スペースは、オブジェクトが作成される場所です。new スペースは、ガベージコレクションが小さく高速になるように設計されています。
Scavenge のシナリオを想像してみましょう。
A、B、C、Dを割り当てました。| A | B | C | D | <unallocated> |Eを割り当てたい- 十分なスペースがなく、メモリが枯渇しました
- すると、(ガベージ) コレクションがトリガーされます
- 死んだオブジェクトが収集されます
- 生きているオブジェクトは残ります
BとDが死んでいたと仮定します| A | C | <unallocated> |- これで
Eを割り当てることができます| A | C | E | <unallocated> |
v8 は、2 回の Scavenge 操作後にガベージコレクションされなかったオブジェクトを old スペースにプロモートします。
👉 完全な Scavenge シナリオ
Mark-sweep
Mark-sweep は、old スペースからオブジェクトを収集するために使用されます。old スペースは、new スペースを生き残ったオブジェクトが存在する場所です。
このアルゴリズムは 2 つのフェーズで構成されています。
- マーク: まだ生きているオブジェクトを黒、その他を白としてマークします。
- スイープ: 白いオブジェクトをスキャンし、それらを空きスペースに変換します。
👉 実際には、マークとスイープのステップはもう少し精巧です。詳細については、このドキュメントをお読みください。
--trace-gc の動作
メモリリーク
ここで、前のターミナルウィンドウにすぐに戻ると、コンソールに多くの Mark-sweep イベントが表示されます。また、イベント後に収集されるメモリ量が微々たるものであることもわかります。
私たちはガベージコレクションの専門家になりました! 何を推測できるでしょうか?
おそらくメモリリークがあります! しかし、どうすれば確信できるのでしょうか? (注意:この例ではかなり明らかですが、実際のアプリケーションではどうでしょうか?)
しかし、どのようにコンテキストを特定できるのでしょうか?
不適切な割り当てのコンテキストを取得する方法
- old スペースが継続的に増加しているとします。
--max-old-space-sizeを減らして、合計ヒープが制限に近づくようにします。- メモリ不足になるまでプログラムを実行します。
- 生成されたログは、失敗したコンテキストを示します。
- OOM (メモリ不足) になった場合は、ヒープサイズを約 10% 増やして数回繰り返します。同じパターンが観察された場合、メモリリークを示しています。
- OOM がない場合は、ヒープサイズをその値に固定します。パックされたヒープは、メモリフットプリントと計算レイテンシを削減します。
たとえば、次のコマンドで script.mjs を実行してみてください。
node --trace-gc --max-old-space-size=50 script.mjs
OOM が発生するはずです。
[...]
<--- Last few GCs --->
[40928:0x148008000] 509 ms: Mark-sweep 46.8 (65.8) -> 40.6 (77.3) MB, 6.4 / 0.0 ms (+ 1.4 ms in 11 steps since start of marking, biggest step 0.2 ms, walltime since start of marking 24 ms) (average mu = 0.977, current mu = 0.977) finalize incrementa[40928:0x148008000] 768 ms: Mark-sweep 56.3 (77.3) -> 47.1 (83.0) MB, 35.9 / 0.0 ms (average mu = 0.927, current mu = 0.861) allocation failure scavenge might not succeed
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory [...]
次に、100MB で試してみてください。
node --trace-gc --max-old-space-size=100 script.mjs
同様のことが起こるはずですが、唯一の違いは、最後の GC トレースに大きなヒープサイズが含まれることです。
<--- Last few GCs --->
[40977:0x128008000] 2066 ms: Mark-sweep (reduce) 99.6 (102.5) -> 99.6 (102.5) MB, 46.7 / 0.0 ms (+ 0.0 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 47 ms) (average mu = 0.154, current mu = 0.155) allocati[40977:0x128008000] 2123 ms: Mark-sweep (reduce) 99.6 (102.5) -> 99.6 (102.5) MB, 47.7 / 0.0 ms (+ 0.0 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 48 ms) (average mu = 0.165, current mu = 0.175) allocati
注:実際のアプリケーションのコンテキストでは、コード内でリークしたオブジェクトを見つけるのは面倒な場合があります。ヒープスナップショットがそれを見つけるのに役立ちます。ヒープスナップショット専用のガイドをご覧ください。
パフォーマンスの低下
ガベージコレクションが多すぎるか、オーバーヘッドを引き起こしているかをどのように判断しますか?
- トレースデータ、特に連続するコレクション間の時間を確認します。
- トレースデータ、特に GC に費やされた時間を確認します。
- 2 つの GC 間の時間が GC に費やされた時間よりも短い場合、アプリケーションは深刻な飢餓状態にあります。
- 2 つの GC 間の時間と GC に費やされた時間が非常に長い場合、アプリケーションはより小さいヒープを使用できる可能性があります。
- 2 つの GC 間の時間が GC に費やされた時間よりもはるかに長い場合、アプリケーションは比較的健全です。
リークを修正する
では、リークを修正しましょう。エントリを保存するためにオブジェクトを使用する代わりに、ファイルを使用できます。
スクリプトを少し変更しましょう。
// script-fix.mjs
import from 'node:fs/promises';
import from 'node:os';
let = 1_000_000;
const = `entries-${.()}`;
async function () {
const = {
: .(),
: .(),
: .(),
: .(),
};
await .(, .() + '\n');
}
async function () {
const = await .();
.(`File size ${.} bytes`);
}
// execution
(async () => {
await .(, '----START---\n');
while ( > 0) {
await ();
..(`~~> ${} entries to record\r`);
--;
}
await ();
})();
データを保存するために Set を使用することは決して悪い習慣ではありません。プログラムのメモリフットプリントに注意するだけです。
注:この演習のソースコードは、Node.js Diagnostics リポジトリにあります。
では、このスクリプトを実行しましょう。
node --trace-gc script-fix.mjs
2 つのことに気づくはずです。
- Mark-sweep イベントの出現頻度が低くなる
- メモリフットプリントは 25MB を超えませんが、最初のスクリプトでは 130MB を超えていました。
新しいバージョンは最初のバージョンよりもメモリへの負荷が少ないため、これは非常に理にかなっています。
ポイント: このスクリプトを改善することについてどう思いますか? スクリプトの新しいバージョンが遅いことに気づくかもしれません。もう一度 Set を使用し、メモリが特定のサイズに達したときにのみその内容をファイルに書き込むのはどうでしょうか?
getheapstatisticsAPI が役立ちます。
おまけ: プログラムによるガベージコレクションのトレース
v8 モジュールの使用
プロセスの全ライフタイムからトレースを取得したくない場合があります。その場合は、プロセス内からフラグを設定します。v8 モジュールは、フラグを動的に設定するための API を公開しています。
import from 'v8';
// enabling trace-gc
.('--trace-gc');
// disabling trace-gc
.('--notrace-gc');
パフォーマンスフックの使用
Node.js では、パフォーマンスフックを使用してガベージコレクションをトレースできます。
const { } = ('node:perf_hooks');
// Create a performance observer
const = new ( => {
const = .()[0];
/*
The entry is an instance of PerformanceEntry containing
metrics of a single garbage collection event.
For example:
PerformanceEntry {
name: 'gc',
entryType: 'gc',
startTime: 2820.567669,
duration: 1.315709,
kind: 1
}
*/
});
// Subscribe to notifications of GCs
.({ : ['gc'] });
// Stop subscription
.();
パフォーマンスフックによるトレースの調査
GC 統計を PerformanceObserver のコールバックから PerformanceEntry として取得できます。
例:
{
"name": "gc",
"entryType": "gc",
"startTime": 2820.567669,
"duration": 1.315709,
"kind": 1
}
| プロパティ | 解釈 |
|---|---|
| name | パフォーマンスエントリの名前。 |
| entryType | パフォーマンスエントリの種類。 |
| startTime | パフォーマンスエントリの開始時刻を示す高解像度のミリ秒タイムスタンプ。 |
| duration | このエントリの合計経過時間 (ミリ秒)。 |
| kind | 発生したガベージコレクション操作の種類。 |
| flags | GC に関する追加情報。 |
詳細については、パフォーマンスフックに関するドキュメントを参照してください。