golang map并发安全性

4,085 阅读7分钟

非线程安全map

Go语言中的 map 在并发情况下,只读是线程安全的,同时读写是线程不安全的 如以下代码

// 创建一个int到int的映射
m := make(map[int]int)
// 开启一段并发代码
go func() {
    // 不停地对map进行写入
    for {
        m[1] = 1
    }
}()
// 开启一段并发代码
go func() {
    // 不停地对map进行读取
    for {
        _ = m[1]
    }
}()
// 无限循环, 让并发程序在后台执行
for {

}

运行代码会报错,输出如下:

fatal error: concurrent map read and map write

错误信息显示,并发的 map 读和 map 写,也就是说使用了两个并发函数不断地对 map 进行读和写而发生了竞态问题,map 内部会对这种并发操作进行检查并提前发现

线程安全map

MutexMap的实现

上面讲了map在并发环境下非线程安全的例子以及原因,如果使用互斥锁可以使得在并发环境下某些对象只能在某些事件内只有一个goroutine持有对其进行CRUD操作,那么我们可以使用互斥锁对map对其进行封装,例子代码如下

type MutexMap struct {
   lock sync.Mutex
   m    map[string]interface{}
}

func NewMutexMap() *MutexMap {
   return &MutexMap{
      lock: sync.Mutex{},
      m:    make(map[string]interface{}),
   }
}

func (receiver *MutexMap) Get(key string) (interface{}, bool) {
   receiver.lock.Lock()
   value, ok := receiver.m[key]
   receiver.lock.Unlock()
   return value, ok
}

func (receiver *MutexMap) Set(key string, value interface{}) {
   receiver.lock.Lock()
   receiver.m[key] = value
   receiver.lock.Unlock()
}
func (receiver *MutexMap) Del(key string) {
   receiver.lock.Lock()
   delete(receiver.m, key)
   receiver.lock.Unlock()
}

上述代码中使用了一个结构体对sync.Mutexmap进行了封装,并且对外提供了一系列的方法以供使用,从上述方法中可以看到,在Get(),Set(),Del()方法中进行map操作前均对其进行了互斥锁加锁操作,这样使得该结构体对象可以在并发环境下保证只有一个goroutine持有对其map进行CRUD操作,这样也就消除了并发读写问题的影响。

但是有一个问题也就产生了,由于互斥锁的特性,虽然可以提供线程安全的 map,但是在大量并发读写的情况下,锁的竞争会非常激烈。尤其在读的并发非常大的时候,互斥锁会严重影响读取的性能,因为在通过map底层源码发现,并发的读并不会产生panic只有并发读写时,才会发生,因此,互斥锁会导致整体读的效率下降很多,此时我们就应该使用读写锁来进行优化。

读写锁就是一个可以并发读但是不可以并发写的锁,由于互斥锁的特性,会导致将所有goroutine(下称协程)串行化,从而影响整体程序运行的性能,如果写的数量大于读的数量时,性能损耗暂且可以忽略不计(建议不要忽略),但是当读的数量大于写的数量时,互斥锁对性能的影响是很大的,所以此刻我们需要采用读写锁来进行读操作和写操作的分开。

RWMutexMap的实现

现在我们就对MutexMap进行读写锁的优化,优化后的代码如下:

type RWMutexMap struct {
   lock sync.RWMutex
   m    map[string]interface{}
}

func NewRWMutexMap() *RWMutexMap {
   return &RWMutexMap{
      lock: sync.RWMutex{},
      m:    make(map[string]interface{}),
   }
}

func (receiver *RWMutexMap) Get(key string) (interface{}, bool) {
   receiver.lock.RLock()
   value, ok := receiver.m[key]
   receiver.lock.RUnlock()
   return value, ok
}

func (receiver *RWMutexMap) Set(key string, value interface{}) {
   receiver.lock.Lock()
   receiver.m[key] = value
   receiver.lock.Unlock()
}
func (receiver *RWMutexMap) Del(key string) {
   receiver.lock.Lock()
   delete(receiver.m, key)
   receiver.lock.Unlock()
}

