要約:クイック修正
単一の goroutine がデフォルトの 1GB スタック制限を使い切ると、Go はこのエラーを発生させます。これはほぼ間違いなく無限再帰が原因です。Go は末尾呼び出し最適化を行わないため、再帰の各ステップでメモリを消費します。修正するには、厳密な**終了条件(ベースケース)**を追加するか、ロジックを標準的な for ループにリファクタリングする必要があります。
// 悪い例:脱出方法がない
func count(i int) {
fmt.Println(i)
count(i + 1) // 約500,000回の呼び出し後にクラッシュします
}
// 良い例:ガードされた再帰
func count(i int) {
if i > 1000 {
return
}
fmt.Println(i)
count(i + 1)
}
Go がエラーを出す理由
Go はメモリを積極的に、かつ安全に管理します。新しい goroutine は、わずか 2KB のスタックから始まります。関数呼び出しが深くなるにつれて、ランタイムは動的にスタックサイズを倍増させ、データをコピーします。しかし、バグのある関数がサーバーの RAM をすべて使い果たさないように、Go はハードリミットを課しています。64ビットシステムでは、その制限は正確に **1,000,000,000 バイト(1GB)**です。
goroutine stack exceeds... limit というエラーが表示された場合、コードが「死の連鎖(デス・スパイラル)」に陥り、関数が戻ることなく互いを呼び出し続けている可能性があります。
主な原因
- **終了条件の欠落:** 特定の入力に対して `return` パスを持たない再帰関数。
- **循環呼び出し:** 関数 A が関数 B を呼び出し、関数 B が関数 A を呼び戻す。これにより、数ミリ秒で 1GB の制限を消費するループが発生します。
- **データが深すぎる:** 連結リストやツリーの探索において、予想外に大規模な(例:1,000,000 レベル以上の)深さを扱っている可能性があります。
- **末尾呼び出しの罠:** 関数型言語の経験がある開発者の多くは、Go に末尾呼び出し最適化 (TCO) があると考えがちですが、実際にはありません。たとえ再帰呼び出しが最終行であっても、新しいフレームがスタックにプッシュされます。
修正方法
1. ベースケース(終了条件)の監査
終了ロジックの計算を再確認してください。決してトリガーされない条件を書いてしまうのは簡単です。例えば、if i == 0 をチェックしているのに、コードが誤って i を 1 から 2 にインクリメントしている場合、スタックが爆発するまで関数が実行され続けます。
// ベースケースが予期しない入力を処理することを確認する
func walk(node *Node) {
if node == nil {
return
}
// ... ノードを処理
for _, child := range node.Children {
walk(child)
}
}
2. グラフ内のサイクル検出
グラフやポインタを多用する構造をクロールしている場合、同じノードを再訪している可能性があります。追跡メカニズムがないと、無限にループします。map を使用して、すでに訪問した場所を追跡してください。
func traverse(n *Node, visited map[*Node]bool) {
if n == nil || visited[n] {
return // ここでサイクルを止める
}
visited[n] = true
for _, edge := range n.Edges {
traverse(edge, visited)
}
}
3. 処理をヒープに移動する(反復処理)
Go では、深い階層の操作において反復処理(イテレーション)の方がほぼ常に安全です。for ループとスライスを使用することで、メモリの負荷を 1GB 制限のスタックから、より大容量のシステムヒープに移動できます。スライスは数ギガバイトまで拡張可能ですが、スタックは固定の壁です。
// 再帰的(スタックオーバーフローのリスクあり)
func sum(n int) int {
if n <= 0 { return 0 }
return n + sum(n-1)
}
// 反復的(非常に堅牢)
func sum(n int) int {
total := 0
for i := n; i > 0; i-- {
total += i
}
return total
}
4. 手動スタックを使用して複雑なツリーを処理する
スタック制限を当然のように超える構造を探索する必要がある場合は、手動でスタックをシミュレートします。これは、深いディレクトリの探索や複雑なパーサーでよく見られるパターンです。
func iterativeWalk(root *Node) {
workQueue := []*Node{root}
for len(workQueue) > 0 {
// 最後の要素を取り出す
n := workQueue[len(workQueue)-1]
workQueue = workQueue[:len(workQueue)-1]
if n != nil {
// 子要素を手動ヒープスタックにプッシュし直す
workQueue = append(workQueue, n.Right, n.Left)
}
}
}
検証とテスト
コードを直すだけでなく、クラッシュが解消されたことを証明してください。最初の失敗を引き起こしたデータセットの 2 倍の大きさでアプリケーションを実行します。プロセスメモリを監視してください。RSS (Resident Set Size) が垂直に急上昇せず安定していれば、修正は成功しています。
極端なエッジケースでは、debug.SetMaxStack を使用して制限を引き上げることができますが、これはあくまで診断ツールとして扱い、本番環境の場当たり的な処置とはしないでください。
import "runtime/debug"
func init() {
// プロセスが最終的に終了するか確認するために制限を 2GB に引き上げる
debug.SetMaxStack(2000000000)
}
さらに詳しく
- Go ランタイムソース: `runtime/stack.go` - 1GB の制限がどのように適用されているかを確認。
- なぜ Go には TCO がないのか: スタックトレースとデバッグに関する Go の issue トラッカーでの議論。

