Go 1.9の新しいsync.Mapに対処する

Go 1.9の革新の1つは、標準ライブラリに新しいsync.Mapタイプを追加したことです。このライブラリの種類と目的を理解していない場合は、この記事が役立ちます。







出力、TL、DRのみに関心がある場合:







多数のプロセッサコア(32+)を搭載した高負荷(および100ns解決)システムがある場合、標準マップ+ sync.RWMutexの代わりにsync.Mapを使用できます。 その他の場合、sync.Mapは特に必要ありません。







詳細が興味深い場合は、基本から始めましょう。







タイプマップ



「キー」-「値」形式のデータを扱う場合、必要なのは組み込みのmap



タイプ(マップ)だけです。 マップの使用方法に関する優れた紹介は、 Effective Goおよび「Go Maps in Action」ブログ投稿にあります。







map



は一般的なデータ構造であり、キーはスライスと関数を除く任意のタイプで、値は一般的に任意のタイプです。 これは本質的に最適化されたハッシュテーブルです。 マップの内部構造に興味があるなら、過去にGopherConがこのトピックについて非常に良い報告をしていました







マップの使用方法を思い出してください。







 //  m := make(map[string]int) //  m["habr"] = 42 //  val := m["habr"] //   comma,ok val, ok := m["habr"] // ok  true,    //  for k, v := range m { ... } //  delete(m, "habr")
      
      





反復中に、mapの値が変更される場合があります。







ご存知のとおり、Goは、並行プログラム(マルチプロセッサシステムで効果的に動作するプログラム)を作成するために作成された言語です。 ただし、マップタイプは同時アクセスに対して安全ではありません。 つまり、読むのはもちろん安全です。1000のゴルーチンを恐れることなくマップから読み取ることができますが、同時に書き込むこともできません。 Go 1.8より前は、同時アクセス(異なるゴルーチンからの読み取りと書き込み)が不確実性をもたらし、Go 1.8の後、この状況は「並行マップ書き込み」というメッセージで明らかにパニックを投げ始めました。







マップがスレッドセーフではない理由



マップスレッドを安全にするかどうかの決定は容易ではありませんでしたが、そうしないことに決めました-このセキュリティは無料では提供されません。 必要でない場合、ミューテックスのような追加の同期ツールはプログラムを不必要に遅くし、必要な場合、 sync.Mutexを使用してこのセキュリティを実装することは難しくありません。







現在の実装では、マップは非常に高速です。







速度とスレッドの安全性の間のこのような妥協点は、第1と第2のオプションの両方を持つ機会を残します。 ミューテックスのない超高速マップを使用するか、少し遅くなりますが、並列アクセスに対して安全です。 Goでは、複数のゴルーチンと並行して変数を使用することが並行プログラムを記述する唯一の方法ではないことを理解することが重要です。したがって、この場合は最初に思われるほど頻繁ではありません。







これがどのように行われるかを見てみましょう。







マップ+ sync.Mutex



スレッドセーフマップの実装は非常に簡単です。新しいデータ構造を作成し、その中にミューテックスを埋め込みます。 構造体は、MyMapでさえ、好きな名前で呼び出すことができますが、意味のある名前を付けることは理にかなっています。特定の問題を解決している可能性が高いです。







 type Counters struct { sync.Mutex m map[string]int }
      
      





ミューテックスを初期化する必要はありません。その「ゼロ値」はロック解除されたミューテックスであり、すぐに使用でき、マップが必要です。そのため、コンストラクター関数を作成すると便利です(必要ではありません)。







 func NewCounters() *Counters { return &Counters{ m: make(map[string]int), } }
      
      





現在、Counters型の変数にはLock()



およびUnlock()



メソッドがありますが、生活を簡素化し、この型を他のパッケージから使用したい場合は、 Load()



Store()



