[インデックス 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