go语言的原生map引发的一个坑

65

go语言原生map引发的一个坑

总所周知,go语言原生的map并不是并发安全的,所以为了保证map的并发安全,最简单的方式就是给map加一个锁。

年初写项目的时候,刚接触go语言,冒冒失失的就写出了类似下面这样的代码:

package problem

import (
    "fmt"
    "sync"
)

type dict struct {
    m    map[int]string
    lock sync.RWMutex
}  

func NewDict() *dict {
    return &dict{m: map[int]string{}}
}

func (d dict) Set(key int, value string) {
    d.lock.Lock()
    defer d.lock.Unlock()
    d.m[key] = value
}

func (d dict) Get(key int) (string, bool) {
    d.lock.RLock()
    defer d.lock.RUnlock()
    value, ok := d.m[key]
    return value, ok
}
  
func (d dict) Del(key int) {
    d.lock.Lock()
    defer d.lock.Unlock()
    delete(d.m, key)
}

当时也没有用go语言的race检测,就直接用了。相信学过go语言的人一眼就能看出这个代码的问题,是的,是非常基础的指针接收者和值接收者的问题。

指针接收者在协程并发读写的时候,确实只有一个dict指针指向那个dict地址,然后lock锁也是最初定义的那个锁,所以不会出现concurrent map read and write的问题。

但是如果是值接收者,协程在并发读写的时候,实际是对这个dict对象的一个拷贝。

这时候map在go语言里,创建出来的时候就是往外传递的一个指向map的指针,拷贝出来的指针,是指向同一个map,所以常规的插入查询删除数据是不会出现异常。只不过是线程不安全的。因为拷贝的lock是一个新锁,这样每个协程有一个锁,虽然写的有个lock但是就跟没加锁一样。

加上指针打印变量地址就很清楚了:

package problem

import (
    "fmt"
    "sync"
)

type dict struct {
    m    map[int]string
    lock sync.RWMutex
}

func NewDict() *dict {
    return &dict{m: map[int]string{}}
}

func (d dict) Set(key int, value string) {
    d.lock.Lock()
    defer d.lock.Unlock()
    fmt.Printf("Set %p, %p\n", &d.lock, d.m)
    d.m[key] = value
}

func (d dict) Get(key int) (string, bool) {
    d.lock.RLock()
    defer d.lock.RUnlock()
    fmt.Printf("Get %p, %p\n", &d.lock, d.m)
    value, ok := d.m[key]
    return value, ok
}

func (d dict) Del(key int) {
    d.lock.Lock()
    defer d.lock.Unlock()
    fmt.Printf("Del %p, %p\n", &d.lock, d.m)
    delete(d.m, key)
}
func main() {
    d := problem.NewDict()

    go func() {
        for {
            d.Set(1, "one")
            d.Del(1)
        }
    }()
  
    go func() {
        for {
            fmt.Println(d.Get(1))
        }
    }()

    select {}
}

加race检测运行结果:

Del 0xc00041dac8, 0xc00006c300
Set 0xc00041dae8, 0xc00006c300
Del 0xc00041db08, 0xc00006c300
Set 0xc00041db28, 0xc00006c300
Del 0xc00041db48, 0xc00006c300
Set 0xc00041db68, 0xc00006c300
Del 0xc00041db88, 0xc00006c300
Get 0xc000416008, 0xc00006c300
Set 0xc00041dba8, 0xc00006c300
Del 0xc00041dbc8, 0xc00006c300
fatal error: concurrent map read and map write

可以看出每一次操作,锁的地址都发生了改变,是新的变量。

但是改成指针接收者之后:

one true
Del 0xc000004468, 0xc00006c300
Get 0xc000004468, 0xc00006c300
 false
Set 0xc000004468, 0xc00006c300
Get 0xc000004468, 0xc00006c300
one true
Del 0xc000004468, 0xc00006c300
Get 0xc000004468, 0xc00006c300
 false
Set 0xc000004468, 0xc00006c300
Get 0xc000004468, 0xc00006c300

可以看出每一次操作,锁的地址都是同一个。

虽然解决了并发的问题,但是这种加一个大锁锁住整个map的方式,在map存储的数据很多的时候,性能肯定不高,在go1.9引入sync.Map之前,比较流行的做法就是使用分段锁。具体项目中使用哪种,也是根据实际需求决定。

后来就把项目中的map改用分段锁来保证线程安全,分段锁:$\color$