ホームページ  >  記事  >  バックエンド開発  >  Golang での sync.Map の簡単な分析

Golang での sync.Map の簡単な分析

青灯夜游
青灯夜游転載
2023-01-29 19:58:473655ブラウズ

この記事は、Golang を学習し、Golang の sync.Map を深く理解するのに役立ちます。

Golang での sync.Map の簡単な分析

Go が map を提供することはわかっています。この型ではキーと値のペアのデータを保存できますが、これを同時状況で使用すると map を実行すると、同時読み取りと書き込みがサポートされていないことがわかります (エラーが報告されます)。 この場合、sync.Mutex を使用して同時実行の安全性を確保できますが、これにより読み取りおよび書き込み時にロックが必要になり、パフォーマンスの低下につながります。 比較的非効率的なミューテックス ロックの使用方法に加えて、sync.Map を使用して同時実行の安全性を確保することもでき、シナリオによっては sync.Mutex を使用するよりも効率的です。パフォーマンス。 この記事では、map の後に sync.Map が必要な理由など、誰もが関心を持つ sync.Map の問題のいくつかについて説明します。なぜ速いのでしょうか? sync.Map に該当するシナリオ (注: すべてのケースで高速であるわけではありません) など。

sync.Map の設計と実装の原則については、次の記事で説明します。

同時実行時のマップの問題

mapのソース コードを見ると、その多くが 致命的エラーを引き起こすことがわかります。 mapaccess1 (map から key を読み取る関数) など、map が書き込まれていることが判明した場合、 致命的 エラーです。 [関連する推奨事項: Go ビデオ チュートリアル プログラミング教育 ]

if h.flags&hashWriting != 0 {
    fatal("concurrent map read and map write")
}

map 同時読み取りおよび書き込み例外の例

以下は実際の例です使用中 例:

var m = make(map[int]int)

// 往 map 写 key 的协程
go func() {
   // 往 map 写入数据
    for i := 0; i 60d106c574ec170fbefcb160f65633bd 0; i-- {
        _ = m[i]
    }
}()

// 等待两个协程执行完毕
time.Sleep(time.Second)

これによりエラーが発生します:

fatal error: concurrent map read and map write

これは、map への読み取りと書き込みを同時に行っており、map は同時読み取りと書き込みをサポートしていないため、エラーが報告されます。 map で同時読み取りと書き込みが許可されている場合、それを使用するときに多くの混乱が生じる可能性があります。 (具体的な混乱については、マルチスレッドのシナリオと比較することで考えることができます。この記事では詳しく説明しません)。

sync.Mutex を使用して同時実行の安全性を確保する

map 同時読み取りおよび書き込みエラーの問題の解決策の 1 つは、sync.Mutex を使用することです。 同時実行の安全性を確保するには、 ただし、これにより読み取りおよび書き込み時にロックが必要になり、パフォーマンスの低下につながります。

Use sync.Mutex to ensure concurrency safety. 上記のコードは次のように変更できます:

var m = make(map[int]int)
// 互斥锁
var mu sync.Mutex

// 写 map 的协程
go func() {
    for i := 0; i < 10000; i++ {
        mu.Lock() // 写 map,加互斥锁
        m[i] = i
        mu.Unlock()
    }
}()

// 读 map 的协程序
go func() {
    for i := 10000; i > 0; i-- {
        mu.Lock() // 读 map,加互斥锁
        _ = m[i]
        mu.Unlock()
    }
}()

time.Sleep(time.Second)

この方法では、エラーは報告されませんが、読み取りおよび書き込み時にロックする必要があるため、パフォーマンスが低下します。 (より高いパフォーマンスが必要な場合は、読み続けてください。急いで sync.Mutex を使用しないでください。)

sync.Mutex の一般的な使用法2 つの独立変数を定義する代わりに、構造体に sync.Mutex を埋め込みます。

sync.RWMutex を使用して同時実行の安全性を確保する

前のセクションでは、sync.Mutex を使用して同時実行の安全性を確保しましたが、読み取りと書き込みの際にミューテックス ロックを追加する必要があります。 これは、複数のコルーチンが同時読み取りを実行する場合でも、ロックを待つ必要があることを意味します。 ただし、ミューテックス ロックの粒度が大きすぎますが、実際には同時読み取りに大きな問題はないため、許可する必要があります。同時読み取りを許可すると、パフォーマンスを向上させることができます

もちろん Go の開発者もこれを考慮しているため、sync.RWMutexsync パッケージで提供されています。このロックにより同時読み取りが可能になりますが、書き込みはロックを待つ必要があります。 つまり、 コルーチンが書き込みロックを保持している場合、他のコルーチンは読み取りも書き込みもできず、書き込みロックが解放されるのを待った後にのみ の読み取りと書き込みが可能になります。

Use sync.RWMutex to ensure concurrency safety. 次のように変更できます:

var m = make(map[int]int)
// 读写锁(允许并发读,写的时候是互斥的)
var mu sync.RWMutex

