Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goのエラーハンドリングを集約するライブラリを作った + Goが管理するスタック周りの挙動について

お試しで、Goのエラーハンドリングを省略するための try というライブラリを作っているので紹介します(最後にちょっとした告知があります)。

github.com

これを使うと、よくある if err != nil を次のように記述できます。

// HandleとCheckは必ず同じスコープに書いて下さい

cp, err := try.Handle()
if err != nil {
    // 下のCheckでエラーが発生したらここに飛ぶ
    log.Fatalln("Error:", err)
}
s := try.Check1(os.ReadFile("/tmp/xxx"))(cp)
u := try.Check1(url.Parse(string(s)))(cp)
fmt.Println(u.Path)

try パッケージのAPIデザインはError Handling — Problem Overviewを参考にしています。

動作としては、まず Handle で戻り先を記憶しています。このとき、最初の Handle 呼び出しでは必ず errnil になるように返します。なので次の行にある if err != nil のブロックは実行されません。

その後、Check1 を呼び出していますが、ここで戻り値の最後がエラーであれば cp を生成したときの Handle まで巻き戻り、HandleCheck1 が受け取ったエラーを返します。なので今度は if err != nil のブロックが実行されて、ここでは log.Fatalln を呼んでいるのでプログラムが終了します。

Go Playgroundも作ってみたので実際に動かしてみてください。いくつか既知の問題があるけれどだいたい動いたので、こうして記事を書いていますが、下に実装を書いているように Goチームが保証していない挙動を利用している ので、環境や将来のバージョンで動かなくなる可能性があります。また、実績もまだ手元で動いたものしかないので、安定稼働が求められる場面では絶対に使わないで ください。

どちらかというと、この記事では try を実現するために調べたことを紹介するのが目的です。

tryは何をやっているのか

基本はC言語setjmp または longjmp と同じことをしていますが、前提が多いので順番に説明します。

ABIInternal

Goは amd64arm64 などのCPUアーキテクチャごとにABIを定義しています。Go 1.25では ABIInternal という仕様があり、以下のドキュメントにまとまっています。

この仕様では、例えば amd64 アーキテクチャで関数を実行しているとき、以下のようなスタックのレイアウトになります。

関数を実行しているときのスタックレイアウトです

ここで次の関数を呼び出すと、戻り先のアドレス*1と親フレームのBPが保存されているアドレスを下位のスタックに追加します。

関数呼び出し直後のスタックレイアウトです

これで、あとは実際に記述したコードを実行していきます。実行途中でスタックの操作が行われれば SP レジスタも更新されます。

関数呼び出し前処理が終わった時点のスタックレイアウトです

言い方を変えると、BP レジスタは現在のスタックフレームが管理するスタック領域の底アドレスを保持します。スタックは上位アドレスから下位アドレスに向かって伸びるので、底アドレスはメモリアドレス的には上位アドレスです。BP レジスタの値は関数を実行している間は基本的に変わりません。SP レジスタはスタックフレームの下位アドレスを保持します。こちらは関数が実行されている間、スタックの利用状況に応じて増減します。

ここで try を実装するために重要なのは以下の3点です。

  • BP レジスタより8バイト上のアドレスには、関数がリターンするアドレスが入っている
  • BP レジスタが指すメモリアドレスには、親フレーム(関数呼び出し前)の BP レジスタの値が入っている
  • SP レジスタには、その時点で利用しているスタックの先頭アドレスが入っている

このように BP レジスタを使ったリンクリストになっているので、この構造を使うと呼び出しスタックを順に辿ることができます。ということは、スタックを辿って必要な値を探し、SP レジスタが指すアドレスを書き換えれば任意の場所にジャンプできるわけですね。

なので Handle では、以下5つのレジスタCheckpoint 構造体*2に詰めています。arm64 もだいたい同じレジスタを持っています。

