[インデックス 18441] ファイルの概要
このコミットは、GoランタイムにおけるCPUプロファイリング中に発生するクラッシュを修正するものです。具体的には、mp->mcache
という重要なランタイム内部データ構造が、runtime·helpgc
とsigprof
(シグナルプロファイラ)によって同時に変更されることによって引き起こされる競合状態を解消します。この競合状態により、mcache
がnil
に設定されたままになり、ガベージコレクション(GC)がクラッシュする可能性がありました。
コミット
Author: Dmitriy Vyukov dvyukov@google.com Date: Mon Feb 10 20:24:47 2014 +0400
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/373e1e94d8bf4985bb1e0452d95ed500106dc631
元コミット内容
runtime: fix crash during cpu profiling
mp->mcache can be concurrently modified by runtime·helpgc.
In such case sigprof can remember mcache=nil, then helpgc sets it to non-nil,
then sigprof restores it back to nil, GC crashes with nil mcache.
R=rsc
CC=golang-codereviews
https://golang.org/cl/58860044
変更の背景
このコミットの背景には、GoプログラムのCPUプロファイリング中にランタイムがクラッシュするという深刻なバグがありました。このクラッシュは、Goランタイムの内部で利用されるmcache
というデータ構造の取り扱いにおける競合状態が原因でした。
CPUプロファイリングは、プログラムがCPU時間をどこで消費しているかを特定するために非常に重要なツールです。Goでは、SIGPROF
シグナル(Unix系システムにおけるプロファイリングシグナル)を利用して、定期的にプログラムの実行状態をサンプリングします。このサンプリングは、runtime·sigprof
関数によって処理されます。
問題は、runtime·sigprof
がプロファイリング情報を収集する際に、現在のM
(OSスレッドを表すランタイムの構造体)に関連付けられたmcache
を一時的にnil
に設定し、プロファイリング処理後に元の値に戻すという操作を行っていた点にありました。mcache
は、メモリ割り当てを高速化するためのスレッドローカルなキャッシュであり、ガベージコレクタ(GC)がメモリを管理する上で不可欠なものです。
同時に、Goランタイムにはruntime·helpgc
という関数が存在します。これは、GCが実行されている際に、他のM
(OSスレッド)がGCを手伝うために呼び出されることがあります。runtime·helpgc
もまた、mcache
を操作する可能性があります。
この二つの操作(sigprof
によるmcache
の一時的なnil
化と、helpgc
によるmcache
の利用/設定)が同時に発生すると、以下のような競合状態が発生し、mcache
がnil
のままGCが実行されてクラッシュするという事態を招いていました。
sigprof
が実行され、mp->mcache
の現在の値を保存し、mp->mcache
をnil
に設定する。sigprof
がプロファイリング処理を行っている間に、runtime·helpgc
が別のゴルーチンで実行され、mp->mcache
がnil
でないことを期待して操作を行う、あるいはmp->mcache
を非nil
の値に設定する。sigprof
がプロファイリング処理を終え、保存しておいたmcache
の値(この場合はnil
)をmp->mcache
に復元してしまう。- 結果として、
mp->mcache
がnil
のままになり、その後にメモリ割り当てやGCがmcache
を利用しようとすると、nil
ポインタ参照が発生し、ランタイムがクラッシュする。
このバグは、CPUプロファイリングというデバッグ・最適化に不可欠な機能を使用する際に、ランタイムの安定性を著しく損なうものであったため、早急な修正が必要とされました。
前提知識の解説
このコミットを理解するためには、Goランタイムのいくつかの重要な概念と、並行処理における一般的な問題についての知識が必要です。
Goランタイム (Go Runtime)
Goプログラムは、Goランタイムと呼ばれる軽量な実行環境上で動作します。ランタイムは、ガベージコレクション、スケジューリング(ゴルーチンの管理)、メモリ管理、システムコールインターフェースなど、プログラムの実行に必要な多くの低レベルな機能を提供します。C言語で書かれた部分が多く、OSとのインタラクションやパフォーマンスクリティカルな処理を担当します。
M, P, G スケジューラ (M, P, G Scheduler)
Goの並行処理モデルは、M, P, Gという3つの主要な抽象概念に基づいています。
- G (Goroutine): Goにおける軽量なスレッドのようなものです。数千、数万のゴルーチンを同時に実行できます。
- M (Machine): OSスレッドを表します。Goランタイムは、OSスレッドをMとして抽象化し、その上でGを実行します。MはOSのスケジューラによって管理されます。
- P (Processor): 論理プロセッサを表します。GはP上で実行されます。PはMにアタッチされ、MがGを実行するためのコンテキストを提供します。Pの数は通常、CPUのコア数に制限されます。
このモデルにより、Goランタイムは効率的にゴルーチンをOSスレッドにマッピングし、並行実行を実現します。
CPUプロファイリングと SIGPROF
CPUプロファイリングは、プログラムの実行中にCPUがどの関数で時間を費やしているかを分析する手法です。Goでは、pprof
ツールがこの機能を提供します。Unix系システムでは、SIGPROF
シグナルがプロファイリングのために使用されます。
SIGPROF
は、タイマーに基づいて定期的にプロセスに送信されるシグナルです。このシグナルを受信すると、Goランタイムはシグナルハンドラ(runtime·sigprof
)を実行し、現在の実行スタックをサンプリングして、どの関数が実行中であったかを記録します。これにより、時間の経過とともにどの関数がCPUを多く使用しているかの統計情報を収集できます。
mcache
(Memory Cache)
mcache
は、Goランタイムのメモリ管理システムの一部です。これは、各M
(OSスレッド)に紐付けられたスレッドローカルなメモリキャッシュです。小さなオブジェクトの割り当てを高速化するために使用されます。
Goランタイムは、メモリをページ単位で管理し、これらのページをmspan
と呼ばれる構造体で追跡します。mcache
は、特定のサイズのオブジェクトを割り当てるためのmspan
のリストを保持しています。これにより、メモリ割り当てのたびにグローバルなヒープロックを取得する必要がなくなり、並行処理のパフォーマンスが向上します。
mcache
は、ガベージコレクション(GC)の際に重要な役割を果たします。GCは、不要になったメモリを回収するためにヒープをスキャンしますが、この際にmcache
の内容も考慮に入れる必要があります。
ガベージコレクション (Garbage Collection, GC)
Goは自動メモリ管理(ガベージコレクション)を採用しています。GCは、プログラムが動的に割り当てたメモリのうち、もはや到達不可能(使用されていない)なものを自動的に解放するプロセスです。GoのGCは、並行かつインクリメンタルに動作し、プログラムの実行を長時間停止させることなくメモリを回収しようとします。
競合状態 (Race Condition)
競合状態は、複数の並行プロセスやスレッドが共有リソース(この場合はmp->mcache
)にアクセスし、そのアクセス順序によって結果が非決定的に変わる場合に発生するバグです。競合状態は、プログラムのクラッシュ、データの破損、不正な結果など、予測不能な動作を引き起こす可能性があります。
このコミットで修正された問題は、sigprof
とhelpgc
という2つの異なるコンテキストがmp->mcache
という共有リソースに同時にアクセスし、その操作のタイミングによってmcache
が不正な状態になるという典型的な競合状態でした。
mp->mallocing
mp->mallocing
は、M
(OSスレッド)が現在メモリ割り当て中であるかどうかを示すフラグです。Goランタイムでは、メモリ割り当ては非常に頻繁に行われる操作であり、特定のランタイム処理(例えば、プロファイリングやGCヘルパー)が実行されている間は、メモリ割り当てを一時的に無効化したり、特別な処理を必要としたりする場合があります。
このフラグは、mcache
のようなメモリ管理に関連するデータ構造へのアクセスを同期したり、特定の操作中にメモリ割り当てをブロックしたりするために使用されます。
技術的詳細
このコミットの技術的詳細を深く掘り下げると、Goランタイムの低レベルな同期メカニズムと、シグナルハンドラ内での安全な操作の重要性が浮き彫りになります。
問題の核心: mcache
の競合
以前の実装では、runtime·sigprof
関数(CPUプロファイリングのシグナルハンドラ)は、プロファイリング処理中にmp->mcache
を一時的にnil
に設定していました。これは、プロファイリング中にメモリ割り当てが行われることを防ぐため、またはmcache
の状態がプロファイリングのサンプリングに影響を与えないようにするためと考えられます。
// 変更前: runtime·sigprof
mcache = mp->mcache; // 現在のmcacheを保存
mp->mcache = nil; // mcacheをnilに設定
// ... プロファイリング処理 ...
mp->mcache = mcache; // 保存したmcacheを復元
このアプローチは、単一スレッドのコンテキストでは問題ありませんが、Goランタイムは複数のM
(OSスレッド)が並行して動作する環境です。特に、ガベージコレクションのヘルパー関数であるruntime·helpgc
は、GCが実行されている間に他のM
によって呼び出され、メモリ割り当てを行う可能性があります。runtime·helpgc
は、GCのマークフェーズ中に新しいオブジェクトを割り当てたり、既存のオブジェクトを移動したりする際にmcache
を利用します。
競合状態のシナリオは以下の通りです。
M1
でruntime·sigprof
が実行される。M1
はmp->mcache
をnil
に設定する。M1
がプロファイリング処理を実行している間に、M2
でruntime·helpgc
が実行される。runtime·helpgc
は、M1
のmp->mcache
がnil
であることを検出し、新しいmcache
を割り当ててM1
のmp->mcache
に設定する(または、M1
がGCを手伝うためにmcache
を必要とする)。M1
のruntime·sigprof
がプロファイリング処理を終え、最初に保存しておいたmcache
の値(これはnil
であった)をmp->mcache
に復元してしまう。- 結果として、
M1
のmp->mcache
は再びnil
になり、その後にM1
がメモリ割り当てを行おうとすると、nil
ポインタ参照が発生し、ランタイムがクラッシュする。特に、GCがmcache
にアクセスしようとした際にクラッシュが発生していました。
修正アプローチ: mp->mallocing
の利用
このコミットでは、mcache
を直接nil
に設定する代わりに、mp->mallocing
というフラグを利用することで競合状態を回避しています。
mp->mallocing
は、現在のM
がメモリ割り当て操作中であることを示すカウンタです。このカウンタをインクリメントすることで、sigprof
が実行されている間は、そのM
がメモリ割り当て中であるとランタイムに通知します。
// 変更後: runtime·sigprof
// mcacheを直接nilに設定する代わりに、mallocingカウンタをインクリメント
mp->mallocing++;
// ... プロファイリング処理 ...
// 処理終了後、mallocingカウンタをデクリメント
mp->mallocing--;
この変更のポイントは以下の通りです。
mcache
の直接操作の廃止:runtime·sigprof
はmp->mcache
を直接nil
に設定したり、復元したりしなくなりました。これにより、helpgc
との間のmcache
に対する競合が根本的に解消されます。mp->mallocing
の利用:mp->mallocing
カウンタは、M
がメモリ割り当てを行っている最中であることをランタイムの他の部分に伝えます。Goランタイムのメモリ割り当てルーチンは、このmallocing
フラグをチェックし、必要に応じて動作を調整します。例えば、mallocing
が非ゼロの場合、メモリ割り当てはmcache
を介さずに、より安全な(ただし遅い)グローバルなメモリ割り当てパスを使用するようになります。- シグナルハンドラの安全性: シグナルハンドラは、非同期に発生し、任意の時点で実行中のコードを中断する可能性があるため、非常に慎重に設計する必要があります。シグナルハンドラ内で複雑な操作や、ロックを伴う操作、メモリ割り当てを行うことは、デッドロックや競合状態を引き起こす可能性があるため、一般的に避けるべきです。この修正は、
mcache
の直接操作という潜在的に危険な行為を排除し、より安全なmallocing
フラグの操作に置き換えることで、シグナルハンドラの堅牢性を向上させています。
この修正により、sigprof
が実行されている間でも、helpgc
がmcache
を安全に操作できるようになり、CPUプロファイリング中のランタイムクラッシュが解消されました。mp->mallocing
のインクリメントとデクリメントは、アトミックな操作であり、競合状態を引き起こすことなく、M
のメモリ割り当て状態を正確に反映します。
コアとなるコードの変更箇所
変更は src/pkg/runtime/proc.c
ファイルの runtime·sigprof
関数内で行われています。
--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -2115,7 +2115,6 @@ runtime·sigprof(uint8 *pc, uint8 *sp, uint8 *lr, G *gp, M *mp)
{
int32 n;
bool traceback;
- MCache *mcache; // この行が削除された
// Do not use global m in this function, use mp instead.
// On windows one m is sending reports about all the g's, so m means a wrong thing.
byte m;
@@ -2127,8 +2126,7 @@ runtime·sigprof(uint8 *pc, uint8 *sp, uint8 *lr, G *gp, M *mp)
return;
// Profiling runs concurrently with GC, so it must not allocate.
- mcache = mp->mcache; // この行が削除された
- mp->mcache = nil; // この行が削除された
+ mp->mallocing++; // この行が追加された
// Define that a "user g" is a user-created goroutine, and a "system g"
// is one that is m->g0 or m->gsignal. We've only made sure that we
@@ -2216,7 +2214,7 @@ runtime·sigprof(uint8 *pc, uint8 *pc, uint8 *lr, G *gp, M *mp)
runtime·lock(&prof);
if(prof.fn == nil) {
runtime·unlock(&prof);
- mp->mcache = mcache; // この行が削除された
+ mp->mallocing--; // この行が追加された
return;
}
n = 0;
@@ -2229,7 +2227,7 @@ runtime·sigprof(uint8 *pc, uint8 *sp, uint8 *lr, G *gp, M *mp)
}
prof.fn(prof.pcbuf, n);
runtime·unlock(&prof);
- mp->mcache = mcache; // この行が削除された
+ mp->mallocing--; // この行が追加された
}
// Arrange to call fn with a traceback hz times a second.
コアとなるコードの解説
このコミットの核心的な変更は、runtime·sigprof
関数内でmp->mcache
を直接操作するロジックを削除し、代わりにmp->mallocing
カウンタをインクリメント/デクリメントするロジックに置き換えた点です。
-
MCache *mcache;
の削除:- 以前は、
mp->mcache
の値を一時的に保存するためのローカル変数mcache
が宣言されていました。この変数は、プロファイリング処理後にmp->mcache
を元の状態に戻すために使用されていました。 - この変数が不要になったのは、
mp->mcache
を直接nil
に設定し、後で復元するというパターンが廃止されたためです。
- 以前は、
-
mcache = mp->mcache;
とmp->mcache = nil;
の削除:- これら2行は、プロファイリング処理の開始時に
mp->mcache
をnil
に設定し、その前の値を保存する役割を担っていました。 - この操作が競合状態の原因でした。
sigprof
がmcache
をnil
に設定した直後に、helpgc
のような別のランタイムルーチンが同じM
のmcache
を非nil
の値に設定する可能性があり、sigprof
が処理を終えた際にnil
を復元してしまうことで問題が発生していました。
- これら2行は、プロファイリング処理の開始時に
-
mp->mallocing++;
の追加:- この行は、
runtime·sigprof
が実行される際に、現在のM
がメモリ割り当て操作中であるという状態をランタイムに通知します。 mp->mallocing
は、M
がメモリ割り当てを行っている回数を追跡するカウンタです。このカウンタがゼロでない場合、ランタイムのメモリ割り当てルーチンは、mcache
を介した高速な割り当てパスではなく、より安全な(ただし、通常は遅い)グローバルな割り当てパスを使用するようにフォールバックします。- これにより、
sigprof
が実行されている間は、mcache
が一時的にnil
に設定されることなく、helpgc
などの他のルーチンがmcache
を安全に利用できるようになります。
- この行は、
-
mp->mcache = mcache;
の削除とmp->mallocing--;
の追加:- プロファイリング処理が終了した後、以前は保存しておいた
mcache
の値をmp->mcache
に復元していました。この復元操作も競合状態の一因でした。 - 新しいコードでは、
mp->mallocing
カウンタをデクリメントします。これにより、M
がメモリ割り当て操作を終了したことをランタイムに通知し、通常の高速なmcache
を利用した割り当てパスに戻れるようになります。
- プロファイリング処理が終了した後、以前は保存しておいた
この変更により、runtime·sigprof
はmcache
の直接的な状態変更から切り離され、mp->mallocing
というより抽象的で安全なメカニズムを通じて、メモリ割り当ての挙動を制御するようになりました。これは、シグナルハンドラのような非同期で実行されるコードにおいて、共有データ構造へのアクセスをより堅牢にするための典型的なパターンです。
関連リンク
- GoのCPUプロファイリングに関する公式ドキュメント: https://go.dev/doc/diagnose-cpu-mem
- GoランタイムのM, P, Gスケジューラに関する解説: https://go.dev/blog/go-concurrency-patterns-timing-out-and-cancellation (直接M,P,Gの解説ではないが、Goの並行処理モデルの理解に役立つ)
- Goのメモリ管理とmcacheに関する議論(より深い理解のため): Goのソースコードや、Goのメモリ管理に関する技術ブログ記事などを参照すると良いでしょう。
参考にした情報源リンク
- Goの公式ドキュメント
- Goのソースコード (
src/runtime/proc.go
,src/runtime/mcache.go
など) - Goランタイムの内部構造に関する技術ブログや論文 (例: "Go's work-stealing scheduler", "The Go scheduler")
- 競合状態に関する一般的なプログラミングの概念に関する資料
SIGPROF
シグナルとCPUプロファイリングの仕組みに関する資料- GoのIssueトラッカーやコードレビューシステム (Gerrit) の関連する議論 (例:
https://golang.org/cl/58860044
は元のコードレビューへのリンク) - Goの
pprof
ツールのドキュメント - Goのガベージコレクションに関する技術解説I have generated the detailed technical explanation in Markdown format, following all the instructions, including the specific chapter structure. The output is in Japanese and is as detailed as requested, covering the background, prerequisite knowledge, and technical specifics. I have used the commit data and leveraged web search for comprehensive information.
I will now output the generated Markdown content to standard output.