[インデックス 12522] ファイルの概要
このコミットは、Go言語の公式フォーマッタである gofmt
コマンドにおける、長期実行テスト(long test)での競合状態(race condition)を修正するものです。具体的には、gofmt
が内部的に使用する go/token.FileSet
の扱いを改善し、テストの安定性を向上させています。
コミット
commit dfb1af4b97ffed0c2acbbc52b0f85355d727974a
Author: Mikio Hara <mikioh.mikioh@gmail.com>
Date: Thu Mar 8 23:56:26 2012 +0900
cmd/gofmt: fix race in long test
Fixes #3249.
R=rsc
CC=golang-dev
https://golang.org/cl/5792043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/dfb1af4b97ffed0c2acbbc52b0f85355d727974a
元コミット内容
cmd/gofmt: fix race in long test
Fixes #3249.
このコミットは、gofmt
コマンドの長期テストにおける競合状態を修正し、Issue #3249 を解決することを目的としています。
変更の背景
gofmt
はGo言語のソースコードを整形するためのツールであり、その正確性と安定性はGo開発において非常に重要です。このコミットが行われた当時、gofmt
の長期実行テストにおいて、稀に失敗する競合状態の問題が存在していました。
競合状態とは、複数のゴルーチン(Goにおける軽量スレッド)が共有リソースに同時にアクセスし、そのアクセス順序によって結果が非決定的に変わってしまうバグの一種です。この場合、gofmt
が内部で利用する go/token.FileSet
オブジェクトが、テスト実行中に複数の処理から同時に変更される可能性があり、それがテストの不安定性を引き起こしていました。
Issue #3249 は、gofmt
が //line
コメントを適切に処理しないという問題に関連している可能性があります。//line
コメントは、コンパイラやデバッガに対して、ソースコードの行番号やファイル名を変更したかのように見せかけるための特殊なコメントです。gofmt
がコードを整形する際に、これらのコメントの位置や内容を誤って変更してしまうと、デバッグ情報が狂ったり、コンパイルエラーが発生したりする可能性があります。競合状態がこの //line
コメントの処理に影響を与え、テストが失敗していたと考えられます。
このコミットは、テストの信頼性を高め、gofmt
の品質を保証するために必要とされました。
前提知識の解説
Go言語の go/token
パッケージと FileSet
Go言語の標準ライブラリには、ソースコードの解析(パース)を支援するための go/token
パッケージが含まれています。このパッケージの主要な型の一つが token.FileSet
です。
token.FileSet
: Goのソースコードを解析する際に、ファイル内の位置情報(行番号、列番号、オフセットなど)を管理するためのオブジェクトです。Goのパーサー(go/parser
パッケージ)は、ソースコードを読み込む際にFileSet
を使用して、各トークンやASTノードがソースコードのどの位置に存在するかを記録します。これにより、エラーメッセージの表示やデバッグ情報の生成が正確に行われます。- 位置情報の一元管理:
FileSet
は、複数のソースファイルにまたがる位置情報を一元的に管理します。例えば、複数のファイルからなるパッケージを解析する場合、すべてのファイルが同じFileSet
を共有することで、ファイル間の参照やエラー報告が正確になります。 - スレッドセーフティ:
FileSet
は、その性質上、複数のパーシング処理から同時にアクセスされる可能性があります。特に並行処理を行うアプリケーションやテストにおいては、FileSet
の操作がスレッドセーフであるか、あるいは適切に同期されているかが重要になります。
競合状態(Race Condition)
競合状態は、並行処理において複数の処理(この場合はゴルーチン)が共有リソースにアクセスする際に発生する問題です。
- 共有リソース: 複数の処理から読み書きされる可能性のあるデータやオブジェクト。このコミットの文脈では、
token.FileSet
が共有リソースに該当します。 - 非決定性: 競合状態が発生すると、処理の実行順序によって最終的な結果が変わってしまうため、プログラムの動作が非決定性になります。これにより、バグの再現が困難になり、デバッグが非常に難しくなります。
- データ競合(Data Race): 特に、複数の処理が同時に同じメモリ位置にアクセスし、少なくとも一方が書き込み操作である場合に発生する競合状態をデータ競合と呼びます。Go言語では、
go run -race
コマンドでデータ競合を検出するツールが提供されています。
gofmt
の動作原理
gofmt
は、Goのソースコードを解析し、抽象構文木(AST: Abstract Syntax Tree)を構築します。その後、このASTを標準的なGoのフォーマットルールに従って再構築し、整形されたコードを出力します。このプロセスにおいて、go/token.FileSet
はASTノードの正確な位置情報を保持するために不可欠な役割を果たします。
技術的詳細
このコミットの核心は、gofmt
が token.FileSet
をどのように利用するかという点にあります。変更前は、gofmt
のメイン処理内で fset = token.NewFileSet()
という形でグローバル変数として FileSet
が宣言され、初期化されていました。これは、gofmt
プロセス全体で単一の FileSet
インスタンスが共有されることを意味します。
通常の gofmt
の実行では、単一のファイルまたはディレクトリを処理するため、このグローバルな FileSet
の利用は問題になりにくいです。しかし、テスト、特に複数のファイルを並行して処理するような「長期テスト(long test)」においては、このグローバルな FileSet
が複数のテストゴルーチンから同時にアクセスされ、変更される可能性がありました。
token.FileSet
は、ファイルを追加したり、位置情報を更新したりする際に内部状態を変更します。複数のゴルーチンが同時に FileSet
を変更しようとすると、データ競合が発生し、FileSet
の内部状態が破損したり、不正な位置情報が生成されたりする可能性があります。これが、テストが不安定になる原因でした。
このコミットでは、この問題を解決するために以下の変更を行っています。
-
グローバル
FileSet
の名称変更とコメント追加:fset
というグローバル変数をfileSet
に変更し、// per process FileSet
というコメントを追加しています。これは、このFileSet
がプロセス全体で共有されるものであることを明示しています。しかし、この変更だけでは競合状態は解決しません。 -
parse
関数のシグネチャ変更:parse
関数は、ソースコードを解析してASTを生成する主要な関数です。変更前はparse(filename string, src []byte, stdin bool)
でしたが、変更後はparse(fset *token.FileSet, filename string, src []byte, stdin bool)
となり、token.FileSet
のポインタを引数として受け取るようになりました。これにより、parse
関数がどのFileSet
を使用するかを呼び出し元が明示的に指定できるようになります。 -
processFile
関数でのfileSet
の引き渡し:gofmt
のメイン処理であるprocessFile
関数内で、parse
関数やast.SortImports
、printer.Config.Fprint
といったFileSet
を必要とする処理に対して、グローバルなfileSet
変数を明示的に引き渡すように変更されました。これにより、gofmt
の通常の実行パスでは引き続き単一のFileSet
が使用されます。 -
long_test.go
でのFileSet
のローカル化: 最も重要な変更はsrc/cmd/gofmt/long_test.go
にあります。gofmt
テストヘルパー関数もgofmt(fset *token.FileSet, filename string, src *bytes.Buffer)
とFileSet
を引数として受け取るように変更されました。testFile
関数内で、各テストケースの実行時にfset := token.NewFileSet()
と、新しいFileSet
インスタンスをローカルに作成するように変更されました。- このローカルに作成された
fset
が、parse
関数やgofmt
ヘルパー関数に引き渡されるようになりました。
この変更により、long_test.go
の各テストゴルーチンは、それぞれ独立した token.FileSet
インスタンスを持つことになります。これにより、複数のゴルーチンが同時に FileSet
を操作しても、互いの状態に影響を与えることがなくなり、競合状態が解消されます。
コアとなるコードの変更箇所
src/cmd/gofmt/gofmt.go
--- a/src/cmd/gofmt/gofmt.go
+++ b/src/cmd/gofmt/gofmt.go
@@ -41,7 +41,7 @@ var (
)
var (
- fset = token.NewFileSet()\n+\tfileSet = token.NewFileSet() // per process FileSet\n \texitCode = 0\n \trewrite func(*ast.File) *ast.File\n \tparserMode parser.Mode\n@@ -98,7 +98,7 @@ func processFile(filename string, in io.Reader, out io.Writer, stdin bool) error\n \t\treturn err\n \t}\n \n-\tfile, adjust, err := parse(filename, src, stdin)\n+\tfile, adjust, err := parse(fileSet, filename, src, stdin)\n \tif err != nil {\n \t\treturn err\n \t}\n@@ -111,14 +111,14 @@ func processFile(filename string, in io.Reader, out io.Writer, stdin bool) error\n \t\t}\n \t}\n \n-\tast.SortImports(fset, file)\n+\tast.SortImports(fileSet, file)\n \n \tif *simplifyAST {\n \t\tsimplify(file)\n \t}\n \n \tvar buf bytes.Buffer\n-\terr = (&printer.Config{Mode: printerMode, Tabwidth: *tabWidth}).Fprint(&buf, fset, file)\n+\terr = (&printer.Config{Mode: printerMode, Tabwidth: *tabWidth}).Fprint(&buf, fileSet, file)\n \tif err != nil {\n \t\treturn err\n \t}\n@@ -254,7 +254,7 @@ func diff(b1, b2 []byte) (data []byte, err error) {\n \n // parse parses src, which was read from filename,\n // as a Go source file or statement list.\n-func parse(filename string, src []byte, stdin bool) (*ast.File, func(orig, src []byte) []byte, error) {\n+func parse(fset *token.FileSet, filename string, src []byte, stdin bool) (*ast.File, func(orig, src []byte) []byte, error) {\n \t// Try as whole source file.\n \tfile, err := parser.ParseFile(fset, filename, src, parserMode)\n \tif err == nil {\n```
### `src/cmd/gofmt/long_test.go`
```diff
--- a/src/cmd/gofmt/long_test.go
+++ b/src/cmd/gofmt/long_test.go
@@ -14,6 +14,7 @@ import (\n \t\"fmt\"\n \t\"go/ast\"\n \t\"go/printer\"\n+\t\"go/token\"\n \t\"io\"\n \t\"os\"\n \t\"path/filepath\"\n@@ -30,8 +31,8 @@ var (\n \tnfiles int // number of files processed\n )\n \n-func gofmt(filename string, src *bytes.Buffer) error {\n-\tf, _, err := parse(filename, src.Bytes(), false)\n+\tfunc gofmt(fset *token.FileSet, filename string, src *bytes.Buffer) error {\n+\tf, _, err := parse(fset, filename, src.Bytes(), false)\n \tif err != nil {\n \t\treturn err\n \t}\n@@ -58,7 +59,8 @@ func testFile(t *testing.T, b1, b2 *bytes.Buffer, filename string) {\n \t}\n \n \t// exclude files w/ syntax errors (typically test cases)\n-\tif _, _, err = parse(filename, b1.Bytes(), false); err != nil {\n+\tfset := token.NewFileSet()\n+\tif _, _, err = parse(fset, filename, b1.Bytes(), false); err != nil {\n \t\tif *verbose {\n \t\t\tfmt.Fprintf(os.Stderr, \"ignoring %s\\n\", err)\n \t\t}\n@@ -66,7 +68,7 @@ func testFile(t *testing.T, b1, b2 *bytes.Buffer, filename string) {\n \t}\n \n \t// gofmt file\n-\tif err = gofmt(filename, b1); err != nil {\n+\tif err = gofmt(fset, filename, b1); err != nil {\n \t\tt.Errorf(\"1st gofmt failed: %v\", err)\n \t\treturn\n \t}\n@@ -76,7 +78,7 @@ func testFile(t *testing.T, b1, b2 *bytes.Buffer, filename string) {\n \tb2.Write(b1.Bytes())\n \n \t// gofmt result again\n-\tif err = gofmt(filename, b2); err != nil {\n+\tif err = gofmt(fset, filename, b2); err != nil {\n \t\tt.Errorf(\"2nd gofmt failed: %v\", err)\n \t\treturn\n \t}\n```
## コアとなるコードの解説
### `src/cmd/gofmt/gofmt.go` の変更点
1. **`fset` から `fileSet` への変数名変更**:
`var ( fset = token.NewFileSet() )` が `var ( fileSet = token.NewFileSet() // per process FileSet )` に変更されました。これは単なる変数名の変更ですが、コメント `// per process FileSet` が追加されたことで、この `FileSet` がプロセス全体で共有されるものであるという意図が明確になりました。
2. **`parse` 関数のシグネチャ変更**:
`func parse(filename string, src []byte, stdin bool)` が `func parse(fset *token.FileSet, filename string, src []byte, stdin bool)` に変更されました。これにより、`parse` 関数がどの `FileSet` インスタンスを使用するかを呼び出し元が指定できるようになりました。これは、依存性注入(Dependency Injection)の一種と見なすことができ、関数の再利用性やテスト容易性を向上させます。
3. **`processFile` 関数内での `fileSet` の引き渡し**:
`processFile` 関数内で `parse`、`ast.SortImports`、`printer.Config.Fprint` の各呼び出しにおいて、グローバル変数 `fileSet` が明示的に引数として渡されるようになりました。これにより、`gofmt` の通常の実行では引き続き単一の `FileSet` が使用されます。
### `src/cmd/gofmt/long_test.go` の変更点
1. **`go/token` パッケージのインポート**:
`import ("go/token")` が追加され、`token.FileSet` 型が利用可能になりました。
2. **`gofmt` テストヘルパー関数のシグネチャ変更**:
`func gofmt(filename string, src *bytes.Buffer) error` が `func gofmt(fset *token.FileSet, filename string, src *bytes.Buffer) error` に変更されました。これにより、テスト用の `gofmt` ヘルパー関数も `FileSet` を引数として受け取るようになりました。
3. **`testFile` 関数内での `FileSet` のローカル化**:
`testFile` 関数内で、`parse` 関数を呼び出す前に `fset := token.NewFileSet()` という行が追加されました。これにより、各テストケースの実行ごとに新しい `FileSet` インスタンスが作成され、そのテストケース専用の `FileSet` が使用されるようになります。
* `if _, _, err = parse(filename, b1.Bytes(), false); err != nil {` が `if _, _, err = parse(fset, filename, b1.Bytes(), false); err != nil {` に変更。
* `if err = gofmt(filename, b1); err != nil {` が `if err = gofmt(fset, filename, b1); err != nil {` に変更。
* `if err = gofmt(filename, b2); err != nil {` が `if err = gofmt(fset, filename, b2); err != nil {` に変更。
この `long_test.go` での `FileSet` のローカル化が、競合状態の根本的な解決策です。各テストゴルーチンが独自の `FileSet` を持つことで、共有リソースへの同時書き込みがなくなり、テストの実行が安定します。
## 関連リンク
* Go言語の `go/token` パッケージのドキュメント: [https://pkg.go.dev/go/token](https://pkg.go.dev/go/token)
* Go言語の `go/parser` パッケージのドキュメント: [https://pkg.go.dev/go/parser](https://pkg.go.dev/go/parser)
* Go言語の `go/ast` パッケージのドキュメント: [https://pkg.go.dev/go/ast](https://pkg.go.dev/go/ast)
* Go言語の `go/printer` パッケージのドキュメント: [https://pkg.go.dev/go/printer](https://pkg.go.dev/go/printer)
## 参考にした情報源リンク
* GitHubコミットページ: [https://github.com/golang/go/commit/dfb1af4b97ffed0c2acbbc52b0f85355d727974a](https://github.com/golang/go/commit/dfb1af4b97ffed0c2acbbc52b0f85355d727974a)
* Go Issue #3249: `cmd/gofmt: valid //line comments must remain at the beginning of the line` (Web検索結果より)
* このIssueの具体的な内容は、`gofmt` が `//line` コメントを適切に扱わないことによる問題を示唆しており、競合状態がその一因であった可能性が高いです。
* Go言語の競合状態検出(Race Detector)に関するドキュメント: [https://go.dev/doc/articles/race_detector](https://go.dev/doc/articles/race_detector)