[インデックス 18294] ファイルの概要
このコミットは、Go言語の標準ライブラリである expvar パッケージに対する変更です。expvar パッケージは、実行中のGoプログラムの内部状態をHTTP経由で公開するためのシンプルなメカニズムを提供します。これにより、プログラムのメトリクスやデバッグ情報を簡単に監視できます。具体的には、expvar は Var インターフェースを実装する様々な型の変数を公開し、それらの値をJSON形式で /debug/vars エンドポイントから取得できるようにします。
コミット
このコミットは、expvar パッケージにおける2つの主要な問題を解決します。一つは、Map 型の変数や公開されているトップレベルの変数の出力順序が不定であることによる視認性の問題、もう一つは、Map 内の Int や Float 型の値を同時に更新しようとした際に発生する可能性のある競合状態(レースコンディション)です。このコミットにより、出力されるマップのキーがソートされるようになり、また、マップ内の数値型変数の更新における競合状態が修正されました。
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/cfb9cf0f0679b07583b8667487c516b1f77f2292
元コミット内容
expvar: sort maps, fix race
It's pretty distracting to use expvar with the output of both
the top-level map and map values jumping around randomly.
Also fixes a potential race where multiple clients trying to
increment a map int or float key at the same time could lose
updates.
R=golang-codereviews, couchmoney
CC=golang-codereviews
https://golang.org/cl/54320043
変更の背景
このコミットが行われた背景には、expvar パッケージの利用における実用上の課題と、潜在的なバグの修正があります。
-
マップの出力順序の不定性: Go言語の
mapは、その設計上、要素のイテレーション順序が保証されません。これは、マップがハッシュテーブルとして実装されており、要素の格納順序が内部のハッシュ関数やメモリ配置に依存するためです。expvarパッケージが公開するMap型の変数や、Publish関数で登録されるトップレベルの変数 (vars) は、内部的にmap[string]Varを使用しています。このため、/debug/varsエンドポイントにアクセスするたびに、JSON出力におけるキーの順序がランダムに変わってしまうという問題がありました。これは、特に監視ツールや人間がデバッグ情報を読み取る際に、視認性を著しく低下させ、比較や分析を困難にしていました。ユーザーは安定した出力順序を求めていました。 -
マップ内の数値型変数更新における競合状態:
expvarのMap型には、Add(Int用) およびAddFloat(Float用) メソッドがあります。これらのメソッドは、マップ内の既存のIntまたはFloat変数の値をアトミックに増減させることを目的としています。しかし、元の実装では、これらのメソッドがマップに新しいキーを追加する際に、sync.RWMutexのロックの取得と解放のタイミングに問題がありました。具体的には、RLock(読み取りロック) を取得した状態でキーの存在チェックを行い、キーが存在しない場合にRUnlockしてからLock(書き込みロック) を取得して新しい変数をマップに追加していました。この短い期間に、別のゴルーチンが同じキーに対してAddやAddFloatを呼び出すと、両方のゴルーチンが新しい変数を生成し、最終的に一方の更新が失われる(上書きされる)可能性がありました。これは典型的な競合状態であり、データの整合性を損なう深刻なバグでした。
これらの問題に対処するため、マップのキーをソートして出力する機能と、数値型変数の更新における競合状態を解消する修正が導入されました。
前提知識の解説
1. expvar パッケージ
expvar パッケージは、Goプログラムの内部状態をHTTP経由で公開するためのシンプルなメカニズムを提供します。
Varインターフェース:expvarパッケージで公開できるすべての変数は、String() stringメソッドを持つVarインターフェースを実装する必要があります。これにより、変数の文字列表現を取得できます。Int,Float,String,Map,Func:expvarパッケージは、int64、float64、string、map[string]Var、およびカスタム関数を公開するための具体的な型 (Int,Float,String,Map,Func) を提供します。Publish(name string, v Var): この関数は、指定された名前でVarインターフェースを実装する変数を公開します。公開された変数は、デフォルトで/debug/varsHTTPエンドポイントからJSON形式でアクセスできるようになります。/debug/vars:expvarパッケージをインポートすると、自動的に/debug/varsというHTTPエンドポイントが登録されます。このエンドポイントにアクセスすると、公開されているすべての変数の現在の値がJSON形式で返されます。
2. Go言語における map のイテレーション順序
Go言語の map は、キーと値のペアを格納するための組み込みのデータ構造です。重要な特性として、map のイテレーション順序は保証されません。つまり、for key, value := range myMap のようにマップをループするたびに、キーと値が返される順序は異なる可能性があります。これは、マップがハッシュテーブルとして実装されており、要素の格納順序が内部のハッシュ関数やメモリ配置に依存するためです。この不定性は、意図的な設計であり、開発者が特定の順序に依存しないように促すとともに、マップの実装を最適化するための柔軟性を提供します。しかし、expvar のように外部に情報を公開する際には、この不定性が問題となることがあります。
3. 競合状態 (Race Condition) と sync.RWMutex
- 競合状態: 複数のゴルーチンが共有リソース(メモリ上の変数など)に同時にアクセスし、少なくとも1つのゴルーチンがそのリソースを変更する場合に発生する可能性があります。アクセスするタイミングによって結果が非決定的に変わるため、プログラムの動作が予測不能になり、バグの原因となります。
sync.RWMutex: Go言語のsyncパッケージが提供する読み書きミューテックスです。Lock()/Unlock(): 排他ロック。一度に1つのゴルーチンのみがリソースにアクセスできます。書き込み操作に適しています。RLock()/RUnlock(): 読み取りロック。複数のゴルーチンが同時にリソースを読み取ることができますが、書き込み操作はブロックされます。読み取り操作が多い場合にパフォーマンスを向上させます。RWMutexは、読み取り操作が頻繁に行われ、書き込み操作が比較的少ない場合に特に有効です。読み取りロック中に書き込みロックを要求すると、すべての読み取りロックが解放されるまで書き込みロックは待機します。
このコミットでは、Map の Add および AddFloat メソッドにおける競合状態を解決するために、sync.RWMutex の適切な使用が鍵となります。
技術的詳細
このコミットの技術的詳細は、主に以下の2点に集約されます。
-
マップのキーのソート:
expvar.Map型にkeys []stringフィールドが追加されました。これは、マップmに格納されているキーのソート済みリストを保持するためのものです。- トップレベルの公開変数
vars(これもmap[string]Var型) についても同様に、varKeys []stringが追加されました。 - 新しいキーがマップに追加されるたびに、
updateKeys()メソッド(Map型の場合)またはPublish関数(トップレベルの変数varsの場合)内で、sort.Strings()を使用してkeysまたはvarKeysスライスがソートされます。これにより、常にソートされたキーのリストが利用可能になります。 Map.String()メソッドやMap.Do()メソッド、およびトップレベルのDo()関数は、マップmを直接イテレートする代わりに、このソートされたkeysまたはvarKeysスライスを使用してキーを順番に処理するように変更されました。これにより、出力されるJSONの順序が常に安定します。
-
競合状態の修正:
Map.Add()およびMap.AddFloat()メソッドにおける競合状態は、主に新しいIntまたはFloat変数をマップに追加するロジックにありました。- 元のコードでは、まず読み取りロック (
RLock) を取得してキーの存在を確認し、存在しない場合に読み取りロックを解放 (RUnlock) してから書き込みロック (Lock) を取得して新しい変数を追加していました。このRUnlock()とLock()の間の短い期間に、別のゴルーチンが同じキーに対して操作を行うと、競合が発生しました。 - 修正後のコードでは、
RLockでキーが存在しないことが確認された場合、すぐにRUnlockするのではなく、書き込みロック (Lock) を取得した状態で再度キーの存在を確認するように変更されました。この「二重チェックロック (Double-Checked Locking)」パターンにより、最初のチェックでキーが存在しないと判断された後、書き込みロックを取得した安全なコンテキスト内で最終的な確認を行うことで、複数のゴルーチンが同時に新しい変数を生成して上書きし合うことを防ぎます。もし書き込みロックを取得した時点で既に別のゴルーチンが変数を追加していれば、その既存の変数を使用し、そうでなければ新しい変数を安全に追加します。 - 新しい変数が追加された際には、忘れずに
updateKeys()を呼び出して、ソート済みキーリストを更新します。
これらの変更により、expvar パッケージはより予測可能で堅牢な動作を提供するようになりました。
コアとなるコードの変更箇所
src/pkg/expvar/expvar.go
-
Int,Float,String型のフィールド順序変更:Intstruct:i int64とmu sync.RWMutexの順序がmu sync.RWMutexとi int64に変更。Floatstruct:f float64とmu sync.RWMutexの順序がmu sync.RWMutexとf float64に変更。Stringstruct:s stringとmu sync.RWMutexの順序がmu sync.RWMutexとs stringに変更。- 解説: これは機能的な変更ではなく、慣習的な変更です。ミューテックスを構造体の先頭に置くことで、アライメントや可読性が向上する場合があります。
-
Map型へのkeysフィールド追加:// Map is a string-to-Var map variable that satisfies the Var interface. type Map struct { mu sync.RWMutex m map[string]Var keys []string // sorted }- 解説: マップのキーをソートして保持するためのスライスが追加されました。
-
Map.String()メソッドの変更:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -106,13 +108,13 @@ func (v *Map) String() string { var b bytes.Buffer fmt.Fprintf(&b, "{") first := true - for key, val := range v.m { + v.Do(func(kv KeyValue) { if !first { fmt.Fprintf(&b, ", ") } - fmt.Fprintf(&b, "\"%s\": %v", key, val) + fmt.Fprintf(&b, "\"%s\": %v", kv.Key, kv.Value) first = false - } + }) fmt.Fprintf(&b, "}") return b.String() }- 解説:
v.mを直接イテレートする代わりに、v.Do()メソッド(後述)を呼び出すように変更されました。これにより、ソートされた順序でキーと値が処理されるようになります。
- 解説:
-
Map.updateKeys()メソッドの追加:// updateKeys updates the sorted list of keys in v.keys. // must be called with v.mu held. func (v *Map) updateKeys() { if len(v.m) == len(v.keys) { // No new key. return } v.keys = v.keys[:0] for k := range v.m { v.keys = append(v.keys, k) } sort.Strings(v.keys) }- 解説:
Mapに新しいキーが追加された際に、v.keysスライスを更新し、ソートするためのヘルパー関数です。v.mu(書き込みロック) が保持されている状態で呼び出されることを前提としています。
- 解説:
-
Map.Set()メソッドでのupdateKeys()呼び出し:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -129,6 +148,7 @@ func (v *Map) Set(key string, av Var) { v.mu.Lock() defer v.mu.Unlock() v.m[key] = av + v.updateKeys() }- 解説:
Setメソッドで新しいキーが追加されたり、既存のキーの値が更新されたりする際に、updateKeys()が呼び出され、キーリストがソートされます。
- 解説:
-
Map.Add()およびMap.AddFloat()メソッドにおける競合状態の修正とupdateKeys()呼び出し:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -139,9 +158,11 @@ func (v *Map) Add(key string, delta int64) { if !ok { // check again under the write lock v.mu.Lock() - if _, ok = v.m[key]; !ok { + av, ok = v.m[key] + if !ok { av = new(Int) v.m[key] = av + v.updateKeys() } v.mu.Unlock() } @@ -162,9 +181,11 @@ func (v *Map) AddFloat(key string, delta float64) { if !ok { // check again under the write lock v.mu.Lock() - if _, ok = v.m[key]; !ok { + av, ok = v.m[key] + if !ok { av = new(Float) v.m[key] = av + v.updateKeys() } v.mu.Unlock() }- 解説:
RLockでキーが存在しない場合に、Lockを取得した後に再度キーの存在を確認する「二重チェックロック」パターンが導入されました。これにより、新しいIntまたはFloat変数をマップに追加する際の競合状態が解消されます。また、新しい変数が追加された際にupdateKeys()が呼び出され、キーリストが更新されます。
- 解説:
-
Map.Do()メソッドの変更:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -181,15 +202,15 @@ func (v *Map) Do(f func(KeyValue)) { func (v *Map) Do(f func(KeyValue)) { v.mu.RLock() defer v.mu.RUnlock() - for k, v := range v.m { - f(KeyValue{k, v}) + for _, k := range v.keys { + f(KeyValue{k, v.m[k]}) } }- 解説:
v.mを直接イテレートする代わりに、ソートされたv.keysスライスをイテレートするように変更されました。これにより、Doメソッドを介してマップの要素を処理する際に、常にソートされた順序が保証されます。
- 解説:
-
トップレベルの公開変数
varsのvarKeysフィールド追加:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -215,8 +236,9 @@ func (f Func) String() string { // All published variables. var ( - mutex sync.RWMutex - vars map[string]Var = make(map[string]Var) + mutex sync.RWMutex + vars = make(map[string]Var) + varKeys []string // sorted )- 解説: トップレベルで公開される変数のキーをソートして保持するためのスライスが追加されました。
-
Publish()関数でのvarKeys更新とソート:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -229,6 +251,8 @@ func Publish(name string, v Var) { log.Panicln("Reuse of exported var name:", name) } vars[name] = v + varKeys = append(varKeys, name) + sort.Strings(varKeys) }- 解説: 新しい変数が
Publishされるたびに、varKeysにキーが追加され、sort.Strings()でソートされます。
- 解説: 新しい変数が
-
トップレベルの
Do()関数でのvarKeys利用:--- a/src/pkg/expvar/expvar.go +++ b/src/pkg/expvar/expvar.go @@ -270,8 +294,8 @@ func NewString(name string) *String { func Do(f func(KeyValue)) { mutex.RLock() defer mutex.RUnlock() - for k, v := range vars { - f(KeyValue{k, v}) + for _, k := range varKeys { + f(KeyValue{k, vars[k]}) } }- 解説: トップレベルの
varsマップを直接イテレートする代わりに、ソートされたvarKeysスライスをイテレートするように変更されました。
- 解説: トップレベルの
src/pkg/expvar/expvar_test.go
-
RemoveAll()関数でのvarKeysリセット:--- a/src/pkg/expvar/expvar_test.go +++ b/src/pkg/expvar/expvar_test.go @@ -15,6 +18,7 @@ func RemoveAll() { mutex.Lock() defer mutex.Unlock() vars = make(map[string]Var) + varKeys = nil }- 解説: テストのクリーンアップ時に、
varKeysもリセットされるように変更されました。
- 解説: テストのクリーンアップ時に、
-
TestHandler()テスト関数の追加:func TestHandler(t *testing.T) { RemoveAll() m := NewMap("map1") m.Add("a", 1) m.Add("z", 2) m2 := NewMap("map2") for i := 0; i < 9; i++ { m2.Add(strconv.Itoa(i), int64(i)) } rr := httptest.NewRecorder() rr.Body = new(bytes.Buffer) expvarHandler(rr, nil) want := `{\n"map1": {"a": 1, "z": 2},\n"map2": {"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8}\n}\n` if got := rr.Body.String(); got != want { t.Errorf("HTTP handler wrote:\n%s\nWant:\n%s", got, want) } }- 解説:
expvarのHTTPハンドラ (expvarHandler) が、マップのキーを正しくソートしてJSONを出力するかどうかを検証するためのテストが追加されました。map1とmap2という2つのマップを作成し、それぞれにキーを追加した後、期待されるソートされたJSON出力と比較しています。
- 解説:
コアとなるコードの解説
マップのソート機能
expvar パッケージの Map 型と、グローバルな公開変数マップ vars の両方に、キーのソート機能が導入されました。
-
Map型のkeysフィールドとupdateKeys()メソッド:Mapstruct にkeys []stringが追加されたことで、Mapインスタンスは自身のキーのソート済みリストを内部に保持できるようになりました。updateKeys()メソッドは、Mapの内部マップmに変更があった際に呼び出され、v.keysスライスをクリアし、mのすべてのキーを再収集してsort.Strings()でソートします。このメソッドは、Map.Set(),Map.Add(),Map.AddFloat()の中で、新しいキーが追加される可能性のあるパスで呼び出されます。Map.String()とMap.Do()メソッドは、従来のfor key, val := range v.mのようにマップを直接イテレートする代わりに、for _, k := range v.keysのようにソートされたv.keysスライスをイテレートするように変更されました。これにより、expvarの出力やDoメソッドで処理される要素の順序が常にアルファベット順に保証されます。
-
グローバルな
varsマップのvarKeysとPublish():expvarパッケージ全体で公開されている変数を管理するvarsマップ (map[string]Var) についても、同様にvarKeys []stringというソート済みキーリストが導入されました。Publish(name string, v Var)関数が呼び出され、新しい変数が公開されるたびに、nameがvarKeysに追加され、sort.Strings(varKeys)によってソートされます。- トップレベルの
Do(f func(KeyValue))関数も、varsマップを直接イテレートする代わりに、ソートされたvarKeysを使用してfを呼び出すように変更されました。これにより、/debug/varsエンドポイントから取得されるJSON出力のトップレベルのキーも常にソートされた順序になります。
このソート機能の導入により、expvar の出力が安定し、人間やツールによる解析が格段に容易になりました。
競合状態の修正
Map.Add() および Map.AddFloat() メソッドにおける競合状態は、新しい Int または Float 変数をマップに追加するロジックの改善によって解決されました。
-
元の問題:
// Map.Add (simplified) v.mu.RLock() av, ok := v.m[key] // Read lock for existence check v.mu.RUnlock() // Release read lock if !ok { v.mu.Lock() // Acquire write lock if _, ok = v.m[key]; !ok { // Re-check (but not fully safe if another goroutine just added it) av = new(Int) v.m[key] = av } v.mu.Unlock() } // ... increment avこのコードでは、
RUnlock()とLock()の間に別のゴルーチンが同じキーを追加してしまうと、両方のゴルーチンが新しいInt(またはFloat) を作成し、最終的に一方のIntがマップに設定され、もう一方のIntはガベージコレクションされ、そのゴルーチンが行った更新が失われる可能性がありました。 -
修正後のロジック (二重チェックロック):
// Map.Add (simplified, after fix) v.mu.RLock() av, ok := v.m[key] v.mu.RUnlock() if !ok { v.mu.Lock() // Acquire write lock av, ok = v.m[key] // Re-check under write lock (this is the crucial part) if !ok { // If still not found, then it's safe to add av = new(Int) v.m[key] = av v.updateKeys() // Update sorted keys } v.mu.Unlock() } // ... increment av修正後のコードでは、最初の
RLockでキーが存在しないと判断された後、すぐにv.mu.Lock()を取得します。そして、書き込みロックが保持されている安全な状態で、再度v.m[key]をチェックします。- もしこの時点で
okがtrueになっていれば、それは別のゴルーチンが既にこのキーを追加したことを意味します。この場合、既存のavを使用して処理を続行します。 - もし
okがまだfalseであれば、このゴルーチンが最初にこのキーを追加する権利を得たことになります。ここで新しいInt(またはFloat) を作成し、マップに追加します。 この「二重チェックロック」パターンにより、複数のゴルーチンが同時に新しい変数を生成して上書きし合うという競合状態が確実に防止されます。
- もしこの時点で
これらの変更は、expvar パッケージの堅牢性と使いやすさを大幅に向上させました。
関連リンク
- Go言語
expvarパッケージのドキュメント: https://pkg.go.dev/expvar - Go言語
syncパッケージのドキュメント: https://pkg.go.dev/sync - Go言語
sortパッケージのドキュメント: https://pkg.go.dev/sort - Go言語におけるマップのイテレーション順序に関する公式ブログ記事 (Go 1.0.3リリースノートの一部): https://go.dev/doc/go1.0.3#map (「Map iteration order is not specified」の項目を参照)
参考にした情報源リンク
- Go言語の公式ドキュメント (
expvar,sync,sortパッケージ) - Go言語のマップのイテレーション順序に関する一般的な知識
- 競合状態とミューテックスに関する一般的なプログラミングの知識
- Go言語のコードレビューシステム (Gerrit) の変更セット: https://golang.org/cl/54320043 (元のコミットメッセージに記載されているリンク)