Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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 の情報を使用してゴルーチンの実行状態を復元します。

entersyscallexitsyscall

これらは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 関数の静的な呼び出しチェーンが、事前に定義されたスタック境界を超えないようにします。entersyscallexitsyscall は、通常 //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 を破損させる可能性がありました。

この問題を解決するために、以下の変更が導入されました。

  1. entersyscall および entersyscallblock での m->locks のインクリメント: システムコールに入る際、m->locks カウンタをインクリメントします。これは、この関数実行中にgGsyscallステータスであり、g->schedが不整合な状態になる可能性があるため、GCがこの状態のgを観測しないようにするためです。これにより、GCが不整合なg->schedを読み取って誤った判断を下すことを防ぎます。

  2. g->stackguard0StackPreempt への設定: entersyscall および entersyscallblock の中で、現在のゴルーチン gstackguard0StackPreempt に設定します。stackguard0 はスタックオーバーフローチェックの閾値であり、StackPreempt に設定することで、次にスタックチェックが発生した際に morestack 関数が呼び出されるようになります。morestack は、Gsyscall ステータスでスタック分割が発生しようとしていることを検出し、runtime: stack split during syscall というパニックを発生させることで、不正な状態でのスタック分割を防ぎます。

  3. runtime.notetsleepg 関数の導入: この関数は、ユーザーゴルーチン(g)上で呼び出される notetsleep のバリアントです。notetsleep は通常、g0(スケジューラが使用する特別なゴルーチン)上で実行されますが、notetsleepg はユーザーゴルーチンがシステムコールをブロックする形で待機する必要がある場合に使用されます。 notetsleepgentersyscallblockexitsyscall の間に挟まれており、この間にはスタック分割をトリガーする可能性のある関数(split functions)が呼び出されないように慎重に配置されています。これにより、システムコール中の安全な待機メカニズムを提供します。

  4. exitsyscallfast 関数の導入と exitsyscall の変更: exitsyscall のロジックが exitsyscallfast という新しいヘルパー関数に分離されました。exitsyscallfast は、P(プロセッサ)を迅速に再取得できる場合に true を返します。 exitsyscall では、まず m->locks をインクリメントし、exitsyscallfast を呼び出します。Pを再取得できた場合、g->stackguard0 を元の g->stackguard に戻します(StackPreempt から)。これにより、通常のスタックチェックが再び有効になります。

  5. 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の再取得ロジックがカプセル化されました。
    • exitsyscallg->stackguard0g->stackguard に戻す処理が追加されました。
  • src/pkg/runtime/lock_futex.c および src/pkg/runtime/lock_sema.c:

    • runtime.notetsleepg 関数が導入され、entersyscallblockexitsyscall の間で notetsleep を呼び出すようになりました。
    • notetsleep 関数の内部ロジックが変更され、notetsleepg から呼び出される静的ヘルパー関数 notetsleep が追加されました。
  • src/pkg/runtime/runtime.c:

    • runtime.timediv 関数が新しく追加されました。これは、64ビットの除算を nosplit 関数内で安全に行うためのものです。
  • src/pkg/runtime/runtime.h:

    • WinCallSEHWinCallbackContext 構造体の定義が移動され、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固有の semasleepfutexsleep の実装が変更され、runtime.timediv を使用するように修正されました。
    • #pragma textflag 7 ディレクティブが追加され、これらの関数が nosplit として扱われるように明示されました。

コアとなるコードの解説

proc.c における entersyscallexitsyscall の変更

// 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->stackguard0StackPreempt に設定してスタック分割を禁止します。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 は、ユーザーゴルーチンがシステムコールをブロックする形で待機する際に使用されます。entersyscallblockexitsyscall の間に 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に関するドキュメント

参考にした情報源リンク