などのラッパー関数を作成すると便利です。 この場合、ミューテックスを埋め込むことはできませんが、単に「プライベート」フィールドにするだけです。







 type Counters struct { mx sync.Mutex m map[string]int } func (c *Counters) Load(key string) (int, bool) { c.mx.Lock() defer c.mx.Unlock() val, ok := cm[key] return val, ok } func (c *Counters) Store(key string, value int) { c.mx.Lock() defer c.mx.Unlock() cm[key] = value }
      
      





ここでは、2つの点に注意する必要があります。









必要に応じて、 Delete()



およびRange()



メソッドを定義して、 Delete()



および反復処理中にマップミューテックスを保護することもできます。







ところで、注意してください。常に特定の問題を解決し、特定のケースごとに異なる使用プロファイルを持つことができるため、「必要な場合」を意図的に作成します。 Range()



が必要ない場合-その実装に時間を浪費しないでください。 必要なときに-いつでも追加できます。 シンプルにしましょう。







これで、安全なデータ構造を簡単に使用できます。







 counters := NewCounters() counters.Store("habr", 42) v, ok := counters.Load("habr")
      
      





繰り返しますが、特定のタスクに応じて、便利な方法を実行できます。 たとえば、カウンタの場合、値を増やすと便利です。 通常のマップでは、次のようなことをします。







 counters["habr"]++
      
      





そして、この構造のために、別のメソッドを作成できます。







 func (c *Counters) Inc(key string) { c.mx.Lock() defer c.mx.Unlock() cm[key]++ } ... counters.Inc("habr")
      
      





しかし、多くの場合、「キー」形式のデータを「値」で処理する場合、アクセスパターンは不均一です。頻繁な記録とまれな読み取り、またはその逆です。 典型的なケースは、更新がまれであり、すべての値の反復(範囲)が頻繁に行われることです。 私たちが思い出すように、マップからの読み取りは安全ですが、今では各読み取り操作をロックし、ロックを解除するのを待つ特別な利益なしで時間を失います。







sync.RWMutex



この状況を解決するために、標準ライブラリにはsync.RWMutexタイプがあります。 Lock()/Unlock()



に加えて、RWMutexには同様の読み取り専用メソッドRLock()/RUnlock()



ます。 メソッドが読み取りのみを必要とする場合、 RLock()



使用します。これは他の読み取り操作をブロックしませんが、書き込み操作をブロックします。逆も同様です。 コードを更新しましょう。







 type Counters struct { mx sync.RWMutex m map[string]int } ... func (c *Counters) Load(key string) (int, bool) { c.mx.RLock() defer c.mx.RUnlock() val, ok := cm[key] return val, ok }
      
      





map+sync.RWMutex



は、マップのほぼ標準であり、異なるゴルーチンから使用する必要があります。 彼らは非常に高速です。







64個のコアと多数の同時読み取り値が得られるまで。







キャッシュの競合



sync.RWMutexコードを見ると、読み取り用にロックしているときに、各ゴルーチンがreaderCount



フィールド(単純なカウンター)を更新する必要があることがreaderCount



ます。 これは、 sync / atomic atomic.AddInt32()パッケージの関数を使用してアトミックに実行されます。 これらの機能は、特定のプロセッサのアーキテクチャ向けに最適化され、アセンブラ実装されています。







各プロセッサコアは、カウンターを更新すると、他のすべてのコアのメモリ内のこのアドレスのキャッシュをフラッシュし、このアドレスの現在の値があることを通知します。 次のカーネルは、カウンターを更新する前に、最初にこの値を別のカーネルのキャッシュから減算する必要があります。







最新のハードウェアでは、L2キャッシュ間の転送には約40ナノ秒かかります。 これはそれほど多くはありませんが、多くのコアが同時にカウンターを更新しようとすると、それぞれがキューに入り、この無効化とキャッシュからの減算を待ちます。 一定の時間に収まらなければならない操作は、コアの数で突然O(N)になります。 この問題はキャッシュ競合と呼ばれます。







