[インデックス 17435] ファイルの概要
このコミットは、Goランタイムの内部動作、特にmcall
関数とエラーハンドリング関数badmcall
およびbadmcall2
の間の制御フローに関する重要な変更を導入しています。主な目的は、ランタイムエラー発生時のスタックトレースの可読性を向上させることです。
コミット
commit 32b770b2c05d69c41f0ab6719dc028cf4c79e334
Author: Keith Randall <khr@golang.org>
Date: Thu Aug 29 15:53:34 2013 -0700
runtime: jump to badmcall instead of calling it.
This replaces the mcall frame with the badmcall frame instead of
leaving the mcall frame on the stack and adding the badmcall frame.
Because mcall is no longer on the stack, traceback will now report what
called mcall, which is what we would like to see in this situation.
R=golang-dev, cshapiro
CC=golang-dev
https://golang.org/cl/13012044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/32b770b2c05d69c41f0ab6719dc028cf4c79e334
元コミット内容
このコミットは、Goランタイムのmcall
関数がbadmcall
またはbadmcall2
を呼び出す際に、従来のCALL
命令からJMP
命令に変更するというものです。これにより、mcall
のスタックフレームがbadmcall
のスタックフレームに置き換えられ、エラー発生時のスタックトレースがより意味のある情報(mcall
を呼び出した元の関数)を示すようになります。
具体的には、以下のファイルが変更されています。
src/pkg/runtime/asm_386.s
(32-bit x86アセンブリ)src/pkg/runtime/asm_amd64.s
(64-bit x86アセンブリ)src/pkg/runtime/asm_arm.s
(ARMアセンブリ)src/pkg/runtime/proc.c
(C言語のランタイムコード)
アセンブリファイルでは、CALL runtime·badmcall(SB)
やCALL runtime·badmcall2(SB)
といった命令が、MOV
命令でbadmcall
またはbadmcall2
のアドレスをレジスタにロードし、そのレジスタにJMP
する形式に置き換えられています。proc.c
では、badmcall
とbadmcall2
の関数シグネチャが変更され、void (*fn)(G*)
という引数を受け取るようになっていますが、これは現時点ではUSED(fn)
とマークされており、直接使用されていません。
変更の背景
Goランタイムは、ゴルーチン(Goの軽量スレッド)のスケジューリングやメモリ管理など、低レベルの操作を効率的に行うために、アセンブリ言語とC言語で書かれた部分を多く含んでいます。mcall
関数は、ゴルーチンのスタックからOSスレッド(M)のシステムスタック(g0
スタック)に切り替える際に使用される非常に重要な関数です。これは、ガベージコレクションやスケジューリングなどの、ゴルーチンの実行を中断してランタイムが直接制御する必要がある操作を行うために必要です。
しかし、mcall
の呼び出しが不正な状態で行われた場合(例えば、既にg0
スタック上にいるにもかかわらずmcall
が呼び出された場合)や、mcall
が呼び出した関数が予期せず戻ってきてしまった場合などには、ランタイムはbadmcall
やbadmcall2
といったエラーハンドリング関数を呼び出してパニックを発生させます。
このコミット以前は、mcall
がbadmcall
をCALL
命令で呼び出していました。CALL
命令は、呼び出し元の関数のリターンアドレスをスタックにプッシュし、新しいスタックフレームを作成します。このため、エラー発生時のスタックトレースには、mcall
のスタックフレームが残ってしまい、そのmcall
を呼び出した真の関数(つまり、問題を引き起こした可能性のあるコード)がトレースから隠れてしまうという問題がありました。
開発者にとって、エラーの根本原因を特定するためには、mcall
がどこから呼び出されたのかを知ることが非常に重要です。この変更は、このデバッグ情報の欠落を解消し、より正確なスタックトレースを提供することを目的としています。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念とアセンブリ言語の基本的な知識が必要です。
-
Goランタイム (Go Runtime): Goプログラムの実行を管理する低レベルのシステムです。ゴルーチンのスケジューリング、メモリ割り当て(ガベージコレクションを含む)、チャネル通信、ネットワークI/Oなど、Go言語の並行処理モデルと効率的な実行を支える基盤を提供します。ランタイムの多くの部分はGo言語自体で書かれていますが、OSとのインタラクションやパフォーマンスが重要な部分はC言語やアセンブリ言語で書かれています。
-
ゴルーチン (Goroutine - G): Go言語における並行実行の単位です。OSスレッドよりもはるかに軽量で、数百万のゴルーチンを同時に実行することも可能です。Goランタイムがゴルーチンのスケジューリングと実行を管理します。各ゴルーチンは独自のスタックを持っています。
-
マシン (Machine - M): OSスレッドを表します。Goランタイムは、OSスレッドを抽象化したMを管理し、その上でゴルーチンを実行します。MはOSスケジューラによってスケジューリングされます。
-
プロセッサ (Processor - P): Mがゴルーチンを実行するために必要な論理プロセッサを表します。Pは、実行可能なゴルーチンのキューを保持し、Mにゴルーチンをディスパッチします。通常、Pの数はCPUのコア数に等しく設定されます。
-
g0
スタック (g0 stack): 各M(OSスレッド)には、そのM専用のシステムスタックが存在します。これをg0
スタックと呼びます。Goランタイムの重要な内部処理(例えば、ゴルーチンのスケジューリング、ガベージコレクションのマークフェーズ、システムコールなど)は、ユーザーゴルーチンのスタックではなく、このg0
スタック上で実行されます。これは、ユーザーゴルーチンのスタックが動的に伸縮するため、ランタイムの安定した実行環境を確保するためです。 -
mcall
関数: Goランタイムの内部関数で、現在実行中のゴルーチンのスタックから、そのゴルーチンを実行しているMのg0
スタックに切り替えるために使用されます。このスタック切り替えは、ランタイムがゴルーチンの実行を一時停止し、低レベルのランタイム処理を実行するために不可欠です。 -
badmcall
/badmcall2
関数: これらはGoランタイムのエラーハンドリング関数です。badmcall
:mcall
関数が不正な状況で呼び出された場合にパニックを発生させます。具体的には、既にg0
スタック上にいるにもかかわらずmcall
が呼び出された場合などです。badmcall2
:mcall
が呼び出した関数が、予期せずmcall
に制御を戻してしまった場合にパニックを発生させます。mcall
は通常、スタックを切り替えて別のゴルーチンを実行するか、ランタイムの内部処理を継続するため、呼び出した関数がmcall
に「戻る」ことは想定されていません。
-
スタックフレーム (Stack Frame): 関数が呼び出されるたびに、その関数のローカル変数、引数、および呼び出し元に戻るためのリターンアドレスなどがスタック上に確保されます。この領域をスタックフレームと呼びます。関数が終了すると、そのスタックフレームは解放されます。
-
CALL
命令とJMP
命令 (Assembly Instructions): アセンブリ言語における制御フロー命令です。CALL
(Call): サブルーチンを呼び出す命令です。現在の命令の次のアドレス(リターンアドレス)をスタックにプッシュし、指定されたアドレスにジャンプします。これにより、サブルーチンが終了した後に元の場所に戻ることができます。新しいスタックフレームが作成されます。JMP
(Jump): 無条件に指定されたアドレスにジャンプする命令です。CALL
とは異なり、リターンアドレスをスタックにプッシュしません。これにより、現在の実行コンテキストから完全に離れ、指定されたアドレスから実行を継続します。スタックフレームは作成されず、現在のスタックフレームがそのまま引き継がれるか、ジャンプ先のコードが新しいスタックフレームを構築する際に現在のフレームを上書きする形になります。
-
スタックトレース (Stack Trace / Backtrace): プログラムがクラッシュしたり、エラーが発生したりした際に、その時点までの関数呼び出しの履歴(コールスタック)を表示するものです。デバッグにおいて、エラーがどの関数の連鎖によって引き起こされたのかを特定するために不可欠な情報です。スタックトレースは、スタック上に積まれたリターンアドレスやスタックフレームの情報から再構築されます。
技術的詳細
このコミットの核心は、CALL
命令とJMP
命令のセマンティクスの違いを利用して、スタックトレースの挙動を変更することにあります。
変更前 (CALL
を使用):
mcall
関数内でbadmcall
をCALL
命令で呼び出すと、以下のようになります。
mcall
が実行される。mcall
のスタックフレームがアクティブになる。mcall
がCALL badmcall
を実行する。badmcall
のリターンアドレス(mcall
内のCALL
命令の次の命令のアドレス)がスタックにプッシュされる。badmcall
のスタックフレームが作成され、実行がbadmcall
に移る。badmcall
がパニックを発生させる。- スタックトレースが生成される際、スタックには
mcall
のリターンアドレスが残っているため、トレースにはmcall
のフレームが含まれてしまいます。これにより、mcall
を呼び出した真の関数がトレースのより深い位置に隠れてしまうか、場合によっては表示されないことがあります。
変更後 (JMP
を使用):
mcall
関数内でbadmcall
をJMP
命令で呼び出すと、以下のようになります。
mcall
が実行される。mcall
のスタックフレームがアクティブになる。mcall
がJMP badmcall
を実行する。JMP
命令はリターンアドレスをスタックにプッシュしない。- 実行が直接
badmcall
に移る。この際、badmcall
はmcall
のスタックフレームを「引き継ぐ」形になるか、あるいはbadmcall
自身のスタックフレームを構築する際にmcall
のスタックフレームを上書きします。重要なのは、mcall
へのリターンアドレスがスタックに残らないことです。 badmcall
がパニックを発生させる。- スタックトレースが生成される際、スタックには
mcall
のリターンアドレスが存在しないため、トレースにはmcall
のフレームは含まれません。その代わりに、mcall
を呼び出した元の関数のフレームが直接badmcall
のフレームの前に表示されるようになります。
この変更により、mcall
が不正に呼び出された場合のエラー発生時に、スタックトレースが「mcall
を呼び出したのは誰か」という、デバッグにおいて最も重要な情報を提供するようになります。これは、ランタイムの内部エラーの診断を大幅に改善するものです。
proc.c
におけるbadmcall
とbadmcall2
の関数シグネチャの変更(void (*fn)(G*)
引数の追加)は、直接的な機能変更には寄与していませんが、将来的にこれらのエラーハンドリング関数が、どの関数ポインタ(fn
)が問題を引き起こしたのかをデバッグ情報として出力できるようにするための準備であると考えられます。
コアとなるコードの変更箇所
このコミットの主要な変更は、GoランタイムのアセンブリファイルにおけるCALL
命令からJMP
命令への置き換えです。
src/pkg/runtime/asm_386.s
(32-bit x86)
--- a/src/pkg/runtime/asm_386.s
+++ b/src/pkg/runtime/asm_386.s
@@ -181,14 +181,16 @@ TEXT runtime·mcall(SB), NOSPLIT, $0-4
MOVL m(CX), BX
MOVL m_g0(BX), SI
CMPL SI, AX // if g == m->g0 call badmcall
- JNE 2(PC)
- CALL runtime·badmcall(SB)
+ JNE 3(PC)
+ MOVL $runtime·badmcall(SB), AX
+ JMP AX
MOVL SI, g(CX) // g = m->g0
MOVL (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.sp
PUSHL AX
CALL DI
POPL AX
- CALL runtime·badmcall2(SB)
+ MOVL $runtime·badmcall2(SB), AX
+ JMP AX
RET
src/pkg/runtime/asm_amd64.s
(64-bit x86)
--- a/src/pkg/runtime/asm_amd64.s
+++ b/src/pkg/runtime/asm_amd64.s
@@ -169,16 +169,16 @@ TEXT runtime·mcall(SB), NOSPLIT, $0-8
MOVQ m_g0(BX), SI
CMPQ SI, AX // if g == m->g0 call badmcall
JNE 3(PC)
- ARGSIZE(0)
- CALL runtime·badmcall(SB)
+ MOVQ $runtime·badmcall(SB), AX
+ JMP AX
MOVQ SI, g(CX) // g = m->g0
MOVQ (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.sp
PUSHQ AX
ARGSIZE(8)
CALL DI
POPQ AX
- ARGSIZE(0)
- CALL runtime·badmcall2(SB)
+ MOVQ $runtime·badmcall2(SB), AX
+ JMP AX
RET
src/pkg/runtime/asm_arm.s
(ARM)
--- a/src/pkg/runtime/asm_arm.s
+++ b/src/pkg/runtime/asm_arm.s
@@ -157,12 +157,13 @@ TEXT runtime·mcall(SB), NOSPLIT, $-4-4
MOVW g, R1
MOVW m_g0(m), g
CMP g, R1
- BL.EQ runtime·badmcall(SB)
+ B.NE 2(PC)
+ B runtime·badmcall(SB)
MOVW (g_sched+gobuf_sp)(g), SP
SUB $8, SP
MOVW R1, 4(SP)
BL (R0)
- BL runtime·badmcall2(SB)
+ B runtime·badmcall2(SB)
RET
src/pkg/runtime/proc.c
(C言語)
--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -1997,14 +1997,16 @@ runtime·mcount(void)
}
void
-runtime·badmcall(void) // called from assembly
+runtime·badmcall(void (*fn)(G*)) // called from assembly
{
+\tUSED(fn); // TODO: print fn?
runtime·throw("runtime: mcall called on m->g0 stack");
}
void
-runtime·badmcall2(void) // called from assembly
+runtime·badmcall2(void (*fn)(G*)) // called from assembly
{
+\tUSED(fn);\
runtime·throw("runtime: mcall function returned");
}
コアとなるコードの解説
各アセンブリファイルにおける変更は、基本的に同じロジックを異なるアーキテクチャの命令セットで実装したものです。
x86 (386/AMD64) の場合:
変更前:
CALL runtime·badmcall(SB)
これは、runtime·badmcall
関数を呼び出す標準的な方法です。現在の命令ポインタの次のアドレスをスタックにプッシュし、runtime·badmcall
の先頭にジャンプします。
変更後:
MOVL $runtime·badmcall(SB), AX // 386の場合
MOVQ $runtime·badmcall(SB), AX // AMD64の場合
JMP AX
このシーケンスは、まずruntime·badmcall
関数のアドレスをレジスタAX
(または他の汎用レジスタ)にロードし、次にそのレジスタにJMP
(ジャンプ)します。JMP
命令はリターンアドレスをスタックにプッシュしないため、mcall
のスタックフレームはbadmcall
のスタックトレースには現れません。これにより、スタックトレースはmcall
を呼び出した元の関数を直接指し示すようになります。
ARM の場合:
変更前:
BL.EQ runtime·badmcall(SB)
BL
(Branch with Link) 命令は、ARMアーキテクチャにおける関数呼び出しに相当します。リターンアドレスをリンクレジスタ(LR
)に保存し、指定されたアドレスにジャンプします。BL.EQ
は条件付き分岐で、前の比較結果が等しい場合に分岐します。
変更後:
B.NE 2(PC)
B runtime·badmcall(SB)
B
(Branch) 命令は、ARMアーキテクチャにおける無条件ジャンプです。BL
とは異なり、リターンアドレスを保存しません。B.NE 2(PC)
は、前の比較結果が等しくない場合に2命令先にジャンプし、それ以外の場合は次のB runtime·badmcall(SB)
命令が実行され、runtime·badmcall
に直接ジャンプします。これにより、x86の場合と同様に、mcall
のスタックフレームがトレースから除外されます。
src/pkg/runtime/proc.c
の変更:
void
-runtime·badmcall(void) // called from assembly
+runtime·badmcall(void (*fn)(G*)) // called from assembly
{
+\tUSED(fn); // TODO: print fn?
runtime·throw("runtime: mcall called on m->g0 stack");
}
badmcall
とbadmcall2
の関数シグネチャが変更され、void (*fn)(G*)
という関数ポインタを引数として受け取るようになりました。USED(fn)
は、コンパイラが未使用の引数に関する警告を出さないようにするためのマクロです。TODO: print fn?
というコメントは、将来的にこの引数を利用して、どの関数ポインタが不正なmcall
を引き起こしたのかをデバッグ情報として出力する可能性があることを示唆しています。現時点では、この引数はパニックメッセージには影響を与えていません。
このコミットは、Goランタイムのデバッグ能力を向上させるための、低レベルながらも非常に効果的な改善です。
関連リンク
- Go言語の公式ドキュメント: https://golang.org/doc/
- Goランタイムのソースコード: https://github.com/golang/go/tree/master/src/runtime
- GoのIssue Tracker (このコミットに関連するIssueがある可能性): https://github.com/golang/go/issues
- Goのコードレビューシステム (Gerrit): https://go-review.googlesource.com/ (コミットメッセージにある
https://golang.org/cl/13012044
はGerritのChange-IDです)
参考にした情報源リンク
- Goのランタイムスケジューラに関するブログ記事やドキュメント(
G
,M
,P
モデルの理解に役立つもの) - アセンブリ言語の命令セットリファレンス(
CALL
,JMP
,BL
,B
命令の詳細) - スタックとスタックフレームに関するコンピュータサイエンスの基本的な教科書やオンラインリソース
- Goのソースコード内のコメントや関連するコミット履歴
- Goのメーリングリストやフォーラムでの議論(
golang-dev
など)