上述代码中,将lock sync.Mutex字段替换成为了lock sync.RWMutex字段,并且将Get()方法转而使用读锁进行加锁,此时,当前的 RWMutexMap结构体就针对读多写少的并发情况有了很好的性能提升,

ConcurrentMap的实现

直接对一个map加锁,当map中的值越来越多,访问map的请求越来越多,大家都竞争这一把锁显得并发访问控制变重。在go1.9引入sync.Map 之前,比较流行的做法就是使用分段锁,顾名思义就是将锁分段,将锁的粒度变小,将存储的对象分散到各个分片中,每个分片由一把锁控制,这样使得当需要对在A分片上的数据进行读写时不会影响B分片的读写。 image.png

// Map 分片
type ConcurrentMap []*ConcurrentMapShared

// 每一个Map 是一个加锁的并发安全Map
type ConcurrentMapShared struct {
    items map[string]interface{}
    sync.RWMutex    // 各个分片Map各自的锁
}

主流的分段锁,即通过hash取模的方式找到当前访问的key处于哪一个分片之上,再对该分片进行加锁之后再读写。分片定位时,常用有BKDR, FNV32等hash算法得到key的hash值。

func New() ConcurrentMap {
    // SHARD_COUNT  默认32个分片
    m := make(ConcurrentMap, SHARD_COUNT)
    for i := 0; i < SHARD_COUNT; i++ {
        m[i] = &ConcurrentMapShared{
            items: make(map[string]interface{}),
        }
    }
    return m
}

    在初始化好分片后, 对分片上的数据进行读写时就需要用hash取模进行分段定位来确认即将要读写的分片。

获取段定位

func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
    return m[uint(fnv32(key))%uint(SHARD_COUNT)]
}

// FNV hash
func fnv32(key string) uint32 {
    hash := uint32(2166136261)
    const prime32 = uint32(16777619)
    for i := 0; i < len(key); i++ {
        hash *= prime32
        hash ^= uint32(key[i])
    }
    return hash
}

之后对于map的GET SET 就简单顺利成章的完成

Set And Get

func (m ConcurrentMap) Set(key string, value interface{}) {
    shard := m.GetShard(key) // 段定位找到分片
    shard.Lock()              // 分片上锁
    shard.items[key] = value // 分片操作 
    shard.Unlock()              // 分片解锁
}

func (m ConcurrentMap) Get(key string) (interface{}, bool) {
    shard := m.GetShard(key)
    shard.RLock()
    val, ok := shard.items[key]
    shard.RUnlock()
    return val, ok
}

    由此一个分段锁Map就实现了, 但是比起普通的Map, 常用到的方法比如获取所有key, 获取所有Val 操作是要比原生Map复杂的,因为要遍历每一个分片的每一个数据, 好在golang的并发特性使得解决这类问题变得非常简单

keys和count

// 统计当前分段map中item的个数
func (m ConcurrentMap) Count() int {
    count := 0
    for i := 0; i < SHARD_COUNT; i++ {
        shard := m[i]
        shard.RLock()
        count += len(shard.items)
        shard.RUnlock()
    }
    return count
}

// 获取所有的key
func (m ConcurrentMap) Keys() []string {
    count := m.Count()
    ch := make(chan string, count)

    // 每一个分片启动一个协程 遍历key
    go func() {
        wg := sync.WaitGroup{}
        wg.Add(SHARD_COUNT)
        for _, shard := range m {

            go func(shard *ConcurrentMapShared) {
                defer wg.Done()
                
                shard.RLock()

                // 每个分片中的key遍历后都写入统计用的channel
                for key := range shard.items {
                    ch <- key
                }

                shard.RUnlock()
            }(shard)
        }
        wg.Wait()
        close(ch)
    }()

    keys := make([]string, count)
    // 统计各个协程并发读取Map分片的key
    for k := range ch {
        keys = append(keys, k)
    }
    return keys
}

通过Benchmark进行性能测试

package main

import (
   "fmt"
   "math/rand"
   "sync"
   "testing"
)