// 写入 map 的协程
go func() {
    for i := 0; i < 10000; i++ {
        // 写入的时候需要加锁
        mu.Lock()
        m[i] = i
        mu.Unlock()
    }
}()

// 读取 map 的协程
go func() {
    for i := 10000; i > 0; i-- {
        // 读取的时候需要加锁,但是这个锁是读锁
        // 多个协程可以同时使用 RLock 而不需要等待
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()

// 另外一个读取 map 的协程
go func() {
    for i := 20000; i > 10000; i-- {
        // 读取的时候需要加锁,但是这个锁是读锁
        // 多个协程可以同时使用 RLock 而不需要等待
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()

time.Sleep(time.Second)

これにより、エラーは報告されなくなり、パフォーマンスが向上します。また、読み込み時にロックを待つ必要がないので改善されます。

手順:

  • 複数のコルーチンは待機せずに同時に使用できます RLock これは読み取りロックです。
  • 1 つのコルーチン のみが、書き込みロックである Lock を使用できます。書き込みロックがあると、他のコルーチンは読み書きできなくなります。
  • 書き込みロックを保持しているコルーチンは、Unlock を使用してロックを解放できます。
  • 書き込みロックが解放されると、他のコルーチンがロック (読み取りロックまたは書き込みロック) を取得できるようになります。

つまり、sync.RWMutex を使用する場合、読み取り操作は同時に実行できますが、書き込み操作は相互に排他的です。 これにより、sync.Mutexと比べてロック待ちの回数が減り、当然ながらより高いパフォーマンスが得られます。

gin 框架里面就使用了 sync.RWMutex 来保证 Keys 读写操作的并发安全。

有了读写锁为什么还要有 sync.Map?

通过上面的内容,我们知道了,有下面两种方式可以保证并发安全:

  • 使用 sync.Mutex,但是这样的话,读写都是互斥的,性能不好。
  • 使用 sync.RWMutex,可以并发读,但是写的时候是互斥的,性能相对 sync.Mutex 要好一些。

但是就算我们使用了 sync.RWMutex,也还是有一些锁的开销。那么我们能不能再优化一下呢?答案是可以的。那就是使用 sync.Map

sync.Map 在锁的基础上做了进一步优化,在一些场景下使用原子操作来保证并发安全,性能更好。

使用原子操作替代读锁

但是就算使用 sync.RWMutex,读操作依然还有锁的开销,那么有没有更好的方式呢? 答案是有的,就是使用原子操作来替代读锁。

举一个很常见的例子就是多个协程同时读取一个变量,然后对这个变量进行累加操作:

var a int32

var wg sync.WaitGroup
wg.Add(2)

go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()

go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()

wg.Wait()

// a 期望结果应该是 20000才对。
fmt.Println(a) // 实际:17089,而且每次都不一样

这个例子中,我们期望的结果是 a 的值是 20000,但是实际上,每次运行的结果都不一样,而且都不会等于 20000。 其中很简单粗暴的一种解决方法是加锁,但是这样的话,性能就不好了,但是我们可以使用原子操作来解决这个问题:

var a atomic.Int32

var wg sync.WaitGroup
wg.Add(2)

go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()

go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()

wg.Wait()

fmt.Println(a.Load()) // 20000

锁跟原子操作的性能差多少?

我们来看一下,使用锁和原子操作的性能差多少:

func BenchmarkMutexAdd(b *testing.B) {
   var a int32
   var mu sync.Mutex

   for i := 0; i < b.N; i++ {
      mu.Lock()
      a++
      mu.Unlock()
   }
}

func BenchmarkAtomicAdd(b *testing.B) {
   var a atomic.Int32
   for i := 0; i < b.N; i++ {
      a.Add(1)
   }
}

结果:

BenchmarkMutexAdd-12       100000000          10.07 ns/op
BenchmarkAtomicAdd-12      205196968           5.847 ns/op

我们可以看到,使用原子操作的性能比使用锁的性能要好一些。

也许我们会觉得上面这个例子是写操作,那么读操作呢?我们来看一下:

func BenchmarkMutex(b *testing.B) {
   var mu sync.RWMutex

   for i := 0; i < b.N; i++ {
      mu.RLock()
      mu.RUnlock()
   }
}

func BenchmarkAtomic(b *testing.B) {
   var a atomic.Int32
   for i := 0; i < b.N; i++ {
      _ = a.Load()
   }
}

结果:

BenchmarkMutex-12      100000000          10.12 ns/op
BenchmarkAtomic-12     1000000000          0.3133 ns/op

我们可以看到,使用原子操作的性能比使用锁的性能要好很多。而且在 BenchmarkMutex 里面甚至还没有做读取数据的操作。

sync.Map 里面的原子操作

sync.Map 里面相比 sync.RWMutex,性能更好的原因就是使用了原子操作。 在我们从 sync.Map 里面读取数据的时候,会先使用一个原子 Load 操作来读取 sync.Map 里面的 key(从 read 中读取)。 注意:这里拿到的是 key 的一份快照,我们对其进行读操作的时候也可以同时往 sync.Map 中写入新的 key,这是保证它高性能的一个很关键的设计(类似读写分离)。

sync.Map 里面的 Load 方法里面就包含了上述的流程:

// Load 方法从 sync.Map 里面读取数据。
func (m *Map) Load(key any) (value any, ok bool) {
   // 先从只读 map 里面读取数据。
   // 这一步是不需要锁的,只有一个原子操作。
   read := m.loadReadOnly()
   e, ok := read.m[key]
   if !ok && read.amended { // 如果没有找到,并且 dirty 里面有一些 read 中没有的 key,那么就需要从 dirty 里面读取数据。
      // 这里才需要锁
      m.mu.Lock()
      read = m.loadReadOnly()
      e, ok = read.m[key]
      if !ok && read.amended {
         e, ok = m.dirty[key]
         m.missLocked()
      }
      m.mu.Unlock()
   }
   
   // key 不存在
   if !ok {
      return nil, false
   }
   // 使用原子操作读取
   return e.Load()
}

上面的代码我们可能还看不懂,但是没关系,这里我们只需要知道的是,从 sync.Map 读取数据的时候,会先做原子操作,如果没找到,再进行加锁操作,这样就减少了使用锁的频率了,自然也就可以获得更好的性能(但要注意的是并不是所有情况下都能获得更好的性能)。至于具体实现,在下一篇文章中会进行更加详细的分析。

也就是说,sync.Map 之所以更快,是因为相比 RWMutex,进一步减少了锁的使用,而这也就是 sync.Map 存在的原因了

sync.Map 的基本用法

现在我们知道了,sync.Map 里面是利用了原子操作来减少锁的使用。但是我们好像连 sync.Map 的一些基本操作都还不了解,现在就让我们再来看看 sync.Map 的基本用法。

sync.Map 的使用还是挺简单的,map 中有的操作,在 sync.Map 都有,只不过区别是,在 sync.Map 中,所有的操作都需要通过调用其方法来进行。sync.Map 里面几个常用的方法有(CRUD):

  • Store:我们新增或者修改数据的时候,都可以使用 Store 方法。
  • Load:读取数据的方法。
  • Range:遍历数据的方法。
  • Delete:删除数据的方法。
var m sync.Map

// 写入/修改
m.Store("foo", 1)

// 读取
fmt.Println(m.Load("foo")) // 1 true

// 遍历
m.Range(func(key, value interface{}) bool {
    fmt.Println(key, value) // foo 1
    return true
})

// 删除
m.Delete("foo")
fmt.Println(m.Load("foo")) // nil false

注意:在 sync.Map 中,keyvalue 都是 interface{} 类型的,也就是说,我们可以使用任意类型的 keyvalue。 而不像 map,只能存在一种类型的 keyvalue。从这个角度来看,它的类型类似于 map[any]any

另外一个需要注意的是,Range 方法的参数是一个函数,这个函数如果返回 false,那么遍历就会停止。

sync.Map 的使用场景

sync.Map 源码中,已经告诉了我们 sync.Map 的使用场景:

The Map type is optimized for two common use cases: (1) when the entry for a given
key is only ever written once but read many times, as in caches that only grow,
or (2) when multiple goroutines read, write, and overwrite entries for disjoint
sets of keys. In these two cases, use of a Map may significantly reduce lock
contention compared to a Go map paired with a separate Mutex or RWMutex.

翻译过来就是,Map 类型针对两种常见用例进行了优化:

  • 当给定 key 的条目只写入一次但读取多次时,如在只会增长的缓存中。(读多写少)
  • 当多个 goroutine 读取、写入和覆盖不相交的键集的条目。(不同 goroutine 操作不同的 key)

在这两种情况下,与 Go map 与单独的 MutexRWMutex 配对相比,使用 sync.Map 可以显著减少锁竞争(很多时候只需要原子操作就可以)。

总结

  • 普通的 map 不支持并发读写。
  • 有以下两种方式可以实现 map 的并发读写:
    • 使用 sync.Mutex 互斥锁。读和写的时候都使用互斥锁,性能相比 sync.RWMutex 会差一些。
    • 使用 sync.RWMutex 读写锁。读的锁是可以共享的,但是写锁是独占的。性能相比 sync.Mutex 会好一些。
  • sync.Map 里面会先进行原子操作来读取 key,如果读取不到的时候,才会需要加锁。所以性能相比 sync.Mutexsync.RWMutex 会好一些。
  • sync.Map 里面几个常用的方法有(CRUD):
    • Store:我们新增或者修改数据的时候,都可以使用 Store 方法。
    • Load:读取数据的方法。
    • Range:遍历数据的方法。
    • Delete:删除数据的方法。
  • sync.Map 的使用场景,sync.Map 针对以下两种场景做了优化:
    • key 只会写入一次,但是会被读取多次的场景。
    • 多个 goroutine 读取、写入和覆盖不相交的键集的条目。

更多编程相关知识,请访问:编程视频!!

以上がGolang での sync.Map の簡単な分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.cnで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。