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

[インデックス 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では、badmcallbadmcall2の関数シグネチャが変更され、void (*fn)(G*)という引数を受け取るようになっていますが、これは現時点ではUSED(fn)とマークされており、直接使用されていません。

変更の背景

Goランタイムは、ゴルーチン(Goの軽量スレッド)のスケジューリングやメモリ管理など、低レベルの操作を効率的に行うために、アセンブリ言語とC言語で書かれた部分を多く含んでいます。mcall関数は、ゴルーチンのスタックからOSスレッド(M)のシステムスタック(g0スタック)に切り替える際に使用される非常に重要な関数です。これは、ガベージコレクションやスケジューリングなどの、ゴルーチンの実行を中断してランタイムが直接制御する必要がある操作を行うために必要です。

しかし、mcallの呼び出しが不正な状態で行われた場合(例えば、既にg0スタック上にいるにもかかわらずmcallが呼び出された場合)や、mcallが呼び出した関数が予期せず戻ってきてしまった場合などには、ランタイムはbadmcallbadmcall2といったエラーハンドリング関数を呼び出してパニックを発生させます。

このコミット以前は、mcallbadmcallCALL命令で呼び出していました。CALL命令は、呼び出し元の関数のリターンアドレスをスタックにプッシュし、新しいスタックフレームを作成します。このため、エラー発生時のスタックトレースには、mcallのスタックフレームが残ってしまい、そのmcallを呼び出した真の関数(つまり、問題を引き起こした可能性のあるコード)がトレースから隠れてしまうという問題がありました。

開発者にとって、エラーの根本原因を特定するためには、mcallがどこから呼び出されたのかを知ることが非常に重要です。この変更は、このデバッグ情報の欠落を解消し、より正確なスタックトレースを提供することを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGoランタイムの概念とアセンブリ言語の基本的な知識が必要です。

  1. Goランタイム (Go Runtime): Goプログラムの実行を管理する低レベルのシステムです。ゴルーチンのスケジューリング、メモリ割り当て(ガベージコレクションを含む)、チャネル通信、ネットワークI/Oなど、Go言語の並行処理モデルと効率的な実行を支える基盤を提供します。ランタイムの多くの部分はGo言語自体で書かれていますが、OSとのインタラクションやパフォーマンスが重要な部分はC言語やアセンブリ言語で書かれています。

  2. ゴルーチン (Goroutine - G): Go言語における並行実行の単位です。OSスレッドよりもはるかに軽量で、数百万のゴルーチンを同時に実行することも可能です。Goランタイムがゴルーチンのスケジューリングと実行を管理します。各ゴルーチンは独自のスタックを持っています。

  3. マシン (Machine - M): OSスレッドを表します。Goランタイムは、OSスレッドを抽象化したMを管理し、その上でゴルーチンを実行します。MはOSスケジューラによってスケジューリングされます。

  4. プロセッサ (Processor - P): Mがゴルーチンを実行するために必要な論理プロセッサを表します。Pは、実行可能なゴルーチンのキューを保持し、Mにゴルーチンをディスパッチします。通常、Pの数はCPUのコア数に等しく設定されます。

  5. g0スタック (g0 stack): 各M(OSスレッド)には、そのM専用のシステムスタックが存在します。これをg0スタックと呼びます。Goランタイムの重要な内部処理(例えば、ゴルーチンのスケジューリング、ガベージコレクションのマークフェーズ、システムコールなど)は、ユーザーゴルーチンのスタックではなく、このg0スタック上で実行されます。これは、ユーザーゴルーチンのスタックが動的に伸縮するため、ランタイムの安定した実行環境を確保するためです。

  6. mcall関数: Goランタイムの内部関数で、現在実行中のゴルーチンのスタックから、そのゴルーチンを実行しているMのg0スタックに切り替えるために使用されます。このスタック切り替えは、ランタイムがゴルーチンの実行を一時停止し、低レベルのランタイム処理を実行するために不可欠です。

  7. badmcall / badmcall2関数: これらはGoランタイムのエラーハンドリング関数です。

    • badmcall: mcall関数が不正な状況で呼び出された場合にパニックを発生させます。具体的には、既にg0スタック上にいるにもかかわらずmcallが呼び出された場合などです。
    • badmcall2: mcallが呼び出した関数が、予期せずmcallに制御を戻してしまった場合にパニックを発生させます。mcallは通常、スタックを切り替えて別のゴルーチンを実行するか、ランタイムの内部処理を継続するため、呼び出した関数がmcallに「戻る」ことは想定されていません。
  8. スタックフレーム (Stack Frame): 関数が呼び出されるたびに、その関数のローカル変数、引数、および呼び出し元に戻るためのリターンアドレスなどがスタック上に確保されます。この領域をスタックフレームと呼びます。関数が終了すると、そのスタックフレームは解放されます。

  9. CALL命令とJMP命令 (Assembly Instructions): アセンブリ言語における制御フロー命令です。

    • CALL (Call): サブルーチンを呼び出す命令です。現在の命令の次のアドレス(リターンアドレス)をスタックにプッシュし、指定されたアドレスにジャンプします。これにより、サブルーチンが終了した後に元の場所に戻ることができます。新しいスタックフレームが作成されます。
    • JMP (Jump): 無条件に指定されたアドレスにジャンプする命令です。CALLとは異なり、リターンアドレスをスタックにプッシュしません。これにより、現在の実行コンテキストから完全に離れ、指定されたアドレスから実行を継続します。スタックフレームは作成されず、現在のスタックフレームがそのまま引き継がれるか、ジャンプ先のコードが新しいスタックフレームを構築する際に現在のフレームを上書きする形になります。
  10. スタックトレース (Stack Trace / Backtrace): プログラムがクラッシュしたり、エラーが発生したりした際に、その時点までの関数呼び出しの履歴(コールスタック)を表示するものです。デバッグにおいて、エラーがどの関数の連鎖によって引き起こされたのかを特定するために不可欠な情報です。スタックトレースは、スタック上に積まれたリターンアドレスやスタックフレームの情報から再構築されます。

技術的詳細

このコミットの核心は、CALL命令とJMP命令のセマンティクスの違いを利用して、スタックトレースの挙動を変更することにあります。

変更前 (CALLを使用): mcall関数内でbadmcallCALL命令で呼び出すと、以下のようになります。

  1. mcallが実行される。
  2. mcallのスタックフレームがアクティブになる。
  3. mcallCALL badmcallを実行する。
  4. badmcallのリターンアドレス(mcall内のCALL命令の次の命令のアドレス)がスタックにプッシュされる。
  5. badmcallのスタックフレームが作成され、実行がbadmcallに移る。
  6. badmcallがパニックを発生させる。
  7. スタックトレースが生成される際、スタックにはmcallのリターンアドレスが残っているため、トレースにはmcallのフレームが含まれてしまいます。これにより、mcallを呼び出した真の関数がトレースのより深い位置に隠れてしまうか、場合によっては表示されないことがあります。

変更後 (JMPを使用): mcall関数内でbadmcallJMP命令で呼び出すと、以下のようになります。

  1. mcallが実行される。
  2. mcallのスタックフレームがアクティブになる。
  3. mcallJMP badmcallを実行する。
  4. JMP命令はリターンアドレスをスタックにプッシュしない。
  5. 実行が直接badmcallに移る。この際、badmcallmcallのスタックフレームを「引き継ぐ」形になるか、あるいはbadmcall自身のスタックフレームを構築する際にmcallのスタックフレームを上書きします。重要なのは、mcallへのリターンアドレスがスタックに残らないことです。
  6. badmcallがパニックを発生させる。
  7. スタックトレースが生成される際、スタックにはmcallのリターンアドレスが存在しないため、トレースにはmcallのフレームは含まれません。その代わりに、mcallを呼び出した元の関数のフレームが直接badmcallのフレームの前に表示されるようになります。

この変更により、mcallが不正に呼び出された場合のエラー発生時に、スタックトレースが「mcallを呼び出したのは誰か」という、デバッグにおいて最も重要な情報を提供するようになります。これは、ランタイムの内部エラーの診断を大幅に改善するものです。

proc.cにおけるbadmcallbadmcall2の関数シグネチャの変更(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");
 }

badmcallbadmcall2の関数シグネチャが変更され、void (*fn)(G*)という関数ポインタを引数として受け取るようになりました。USED(fn)は、コンパイラが未使用の引数に関する警告を出さないようにするためのマクロです。TODO: print fn?というコメントは、将来的にこの引数を利用して、どの関数ポインタが不正なmcallを引き起こしたのかをデバッグ情報として出力する可能性があることを示唆しています。現時点では、この引数はパニックメッセージには影響を与えていません。

このコミットは、Goランタイムのデバッグ能力を向上させるための、低レベルながらも非常に効果的な改善です。

関連リンク

参考にした情報源リンク

  • Goのランタイムスケジューラに関するブログ記事やドキュメント(G, M, Pモデルの理解に役立つもの)
  • アセンブリ言語の命令セットリファレンス(CALL, JMP, BL, B命令の詳細)
  • スタックとスタックフレームに関するコンピュータサイエンスの基本的な教科書やオンラインリソース
  • Goのソースコード内のコメントや関連するコミット履歴
  • Goのメーリングリストやフォーラムでの議論(golang-devなど)