func BenchmarkMapShared(b *testing.B) {
   num := 10000
   testCase := genNoRepeatTestCase(num) // 10000个不重复的键值对
   m := NewConcurrentMap()
   for _, entry := range testCase {
      m.Set(entry.Key, entry.Val)
   }
   wg := sync.WaitGroup{}
   wg.Add(b.N * 2)
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      e := testCase[rand.Intn(num)]

      go func(key string, val interface{}) {
         m.Set(key, val)
         wg.Done()
      }(e.Key, e.Val)

      go func(key string) {
         _, _ = m.Get(key)
         wg.Done()
      }(e.Key)

   }
   wg.Wait()
}

func BenchmarkMutexMap(b *testing.B) {
   num := 10000
   testCase := genNoRepeatTestCase(num) // 10000个不重复的键值对
   m := NewMutexMap()
   for _, entry := range testCase {
      m.Set(entry.Key, entry.Val)
   }
   wg := sync.WaitGroup{}
   wg.Add(b.N * 2)
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      e := testCase[rand.Intn(num)]

      go func(key string, val interface{}) {
         m.Set(key, val)
         wg.Done()
      }(e.Key, e.Val)

      go func(key string) {
         _, _ = m.Get(key)
         wg.Done()
      }(e.Key)

   }
   wg.Wait()
}

func BenchmarkRWMutexMap(b *testing.B) {
   num := 10000
   testCase := genNoRepeatTestCase(num) // 10000个不重复的键值对
   m := NewRWMutexMap()
   for _, entry := range testCase {
      m.Set(entry.Key, entry.Val)
   }
   wg := sync.WaitGroup{}
   wg.Add(b.N * 2)
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      e := testCase[rand.Intn(num)]

      go func(key string, val interface{}) {
         m.Set(key, val)
         wg.Done()
      }(e.Key, e.Val)

      go func(key string) {
         _, _ = m.Get(key)
         wg.Done()
      }(e.Key)

   }
   wg.Wait()
}

type Entry struct {
   Key string
   Val string
}

func genNoRepeatTestCase(num int) []*Entry {
   m := make([]*Entry, 0, num)
   for i := 0; i < num; i++ {
      m = append(m, &Entry{
         Key: fmt.Sprintf("key%d", i),
         Val: fmt.Sprintf("value%d", i),
      })
   }
   return m
}

执行命令

go test -bench . -run none -benchmem -benchtime=5s

可以看到分段锁Map性能是最好的 image.png

Sync.Map

    go1.9之后加入了支持并发安全的sync.Map, 通过一份只使用原子操作的数据和一份冗余了只读数据的加锁数据实现一定程度上的读写分离,使得大多数读操作和更新操作是原子操作,写入新数据才加锁的方式来提升性能。

使用

sync.Map 开箱即用,无需像 map 调用 make() 进行显示初始化。此外,sync.Map 的 key 和 value 类型为空接口 interface{},表示可存储任意类型的数据。示例代码如下:

var m sync.Map

func main() {

   //写
   m.Store("dablelv", "27")
   m.Store("cat", "28")

   //读
   v, ok := m.Load("dablelv")
   fmt.Printf("Load: v, ok = %v, %v\n", v, ok)

   //删除
   m.Delete("dablelv")

   //读或写
   v, ok = m.LoadOrStore("dablelv", "18")
   fmt.Printf("LoadOrStore: v, ok = %v, %v\n", v, ok)

   //遍历
   //操作函数
   f := func(key, value interface{}) bool {
      fmt.Printf("Range: k, v = %v, %v\n", key, value)
      return true
   }
   m.Range(f)
}

sync.Map 的主要思想就是读写分离,空间换时间

看看 sync.map 优点:

  1. 空间换时间:通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。
  2. 使用只读数据(read),避免读写冲突。
  3. 动态调整,miss次数多了之后,将dirty数据迁移到read中。
  4. double-checking。
  5. 延迟删除。 删除一个键值只是打标记,只有在迁移dirty数据的时候才清理删除的数据。
  6. 优先从read读取、更新、删除,因为对read的读取不需要锁。 源码解读

juejin.cn/post/711831…