昨年、このRWMutexの問題に関する問題#17973がGoの問題トラッカーで作成されました。 以下のベンチマークは、64コアマシンのRLock()/ RUnlock()で、(RLock / RUnlockを使用して)積極的に読み取るゴルーチンの数が増えると、ほぼ8倍の時間が増加することを示しています。













そして、これは同じ量のゴルチン(256)に対するベンチマークであり、核の数が増加します。













ご覧のとおり、関連するプロセッサコアの数に対する明らかな線形依存性。







標準ライブラリでは、 encoding/json



reflect



またはexpvars



などのパッケージを含め、マップが非常に多く使用され、説明されている問題により、map + RWMutexを直接使用しない高レベルコードでの明らかなスローダウンは発生しませんが、たとえば、リフレクトを使用します。







実際、この問題を解決するために、標準ライブラリとsync.Mapのキャッシュ競合が追加されました。







sync.Map



したがって、もう一度強調します-sync.Mapは、マップ内のキーが安定しており(頻繁に更新されない)、レコードよりも多くの読み取り値がある場合に、標準ライブラリの非常に特定のキャッシュ競合の問題を解決します。







map + RWMutexのキャッシュ競合によりプログラムのボトルネックを明確に特定できなかった場合、ほとんどの場合sync.Map



恩恵をsync.Map



できず、速度が少し低下する可能性があります。







まだあなたの場合は、sync.Map APIの使用方法を見てみましょう。 そして、それを使用することは驚くほど簡単です-以前の私たちのコードのほぼ1対1:







  // counters := NewCounters() <-- var counters sync.Map
      
      





記録:







  counters.Store("habr", 42)
      
      





読書:







  v, ok := counters.Load("habr")
      
      





取り外し:







  counters.Delete("habr")
      
      





sync.Mapから読み取る場合、おそらく正しいタイプにキャストする必要があります。







 v, ok := counters.Load("habr") if ok { val = v.(int) }
      
      





さらに、既存の値を返すLoadAndStore()メソッドもあり、 そうでない場合は新しい値を保存し、 Range()は各反復ステップで関数の引数を取ります:







  v2, ok := counters.LoadOrStore("habr2", 13)
      
      





  counters.Range(func(k, v interface{}) bool { fmt.Println("key:", k, ", val:", v) return true // if false, Range stops })
      
      





APIは、標準ライブラリの使用パターンによってのみ駆動されます。 現在、 sync.Map



パッケージエンコーディング/ { sync.Map



/ xml / json}、mime、archive / zip、reflect、expvars、net / rpcで使用されています。







パフォーマンスの観点から、sync.Mapは、同時読み取り数とコア数に関係なく、マップへの一定のアクセス時間を保証します。 最大4つのコア、多数の並列読み取りを行うsync.Map



は大幅に遅くなる可能性がありますが、その後、マップ+ RWMutexに勝ち始めます。













おわりに



要約sync.Map



は、すべての場面でノンブロッキングマップ構造の普遍的な実装ではありません。 これは、主に標準ライブラリの特定の使用パターンの実装です。 パターンがこれに一致し、プログラムのボトルネックがmap+sync.RWMutex



キャッシュ競合であることを明確に知っている場合は、 map+sync.RWMutex



を自由に使用してください。 それ以外の場合、 sync.Map



は役に立ちません。







マップ+ RWMutexラッパーを書くのが面倒で、高いパフォーマンスが絶対に重要ではないが、スレッドセーフマップが必要な場合は、 sync.Map



も適切なオプションです。 ただし、すべての場合にsync.Mapにあまり期待しないでください。







また、ロックフリーアルゴリズムなどのハッシュテーブルの他の実装が、お客様のケースにより適している場合があります。 同じようなパッケージが長い間存在しており、sync.Mapが標準ライブラリにある唯一の理由は、標準ライブラリの他のパッケージによるアクティブな使用です。







参照資料






All Articles