我们知道,go 里面提供了 map
这种类型让我们可以存储键值对数据,但是如果我们在并发的情况下使用
map
的话,就会发现它是不支持并发地进行读写的(会报错)。
在这种情况下,我们可以使用 sync.Mutex
来保证并发安全,但是这样会导致我们在读写的时候,都需要加锁,这样就会导致性能的下降。
除了使用互斥锁这种相对低效的方式,我们还可以使用 sync.Map
来保证并发安全,它在某些场景下有比使用 sync.Mutex
更高的性能。 本文就来探讨一下 sync.Map
中的一些大家比较感兴趣的问题,比如为什么有了 map
还要
sync.Map
?它为什么快?sync.Map
的适用场景(注意:不是所有情况下都快。)等。
关于 sync.Map
的设计与实现原理,会在下一篇中再做讲解。
map 在并发下的问题
如果我们看过 map
的源码,就会发现其中有不少会引起
fatal
错误的地方,比如 mapaccess1
(从
map
中读取 key
的函数)里面,如果发现正在写
map
,则会有 fatal
错误。
(如果还没看过,可以跟着这篇 《go
map 设计与实现》 看一下)
1 | if h.flags&hashWriting != 0 { |
map 并发读写异常的例子
下面是一个实际使用中的例子:
1 | var m = make(map[int]int) |
这会导致报错:
1 | fatal error: concurrent map read and map write |
这是因为我们同时对 map
进行读写,而 map
不支持并发读写,所以会报错。如果 map
允许并发读写,那么可能在我们使用的时候会有很多错乱的情况出现。
(具体如何错乱,我们可以对比多线程的场景思考一下,本文不展开了)。
使用 sync.Mutex 保证并发安全
对于 map
并发读写报错的问题,其中一种解决方案就是使用
sync.Mutex
来保证并发安全,
但是这样会导致我们在读写的时候,都需要加锁,这样就会导致性能的下降。
使用 sync.Mutex
来保证并发安全,上面的代码可以改成下面这样:
1 | var m = make(map[int]int) |
这样就不会报错了,但是性能会有所下降,因为我们在读写的时候都需要加锁。(如果需要更高性能,可以继续读下去,不要急着使用
sync.Mutex
)
sync.Mutex
的常见的用法是在结构体中嵌入sync.Mutex
,而不是定义独立的两个变量。
使用 sync.RWMutex 保证并发安全
在上一小节中,我们使用了 sync.Mutex
来保证并发安全,但是在读和写的时候我们都需要加互斥锁。
这就意味着,就算多个协程进行并发读,也需要等待锁。
但是互斥锁的粒度太大了,但实际上,并发读是没有什么太大问题的,应该被允许才对,如果我们允许并发读,那么就可以提高性能。
当然 go 的开发者也考虑到了这一点,所以在 sync
包中提供了
sync.RWMutex
,这个锁可以允许进行并发读,但是写的时候还是需要等待锁。
也就是说,一个协程在持有写锁的时候,其他协程是既不能读也不能写的,只能等待写锁释放才能进行读写。
使用 sync.RWMutex
来保证并发安全,我们可以改成下面这样:
1 | var m = make(map[int]int) |
这样就不会报错了,而且性能也提高了,因为我们在读的时候,不需要等待锁。
说明:
- 多个协程可以同时使用
RLock
而不需要等待,这是读锁。 - 只有一个协程可以使用
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
,读操作依然还有锁的开销,那么有没有更好的方式呢?
答案是有的,就是使用原子操作来替代读锁。
举一个很常见的例子就是多个协程同时读取一个变量,然后对这个变量进行累加操作:
1 | var a int32 |
这个例子中,我们期望的结果是 a
的值是
20000
,但是实际上,每次运行的结果都不一样,而且都不会等于
20000
。
其中很简单粗暴的一种解决方法是加锁,但是这样的话,性能就不好了,但是我们可以使用原子操作来解决这个问题:
1 | var a atomic.Int32 |
锁跟原子操作的性能差多少?
我们来看一下,使用锁和原子操作的性能差多少:
1 | func BenchmarkMutexAdd(b *testing.B) { |
结果:
1 | BenchmarkMutexAdd-12 100000000 10.07 ns/op |
我们可以看到,使用原子操作的性能比使用锁的性能要好一些。
也许我们会觉得上面这个例子是写操作,那么读操作呢?我们来看一下:
1 | func BenchmarkMutex(b *testing.B) { |
结果:
1 | BenchmarkMutex-12 100000000 10.12 ns/op |
我们可以看到,使用原子操作的性能比使用锁的性能要好很多。而且在
BenchmarkMutex
里面甚至还没有做读取数据的操作。
sync.Map 里面的原子操作
sync.Map
里面相比
sync.RWMutex
,性能更好的原因就是使用了原子操作。 在我们从
sync.Map
里面读取数据的时候,会先使用一个原子
Load
操作来读取 sync.Map
里面的
key
(从 read
中读取)。 注意:这里拿到的是
key
的一份快照,我们对其进行读操作的时候也可以同时往
sync.Map
中写入新的
key
,这是保证它高性能的一个很关键的设计(类似读写分离)。
sync.Map
里面的 Load
方法里面就包含了上述的流程:
1 | // Load 方法从 sync.Map 里面读取数据。 |
上面的代码我们可能还看不懂,但是没关系,这里我们只需要知道的是,从 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
:删除数据的方法。
1 | var m sync.Map |
注意:在 sync.Map
中,key
和
value
都是 interface{}
类型的,也就是说,我们可以使用任意类型的 key
和
value
。 而不像 map
,只能存在一种类型的
key
和 value
。从这个角度来看,它的类型类似于
map[any]any
。
另外一个需要注意的是,Range
方法的参数是一个函数,这个函数如果返回
false
,那么遍历就会停止。
sync.Map 的使用场景
在 sync.Map
源码中,已经告诉了我们 sync.Map
的使用场景:
1 | The Map type is optimized for two common use cases: (1) when the entry for a given |
翻译过来就是,Map 类型针对两种常见用例进行了优化:
- 当给定
key
的条目只写入一次但读取多次时,如在只会增长的缓存中。(读多写少) - 当多个 goroutine 读取、写入和覆盖不相交的键集的条目。(不同 goroutine 操作不同的 key)
在这两种情况下,与 Go map
与单独的 Mutex
或
RWMutex
配对相比,使用 sync.Map
可以显著减少锁竞争(很多时候只需要原子操作就可以)。
总结
- 普通的
map
不支持并发读写。 - 有以下两种方式可以实现
map
的并发读写:- 使用
sync.Mutex
互斥锁。读和写的时候都使用互斥锁,性能相比sync.RWMutex
会差一些。 - 使用
sync.RWMutex
读写锁。读的锁是可以共享的,但是写锁是独占的。性能相比sync.Mutex
会好一些。
- 使用
sync.Map
里面会先进行原子操作来读取key
,如果读取不到的时候,才会需要加锁。所以性能相比sync.Mutex
和sync.RWMutex
会好一些。sync.Map
里面几个常用的方法有(CRUD
):Store
:我们新增或者修改数据的时候,都可以使用Store
方法。Load
:读取数据的方法。Range
:遍历数据的方法。Delete
:删除数据的方法。
sync.Map
的使用场景,sync.Map
针对以下两种场景做了优化:key
只会写入一次,但是会被读取多次的场景。- 多个 goroutine 读取、写入和覆盖不相交的键集的条目。