Skip to content

Goでスタックが自動で伸縮するしくみ

Goランタイムが管理するスタックの種類のうち、ゴルーチンごとのスタックは2KBから開始し、関数呼び出しでスタックが不足した場合に自動で伸長する。これはソースコードをコンパイルしたときに以下のようなコードが関数の先頭に挿入されていて、SP レジスタの値と管理しているスタックサイズで判定する。

// 本当はアセンブリ言語で書かれているけど雰囲気は同じはず
currentSP -= (関数に必要なスタックサイズ)
if currentSP < currentGoroutine.stack.lo {
runtime.morestack_noctxt() // スタックを伸長する
}

ただし、このコードはGoのコンパイラディレクティブgo:nosplit が与えられた関数には挿入されない。

//go:nosplit
func add() int

関数に必要なスタックサイズはコンパイル時に決定できるので、コードとして埋め込まれている。またはアセンブリを手書きする場合も、関数が利用するスタックサイズは最終パラメータで指定できる。

// $<スタックフレームのサイズ>-<引数と戻り値のサイズ>
TEXT ·add2(SB),NOSPLIT,$0-12
RET

メモリアドレスはどうなるのか

Section titled “メモリアドレスはどうなるのか”

スタックが縮小(shrink)する場合は既存の値がそのまま使えるかもしれないけれど、少なくとも伸長する場合は、同じアドレスで必要なメモリ領域を確保できない可能性がある。このとき、スタックに乗っているポインタはどうなるのか。

結論としては、スタックに乗っているポインタは(unsafe.Pointer であっても)ランタイムによって新しいアドレスに置き換えられることになる。

ただし、あくまでスタックに乗っているランタイムが管理するアドレスなので、ヒープにあるアドレスや、スタック上にあってもuintptr 型の場合は対象外となる。

var i int
p := unsafe.Pointer(&i) // pはスタック領域に置かれている
u := uintptr(p)
println("before:", &i, p, u)
grow() // スタック伸長が必要な処理を行う
println("after:", &i, p, u) // &iとpは移動後のスタックを指すがuは古いスタックのまま