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

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

このコミットは、Go言語の標準ライブラリである expvar パッケージに対する変更です。expvar パッケージは、実行中のGoプログラムの内部状態をHTTP経由で公開するためのシンプルなメカニズムを提供します。これにより、プログラムのメトリクスやデバッグ情報を簡単に監視できます。具体的には、expvarVar インターフェースを実装する様々な型の変数を公開し、それらの値をJSON形式で /debug/vars エンドポイントから取得できるようにします。

コミット

このコミットは、expvar パッケージにおける2つの主要な問題を解決します。一つは、Map 型の変数や公開されているトップレベルの変数の出力順序が不定であることによる視認性の問題、もう一つは、Map 内の IntFloat 型の値を同時に更新しようとした際に発生する可能性のある競合状態(レースコンディション)です。このコミットにより、出力されるマップのキーがソートされるようになり、また、マップ内の数値型変数の更新における競合状態が修正されました。

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 パッケージの利用における実用上の課題と、潜在的なバグの修正があります。

  1. マップの出力順序の不定性: Go言語の map は、その設計上、要素のイテレーション順序が保証されません。これは、マップがハッシュテーブルとして実装されており、要素の格納順序が内部のハッシュ関数やメモリ配置に依存するためです。expvar パッケージが公開する Map 型の変数や、Publish 関数で登録されるトップレベルの変数 (vars) は、内部的に map[string]Var を使用しています。このため、/debug/vars エンドポイントにアクセスするたびに、JSON出力におけるキーの順序がランダムに変わってしまうという問題がありました。これは、特に監視ツールや人間がデバッグ情報を読み取る際に、視認性を著しく低下させ、比較や分析を困難にしていました。ユーザーは安定した出力順序を求めていました。

  2. マップ内の数値型変数更新における競合状態: expvarMap 型には、Add (Int用) および AddFloat (Float用) メソッドがあります。これらのメソッドは、マップ内の既存の Int または Float 変数の値をアトミックに増減させることを目的としています。しかし、元の実装では、これらのメソッドがマップに新しいキーを追加する際に、sync.RWMutex のロックの取得と解放のタイミングに問題がありました。具体的には、RLock (読み取りロック) を取得した状態でキーの存在チェックを行い、キーが存在しない場合に RUnlock してから Lock (書き込みロック) を取得して新しい変数をマップに追加していました。この短い期間に、別のゴルーチンが同じキーに対して AddAddFloat を呼び出すと、両方のゴルーチンが新しい変数を生成し、最終的に一方の更新が失われる(上書きされる)可能性がありました。これは典型的な競合状態であり、データの整合性を損なう深刻なバグでした。

これらの問題に対処するため、マップのキーをソートして出力する機能と、数値型変数の更新における競合状態を解消する修正が導入されました。

前提知識の解説

1. expvar パッケージ

expvar パッケージは、Goプログラムの内部状態をHTTP経由で公開するためのシンプルなメカニズムを提供します。

  • Var インターフェース: expvar パッケージで公開できるすべての変数は、String() string メソッドを持つ Var インターフェースを実装する必要があります。これにより、変数の文字列表現を取得できます。
  • Int, Float, String, Map, Func: expvar パッケージは、int64float64stringmap[string]Var、およびカスタム関数を公開するための具体的な型 (Int, Float, String, Map, Func) を提供します。
  • Publish(name string, v Var): この関数は、指定された名前で Var インターフェースを実装する変数を公開します。公開された変数は、デフォルトで /debug/vars HTTPエンドポイントから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 は、読み取り操作が頻繁に行われ、書き込み操作が比較的少ない場合に特に有効です。読み取りロック中に書き込みロックを要求すると、すべての読み取りロックが解放されるまで書き込みロックは待機します。

このコミットでは、MapAdd および AddFloat メソッドにおける競合状態を解決するために、sync.RWMutex の適切な使用が鍵となります。

技術的詳細

