Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 14796] ファイルの概要

このコミットは、Goコンパイラ(cmd/5g, cmd/6g, cmd/8g)において、panic発生時に戻り値が正しくフラッシュされないというバグ(Issue 4066)を修正するものです。具体的には、defer関数が存在する場合に、パニック発生時の戻り値のレジスタ割り当てが適切に行われるように、コンパイラのレジスタ割り当てロジックが変更されています。これにより、パニックが発生しても、defer処理が実行された後に期待される戻り値が保証されるようになります。

コミット

commit f1e4ee3f49fd19d72fa3bbcbce4aab5c2fbef2ed
Author: Daniel Morsing <daniel.morsing@gmail.com>
Date:   Fri Jan 4 17:07:21 2013 +0100

    cmd/5g, cmd/6g, cmd/8g: flush return parameters in case of panic.
    
    Fixes #4066.
    
    R=rsc, minux.ma
    CC=golang-dev
    https://golang.org/cl/7040044

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/f1e4ee3f49fd19d72fa3bbcbce4aab5c2fbef2ed

元コミット内容

cmd/5g, cmd/6g, cmd/8g: flush return parameters in case of panic.cmd/5g, cmd/6g, cmd/8g: パニック発生時に戻り値をフラッシュする。)

Fixes #4066. (Issue 4066を修正。)

変更の背景

このコミットは、Go言語のIssue 4066「return values not being spilled eagerly enough」を修正するために行われました。この問題は、関数内でpanicが発生し、かつdeferステートメントが存在する場合に、関数の戻り値が正しく設定されない、または期待される値にならないというものでした。

Go言語では、panicが発生すると通常の実行フローが中断され、スタックを巻き戻しながらdefer関数が実行されます。defer関数は、リソースの解放やエラーハンドリングなど、クリーンアップ処理を行うためによく使用されます。しかし、このIssue 4066では、panicが発生した際に、コンパイラが戻り値の変数をレジスタからメモリに適切に「スピル」(退避)しないことが原因で、defer関数が実行される時点での戻り値が古い値のままになってしまう可能性がありました。

特に、ループ内で戻り値が更新され、そのループ内でpanicが発生するようなシナリオでこの問題が顕在化しました。defer関数がrecoverを使ってパニックから回復し、関数の実行が継続される場合、最終的な戻り値が期待通りにならないというバグにつながっていました。

前提知識の解説

Go言語のpanicrecover

  • panic: Go言語におけるランタイムエラーのメカニズムです。panicが呼び出されると、現在の関数の実行が即座に停止し、その関数のdefer関数が実行されます。その後、呼び出し元の関数へとスタックが巻き戻され(unwind)、各関数のdefer関数が順に実行されます。このプロセスは、プログラムがクラッシュするか、recoverが呼び出されるまで続きます。
  • defer: deferステートメントは、それを囲む関数がリターンする直前(panicによるスタックの巻き戻し中も含む)に実行される関数をスケジュールします。複数のdeferステートメントがある場合、LIFO(Last-In, First-Out)の順序で実行されます。
  • recover: recoverdefer関数内でのみ有効な組み込み関数です。panicが発生している最中にrecoverが呼び出されると、panicを停止させ、panicに渡された値を返します。これにより、プログラムのクラッシュを防ぎ、エラーハンドリングを行うことができます。

Goコンパイラのレジスタ割り当てと「スピル」

Goコンパイラは、プログラムの実行速度を最適化するために、頻繁に使用される変数をCPUのレジスタに割り当てます。レジスタはメモリよりも高速にアクセスできるため、パフォーマンスが向上します。

しかし、レジスタの数は限られているため、コンパイラは必要に応じてレジスタに割り当てられた変数をメモリに退避させる必要があります。この操作を「スピル」(spill)と呼びます。例えば、関数呼び出しの前後や、レジスタが不足した場合などにスピルが行われます。

Issue 4066は、panicが発生し、defer関数が実行されるという特殊な状況下で、コンパイラが戻り値の変数をレジスタからメモリに適切にスピルしないことが原因で発生しました。これにより、defer関数が戻り値にアクセスしようとした際に、レジスタ内の最新の値ではなく、メモリ内の古い値を見てしまう可能性がありました。

技術的詳細

このコミットは、Goコンパイラのバックエンド部分、特にレジスタ割り当てとコード生成を担当するreg.cファイル群(src/cmd/5g/reg.c, src/cmd/6g/reg.c, src/cmd/8g/reg.c)に変更を加えています。これらのファイルは、それぞれ異なるアーキテクチャ(5g: ARM, 6g: AMD64, 8g: x86)向けのコンパイラです。

変更の核心は、prop関数内のレジスタ割り当てロジックにあります。prop関数は、レジスタ割り当てのプロパゲーション(伝播)を行う部分で、命令ごとにレジスタの状態を追跡し、変数がレジスタに割り当てられているか、メモリにスピルされているかなどを管理します。

