转自https://colobu.com/2017/07/11/dive-into-sync-Map/
Go 1.6より前のバージョンでは、組み込みのマップタイプは部分的にゴルーチンセーフであり、同時読み取りに問題はなく、同時書き込みに問題がある可能性があります。go 1.6以降、マップの読み取りと書き込みを同時に行うとエラーが報告されます。この問題は一部の有名なオープンソースライブラリに存在するため、go 1.9より前の解決策は、追加のロックをバインドするか、新しい構造にカプセル化するか、ロックを個別に使用することですいいんだよ。
この記事では、sync.Mapの特定の実装について詳しく説明し、関数を追加するためにコードがどのように複雑になるか、およびsync.Mapを実装する際の作成者の考えを確認します。
同時実行の問題があるマップ
公式のよくある質問では、組み込みのマップはスレッド(ゴルーチン)で安全ではないと述べています。
まず、読み取りと書き込みを同時に行うためのコードを見てみましょう。次のプログラムでは、1つのゴルーチンが読み取りを続け、1つのゴルーチンが同じキー値を書き込みます。つまり、読み取りと書き込みのキーが同じでなく、マップに「展開」などの操作がない場合でも、コードそれでもエラーが報告されます。
package main
func main() {
m := make(map[int]int)
go func() {
for {
_ = m[1]
}
}()
go func() {
for {
m[2] = 2
}
}()
select {}
}
エラーメッセージは次のとおりです。致命的なエラー:マップの読み取りと書き込みの同時実行。
Goのソースコードhashmap_fast.go#L118を見ると、読み取り時にhashWritingフラグがチェックされていることがわかります。このフラグがある場合は、同時実行エラーが報告されます。
このフラグは書き込み時に設定されます:hashmap.go#L542
h.flags |= hashWriting
hashmap.go#L628が設定されると、このマークはキャンセルされます。
もちろん、コードには複数の読み取りと書き込みの同時チェックがあります。たとえば、書き込み時に同時書き込みがあるかどうかをチェックします。キーを削除する場合は書き込みと同様です。トラバースする場合、読み取りと書き込みの同時実行に問題があります。
マップの同時実行性の問題を見つけるのはそれほど簡単ではない場合があります。-raceパラメーターを使用して確認できます。
Go1.9の前の解決策
ただし、多くの場合、特に特定の規模のプロジェクトでは、マップオブジェクトを同時に使用します。マップは、常にゴルーチンによって共有されるデータを保存します。簡単な解決策は、Go公式ブログのGoマップの動作に関する記事に記載されています。
var counter = struct{
sync.RWMutex
m map[string]int
}{m: make(map[string]int)}
埋め込み構造を使用して、マップに読み取り/書き込みロックを追加します。
データを読み取るときにロックすると便利です。
counter.RLock()
n := counter.m["some_key"]
counter.RUnlock()
fmt.Println("some_key:", n)
データを書き込む場合:
counter.Lock()
counter.m["some_key"]++
counter.Unlock()
sync.Map
上記の解決策は非常に簡潔であり、Mutexの代わりに読み取り/書き込みロックを使用すると、読み取りおよび書き込み時のロックのパフォーマンスがさらに低下する可能性があると言えます。
ただし、シナリオによっては問題もあります。Javaに精通している場合は、JavaのConcurrentHashMapの実装を比較できます。非常に大きなマップデータの場合、ロックにより、大規模な同時クライアントがロックを競合します。 、Javaのソリューションはシャードであり、内部で複数のロックを使用し、各間隔でロックを共有するため、ロックを共有するデータのパフォーマンスへの影響が軽減されます。Orcamanは、このアイデアの実装を提供します。また、Go関連の開発者にこのスキームをGoに実装するかどうかを尋ねました。実装が複雑なため、答えは「はい」です。ただし、特別なパフォーマンスの向上とアプリケーションシナリオがない限り、開発に関するニュースはありません。 。
では、sync.MapはGo 1.9でどのように実装されていますか?並行性をどのように解決し、パフォーマンスを向上させますか?
sync.Mapの実装にはいくつかの最適化ポイントがあり、それらは最初にリストされており、後で分析します。
時間のためのスペース。冗長な2つのデータ構造(読み取り、ダーティ)により、パフォーマンスに対するロックの効果が実現されます。
読み取り/書き込みの競合を回避するには、読み取り専用データ(読み取り)を使用します。
動的調整。ミス時間が長くなると、ダーティデータがアップグレードされて読み取られます。
ダブルチェック。
削除の遅延。キー値の削除は単なるマークであり、削除されたデータはダーティがプロモートされた場合にのみクリーンアップされます。
読み取りの読み取りにはロックが必要ないため、読み取り、更新、および読み取りからの削除が優先されます。
以下では、その実現のアイデアを理解するために、sync.Mapのキーコードを紹介します。
まず、sync.Mapのデータ構造を見てみましょう。
type Map struct {
// 当涉及到dirty数据的操作的时候,需要使用这个锁
mu Mutex
// 一个只读的数据结构,因为只读,所以不会有读写冲突。
// 所以从这个数据中读取总是安全的。
// 实际上,实际也会更新这个数据的entries,如果entry是未删除的(unexpunged), 并不需要加锁。如果entry已经被删除了,需要加锁,以便更新dirty数据。
read atomic.Value // readOnly
// dirty数据包含当前的map包含的entries,它包含最新的entries(包括read中未删除的数据,虽有冗余,但是提升dirty字段为read的时候非常快,不用一个一个的复制,而是直接将这个数据结构作为read字段的一部分),有些数据还可能没有移动到read字段中。
// 对于dirty的操作需要加锁,因为对它的操作可能会有读写竞争。
// 当dirty为空的时候, 比如初始化或者刚提升完,下一次的写操作会复制read字段中未删除的数据到这个数据中。
dirty map[interface{}]*entry
// 当从Map中读取entry的时候,如果read中不包含这个entry,会尝试从dirty中读取,这个时候会将misses加一,
// 当misses累积到 dirty的长度的时候, 就会将dirty提升为read,避免从dirty中miss太多次。因为操作dirty需要加锁。
misses int
}
そのデータ構造は非常に単純で、値にはread、mu、dirty、misssの4つのフィールドが含まれています。
読み取りおよびダーティの冗長データ構造を使用します。ダーティには、読み取りで削除されたエントリが含まれ、新しく追加されたエントリがダーティに追加されます。
読み取りのデータ構造は次のとおりです。
type readOnly struct {
m map[interface{}]*entry
amended bool // 如果Map.dirty有些数据不在中的时候,这个值为true
}
修正済みは、readOnly.mに含まれていないデータがMap.dirtyにあることを示しています。したがって、Map.readからデータが見つからない場合は、Map.dirtyに移動して見つける必要があります。
Map.readの変更は、アトミック操作によって行われます。
読み取りとダーティには冗長データがありますが、これらのデータはポインターを介して同じデータを指しているため、マップの値は大きくなりますが、冗長スペースの占有は制限されます。
readOnly.mおよびMap.dirtyに格納されている値のタイプは* entryであり、ユーザーが格納している値を指すポインターpが含まれています。
type entry struct {
p unsafe.Pointer // *interface{}
}
pには3つの値があります:
nil:エントリが削除され、m.dirtyが削除されました:エントリが削除され、m.dirtyがnil
でなく、このエントリがm.dirtyに存在しません
その他:エントリが通常の値
以上sync.Mapのデータ構造では、Load、Store、Delete、Rangeの4つの方法に焦点を当てましょう。他の補助的な方法は、これらの4つの方法を参照することで理解できます。
負荷
ロード方法は、キーを提供し、対応する値の値を見つけ、存在しない場合は、okを介して反映することです。
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 1.首先从m.read中得到只读readOnly,从它的map中查找,不需要加锁
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2. 如果没找到,并且m.dirty中有新数据,需要从m.dirty查找,这个时候需要加锁
if !ok && read.amended {
m.mu.Lock()
// 双检查,避免加锁的时候m.dirty提升为m.read,这个时候m.read可能被替换了。
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
// 如果m.read中还是不存在,并且m.dirty中有新数据
if !ok && read.amended {
// 从m.dirty查找
e, ok = m.dirty[key]
// 不管m.dirty中存不存在,都将misses计数加一
// missLocked()中满足条件后就会提升m.dirty
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}
ここで懸念される2つの値があります。1つは、最初にm.readからロードすることです。存在せず、m.dirtyに新しいデータがある場合は、ロックしてから、m.dirtyからロードします。
2つ目は、ここではダブルチェック処理が使用されていることです。これは、次の2つのステートメントでは、これらの2行のステートメントがアトミック操作ではないためです。
if !ok && read.amended {
m.mu.Lock()
最初の文が実行されると条件は満たされますが、ロックする前にm.dirtyがm.readにプロモートされる可能性があるため、ロック後にm.readをチェックする必要があります。このメソッドは後続のメソッドで使用されます。
ダブルチェックの技術はJavaプログラマーにはよく知られています。シングルトンモードの実現の1つは、ダブルチェックの技術を使用することです。
クエリしたキー値がm.readに存在する場合、ロックして直接返す必要がないことがわかります。これは理論的には優れています。m.readに存在しない場合でも、数回ミスすると、m.dirtyがm.readに昇格し、m.readから検索されます。したがって、更新/追加が少なく、多くのキーがロードされている場合、パフォーマンスは基本的にロックフリーマップのパフォーマンスと同様です。
m.dirtyがどのように宣伝されたか見てみましょう。missLockedメソッドはm.dirtyを増やす可能性があります。
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}
上記のコードの最後の3行は、m.dirtyをプロモートし、単にm.dirtyをreadOnlyのmフィールドとして使用し、m.readをアトミックに更新するためのものです。アップグレード後、m.dirtyとm.missesはリセットされ、m.read.amendedはfalseになります。
お店
この方法は、エントリを更新または追加するためのものです。
func (m *Map) Store(key, value interface{}) {
// 如果m.read存在这个键,并且这个entry没有被标记删除,尝试直接存储。
// 因为m.dirty也指向这个entry,所以m.dirty也保持最新的entry。
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
// 如果`m.read`不存在或者已经被标记删除
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() { //标记成未被删除
m.dirty[key] = e //m.dirty中不存在这个键,所以加入m.dirty
}
e.storeLocked(&value) //更新
} else if e, ok := m.dirty[key]; ok { // m.dirty存在这个键,更新
e.storeLocked(&value)
} else { //新键值
if !read.amended { //m.dirty中没有新的数据,往m.dirty中增加第一个新键
m.dirtyLocked() //从m.read中复制未删除的数据
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value) //将这个entry加入到m.dirty中
}
m.mu.Unlock()
}
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[interface{}]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
func (e *entry) tryExpungeLocked() (isExpunged bool) {
p := atomic.LoadPointer(&e.p)
for p == nil {
// 将已经删除标记为nil的数据标记为expunged
if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
return true
}
p = atomic.LoadPointer(&e.p)
}
return p == expunged
}
ご覧のとおり、上記の操作は、最初に操作m.readで開始し、条件が満たされていない場合はロックを追加してから、m.dirtyを操作します。
ストアは、特定の状況(初期化またはm.dirtyがプロモートされた直後)でm.readからデータをコピーする場合があります。この時点でm.readのデータ量が非常に多い場合、パフォーマンスに影響を与える可能性があります。
削除
キー値を削除します。
func (m *Map) Delete(key interface{}) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
delete(m.dirty, key)
}
m.mu.Unlock()
}
if ok {
e.delete()
}
}
同様に、削除操作は引き続きm.readから開始されます。エントリがm.readに存在せず、m.dirtyに新しいデータがある場合は、ロックしてm.dirtyから削除してみてください。
それでも再確認する必要があることに注意してください。存在しないかのようにm.dirtyから直接削除するだけですが、m.readから削除すると、直接削除されるのではなく、次のようにマークされます。
func (e *entry) delete() (hadValue bool) {
for {
p := atomic.LoadPointer(&e.p)
// 已标记为删除
if p == nil || p == expunged {
return false
}
// 原子操作,e.p标记为nil
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return true
}
}
}
範囲
for…rangemapは組み込みの言語機能であるため、rangeを使用してsync.Mapをトラバースする方法はありませんが、Rangeメソッドを使用してコールバックをトラバースできます。
func (m *Map) Range(f func(key, value interface{}) bool) {
read, _ := m.read.Load().(readOnly)
// 如果m.dirty中有新数据,则提升m.dirty,然后在遍历
if read.amended {
//提升m.dirty
m.mu.Lock()
read, _ = m.read.Load().(readOnly) //双检查
if read.amended {
read = readOnly{m: m.dirty}
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
// 遍历, for range是安全的
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}
Rangeメソッドが呼び出される前にm.dirtyの昇格を実行できますが、m.dirtyの昇格は時間のかかる操作ではありません。
sync.Mapのパフォーマンス
パフォーマンステストは、Go 1.9ソースコードで提供されています:map_bench_test.go、map_reference_test.go
また、これらのコードに基づいて少し変更し、次のテストデータを取得しました。以前のソリューションと比較して、パフォーマンスがいくらか向上しています。特にパフォーマンスが気になる場合は、sync.Mapを検討してください。
BenchmarkHitAll/*sync.RWMutexMap-4 20000000 83.8 ns/op
BenchmarkHitAll/*sync.Map-4 30000000 59.9 ns/op
BenchmarkHitAll_WithoutPrompting/*sync.RWMutexMap-4 20000000 96.9 ns/op
BenchmarkHitAll_WithoutPrompting/*sync.Map-4 20000000 64.1 ns/op
BenchmarkHitNone/*sync.RWMutexMap-4 20000000 79.1 ns/op
BenchmarkHitNone/*sync.Map-4 30000000 43.3 ns/op
BenchmarkHit_WithoutPrompting/*sync.RWMutexMap-4 20000000 81.5 ns/op
BenchmarkHit_WithoutPrompting/*sync.Map-4 30000000 44.0 ns/op
BenchmarkUpdate/*sync.RWMutexMap-4 5000000 328 ns/op
BenchmarkUpdate/*sync.Map-4 10000000 146 ns/op
BenchmarkUpdate_WithoutPrompting/*sync.RWMutexMap-4 5000000 336 ns/op
BenchmarkUpdate_WithoutPrompting/*sync.Map-4 5000000 324 ns/op
BenchmarkDelete/*sync.RWMutexMap-4 10000000 155 ns/op
BenchmarkDelete/*sync.Map-4 30000000 55.0 ns/op
BenchmarkDelete_WithoutPrompting/*sync.RWMutexMap-4 10000000 173 ns/op
BenchmarkDelete_WithoutPrompting/*sync.Map-4 10000000 147 ns/op
その他
sync.MapにはLenメソッドがなく、現在それを追加する兆候がないため(issue#20680)、現在のMapの有効なエントリの数を取得する場合は、Rangeメソッドを使用して1回トラバースする必要がありますが、これはより面倒です。
指定されたキーが存在する場合、LoadOrStoreメソッドは既存の値(Load)を返します。存在しない場合、指定されたキー値(Store)を保存します。