このコミットの技術的詳細は、主に以下の2点に集約されます。

  1. マップのキーのソート:

    • 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の順序が常に安定します。
  2. 競合状態の修正:

    • Map.Add() および Map.AddFloat() メソッドにおける競合状態は、主に新しい Int または Float 変数をマップに追加するロジックにありました。
    • 元のコードでは、まず読み取りロック (RLock) を取得してキーの存在を確認し、存在しない場合に読み取りロックを解放 (RUnlock) してから書き込みロック (Lock) を取得して新しい変数を追加していました。この RUnlock()Lock() の間の短い期間に、別のゴルーチンが同じキーに対して操作を行うと、競合が発生しました。
    • 修正後のコードでは、RLock でキーが存在しないことが確認された場合、すぐに RUnlock するのではなく、書き込みロック (Lock) を取得した状態で再度キーの存在を確認するように変更されました。この「二重チェックロック (Double-Checked Locking)」パターンにより、最初のチェックでキーが存在しないと判断された後、書き込みロックを取得した安全なコンテキスト内で最終的な確認を行うことで、複数のゴルーチンが同時に新しい変数を生成して上書きし合うことを防ぎます。もし書き込みロックを取得した時点で既に別のゴルーチンが変数を追加していれば、その既存の変数を使用し、そうでなければ新しい変数を安全に追加します。
    • 新しい変数が追加された際には、忘れずに updateKeys() を呼び出して、ソート済みキーリストを更新します。

これらの変更により、expvar パッケージはより予測可能で堅牢な動作を提供するようになりました。

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

src/pkg/expvar/expvar.go

  1. Int, Float, String 型のフィールド順序変更:

    • Int struct: i int64mu sync.RWMutex の順序が mu sync.RWMutexi int64 に変更。
    • Float struct: f float64mu sync.RWMutex の順序が mu sync.RWMutexf float64 に変更。
    • String struct: s stringmu sync.RWMutex の順序が mu sync.RWMutexs string に変更。
      • 解説: これは機能的な変更ではなく、慣習的な変更です。ミューテックスを構造体の先頭に置くことで、アライメントや可読性が向上する場合があります。
  2. 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
    }
    
    • 解説: マップのキーをソートして保持するためのスライスが追加されました。
  3. 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() メソッド(後述)を呼び出すように変更されました。これにより、ソートされた順序でキーと値が処理されるようになります。
  4. 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 (書き込みロック) が保持されている状態で呼び出されることを前提としています。
  5. 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() が呼び出され、キーリストがソートされます。
  6. 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() が呼び出され、キーリストが更新されます。
  7. 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 メソッドを介してマップの要素を処理する際に、常にソートされた順序が保証されます。
  8. トップレベルの公開変数 varsvarKeys フィールド追加:

    --- 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
      )
    
    • 解説: トップレベルで公開される変数のキーをソートして保持するためのスライスが追加されました。
  9. 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() でソートされます。
  10. トップレベルの 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

  1. 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 もリセットされるように変更されました。
  2. 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を出力するかどうかを検証するためのテストが追加されました。map1map2 という2つのマップを作成し、それぞれにキーを追加した後、期待されるソートされたJSON出力と比較しています。

コアとなるコードの解説

マップのソート機能

expvar パッケージの Map 型と、グローバルな公開変数マップ vars の両方に、キーのソート機能が導入されました。

  1. Map 型の keys フィールドと updateKeys() メソッド:

    • Map struct に 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 メソッドで処理される要素の順序が常にアルファベット順に保証されます。
  2. グローバルな vars マップの varKeysPublish():

    • expvar パッケージ全体で公開されている変数を管理する vars マップ (map[string]Var) についても、同様に varKeys []string というソート済みキーリストが導入されました。
    • Publish(name string, v Var) 関数が呼び出され、新しい変数が公開されるたびに、namevarKeys に追加され、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] をチェックします

    • もしこの時点で oktrue になっていれば、それは別のゴルーチンが既にこのキーを追加したことを意味します。この場合、既存の av を使用して処理を続行します。
    • もし ok がまだ false であれば、このゴルーチンが最初にこのキーを追加する権利を得たことになります。ここで新しい Int (または Float) を作成し、マップに追加します。 この「二重チェックロック」パターンにより、複数のゴルーチンが同時に新しい変数を生成して上書きし合うという競合状態が確実に防止されます。

これらの変更は、expvar パッケージの堅牢性と使いやすさを大幅に向上させました。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント (expvar, sync, sort パッケージ)
  • Go言語のマップのイテレーション順序に関する一般的な知識
  • 競合状態とミューテックスに関する一般的なプログラミングの知識
  • Go言語のコードレビューシステム (Gerrit) の変更セット: https://golang.org/cl/54320043 (元のコミットメッセージに記載されているリンク)