[インデックス 18672] ファイルの概要
このコミットは、Goランタイムにおけるスタック管理の根本的な変更を導入しています。具体的には、スタックの拡張(オーバーフロー時)と縮小(GC時)を、新しいサイズのスタックへのフレームのコピーによって行うように変更しています。これにより、従来のセグメント化されたスタック管理から、より効率的でガベージコレクションに優しいスタックモデルへの移行が図られています。
変更された主なファイルは以下の通りです。
src/pkg/runtime/malloc.h: メモリ割り当て関連のヘッダーファイル。SysFault関数の追加と、スタックフレームのレイアウトに関するコンパイラからの情報(BitVector、StackMap構造体)が追加されています。src/pkg/runtime/mem_darwin.c,src/pkg/runtime/mem_dragonfly.c,src/pkg/runtime/mem_freebsd.c,src/pkg/runtime/mem_linux.c,src/pkg/runtime/mem_netbsd.c,src/pkg/runtime/mem_openbsd.c,src/pkg/runtime/mem_plan9.c,src/pkg/runtime/mem_solaris.c,src/pkg/runtime/mem_windows.c: 各OS固有のメモリ管理ファイル。SysFault関数の実装が追加されています。src/pkg/runtime/mgc0.c: ガベージコレクションの主要なファイル。スタックマップ関連の定義がmalloc.hに移動し、scanframe関数の戻り値がboolに変更され、addstackroots内でスタックの縮小が呼び出されるようになりました。src/pkg/runtime/proc.c: プロセス(goroutine)管理のファイル。スタックコピーを有効にするruntime·copystack変数の導入と、gfput/gfgetにおける非標準スタックサイズの解放・再割り当てロジックが追加されています。src/pkg/runtime/runtime.h: ランタイムの主要なヘッダーファイル。runtime·copystackの宣言、runtime·gentracebackのコールバック関数の型変更、runtime·shrinkstackの宣言が追加されています。src/pkg/runtime/stack.c: スタック管理の主要なファイル。スタックの拡張・縮小ロジックの大部分が実装されています。StackDebug、StackFromSystem、StackFaultOnFreeといったデバッグ・設定用の定数が追加され、copystack、checkframecopy、adjustframe、adjustpointers、adjustctxt、adjustdefers、shrinkstackといった重要な関数が追加・変更されています。src/pkg/runtime/stack.h: スタック関連のヘッダーファイル。StackMinの値が変更されています。src/pkg/runtime/traceback_arm.c,src/pkg/runtime/traceback_x86.c: トレースバック関連のファイル。runtime·gentracebackのコールバック関数の型が変更され、コールバックがfalseを返した場合にトレースバックを停止するロジックが追加されています。
コミット
commit 1665b006a57099d7bdf5c9f1277784d36b7168d9
Author: Keith Randall <khr@golang.org>
Date: Wed Feb 26 23:28:44 2014 -0800
runtime: grow stack by copying
On stack overflow, if all frames on the stack are
copyable, we copy the frames to a new stack twice
as large as the old one. During GC, if a G is using
less than 1/4 of its stack, copy the stack to a stack
half its size.
TODO
- Do something about C frames. When a C frame is in the
stack segment, it isn't copyable. We allocate a new segment
in this case.
- For idempotent C code, we can abort it, copy the stack,
then retry. I'm working on a separate CL for this.
- For other C code, we can raise the stackguard
to the lowest Go frame so the next call that Go frame
makes triggers a copy, which will then succeed.
- Pick a starting stack size?
The plan is that eventually we reach a point where the
stack contains only copyable frames.
LGTM=rsc
R=dvyukov, rsc
CC=golang-codereviews
https://golang.org/cl/54650044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/1665b006a57099d7bdf5c9f1277784d36b7168d9
元コミット内容
このコミットは、Goランタイムにおけるスタックの管理方法を根本的に変更するものです。スタックオーバーフローが発生した場合、スタック上のすべてのフレームがコピー可能であれば、現在のスタックの2倍の大きさの新しいスタックにフレームをコピーします。また、ガベージコレクション(GC)中に、あるGoroutineがそのスタックの1/4未満しか使用していない場合、スタックを半分のサイズの新しいスタックにコピーして縮小します。
この変更にはいくつかのTODO項目が残されています。特に、C言語で書かれたフレーム(Cフレーム)の扱いが課題です。Cフレームがスタックセグメント内にある場合、それらはコピーできません。この場合、新しいセグメントが割り当てられます。将来的には、冪等なCコードに対しては、処理を中断し、スタックをコピーしてから再試行するアプローチが検討されています。その他のCコードについては、スタックガードを最も低いGoフレームまで引き上げ、次のGoフレームからの呼び出しがコピーをトリガーするようにすることで、コピーが成功するようにする計画があります。また、初期スタックサイズの選択についても検討が必要です。
最終的な目標は、スタックがコピー可能なフレームのみで構成される状態に到達することです。
変更の背景
Go言語の初期のランタイムでは、スタックは「セグメント化されたスタック(segmented stacks)」として実装されていました。これは、必要に応じて小さなスタックセグメントを動的に割り当て、それらをリンクしてスタックを拡張する方式です。この方式の利点は、初期スタックサイズを小さくできるため、多数のGoroutineを起動してもメモリ消費を抑えられる点にありました。しかし、セグメント化されたスタックにはいくつかの欠点がありました。
- スタックの分割によるパフォーマンスオーバーヘッド: 関数呼び出しのたびに、現在のスタックセグメントの残りの容量をチェックし、必要であれば新しいセグメントに切り替えるためのオーバーヘッドが発生しました。これは特に、深い再帰呼び出しや、スタックを頻繁に拡張・縮小するようなワークロードにおいて顕著でした。
- ガベージコレクションの複雑性: セグメント化されたスタックは、メモリ上に連続していないため、ガベージコレクタがスタックをスキャンする際に複雑なロジックが必要でした。ポインタの調整もセグメントをまたぐ可能性があり、効率を低下させる要因となっていました。
- Cgoとの相互運用性の問題: C言語のコード(Cgoを介して呼び出されるもの)は、Goランタイムのスタック管理とは異なるスタックモデルを使用します。セグメント化されたスタックとCスタックの間の切り替えや、CコードがGoスタックにポインタを持つ場合の管理が複雑でした。特に、CコードがGoスタックの途中に割り込むと、そのGoスタックセグメントはコピー不可能となり、スタックの移動が困難になる問題がありました。
このコミットは、これらの問題を解決するための一歩として、スタックの拡張と縮小を「コピー」によって行う方式を導入しています。これは、将来的にはセグメント化されたスタックを廃止し、よりシンプルで効率的な「連続したスタック(contiguous stacks)」モデルへの移行を目指すための重要な中間ステップでした。スタックをコピーすることで、メモリ上の連続性を保ちつつ、必要に応じてスタックサイズを動的に調整できるようになります。これにより、ガベージコレクションの効率化、スタックオーバーフロー時の処理の単純化、そしてCgoとの相互運用性の改善が期待されました。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念とOSレベルのメモリ管理に関する知識が必要です。
1. Goroutineとスタック
- Goroutine: Go言語における軽量な実行単位です。OSのスレッドよりもはるかに軽量で、数百万のGoroutineを同時に実行することも可能です。各Goroutineは独自のスタックを持っています。
- スタック: 関数呼び出しの際に、ローカル変数、関数引数、戻りアドレスなどが格納されるメモリ領域です。GoのGoroutineスタックは、プログラムの実行中に動的にサイズが変更される可能性があります。
2. ガベージコレクション (GC)
- ガベージコレクション: プログラムが不要になったメモリを自動的に解放する仕組みです。GoのGCは、到達可能性(reachability)に基づいて動作します。つまり、プログラムから到達可能なオブジェクトは「生きている」と判断され、それ以外は「死んでいる」と判断されて解放されます。
- スタックスキャン: GCの重要なフェーズの一つで、Goroutineのスタックをスキャンして、スタック上に存在するポインタ(ヒープ上のオブジェクトを指すもの)を特定し、それらのオブジェクトを「生きている」とマークします。スタック上のポインタが正確に識別できないと、誤って使用中のメモリが解放されたり、解放済みのメモリが使用されたりする可能性があります。
- ポインタマップ (Pointer Maps): コンパイラによって生成されるメタデータで、スタックフレーム内のどの位置にポインタが存在するかを示す情報です。これにより、GCはスタックを正確にスキャンし、ポインタのみを追跡することができます。このコミットで導入される
StackMapやBitVectorは、このポインタマップの情報を表現するための構造体です。
3. OSレベルのメモリ管理
mmap(memory map): Unix系OSにおけるシステムコールで、ファイルやデバイス、または匿名メモリ領域をプロセスの仮想アドレス空間にマッピングするために使用されます。メモリを確保したり、既存のメモリ領域の保護属性を変更したりする際に利用されます。munmap(memory unmap):mmapによってマッピングされたメモリ領域のマッピングを解除するシステムコールです。PROT_NONE:mmapやmprotect(WindowsのVirtualProtectに相当)で指定できる保護フラグの一つで、メモリ領域へのアクセスを完全に禁止します。このフラグを設定されたメモリ領域にアクセスしようとすると、セグメンテーション違反(segmentation fault)などのエラーが発生します。デバッグ目的や、使用済みメモリの不正アクセスを検出するために利用されることがあります。VirtualAlloc(Windows): Windows APIの一つで、プロセスの仮想アドレス空間にメモリ領域を予約またはコミットするために使用されます。Unix系のmmapに相当します。VirtualProtect(Windows): Windows APIの一つで、仮想アドレス空間内のコミットされたページ領域の保護オプションを変更するために使用されます。Unix系のmprotectに相当します。このコミットで追加されたSysFault関数は、WindowsではVirtualProtectを使用してメモリ領域をPAGE_NOACCESS(PROT_NONEに相当)に設定します。
4. CgoとCフレーム
- Cgo: GoプログラムからC言語のコードを呼び出すためのGoの機能です。
- Cフレーム: Cgoを介してC関数が呼び出された際に、Cスタック上に積まれるスタックフレームです。GoランタイムはCスタックの構造を直接管理しないため、Cフレーム内にGoのポインタが存在する場合や、CフレームがGoスタックの途中に割り込む場合に、スタックの移動やGCスキャンが複雑になります。コミットメッセージのTODOにあるように、Cフレームの扱いはスタックコピー方式における主要な課題の一つです。
技術的詳細
このコミットの核となる技術的詳細は、GoランタイムがどのようにGoroutineのスタックを動的に拡張・縮小し、その際にスタック上のポインタを正確に調整するかという点にあります。
1. スタックの拡張 (Grow Stack)
従来のセグメント化されたスタックでは、スタックオーバーフローが発生すると新しい小さなセグメントが割り当てられ、既存のセグメントにリンクされていました。このコミットでは、この挙動が変更されます。
- コピー可能なフレームの検出:
runtime·newstack関数内で、スタックの最上位セグメントにあるすべてのフレームが「コピー可能」であるかどうかがcopyabletopsegment関数によってチェックされます。- 「コピー可能」とは、そのスタックフレーム内に存在するポインタが、スタックの移動に伴って正確に調整できることを意味します。これは、コンパイラが生成するポインタマップ情報(
FUNCDATA_LocalsPointerMapsやFUNCDATA_ArgsPointerMaps)が利用可能であることに依存します。 runtime·main関数(Goランタイムの初期化部分でCで書かれている)のフレームは特別にコピー可能とみなされます。これは、そのフレームが他のスタック位置へのポインタを持たず、唯一のポインタ(deferチェーンからのもの)がスタックコピー中に明示的に処理されるためです。- Cフレームは一般的にコピー不可能とされます。これは、Cフレーム内のポインタの位置がGoランタイムには不明であり、またグローバル変数やヒープ上の値がCフレーム内を指している可能性があるためです。Cフレームが存在する場合、スタックはコピーされず、代わりに新しいスタックセグメントが割り当てられる従来の挙動が維持されます。
- 「コピー可能」とは、そのスタックフレーム内に存在するポインタが、スタックの移動に伴って正確に調整できることを意味します。これは、コンパイラが生成するポインタマップ情報(
- スタックのコピー: すべてのフレームがコピー可能であると判断された場合、現在のスタックサイズの2倍の新しいスタックが割り当てられます。
copystack関数が呼び出され、古いスタックの内容が新しいスタックにコピーされます。- この際、古いスタック上のポインタ(ローカル変数、引数、deferレコード、Goroutineのコンテキストなど)は、新しいスタック上の対応する位置を指すように調整されます。この調整は、
adjustframe、adjustpointers、adjustctxt、adjustdefersといった関数によって行われます。 - ポインタの調整には、コンパイラが生成した
StackMapとBitVectorが使用されます。これらは、スタックフレーム内のどのオフセットにポインタが存在するかを示すビットマップ情報を提供します。
- 古いスタックの解放: コピーが完了し、ポインタがすべて調整された後、古いスタックメモリは解放されます。デバッグ目的で
StackFaultOnFreeが有効な場合、解放されたメモリ領域はPROT_NONEで保護され、不正なアクセスを検出できるようになります。
2. スタックの縮小 (Shrink Stack)
ガベージコレクションの際に、Goroutineが割り当てられたスタック領域の大部分を使用していない場合、スタックを縮小してメモリを解放します。
- 縮小の条件:
runtime·shrinkstack関数は、GC中にaddstackrootsから呼び出されます。- 現在のスタックサイズが最小スタックサイズ(
FixedStack)の2倍より小さい場合は縮小しません。 - スタックの使用量が現在のスタックサイズの1/4未満である場合に縮小を試みます。
- 現在のスタックサイズが最小スタックサイズ(
- コピー可能なフレームの検出: 拡張時と同様に、
copyabletopsegment関数によってスタックの最上位セグメントがコピー可能であるかどうかがチェックされます。 - スタックのコピー: コピー可能であれば、現在のスタックサイズの半分の新しいスタックが割り当てられ、
copystack関数によって内容がコピーされ、ポインタが調整されます。 - 古いスタックの解放: 古いスタックメモリは解放されます。
3. ポインタの調整メカニズム
スタックコピーの最も重要な側面は、スタック上のポインタを正確に調整することです。
StackMapとBitVector:StackMapは、特定のPC値(プログラムカウンタ)に対応するスタックフレームのポインタマップ情報へのインデックスを保持します。BitVectorは、スタックフレーム内のポインタのオフセットを示すビットマップデータです。例えば、BitsPerPointerが2の場合、各ポインタスロットは2ビットで表現され、BitsNoPointer(ポインタなし)、BitsPointer(通常のポインタ)、BitsIface(インターフェース値のポインタ)、BitsEface(空インターフェース値のポインタ)などの種類を示します。
adjustpointers関数: この関数は、BitVectorの情報に基づいて、指定されたメモリ領域(スタックフレームの一部)をスキャンし、ポインタと識別された値が古いスタック領域を指している場合、その値を新しいスタック領域内の対応する位置を指すようにdelta(新旧スタックのベースアドレスの差分)分だけ加算して調整します。adjustframe関数: 各スタックフレームに対して呼び出され、そのフレームのローカル変数と引数/戻り値領域内のポインタをadjustpointersを使って調整します。adjustctxtとadjustdefers関数: Goroutineのコンテキスト(gp->sched.ctxt)やdeferレコードもスタック上のポインタを持つ可能性があるため、これらも同様に調整されます。特にdeferレコードは、遅延実行される関数の引数やクロージャがスタック上の値をキャプチャしている場合があるため、そのポインタも調整が必要です。
4. SysFaultの導入
SysFaultは、特定のメモリ領域をアクセス不可(PROT_NONE)に設定する新しいシステムコールラッパーです。これは主にデバッグ目的で使用され、解放されたスタックメモリへの不正なアクセスを検出するためにStackFaultOnFreeが有効な場合に利用されます。これにより、use-after-freeバグの特定が容易になります。
5. Cフレームの課題
コミットメッセージにも明記されているように、Cフレームの扱いはこのスタックコピー方式の主要な課題です。CフレームはGoランタイムがポインタマップ情報を持たないため、コピー可能と判断できません。このため、Cフレームがスタックの最上位セグメントに存在する場合、スタックコピーは行われず、従来のセグメント割り当て方式がフォールバックとして使用されます。将来的には、Cフレームの特性(冪等性など)に応じて、より洗練されたハンドリングが検討されています。
コアとなるコードの変更箇所
このコミットにおけるコアとなるコードの変更は、主にsrc/pkg/runtime/stack.cに集中しています。
-
src/pkg/runtime/stack.c:StackDebug,StackFromSystem,StackFaultOnFreeといったデバッグ/設定用の定数が追加されました。copyabletopsegment関数が追加され、スタックの最上位セグメントがコピー可能かどうかを判断します。adjustpointers関数が追加され、BitVectorに基づいてスタック上のポインタを調整します。adjustframe関数が追加され、個々のスタックフレーム内のポインタを調整します。adjustctxt関数が追加され、Goroutineのコンテキスト内のポインタを調整します。adjustdefers関数が追加され、deferレコード内のポインタを調整します。copystack関数が追加され、実際にスタックの内容を新しい場所にコピーし、関連するポインタを調整する主要なロジックを実装しています。runtime·newstack関数が大幅に修正され、スタックオーバーフロー時にcopystackを呼び出してスタックを拡張するロジックが追加されました。runtime·shrinkstack関数が追加され、GC時にスタックの使用量が少ない場合にスタックを縮小するロジックを実装しています。
-
src/pkg/runtime/proc.c:runtime·copystackという新しいグローバル変数が導入され、スタックコピー機能の有効/無効を制御します。これはGOCOPYSTACK環境変数によって設定可能です。gfputとgfget関数(Goroutineのフリーリスト管理)が変更され、非標準サイズのスタックを持つGoroutineのスタックを解放・再割り当てするロジックが追加されました。これは、スタックコピーによってGoroutineのスタックサイズが変更される可能性があるためです。
-
src/pkg/runtime/malloc.h:SysFault関数の宣言が追加されました。BitVectorとStackMap構造体の定義が追加され、コンパイラが生成するポインタマップ情報を表現します。
-
src/pkg/runtime/mgc0.c:scanframe関数のシグネチャが変更され、boolを返すようになりました。これは、トレースバック中にコピー不可能なフレームを検出した場合にスキャンを早期に終了させるためです。addstackroots関数内でruntime·shrinkstack(gp)が呼び出されるようになり、GC中にスタックの縮小がトリガーされるようになりました。
-
OS固有のメモリ管理ファイル (
src/pkg/runtime/mem_*.c):- 各OS(Darwin, Dragonfly, FreeBSD, Linux, NetBSD, OpenBSD, Plan 9, Solaris, Windows)のメモリ管理ファイルに
runtime·SysFault関数の実装が追加されました。これは、mmap(Unix系)またはVirtualProtect(Windows)を使用してメモリ領域をアクセス不可に設定します。
- 各OS(Darwin, Dragonfly, FreeBSD, Linux, NetBSD, OpenBSD, Plan 9, Solaris, Windows)のメモリ管理ファイルに
コアとなるコードの解説
ここでは、主要な変更点であるsrc/pkg/runtime/stack.c内の関数を中心に解説します。
typedef struct BitVector BitVector; と typedef struct StackMap StackMap;
これらは、コンパイラが生成するスタックフレームのポインタマップ情報を表現するための構造体です。
BitVector: スタックフレーム内のポインタの有無や種類を示すビットマップデータ。nはビット数、dataはビットデータを格納する配列です。StackMap: 特定のPC値に対応するBitVectorへのインデックスを保持します。
static int32 copyabletopsegment(G *gp)
この関数は、与えられたGoroutine gp のスタックの最上位セグメントがコピー可能であるかどうかをチェックし、コピー可能であればそのセグメント内のフレーム数を返します。コピー不可能であれば -1 を返します。
内部ではruntime·gentracebackを呼び出し、各フレームに対してcheckframecopyコールバック関数を実行します。
static bool checkframecopy(Stkframe *frame, void *arg)
copyabletopsegmentから呼び出されるコールバック関数です。各スタックフレームがコピー可能であるかを判断します。
runtime·mainのフレームは特別にコピー可能とみなされます。- Goのフレームについては、ローカル変数や引数/戻り値のポインタマップ情報(
FUNCDATA_LocalsPointerMaps,FUNCDATA_ArgsPointerMaps)が存在するかどうかを確認します。これらの情報がない場合、フレームはコピー不可能と判断されます。 - Cフレームは一般的にコピー不可能と判断されます。
static void adjustpointers(byte **scanp, BitVector *bv, AdjustInfo *adjinfo, Func *f)
この関数は、BitVector bv で記述されたメモリ領域 scanp をスキャンし、その中に含まれるポインタを調整します。
adjinfoには、古いスタックの範囲(oldstk,oldbase)と、新しいスタックへのオフセット(delta)が含まれます。bvのビット情報に基づいて、scanpが指すメモリ位置がポインタであると判断された場合、そのポインタが古いスタック領域を指していれば、delta分だけ値を加算して新しいスタック領域を指すように変更します。- インターフェース値(
BitsIface,BitsEface)についても、その内部のデータポインタが調整されます。
static bool adjustframe(Stkframe *frame, void *arg)
copystackから呼び出されるコールバック関数で、個々のスタックフレーム内のポインタを調整します。
- フレームのローカル変数領域と引数/戻り値領域に対して、それぞれ対応するポインタマップ(
FUNCDATA_LocalsPointerMaps,FUNCDATA_ArgsPointerMaps)を取得し、adjustpointersを呼び出してポインタを調整します。
static void adjustctxt(G *gp, AdjustInfo *adjinfo)
Goroutineのコンテキスト(gp->sched.ctxt)が古いスタック領域内のアドレスを指している場合、adjinfo->delta分だけ調整します。
static void adjustdefers(G *gp, AdjustInfo *adjinfo)
Goroutineのdeferチェーンを走査し、deferレコード自体や、遅延実行される関数の引数、クロージャが古いスタック領域内のアドレスを指している場合、それらのポインタを調整します。
static void copystack(G *gp, uintptr nframes, uintptr newsize)
スタックコピーの主要な関数です。
- 新しいサイズのスタックメモリを
runtime·stackallocで割り当てます。 runtime·gentracebackを呼び出し、adjustframeコールバックを使って、コピー対象のフレーム内のポインタを新しいスタック位置に合わせて調整します。adjustctxtとadjustdefersを呼び出し、Goroutineのコンテキストとdeferレコード内のポインタを調整します。- 古いスタックの内容を新しいスタックに
runtime·memmoveでコピーします。 - Goroutineのスタック関連のフィールド(
stackbase,stackguard,stackguard0,sched.spなど)を新しいスタックの情報に更新します。 - 古いスタックメモリを
runtime·stackfreeで解放します。
void runtime·newstack(void)
スタックオーバーフロー時に呼び出される関数です。
runtime·copystackが有効で、かつスタックの最上位セグメントがコピー可能であれば、copystackを呼び出してスタックを拡張します。- コピーができない場合(Cフレームが存在するなど)は、従来のセグメント割り当て方式で新しいスタックセグメントを割り当てます。
void runtime·shrinkstack(G *gp)
ガベージコレクション中に呼び出され、Goroutineのスタックを縮小します。
- スタックの使用量が少ない(1/4未満)かつ、スタックが最小サイズより大きい場合に縮小を試みます。
copyabletopsegmentでコピー可能であることを確認し、コピー可能であればcopystackを呼び出してスタックを半分のサイズに縮小します。
void runtime·SysFault(void *v, uintptr n)
OS固有のメモリ管理ファイルに実装された関数で、指定されたメモリ領域 v から n バイトをアクセス不可に設定します。
- Unix系OSでは
mmapをPROT_NONEフラグ付きで呼び出します。 - Windowsでは
VirtualProtectをPAGE_NOACCESSフラグ付きで呼び出します。 - これは主にデバッグ目的で、解放されたスタックメモリへの不正なアクセスを検出するために使用されます。
関連リンク
- Go CL 54650044: https://golang.org/cl/54650044
参考にした情報源リンク
- Goのスタック管理の歴史と進化に関する公式ドキュメントやブログ記事(特に、セグメント化されたスタックから連続したスタックへの移行に関するもの)。
- Goのガベージコレクションの仕組みに関するドキュメント。
- OSのメモリ管理(
mmap,VirtualAlloc,VirtualProtectなど)に関する一般的な情報。 - Goのランタイムソースコードの他の部分(特に
runtime/stack.cの変更前後のバージョン)。 - GoのIssueトラッカーで、スタック管理やCgoに関する議論。
(注:具体的なURLは、検索結果に基づいて適宜追加してください。上記は一般的な情報源のカテゴリです。)# [インデックス 18672] ファイルの概要
このコミットは、Goランタイムにおけるスタック管理の根本的な変更を導入しています。具体的には、スタックの拡張(オーバーフロー時)と縮小(GC時)を、新しいサイズのスタックへのフレームのコピーによって行うように変更しています。これにより、従来のセグメント化されたスタック管理から、より効率的でガベージコレクションに優しいスタックモデルへの移行が図られています。
変更された主なファイルは以下の通りです。
src/pkg/runtime/malloc.h: メモリ割り当て関連のヘッダーファイル。SysFault関数の追加と、スタックフレームのレイアウトに関するコンパイラからの情報(BitVector、StackMap構造体)が追加されています。src/pkg/runtime/mem_darwin.c,src/pkg/runtime/mem_dragonfly.c,src/pkg/runtime/mem_freebsd.c,src/pkg/runtime/mem_linux.c,src/pkg/runtime/mem_netbsd.c,src/pkg/runtime/mem_openbsd.c,src/pkg/runtime/mem_plan9.c,src/pkg/runtime/mem_solaris.c,src/pkg/runtime/mem_windows.c: 各OS固有のメモリ管理ファイル。SysFault関数の実装が追加されています。src/pkg/runtime/mgc0.c: ガベージコレクションの主要なファイル。スタックマップ関連の定義がmalloc.hに移動し、scanframe関数の戻り値がboolに変更され、addstackroots内でスタックの縮小が呼び出されるようになりました。src/pkg/runtime/proc.c: プロセス(goroutine)管理のファイル。スタックコピーを有効にするruntime·copystack変数の導入と、gfput/gfgetにおける非標準スタックサイズの解放・再割り当てロジックが追加されています。src/pkg/runtime/runtime.h: ランタイムの主要なヘッダーファイル。runtime·copystackの宣言、runtime·gentracebackのコールバック関数の型変更、runtime·shrinkstackの宣言が追加されています。src/pkg/runtime/stack.c: スタック管理の主要なファイル。スタックの拡張・縮小ロジックの大部分が実装されています。StackDebug、StackFromSystem、StackFaultOnFreeといったデバッグ・設定用の定数が追加され、copystack、checkframecopy、adjustframe、adjustpointers、adjustctxt、adjustdefers、shrinkstackといった重要な関数が追加・変更されています。src/pkg/runtime/stack.h: スタック関連のヘッダーファイル。StackMinの値が変更されています。src/pkg/runtime/traceback_arm.c,src/pkg/runtime/traceback_x86.c: トレースバック関連のファイル。runtime·gentracebackのコールバック関数の型が変更され、コールバックがfalseを返した場合にトレースバックを停止するロジックが追加されています。
コミット
commit 1665b006a57099d7bdf5c9f1277784d36b7168d9
Author: Keith Randall <khr@golang.org>
Date: Wed Feb 26 23:28:44 2014 -0800
runtime: grow stack by copying
On stack overflow, if all frames on the stack are
copyable, we copy the frames to a new stack twice
as large as the old one. During GC, if a G is using
less than 1/4 of its stack, copy the stack to a stack
half its size.
TODO
- Do something about C frames. When a C frame is in the
stack segment, it isn't copyable. We allocate a new segment
in this case.
- For idempotent C code, we can abort it, copy the stack,
then retry. I'm working on a separate CL for this.
- For other C code, we can raise the stackguard
to the lowest Go frame so the next call that Go frame
makes triggers a copy, which will then succeed.
- Pick a starting stack size?
The plan is that eventually we reach a point where the
stack contains only copyable frames.
LGTM=rsc
R=dvyukov, rsc
CC=golang-codereviews
https://golang.org/cl/54650044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/1665b006a57099d7bdf5c9f1277784d36b7168d9
元コミット内容
このコミットは、Goランタイムにおけるスタックの管理方法を根本的に変更するものです。スタックオーバーフローが発生した場合、スタック上のすべてのフレームがコピー可能であれば、現在のスタックの2倍の大きさの新しいスタックにフレームをコピーします。また、ガベージコレクション(GC)中に、あるGoroutineがそのスタックの1/4未満しか使用していない場合、スタックを半分のサイズの新しいスタックにコピーして縮小します。
この変更にはいくつかのTODO項目が残されています。特に、C言語で書かれたフレーム(Cフレーム)の扱いが課題です。Cフレームがスタックセグメント内にある場合、それらはコピーできません。この場合、新しいセグメントが割り当てられます。将来的には、冪等なCコードに対しては、処理を中断し、スタックをコピーしてから再試行するアプローチが検討されています。その他のCコードについては、スタックガードを最も低いGoフレームまで引き上げ、次のGoフレームからの呼び出しがコピーをトリガーするようにすることで、コピーが成功するようにする計画があります。また、初期スタックサイズの選択についても検討が必要です。
最終的な目標は、スタックがコピー可能なフレームのみで構成される状態に到達することです。
変更の背景
Go言語の初期のランタイムでは、スタックは「セグメント化されたスタック(segmented stacks)」として実装されていました。これは、必要に応じて小さなスタックセグメントを動的に割り当て、それらをリンクしてスタックを拡張する方式です。この方式の利点は、初期スタックサイズを小さくできるため、多数のGoroutineを起動してもメモリ消費を抑えられる点にありました。しかし、セグメント化されたスタックにはいくつかの欠点がありました。
- スタックの分割によるパフォーマンスオーバーヘッド: 関数呼び出しのたびに、現在のスタックセグメントの残りの容量をチェックし、必要であれば新しいセグメントに切り替えるためのオーバーヘッドが発生しました。これは特に、深い再帰呼び出しや、スタックを頻繁に拡張・縮小するようなワークロードにおいて顕著でした。
- ガベージコレクションの複雑性: セグメント化されたスタックは、メモリ上に連続していないため、ガベージコレクタがスタックをスキャンする際に複雑なロジックが必要でした。ポインタの調整もセグメントをまたぐ可能性があり、効率を低下させる要因となっていました。
- Cgoとの相互運用性の問題: C言語のコード(Cgoを介して呼び出されるもの)は、Goランタイムのスタック管理とは異なるスタックモデルを使用します。セグメント化されたスタックとCスタックの間の切り替えや、CコードがGoスタックにポインタを持つ場合の管理が複雑でした。特に、CコードがGoスタックの途中に割り込むと、そのGoスタックセグメントはコピー不可能となり、スタックの移動が困難になる問題がありました。
このコミットは、これらの問題を解決するための一歩として、スタックの拡張と縮小を「コピー」によって行う方式を導入しています。これは、将来的にはセグメント化されたスタックを廃止し、よりシンプルで効率的な「連続したスタック(contiguous stacks)」モデルへの移行を目指すための重要な中間ステップでした。スタックをコピーすることで、メモリ上の連続性を保ちつつ、必要に応じてスタックサイズを動的に調整できるようになります。これにより、ガベージコレクションの効率化、スタックオーバーフロー時の処理の単純化、そしてCgoとの相互運用性の改善が期待されました。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念とOSレベルのメモリ管理に関する知識が必要です。
1. Goroutineとスタック
- Goroutine: Go言語における軽量な実行単位です。OSのスレッドよりもはるかに軽量で、数百万のGoroutineを同時に実行することも可能です。各Goroutineは独自のスタックを持っています。
- スタック: 関数呼び出しの際に、ローカル変数、関数引数、戻りアドレスなどが格納されるメモリ領域です。GoのGoroutineスタックは、プログラムの実行中に動的にサイズが変更される可能性があります。
2. ガベージコレクション (GC)
- ガベージコレクション: プログラムが不要になったメモリを自動的に解放する仕組みです。GoのGCは、到達可能性(reachability)に基づいて動作します。つまり、プログラムから到達可能なオブジェクトは「生きている」と判断され、それ以外は「死んでいる」と判断されて解放されます。
- スタックスキャン: GCの重要なフェーズの一つで、Goroutineのスタックをスキャンして、スタック上に存在するポインタ(ヒープ上のオブジェクトを指すもの)を特定し、それらのオブジェクトを「生きている」とマークします。スタック上のポインタが正確に識別できないと、誤って使用中のメモリが解放されたり、解放済みのメモリが使用されたりする可能性があります。
- ポインタマップ (Pointer Maps): コンパイラによって生成されるメタデータで、スタックフレーム内のどの位置にポインタが存在するかを示す情報です。これにより、GCはスタックを正確にスキャンし、ポインタのみを追跡することができます。このコミットで導入される
StackMapやBitVectorは、このポインタマップの情報を表現するための構造体です。
3. OSレベルのメモリ管理
mmap(memory map): Unix系OSにおけるシステムコールで、ファイルやデバイス、または匿名メモリ領域をプロセスの仮想アドレス空間にマッピングするために使用されます。メモリを確保したり、既存のメモリ領域の保護属性を変更したりする際に利用されます。munmap(memory unmap):mmapによってマッピングされたメモリ領域のマッピングを解除するシステムコールです。PROT_NONE:mmapやmprotect(WindowsのVirtualProtectに相当)で指定できる保護フラグの一つで、メモリ領域へのアクセスを完全に禁止します。このフラグを設定されたメモリ領域にアクセスしようとすると、セグメンテーション違反(segmentation fault)などのエラーが発生します。デバッグ目的や、使用済みメモリの不正アクセスを検出するために利用されることがあります。VirtualAlloc(Windows): Windows APIの一つで、プロセスの仮想アドレス空間にメモリ領域を予約またはコミットするために使用されます。Unix系のmmapに相当します。VirtualProtect(Windows): Windows APIの一つで、仮想アドレス空間内のコミットされたページ領域の保護オプションを変更するために使用されます。Unix系のmprotectに相当します。このコミットで追加されたSysFault関数は、WindowsではVirtualProtectを使用してメモリ領域をPAGE_NOACCESS(PROT_NONEに相当)に設定します。
4. CgoとCフレーム
- Cgo: GoプログラムからC言語のコードを呼び出すためのGoの機能です。
- Cフレーム: Cgoを介してC関数が呼び出された際に、Cスタック上に積まれるスタックフレームです。GoランタイムはCスタックの構造を直接管理しないため、Cフレーム内にGoのポインタが存在する場合や、CフレームがGoスタックの途中に割り込む場合に、スタックの移動やGCスキャンが複雑になります。コミットメッセージのTODOにあるように、Cフレームの扱いはスタックコピー方式における主要な課題の一つです。
技術的詳細
このコミットの核となる技術的詳細は、GoランタイムがどのようにGoroutineのスタックを動的に拡張・縮小し、その際にスタック上のポインタを正確に調整するかという点にあります。
1. スタックの拡張 (Grow Stack)
従来のセグメント化されたスタックでは、スタックオーバーフローが発生すると新しい小さなセグメントが割り当てられ、既存のセグメントにリンクされていました。このコミットでは、この挙動が変更されます。
- コピー可能なフレームの検出:
runtime·newstack関数内で、スタックの最上位セグメントにあるすべてのフレームが「コピー可能」であるかどうかがcopyabletopsegment関数によってチェックされます。- 「コピー可能」とは、そのスタックフレーム内に存在するポインタが、スタックの移動に伴って正確に調整できることを意味します。これは、コンパイラが生成するポインタマップ情報(
FUNCDATA_LocalsPointerMapsやFUNCDATA_ArgsPointerMaps)が利用可能であることに依存します。 runtime·main関数(Goランタイムの初期化部分でCで書かれている)のフレームは特別にコピー可能とみなされます。これは、そのフレームが他のスタック位置へのポインタを持たず、唯一のポインタ(deferチェーンからのもの)がスタックコピー中に明示的に処理されるためです。- Cフレームは一般的にコピー不可能とされます。これは、Cフレーム内のポインタの位置がGoランタイムには不明であり、またグローバル変数やヒープ上の値がCフレーム内を指している可能性があるためです。Cフレームが存在する場合、スタックはコピーされず、代わりに新しいスタックセグメントが割り当てられる従来の挙動が維持されます。
- 「コピー可能」とは、そのスタックフレーム内に存在するポインタが、スタックの移動に伴って正確に調整できることを意味します。これは、コンパイラが生成するポインタマップ情報(
- スタックのコピー: すべてのフレームがコピー可能であると判断された場合、現在のスタックサイズの2倍の新しいスタックが割り当てられます。
copystack関数が呼び出され、古いスタックの内容が新しいスタックにコピーされます。- この際、古いスタック上のポインタ(ローカル変数、引数、deferレコード、Goroutineのコンテキストなど)は、新しいスタック上の対応する位置を指すように調整されます。この調整は、
adjustframe、adjustpointers、adjustctxt、adjustdefersといった関数によって行われます。 - ポインタの調整には、コンパイラが生成した
StackMapとBitVectorが使用されます。これらは、スタックフレーム内のどのオフセットにポインタが存在するかを示すビットマップ情報を提供します。
- 古いスタックの解放: コピーが完了し、ポインタがすべて調整された後、古いスタックメモリは解放されます。デバッグ目的で
StackFaultOnFreeが有効な場合、解放されたメモリ領域はPROT_NONEで保護され、不正なアクセスを検出できるようになります。
2. スタックの縮小 (Shrink Stack)
ガベージコレクションの際に、Goroutineが割り当てられたスタック領域の大部分を使用していない場合、スタックを縮小してメモリを解放します。
- 縮小の条件:
runtime·shrinkstack関数は、GC中にaddstackrootsから呼び出されます。- 現在のスタックサイズが最小スタックサイズ(
FixedStack)の2倍より小さい場合は縮小しません。 - スタックの使用量が現在のスタックサイズの1/4未満である場合に縮小を試みます。
- 現在のスタックサイズが最小スタックサイズ(
- コピー可能なフレームの検出: 拡張時と同様に、
copyabletopsegment関数によってスタックの最上位セグメントがコピー可能であるかどうかがチェックされます。 - スタックのコピー: コピー可能であれば、現在のスタックサイズの半分の新しいスタックが割り当てられ、
copystack関数によって内容がコピーされ、ポインタが調整されます。 - 古いスタックの解放: 古いスタックメモリは解放されます。
3. ポインタの調整メカニズム
スタックコピーの最も重要な側面は、スタック上のポインタを正確に調整することです。
StackMapとBitVector:StackMapは、特定のPC値(プログラムカウンタ)に対応するスタックフレームのポインタマップ情報へのインデックスを保持します。BitVectorは、スタックフレーム内のポインタのオフセットを示すビットマップデータです。例えば、BitsPerPointerが2の場合、各ポインタスロットは2ビットで表現され、BitsNoPointer(ポインタなし)、BitsPointer(通常のポインタ)、BitsIface(インターフェース値のポインタ)、BitsEface(空インターフェース値のポインタ)などの種類を示します。
adjustpointers関数: この関数は、BitVectorbvで記述されたメモリ領域scanpをスキャンし、その中に含まれるポインタを調整します。adjinfoには、古いスタックの範囲(oldstk,oldbase)と、新しいスタックへのオフセット(delta)が含まれます。bvのビット情報に基づいて、scanpが指すメモリ位置がポインタであると判断された場合、そのポインタが古いスタック領域を指していれば、delta分だけ値を加算して新しいスタック領域を指すように変更します。- インターフェース値(
BitsIface,BitsEface)についても、その内部のデータポインタが調整されます。 adjustframe関数: 各スタックフレームに対して呼び出され、そのフレームのローカル変数と引数/戻り値領域内のポインタをadjustpointersを使って調整します。adjustctxtとadjustdefers関数: Goroutineのコンテキスト(gp->sched.ctxt)やdeferレコードもスタック上のポインタを持つ可能性があるため、これらも同様に調整されます。特にdeferレコードは、遅延実行される関数の引数やクロージャがスタック上の値をキャプチャしている場合があるため、そのポインタも調整が必要です。
4. SysFaultの導入
SysFaultは、特定のメモリ領域をアクセス不可(PROT_NONE)に設定する新しいシステムコールラッパーです。これは主にデバッグ目的で使用され、解放されたスタックメモリへの不正なアクセスを検出するためにStackFaultOnFreeが有効な場合に利用されます。これにより、use-after-freeバグの特定が容易になります。
5. Cフレームの課題
コミットメッセージにも明記されているように、Cフレームの扱いはこのスタックコピー方式の主要な課題です。CフレームはGoランタイムがポインタマップ情報を持たないため、コピー可能と判断できません。このため、Cフレームがスタックの最上位セグメントに存在する場合、スタックコピーは行われず、従来のセグメント割り当て方式がフォールバックとして使用されます。将来的には、Cフレームの特性(冪等性など)に応じて、より洗練されたハンドリングが検討されています。
コアとなるコードの変更箇所
このコミットにおけるコアとなるコードの変更は、主にsrc/pkg/runtime/stack.cに集中しています。
-
src/pkg/runtime/stack.c:StackDebug,StackFromSystem,StackFaultOnFreeといったデバッグ/設定用の定数が追加されました。copyabletopsegment関数が追加され、スタックの最上位セグメントがコピー可能かどうかを判断します。adjustpointers関数が追加され、BitVectorに基づいてスタック上のポインタを調整します。adjustframe関数が追加され、個々のスタックフレーム内のポインタを調整します。adjustctxt関数が追加され、Goroutineのコンテキスト内のポインタを調整します。adjustdefers関数が追加され、deferレコード内のポインタを調整します。copystack関数が追加され、実際にスタックの内容を新しい場所にコピーし、関連するポインタを調整する主要なロジックを実装しています。runtime·newstack関数が大幅に修正され、スタックオーバーフロー時にcopystackを呼び出してスタックを拡張するロジックが追加されました。runtime·shrinkstack関数が追加され、GC時にスタックの使用量が少ない場合にスタックを縮小するロジックを実装しています。
-
src/pkg/runtime/proc.c:runtime·copystackという新しいグローバル変数が導入され、スタックコピー機能の有効/無効を制御します。これはGOCOPYSTACK環境変数によって設定可能です。gfputとgfget関数(Goroutineのフリーリスト管理)が変更され、非標準サイズのスタックを持つGoroutineのスタックを解放・再割り当てするロジックが追加されました。これは、スタックコピーによってGoroutineのスタックサイズが変更される可能性があるためです。
-
src/pkg/runtime/malloc.h:SysFault関数の宣言が追加されました。BitVectorとStackMap構造体の定義が追加され、コンパイラが生成するポインタマップ情報を表現します。
-
src/pkg/runtime/mgc0.c:scanframe関数のシグネチャが変更され、boolを返すようになりました。これは、トレースバック中にコピー不可能なフレームを検出した場合にスキャンを早期に終了させるためです。addstackroots関数内でruntime·shrinkstack(gp)が呼び出されるようになり、GC中にスタックの縮小がトリガーされるようになりました。
-
OS固有のメモリ管理ファイル (
src/pkg/runtime/mem_*.c):- 各OS(Darwin, Dragonfly, FreeBSD, Linux, NetBSD, OpenBSD, Plan 9, Solaris, Windows)のメモリ管理ファイルに
runtime·SysFault関数の実装が追加されました。これは、mmap(Unix系)またはVirtualProtect(Windows)を使用してメモリ領域をアクセス不可に設定します。
- 各OS(Darwin, Dragonfly, FreeBSD, Linux, NetBSD, OpenBSD, Plan 9, Solaris, Windows)のメモリ管理ファイルに
コアとなるコードの解説
ここでは、主要な変更点であるsrc/pkg/runtime/stack.c内の関数を中心に解説します。
typedef struct BitVector BitVector; と typedef struct StackMap StackMap;
これらは、コンパイラが生成するスタックフレームのポインタマップ情報を表現するための構造体です。
BitVector: スタックフレーム内のポインタの有無や種類を示すビットマップデータ。nはビット数、dataはビットデータを格納する配列です。StackMap: 特定のPC値に対応するBitVectorへのインデックスを保持します。
static int32 copyabletopsegment(G *gp)
この関数は、与えられたGoroutine gp のスタックの最上位セグメントがコピー可能であるかどうかをチェックし、コピー可能であればそのセグメント内のフレーム数を返します。コピー不可能であれば -1 を返します。
内部ではruntime·gentracebackを呼び出し、各フレームに対してcheckframecopyコールバック関数を実行します。
static bool checkframecopy(Stkframe *frame, void *arg)
copyabletopsegmentから呼び出されるコールバック関数です。各スタックフレームがコピー可能であるかを判断します。
runtime·mainのフレームは特別にコピー可能とみなされます。- Goのフレームについては、ローカル変数や引数/戻り値のポインタマップ情報(
FUNCDATA_LocalsPointerMaps,FUNCDATA_ArgsPointerMaps)が存在するかどうかを確認します。これらの情報がない場合、フレームはコピー不可能と判断されます。 - Cフレームは一般的にコピー不可能と判断されます。
static void adjustpointers(byte **scanp, BitVector *bv, AdjustInfo *adjinfo, Func *f)
この関数は、BitVector bv で記述されたメモリ領域 scanp をスキャンし、その中に含まれるポインタを調整します。
adjinfoには、古いスタックの範囲(oldstk,oldbase)と、新しいスタックへのオフセット(delta)が含まれます。bvのビット情報に基づいて、scanpが指すメモリ位置がポインタであると判断された場合、そのポインタが古いスタック領域を指していれば、delta分だけ値を加算して新しいスタック領域を指すように変更します。- インターフェース値(
BitsIface,BitsEface)についても、その内部のデータポインタが調整されます。
static bool adjustframe(Stkframe *frame, void *arg)
copystackから呼び出されるコールバック関数で、個々のスタックフレーム内のポインタを調整します。
- フレームのローカル変数領域と引数/戻り値領域に対して、それぞれ対応するポインタマップ(
FUNCDATA_LocalsPointerMaps,FUNCDATA_ArgsPointerMaps)を取得し、adjustpointersを呼び出してポインタを調整します。
static void adjustctxt(G *gp, AdjustInfo *adjinfo)
Goroutineのコンテキスト(gp->sched.ctxt)が古いスタック領域内のアドレスを指している場合、adjinfo->delta分だけ調整します。
static void adjustdefers(G *gp, AdjustInfo *adjinfo)
Goroutineのdeferチェーンを走査し、deferレコード自体や、遅延実行される関数の引数、クロージャが古いスタック領域内のアドレスを指している場合、それらのポインタを調整します。
static void copystack(G *gp, uintptr nframes, uintptr newsize)
スタックコピーの主要な関数です。
- 新しいサイズのスタックメモリを
runtime·stackallocで割り当てます。 runtime·gentracebackを呼び出し、adjustframeコールバックを使って、コピー対象のフレーム内のポインタを新しいスタック位置に合わせて調整します。adjustctxtとadjustdefersを呼び出し、Goroutineのコンテキストとdeferレコード内のポインタを調整します。- 古いスタックの内容を新しいスタックに
runtime·memmoveでコピーします。 - Goroutineのスタック関連のフィールド(
stackbase,stackguard,stackguard0,sched.spなど)を新しいスタックの情報に更新します。 - 古いスタックメモリを
runtime·stackfreeで解放します。
void runtime·newstack(void)
スタックオーバーフロー時に呼び出される関数です。
runtime·copystackが有効で、かつスタックの最上位セグメントがコピー可能であれば、copystackを呼び出してスタックを拡張します。- コピーができない場合(Cフレームが存在するなど)は、従来のセグメント割り当て方式で新しいスタックセグメントを割り当てます。
void runtime·shrinkstack(G *gp)
ガベージコレクション中に呼び出され、Goroutineのスタックを縮小します。
- スタックの使用量が少ない(1/4未満)かつ、スタックが最小サイズより大きい場合に縮小を試みます。
copyabletopsegmentでコピー可能であることを確認し、コピー可能であればcopystackを呼び出してスタックを半分のサイズに縮小します。
void runtime·SysFault(void *v, uintptr n)
OS固有のメモリ管理ファイルに実装された関数で、指定されたメモリ領域 v から n バイトをアクセス不可に設定します。
- Unix系OSでは
mmapをPROT_NONEフラグ付きで呼び出します。 - Windowsでは
VirtualProtectをPAGE_NOACCESSフラグ付きで呼び出します。 - これは主にデバッグ目的で、解放されたスタックメモリへの不正なアクセスを検出するために使用されます。
関連リンク
- Go CL 54650044: https://golang.org/cl/54650044
参考にした情報源リンク
- Goのスタック管理の歴史と進化に関する公式ドキュメントやブログ記事(特に、セグメント化されたスタックから連続したスタックへの移行に関するもの)。
- Goのガベージコレクションの仕組みに関するドキュメント。
- OSのメモリ管理(
mmap,VirtualAlloc,VirtualProtectなど)に関する一般的な情報。 - Goのランタイムソースコードの他の部分(特に
runtime/stack.cの変更前後のバージョン)。 - GoのIssueトラッカーで、スタック管理やCgoに関する議論。
(注:具体的なURLは、検索結果に基づいて適宜追加してください。上記は一般的な情報源のカテゴリです。)