GoのARM64コンパイラでバグを発見した経緯
(blog.cloudflare.com)- Cloudflareは、arm64プラットフォームで動作するGoコンパイラで発生するまれな競合状態(レースコンディション)バグを、大規模トラフィックの監視中に発見した
- このバグは、**スタックアンワインディング(stack unwinding)**の過程で、サービスが予期せずパニック状態になったり、メモリアクセスエラーが発生したりする形で現れる
- 原因を追跡する過程で、**Goランタイムの非同期preemption(強制プリエンプション)**と、コンパイラが生成した2つのスタックポインタ調整命令の間で問題が発生していることを確認した
- 最小再現コードにより、このバグがGoランタイム自体の問題であることを証明し、その結果としてスタックポインタが不完全に変更される1命令分の競合状態が存在することを明らかにした
- この問題はgo1.23.12、go1.24.6、go1.25.0で修正され、新しい方式では即時に変更できないスタックポインタ操作を回避することで、レースコンディションが根本的に遮断された
Cloudflareが見つけたGo ARM64コンパイラバグの分析
Cloudflareのデータセンターは世界330以上の都市で毎秒8,400万件のHTTPリクエストを処理しており、このような大規模トラフィック環境では、まれなバグでさえ頻繁に露出するという特徴がある。この記事では、arm64プラットフォーム向けにGoコンパイラが生成したコードで発生した競合状態の問題を、実例とともに詳しく分析している。
奇妙なパニック現象の調査
- Cloudflareネットワーク内では、Magic TransitやMagic WANのような製品のトラフィック処理をKernelに設定するサービスが稼働している
- arm64マシンで、**まれだが繰り返し発生するfatal panic(致命的パニック)**メッセージが監視システムで検出された
- 初期分析の結果、スタックアンワインディングの過程で整合性違反が検出されていた(panic/recoverパターンを使っていた古いコードでパニックが頻発していた)
- 一時的にpanic/recover構造を除去してパニック頻度を下げたものの、その後疑わしい致命的パニックがさらに頻繁に発生するようになった
- これにより、単純なパターン追跡以上の、より深い原因分析が必要だと判断した
Goランタイムおよびスケジューラのデータ構造概要
- Goは軽量なユーザー空間スケジューラとしてM:Nスケジューリング構造を採用している(複数のgoroutineを少数のカーネルスレッドにマッピング)
- スケジューラの中核となる構造体は
g(goroutine)、m(マシン/カーネルスレッド)、p(プロセッサ)を中心に構成される - スタックアンワインディングの失敗やメモリアクセスエラーは、スタックポインタまたはリターンアドレスが異常に変化したときに発生する
スタックアンワインディング中のエラーの構造的原因
- 複数のバックトレースを分析した結果、すべて
(*unwinder).next関数のスタックアンワインディング処理中に発生していた - あるケースではreturn addressがnullであるため異常スタックとみなされ致命的エラーで終了し、別のケースでは**スタックフレーム内のGoスケジューラ構造体mのフィールド(incgo)**にアクセスしようとしてセグメンテーションエラーが発生した
- クラッシュが実際のバグ発生地点からかなり離れた場所で起きており、原因追跡が難しかった
観測されたパターンとGo Netlinkライブラリとの関連
- Stack traceを確認した結果、いずれもGo Netlinkライブラリの
NetlinkSocket.Receive関数でpreemptionが発生した時点にクラッシュが集中していることを確認した - その後、2つの仮説を立てた
- Go Netlinkのunsafe.Pointer使用に起因するバグである可能性
- Goランタイムの非同期preemptionおよびスタックアンワインディング自体で発生するバグである可能性
- コード監査を行ったが、直接的なメモリ破損パターンなどは見つからず、問題の核心はランタイムとスタック運用戦略にあると推定した
非同期Preemptionと競合状態
- Go 1.14で導入された非同期preemption機能は、長時間実行されるgoroutineに対してOSスレッドへシグナル(SIGURG)を送り、強制的にスケジューリングポイントを生成する
- このpreemptionがスタックフレームポインタを調整する2つのアセンブリ命令の間で発生すると、スタックポインタが中間状態にとどまってしまう
- ガベージコレクション、パニック処理、スタックトレース生成のためにスタックをアンワインドする際、誤った位置を読み取って誤った関数アドレスやデータ解釈が起こる
最小再現コードの作成
- スタックフレーム割り当てサイズを調整し、明示的にスタックが調整される関数(
big_stack)と、常時ガベージコレクション呼び出しコードを記述することで競合状態を再現した - 実際にアセンブリコードで2つのADD命令によりスタックポインタが調整され、その間に非同期preemptionが発生すると、スタックアンワインディング過程でクラッシュが起こる
- この欠陥は純粋な標準ライブラリコードだけでも再現可能であり、Goコンパイラが生成するコードに内在する命令単位(1インストラクション分)の脆弱性であることを証明した
ARM64コンパイラレベルの競合ウィンドウの原因
- ARM64アーキテクチャの固定長命令および即値制限のため、スタックポインタ調整には2つ以上の命令が必要になる場合がある
- Goの内部中間表現(IR)ではこうした即値長を認識しておらず、実際のマシンコード変換時にのみ分割命令が挿入される
- このため、スタックフレームの返却(ADD RSP, RSP)に2命令が使われ、preemptionに脆弱な単一インストラクションのウィンドウが生じる
- アンワインダはスタックポインタの正確性を絶対的に必要とするため、命令の途中で停止すると誤った値の解釈や致命的失敗を招く
- 実際のクラッシュフローは次のように構成される:
- 2つのADD命令の間で非同期preemptionが発生
- GCまたはその他の理由でスタックアンワインディングルーチンが動作
- 特異なスタックポインタ位置を探索し、誤った関数アドレスを解釈
- ランタイムクラッシュ
バグ修正と根本的改善
- Cloudflareチームは最小再現コードと詳細な分析内容を基にGo公式リポジトリへ報告し、この問題は迅速にパッチ適用・リリースされた
- go1.23.12、go1.24.6、go1.25.0以降では、一時レジスタに全オフセットを先に計算したうえで単一命令でスタックポインタを変更し、preemptionの脆弱性を解消している
- これによりスタックポインタは常に有効な状態が保証され、競合状態は構造的に遮断された
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET
結論と示唆
- このバグは、特定アーキテクチャにおけるコンパイラのコード生成と**並行性管理(非同期preemption)**が想定外の形で衝突した事例である
- 大規模環境でのみ顕在化する非常にまれな命令レベルの競合状態を、実運用データと科学的推論によって追跡した点が印象的な事例だ
- 最新のGo環境およびARM64アーキテクチャベースのサービスを運用しているなら、該当Goバージョンへのアップグレードが重要になる
1件のコメント
Hacker Newsの意見
LDR Rd, =exprというARMの疑似命令を使う。即座に生成できない定数の場合、PC相対位置に定数を置き、PCを基準にレジスタへロードする。これにより「SPに定数を足す」処理を2つの実行命令に置き換えられ、8バイトのコードと4バイトのデータ領域(17ビット定数用)で合計12バイトが必要になる。関連文書: LDR pseudo-instruction の説明backportというキーワードだけをチェックしているのか気になった。関連コメント: github issue commentsscanfバグを追ったことがあるが、その最中は楽しくなかったものの、問題を正確に突き止めて回帰テストまで書いた後は本当に満足感があった。