既存のコードでは、Issue 1304のワークアラウンドとして、各命令の前に変更されたグローバル変数をフラッシュする処理がありました。これは、for(z=0; z<BITS; z++) { cal.b[z] |= externs.b[z]; } の部分です。externsはグローバル変数のビットマップを表します。

今回の修正では、このグローバル変数のフラッシュ処理に加えて、defer関数が存在する場合(hasdeferフラグが真の場合)に、戻り値の変数(ovar)もフラッシュするロジックが追加されました。ovarは戻り値の変数のビットマップを表します。

具体的には、cal.b[z] |= ovar.b[z];という行が追加されています。ここでcalは「call-used」レジスタのビットマップ、つまり関数呼び出しによって変更される可能性のあるレジスタの集合を表します。ovarは「output variables」(戻り値の変数)のビットマップです。この操作により、defer関数が存在する場合には、各命令の前に戻り値の変数がレジスタからメモリに強制的にスピルされるようになります。

これにより、panicが発生してdefer関数が実行される際、戻り値の変数は常に最新の値がメモリに反映されている状態となり、defer関数がその値を正しく参照できるようになります。

コアとなるコードの変更箇所

以下の変更がsrc/cmd/5g/reg.c, src/cmd/6g/reg.c, src/cmd/8g/reg.cの各ファイルに共通して適用されています。

--- a/src/cmd/5g/reg.c
+++ b/src/cmd/5g/reg.c
@@ -1075,8 +1075,12 @@ prop(Reg *r, Bits ref, Bits cal)
  		default:
  			// Work around for issue 1304:
  			// flush modified globals before each instruction.
- 			for(z=0; z<BITS; z++)
+ 			for(z=0; z<BITS; z++) {
  				cal.b[z] |= externs.b[z];
+ 				// issue 4066: flush modified return variables in case of panic
+ 				if(hasdefer)
+ 					cal.b[z] |= ovar.b[z];
+ 			}
  			break;
  		}
  		for(z=0; z<BITS; z++) {

また、この修正を検証するためのテストケースがtest/fixedbugs/issue4066.goとして追加されています。

// run

// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// issue 4066: return values not being spilled eagerly enough

package main

func main() {
	n := foo()
	if n != 2 {
		println(n)
		panic("wrong return value")
	}
}

type terr struct{}

func foo() (val int) {
	val = 0
	defer func() {
		if x := recover(); x != nil {
			_ = x.(terr)
		}
	}()
	for {
		val = 2
		foo1()
	}
	panic("unreachable")
}

func foo1() {
	panic(terr{})
}

コアとなるコードの解説

変更の核心は、prop関数内のループにif(hasdefer) cal.b[z] |= ovar.b[z];という行が追加されたことです。

  • prop関数: この関数は、Goコンパイラのレジスタ割り当てフェーズの一部であり、プログラムの各命令におけるレジスタの使用状況を分析し、最適化を行います。
  • for(z=0; z<BITS; z++): これはビットマップを操作するためのループです。BITSはビットマップのサイズを示します。
  • cal.b[z] |= externs.b[z];: 既存のコードで、グローバル変数(externs)が変更された場合に、その変更がcal(call-usedレジスタ)に反映されるようにしています。これは、関数呼び出しによってグローバル変数が変更される可能性があるため、その変更を考慮に入れるためのものです。
  • if(hasdefer): この条件は、現在の関数にdeferステートメントが存在するかどうかをチェックします。deferが存在する場合にのみ、以下のロジックが適用されます。これは、deferが存在しない場合は、panicが発生しても戻り値の整合性が問題にならないためです。
  • cal.b[z] |= ovar.b[z];: この行が今回の修正の主要な部分です。
    • ovarは、関数の戻り値の変数を表すビットマップです。
    • cal.b[z] |= ovar.b[z];は、戻り値の変数がレジスタに割り当てられている場合、そのレジスタを「call-used」としてマークします。これにより、コンパイラは、defer関数が実行される可能性がある場合に、戻り値の変数をレジスタからメモリに強制的にスピルするようになります。
    • この「強制スピル」は、panicが発生してdefer関数が実行される際に、戻り値の変数が常に最新の値でメモリに存在することを保証します。これにより、defer関数がrecoverを使ってパニックから回復し、関数の実行が継続された場合でも、最終的な戻り値が期待通りの値になることが保証されます。

追加されたテストケースtest/fixedbugs/issue4066.goは、この修正が正しく機能することを確認します。foo関数はval2に設定し、その後foo1を呼び出してpanicを発生させます。foo関数にはdeferがあり、recoverを使ってパニックから回復します。修正前は、main関数で受け取るn2にならないことがありましたが、修正後は常に2になることが期待されます。

関連リンク

参考にした情報源リンク

  • Go Issue 4066: https://github.com/golang/go/issues/4066
  • Go言語のpanicrecoverに関する公式ドキュメントやチュートリアル (一般的な知識のため特定のURLは省略)
  • コンパイラのレジスタ割り当てに関する一般的な情報 (一般的な知識のため特定のURLは省略)
  • Go言語のコンパイラソースコード (src/cmd/5g/reg.c, src/cmd/6g/reg.c, src/cmd/8g/reg.c)