amd64 arm64 意味
SP RSP スタックポインタ
BP R29 ベースポインタ
DX R26 コンテキストポインタ
(PC) LR Handle の戻り先アドレス
(BP) (R29) Handle を呼び出した関数のスタックポインタ

こうしておいて、Check 関数でエラーが発生したら詰めておいたレジスタの値を復元しています。そうすると Check 関数からリターンしたとき(細工しておいた)戻り先になっているので、Handle から戻るように動作するのでした。

ABIInternalとABI0

Goアセンブリについてですが、2025年時点では、値のやりとりをレジスタベースで行う ABIInternal と、スタックベースで行う ABI0 の2種類があります。ABIInternalコンパイラ内部で使うABIで、一般のユーザーが使うものではありません。

アセンブリのコードを手書きする場合は ABI0 を使うことになります。以前書いたGoアセンブリの書き方ABI0 に準拠しています。なので上で説明したレジスタを操作するときは ABI0 として記述しつつ、ABIInternal なレイアウトのメモリを読むことになって非常に厄介ですが、幸い SPBP レジスタの扱いは変わらないようでした。

ところで、ABI0 はスタックベースなので、例えば 0(FP)8(FP) といったスタック上のメモリを使って値の受け渡しをします。しかしGoで書いたコードは ABIInternal なので、AXBX といったレジスタで値をやり取りします。この違いがあるので、そのままではうまく値を渡せません。このギャップを埋めるためにABI Wrapperといって、ABIInternalABI0 を仲介する層が存在するようでしたが、あまり詳しくは調べていません。

実際のコードはたぶんこの辺り。

スタックの伸長と縮小

次に、スタックの伸縮について。Goではゴルーチンごとに固有のスタック領域を持っています。最初は2KBで開始して、必要になれば伸長されていきます。この処理は、関数呼び出しの先頭部分にコンパイラが以下のようなコードを挿入することで行っています。

// 本当はアセンブリ言語で書かれているけど雰囲気は同じはず
currentSP -= (関数に必要なスタックサイズ)
if currentSP < currentGoroutine.stackPointer {
    runtime.morestack_noctxt() // スタックを伸長する
}
// 以下、実際に書いた関数の処理が続く...

このとき、伸長するときにスタック領域そのものが移動するので、当然ですがメモリアドレスも変わります。スタック上にある unsafe.Pointer 型の値なら、スタックが移動するとき runtime が一緒にアドレスを書き換えてくれるのですが、uintptr 型の値やヒープにある unsafe.Pointer は変わらないようでした。

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は古いスタックのまま

なので、スタックが移動したことを検出して、Handle によって退避した SP などの値も修正しなければなりません。このために、Handle では「Handle を呼び出したスタックフレームの BP レジスタ」を保存しておきます。HandleCheck は同じ関数内で呼ばれる想定なので、Check 関数が呼ばれたときにスタックフレームを遡って Handle と同じフレームを探し、そのアドレスを比較することで移動した距離を反映しています。

おわりに

この他にも、Handle 関数がインライン展開されるように、関数実装の複雑さを減らすなど細かい調整を行っていますが、面白い話題としてはこのくらいでしょうか。

こういった黒魔術はあまり使わないほうがいいとは理解しつつ、仮にもしこれが受け入れられるならエラーハンドリング周りの改善に繋がったらいいなと思って実装してみたのでした。もし何か不具合等がみつかったら日本語でもいいのでissueに起票していただけると助かります。実際にいま分かっている問題もあって、-covermode=atomic オプションをつけてテストを実行するとよくわからない落ち方をします。

# まだ調べてないけど不思議な落ち方をする...
go test -race -covermode=atomic

宣伝

最後に、筆者が現在所属しているはてなでは、10月31日にhatena.go#2というイベントを開催します。実務でGoを使ったときの面白い話が聞けると思うので、お時間が合う人はぜひ参加をお待ちしています。

connpass.com

*1:関数呼び出しを行った次の命令

*2:上のコードで cp としている値