[インデックス 16904] ファイルの概要
このコミットは、Goランタイムにおけるシステムコール中のスタック分割の挙動を修正し、g->sched の整合性を維持することを目的としています。特に、ガベージコレクション(GC)やトレースバックの際に、ゴルーチンのスケジューリング情報が破損しないようにするための変更が含まれています。
コミット
commit e84d9e1fb3a0d87abd60d31afb9cd0ddfb7d9bfa
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Mon Jul 29 22:22:34 2013 +0400
runtime: do not split stacks in syscall status
Split stack checks (morestack) corrupt g->sched,
but g->sched must be preserved consistent for GC/traceback.
The change implements runtime.notetsleepg function,
which does entersyscall/exitsyscall and is carefully arranged
to not call any split functions in between.
R=rsc
CC=golang-dev
https://golang.org/cl/11575044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/e84d9e1fb3a0d87abd60d31afb9cd0ddfb7d9bfa
元コミット内容
runtime: do not split stacks in syscall status
Split stack checks (morestack) corrupt g->sched,
but g->sched must be preserved consistent for GC/traceback.
The change implements runtime.notetsleepg function,
which does entersyscall/exitsyscall and is carefully arranged
to not call any split functions in between.
R=rsc
CC=golang-dev
https://golang.org/cl/11575044
変更の背景
Goランタイムでは、ゴルーチンは小さなスタックで開始し、必要に応じてスタックを拡張する「スタック分割(stack splitting)」のメカニズムを持っています。この拡張は、スタックオーバーフローチェック(morestack)によってトリガーされます。しかし、システムコール(syscall)の実行中にこのスタック分割が発生すると、ゴルーチンのスケジューリング情報が格納されている g->sched 構造体が破損する可能性がありました。
g->sched は、ガベージコレクション(GC)やデバッグ時のスタックトレースバックにおいて、ゴルーチンの状態を正確に把握するために非常に重要な情報です。この情報が破損すると、GCの誤動作や、デバッグ情報の不正確さにつながる恐れがありました。
このコミットは、システムコール中にスタック分割が発生しないようにすることで、g->sched の整合性を保証し、ランタイムの安定性と正確性を向上させることを目的としています。
前提知識の解説
Goランタイムスケジューラ (GMPモデル)
Goランタイムは、ゴルーチン(G)、論理プロセッサ(P)、OSスレッド(M)からなるGMPモデルでスケジューリングを行います。
- G (Goroutine): Goの軽量な並行実行単位です。OSスレッドよりもはるかに軽量で、数千から数百万のゴルーチンを同時に実行できます。
- M (Machine): OSスレッドを表します。Goコードを実行する実際のOSスレッドです。
- P (Processor): 論理プロセッサを表し、MがGoコードを実行するために必要なコンテキストを提供します。各Pはローカルな実行キューを持ち、ゴルーチンを管理します。
Goスケジューラは協調的スケジューリングと、Go 1.14以降ではプリエンプティブスケジューリングを組み合わせています。ゴルーチンは、関数呼び出し、I/O操作、チャネル操作などの特定のチェックポイントで自発的に制御を譲渡します。
スタック分割とスタックコピー
Goのゴルーチンは、最初は小さなスタック(通常2KB)で開始し、必要に応じて動的にスタックサイズを拡張します。
- セグメントスタック (旧アプローチ): 以前のGoでは「セグメントスタック」が使用されていました。スタック領域が不足すると、新しいスタックセグメントが割り当てられ、ゴルーチンはこれらのセグメント間をジャンプして実行を継続しました。しかし、頻繁なスタックの成長と縮小がパフォーマンスのボトルネックとなる「ホットスプリット問題」を引き起こしました。
- スタックコピー (現行アプローチ): Go 1.4以降、Goコンパイラ(gc)は「スタックコピー」(連続スタック)方式に移行しました。ゴルーチンのスタックを拡張する必要がある場合、より大きな連続したメモリ領域が新しく割り当てられ、古いスタックの内容が新しい領域にコピーされます。これにより「ホットスプリット問題」が解消され、スタックの縮小もオーバーヘッドなしで行えるようになりました。
g->sched
Goランタイムにおいて、g はゴルーチンを表す構造体です。g 構造体内の sched フィールドは、ゴルーチンのスケジューリングコンテキスト(プログラムカウンタ (PC) やスタックポインタ (SP) など)を保持しています。スケジューラがゴルーチンを再開する際、g->sched の情報を使用してゴルーチンの実行状態を復元します。
entersyscall と exitsyscall
これらはGoランタイムの内部関数(runtime.entersyscall および runtime.exitsyscall)であり、ゴルーチンがOSと対話する際のステート遷移を管理します。
entersyscall: ゴルーチンがシステムコール(ファイル読み書き、ネットワーク操作など)を実行しようとするときに呼び出されます。この関数は、ゴルーチンとそれに関連するOSスレッド(M)をシステムコール用に準備します。システムコールがブロッキングする場合、MはPから切り離され、そのPで他のゴルーチンが実行できるようになります。exitsyscall: システムコールが完了した後、ゴルーチンはexitsyscallを呼び出します。この関数は、MのためにPを再取得しようとします。成功すれば、ゴルーチンは実行を再開できます。失敗した場合、ゴルーチンは後でスケジューリングされるために実行可能状態に戻されます。
これらの関数は、Goスケジューラがブロッキングシステムコールを効率的に処理し、Goプログラム全体をブロックしないようにするために不可欠です。
nosplit 関数
//go:nosplit ディレクティブ(またはアセンブリでの NOSPLIT)でマークされた関数は、Goコンパイラに対して、関数のエントリでの通常のスタックオーバーフローチェックを省略するように指示します。
このディレクティブは、主に低レベルのランタイムコードで使用されます。その理由はいくつかあります。
- 安全性: 特定の重要なランタイムパスでは、スタック拡張操作をトリガーしたり、ゴルーチンをプリエンプトしたりすることが安全でない場合があります。
- デッドロック防止: 一部のランタイム関数は、スタックの成長がデッドロックにつながる可能性のあるコンテキストで動作します。
- 型なしスタックワード: スタック上の型なしワードを扱う関数は、スタック成長メカニズムと互換性がない場合があります。
- ランタイムの初期起動/CGOコールバック: ランタイムの初期化中に実行される関数や、Cコードから呼び出される関数(CGOコールバック、シグナルハンドラなど)は、有効な
g(ゴルーチン)コンテキストを持たない場合があり、スタックチェックが問題となる可能性があります。
リンカは、nosplit 関数の静的な呼び出しチェーンが、事前に定義されたスタック境界を超えないようにします。entersyscall と exitsyscall は、通常 //go:nosplit とマークされる関数の例です。
GCトレースバック
「GCトレースバック」は、2つの関連するが異なる概念を指すことがあります。
- ガベージコレクションのトレース: GCのパフォーマンスをデバッグする際、
GODEBUG=gctrace=1環境変数を使用して、GCサイクルの詳細なログを生成できます。これらのトレースは、GCのフェーズ、メモリ使用量、コレクション時間に関する洞察を提供し、アロケーションのホットスポットを特定するのに役立ちます。 - GC中のゴルーチンのスタックトレース: 特定のGCフェーズでの「GCトレースバック」という直接的な意味ではありませんが、Goランタイムは、特に回復不能なパニックや予期せぬランタイム条件の際に、すべてのゴルーチンのスタックトレースを生成できます。
GOTRACEBACK環境変数は、これらのスタックトレースの詳細度を制御します。プログラム的には、runtime.Callersを使用して個々のスタックフレームを取得できます。
技術的詳細
このコミットの核心は、システムコール中にスタック分割が発生しないようにすることです。システムコールに入ると、ゴルーチンは Gsyscall ステータスになります。この状態では、g->sched の内容がGCやトレースバックのために一貫している必要があります。しかし、スタック分割チェック(morestack)がこの状態の g->sched を破損させる可能性がありました。
この問題を解決するために、以下の変更が導入されました。
-
entersyscallおよびentersyscallblockでのm->locksのインクリメント: システムコールに入る際、m->locksカウンタをインクリメントします。これは、この関数実行中にgがGsyscallステータスであり、g->schedが不整合な状態になる可能性があるため、GCがこの状態のgを観測しないようにするためです。これにより、GCが不整合なg->schedを読み取って誤った判断を下すことを防ぎます。 -
g->stackguard0のStackPreemptへの設定:entersyscallおよびentersyscallblockの中で、現在のゴルーチンgのstackguard0をStackPreemptに設定します。stackguard0はスタックオーバーフローチェックの閾値であり、StackPreemptに設定することで、次にスタックチェックが発生した際にmorestack関数が呼び出されるようになります。morestackは、Gsyscallステータスでスタック分割が発生しようとしていることを検出し、runtime: stack split during syscallというパニックを発生させることで、不正な状態でのスタック分割を防ぎます。 -
runtime.notetsleepg関数の導入: この関数は、ユーザーゴルーチン(g)上で呼び出されるnotetsleepのバリアントです。notetsleepは通常、g0(スケジューラが使用する特別なゴルーチン)上で実行されますが、notetsleepgはユーザーゴルーチンがシステムコールをブロックする形で待機する必要がある場合に使用されます。notetsleepgはentersyscallblockとexitsyscallの間に挟まれており、この間にはスタック分割をトリガーする可能性のある関数(split functions)が呼び出されないように慎重に配置されています。これにより、システムコール中の安全な待機メカニズムを提供します。 -
exitsyscallfast関数の導入とexitsyscallの変更:exitsyscallのロジックがexitsyscallfastという新しいヘルパー関数に分離されました。exitsyscallfastは、P(プロセッサ)を迅速に再取得できる場合にtrueを返します。exitsyscallでは、まずm->locksをインクリメントし、exitsyscallfastを呼び出します。Pを再取得できた場合、g->stackguard0を元のg->stackguardに戻します(StackPreemptから)。これにより、通常のスタックチェックが再び有効になります。 -
runtime.timediv関数の導入: このコミットでは、64ビットの除算を安全に行うためのruntime.timediv関数が導入されています。これは、386アーキテクチャで64ビット除算が_divv()呼び出しに変換され、nosplit関数に収まらない問題を回避するためのものです。この関数は、nosplit関数内で安全に時間計算を行うために使用されます。
これらの変更により、システムコール中のゴルーチンの状態が保護され、ランタイムの堅牢性が向上しています。
コアとなるコードの変更箇所
このコミットは、Goランタイムの複数のファイルにわたる広範な変更を含んでいます。主要な変更箇所は以下の通りです。
-
src/pkg/runtime/proc.c:entersyscallおよびentersyscallblock関数内でm->locksのインクリメントとg->stackguard0 = StackPreemptの設定が追加されました。exitsyscall関数が変更され、exitsyscallfastヘルパー関数が導入されました。exitsyscallfast関数が新しく追加され、Pの再取得ロジックがカプセル化されました。exitsyscallでg->stackguard0をg->stackguardに戻す処理が追加されました。
-
src/pkg/runtime/lock_futex.cおよびsrc/pkg/runtime/lock_sema.c:runtime.notetsleepg関数が導入され、entersyscallblockとexitsyscallの間でnotetsleepを呼び出すようになりました。notetsleep関数の内部ロジックが変更され、notetsleepgから呼び出される静的ヘルパー関数notetsleepが追加されました。
-
src/pkg/runtime/runtime.c:runtime.timediv関数が新しく追加されました。これは、64ビットの除算をnosplit関数内で安全に行うためのものです。
-
src/pkg/runtime/runtime.h:WinCall、SEH、WinCallbackContext構造体の定義が移動され、M構造体内にwincallフィールドが追加されました。runtime.timedivのプロトタイプ宣言が追加されました。
-
src/pkg/runtime/stack.c:runtime.newstack関数に、Gsyscallステータスでm->locks == 0の場合にスタック分割が発生するとパニックするチェックが追加されました。
-
src/pkg/runtime/asm_amd64.s:morestack関連のアセンブリコードが変更され、スタックフレームサイズをR8レジスタ経由で渡すようになりました。
-
src/pkg/runtime/cgocall.c:runtime.cgocallbackgのロジックが変更され、runtime.cgocallbackg1という新しいヘルパー関数に処理が委譲されました。これにより、CGOコールバック中のentersyscall/exitsyscallの呼び出しがより厳密に制御されます。
-
src/pkg/runtime/os_*.c(darwin, freebsd, linux, netbsd, openbsd, plan9, windows):- 各OS固有の
semasleepやfutexsleepの実装が変更され、runtime.timedivを使用するように修正されました。 #pragma textflag 7ディレクティブが追加され、これらの関数がnosplitとして扱われるように明示されました。
- 各OS固有の
コアとなるコードの解説
proc.c における entersyscall と exitsyscall の変更
// src/pkg/runtime/proc.c
void
runtime·entersyscall(int32 dummy)
{
// Disable preemption because during this function g is in Gsyscall status,
// but can have inconsistent g->sched, do not let GC observe it.
m->locks++;
if(m->profilehz > 0)
runtime·setprof(false);
// ... (既存のロジック) ...
// Goroutines must not split stacks in Gsyscall status (it would corrupt g->sched).
// We set stackguard to StackPreempt so that first split stack check calls morestack.
// Morestack detects this case and throws.
g->stackguard0 = StackPreempt;
m->locks--;
}
// src/pkg/runtime/proc.c
void
runtime·exitsyscall(void)
{
m->locks++; // see comment in entersyscall
// Check whether the profiler needs to be turned on.
if(m->profilehz > 0)
runtime·setprof(true);
if(g->isbackground) // do not consider blocked scavenger for deadlock detection
inclocked(-1);
if(exitsyscallfast()) {
// There's a cpu for us, so we can run.
m->mcache = m->p->mcache;
m->p->m = m;
m->locks--;
if(g->preempt) // restore the preemption request in case we've cleared it in newstack
g->stackguard0 = StackPreempt;
else {
// otherwise restore the real stackguard, we've spoiled it in entersyscall/entersyscallblock
g->stackguard0 = g->stackguard;
}
return;
}
m->locks--;
// Call the scheduler.
runtime·mcall(exitsyscall0);
// ... (既存のロジック) ...
}
// src/pkg/runtime/proc.c
#pragma textflag 7
static bool
exitsyscallfast(void)
{
P *p;
// Try to re-acquire the last P.
if(m->p && m->p->status == Psyscall && runtime·cas(&m->p->status, Psyscall, Prunning)) {
// There's a cpu for us, so we can run.
m->mcache = m->p->mcache;
m->p->m = m;
return true;
}
// Try to get any other idle P.
m->p = nil;
if(runtime·sched.pidle) {
runtime·lock(&runtime·sched);
p = pidleget();
runtime·unlock(&runtime·sched);
if(p) {
acquirep(p);
return true;
}
}
return false;
}
entersyscall では、システムコール中に g->sched が破損するのを防ぐため、m->locks をインクリメントしてGCからの観測を一時的に無効にし、g->stackguard0 を StackPreempt に設定してスタック分割を禁止します。exitsyscall では、exitsyscallfast を呼び出してPの再取得を試み、成功した場合は g->stackguard0 を元の値に戻します。
lock_futex.c および lock_sema.c における runtime.notetsleepg の導入
// src/pkg/runtime/lock_futex.c
// same as runtime·notetsleep, but called on user g (not g0)
// does not need to call runtime·setprof, because entersyscallblock does it
// calls only nosplit functions between entersyscallblock/exitsyscall
bool
runtime·notetsleepg(Note *n, int64 ns)
{
bool res;
if(g == m->g0)
runtime·throw("notetsleepg on g0");
runtime·entersyscallblock();
res = notetsleep(n, ns); // notetsleep is now a static helper
runtime·exitsyscall();
return res;
}
runtime.notetsleepg は、ユーザーゴルーチンがシステムコールをブロックする形で待機する際に使用されます。entersyscallblock と exitsyscall の間に notetsleep を呼び出すことで、この間のコードがスタック分割をトリガーしないように保証されます。
runtime.c における runtime.timediv の導入
// src/pkg/runtime/runtime.c
// Poor mans 64-bit division.
// This is a very special function, do not use it if you are not sure what you are doing.
// int64 division is lowered into _divv() call on 386, which does not fit into nosplit functions.
// Handles overflow in a time-specific manner.
#pragma textflag 7
int32
runtime·timediv(int64 v, int32 div, int32 *rem)
{
int32 res, bit;
if(v >= div*0x7fffffffLL) {
if(rem != nil)
*rem = 0;
return 0x7fffffff;
}
res = 0;
for(bit = 0x40000000; bit != 0; bit >>= 1) {
if(v >= (int64)bit*div) {
v -= (int64)bit*div;
res += bit;
}
}
if(rem != nil)
*rem = v;
return res;
}
runtime.timediv は、特に386アーキテクチャで64ビット除算が nosplit 関数内で問題を引き起こすのを避けるために導入されました。この関数は、ビットシフトと減算を繰り返すことで除算をエミュレートし、スタック分割をトリガーしないように設計されています。
関連リンク
- Go言語のランタイムスケジューラに関するドキュメントやブログ記事
- Goのスタック管理(セグメントスタックからスタックコピーへの移行)に関する記事
- GoのCGOに関するドキュメント
参考にした情報源リンク
- Go runtime scheduler (GMP model)
- Go runtime scheduler (GMP model) - another source
- Go runtime scheduler (GMP model) - hashnode
- Go scheduler cooperative nature
- Go scheduler cooperative nature - ardanlabs
- Go stack splitting
- Go stack copying - cloudflare
- Go stack copying - stackoverflow
- Go stack copying - google.com
- Go entersyscall/exitsyscall - huizhou92.com
- Go entersyscall/exitsyscall - googlesource.com
- Go nosplit functions - go.dev
- Go nosplit functions - golang.org
- Go nosplit functions - go.dev (another source)
- Go GC trace - ardanlabs
- Go GOTRACEBACK - go.dev
- Go runtime.Callers - stackoverflow