-
map
并发读线程安全,并发读写线程不安全。 -
sync.Map
读写分离 空间换时间
Map
Golang1.6之前内置的map
类型是部分goroutine
安全的,并发读是没有问题的,但并发写则会报错。换言之,Golang中map
只读是线程安全的(thread-safe),但在并发环境下读写是线程不安全的(写线程不安全),为什么呢?
例如:并发环境下同时读写map
会发生致命错误,即多个goroutine
同时读写一个map
时会报错。
$ vim map_test.go
package test
import "testing"
func TestMap(t *testing.T){
//创建int到int的映射
m := make(map[int]int)
//开启并发执行单元 用于写入
go func(){
//循环对map进行写入
for{
m[1] = 1
}
}()
//开启并发执行单元 用于读取
go func(){
for{
_ = m[1]
}
}()
//无线循环让并发程序在后台一直执行
for{
}
}
$ go test map_test.go -v -run=TestMap
fatal error: concurrent map read and map write
致命错误: 并发的map读和map写
由于两个并发执行单元同时不断地对map进行读和写而产生了竞态问题,map内部会对这种并发操作进行检查并提前发现。
清理
map
在Golang中是只增不减的一种数组结构,使用delete
删除的时候会打标记,以说明该内存空间empty
了,但不会立即回收。手工回收还需设置为nil
。
package test
import (
"log"
"runtime"
"testing"
)
func statMem(msg string){
var m runtime.MemStats
runtime.ReadMemStats(&m)
log.Printf("%v: Alloc = %vKB, NumGC = %v\n", msg, m.Alloc / 1024, m.NumGC)
}
var m map[int]int
func TestMap(t *testing.T){
statMem("BEGIN")
//添加map值
cnt := 10000
m = make(map[int]int, cnt)
for i:=0; i<cnt; i++{
m[i] = i
}
//手工GC
runtime.GC()
statMem("GC")
//手工nil
m = nil
runtime.GC()
statMem("NIL")
}
$ go test map_test.go -v -run=TestMap
2021/04/12 18:40:36 BEGIN: Alloc = 193KB, NumGC = 0
2021/04/12 18:40:36 GC: Alloc = 492KB, NumGC = 1
2021/04/12 18:40:36 NIL: Alloc = 142KB, NumGC = 2
sync.RWMutex
- 利用传统的
sync.RWMutex + map
实现并发安全的map
-
sync.RWMutex
加锁的方式相当于MySQL中的表级锁
采用嵌入struct
为map
增加读写锁
var counter = struct{
sync.RWMutex
m map[string]int
}{
m:make(map[string]int),
}
读取和写入数据时都可以很方便地加锁
- 读取数据时,采用读锁锁定。
- 写入数据时,采用写锁锁定。
counter.RLock()
val := counter.m["key"]
counter.RUnlock()
log.Println(val)
counter.Lock()
counter.m["key"]++
counter.Unlock()
- 并发读写时加锁,由于锁的粒度为整个
map
因此会存在优化的空间,因此性能并不很很高。 - 并发环境下由于锁的存在,同时只能有一个
goroutine
在临界区工作,因此效率会被大大降低。 - 当
map
数据非常大的情况下,一把锁会导致大并发的客户端共争一把锁。
Java的ConcurrentHashMap的解决方案会采用shard
即在内部使用多个锁,每个区间共享一把锁,这样减少数据共享一把锁带来的性能影响。
sync.Map
Golang在1.9版本中提供了一种效率较高且并发安全的sync.Map
,sync.Map
与普通map
不同,它不是以语言原生态的形式提供,而是在sync
包下的特殊结构。
m := sync.Map{}
m.Store(1, 1)
cnt := 10000
go func(){
i := 0
for i<cnt {
m.Store(i, i)
i++
}
}()
go func(){
i := 0
for i<cnt {
m.Load(i)
i++
}
}()
time.Sleep(time.Second)
fmt.Println(m.Load(1))
-
sync.Map
除了互斥量之外,还运用了原子操作。 -
sync.Map
的思路来自于Java的ConcurrentHashMap
-
sync.Map
其实也相当于表级锁,只不过将读写进行分离,但本质依然是一样的。
Golang1.9中的sync.Map
是如何提升对map
的优化,解决并发提升的性能的呢?
- 优化的方向:把锁的粒度尽可能降低来提高运行速度
- 空间换时间,通过冗余的两个数据结构
read
和dirty
实现加锁对性能的影响。 - 使用只读数据
read
来避免读写冲突 - 动态调整,当
miss
次数多了之后会将dirty
数据提升为read
。 - 双检查(double-checking)
- 延迟删除,删除一个键值只是打标记,只有在提升
dirty
的时候才会清理删除的数据。 - 优先从
read
中读取、更新、删除,因为对read
的读取不需要锁。
type Map struct {
mu Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}
字段 | 类型 | 描述 |
---|---|---|
mu | Mutex | 使用互斥锁来保护dirty字段 |
read | atomic.Value | 只读的存读数据,读是并发安全的。 |
dirty | map[interface{}]*entry | 最新写入的数据(脏数据),写入时会将read中未被删除的数据拷贝到dirty中,需使用互斥锁以保证并发安全。 |
misses | int | 从read读取数据时该字段自增1,当等于len(dirty)时会将dirty拷贝到read中,从而提升读性能。 |
sync.Map
的数据结构很简单,包含四个字段mu
、read
、dirty
、misses
,它使用了冗余的数据结构read
和dirty
。dirty
中会包含read
中未删除的实体,新增的实体会加入到dirty
中。
实现
sync.Map
的实现思路可概括为:读写分离、空间换时间
- 通过
read
和dirty
字段将读写分离,读的数据只存在只读字段read
上,最新写入的数据则存在dirty
字段上。 - 读取时会先检查
read
字段是否存在,若不存在则查询dirty
字段。 - 写入时则只会写入到
dirty
字段 - 读取
read
字段并不需要加锁,但从dirty
字段读取或写入时都需要加锁。 - 另外还提供了
misses
字段来统计read
字段被穿透的次数,所谓的被穿透也就是说需要读取dirty
字段的情况,当超过一定次数时则将dirty
字段数据同步到read
字段上。 - 删除数据时则直接通过标记来延迟删除
sync.Map
特性
-
sync.Map
无需初始化,直接声明即可使用。 -
sync.Map
不能使用map
的方式进行取值或设置,必须使用sync.Map
专有的方法执行增删改查操作。 -
sync.Map
配合回调函数进行遍历,可通过回调函数返回内部遍历的值。
sync.Map
中实际上存在两个map
,一个是专门用于读操作的read map
,使用atomic.Value
方式存储。另一个是专门用于读写操作的dirty map
,单纯使用map
方式存储。
使用时会优先读read map
,若不存在则加锁穿透读dirty map
,同时会记录一条未从read map
读到的计数。当计数值达到一定值,就会将read map
用dirty map
进行覆盖。
sync.Map
通过空间换时间与读写分离的方式,适用于大量读少量写的应用场景。不适用于大量写的场景,因为大量写的场景会导致read map
读不到数据而进一步加锁读取,同时dirty map
也会一直晋升为read map
,整体性能会比较差。
readOnly
read map
的数据结构
type readOnly struct{
m map[interface{}]*entry
amended bool //若dirty中数据m不一致则为false
}
amended
字段指明了Map.dirty
中是否存在readOnly.m
未包含的数据,若从Map.read
字段中找不到数据的话,会进一步到Map.dirty
中查找。另外对Map.read
的修改是通过原子操作进行的。
虽然read
和dirty
存在冗余数据,但这些数据是通过指针指向同一个数据,所以尽管Map
的value
会很大,但冗余空间占用的还是很有限的。
entry
readOnly.m
和Map.dirty
存储的值类型是*entry
,它包含一个指针p
,指向用户存储的value
值。
type entry struct{
p unsafe.Pointer //*interface{}
}
指针p
具有三种值
-
nil
:entry
已经被删除同时m.dirty
为nil
-
expunged
:entry
已被删除且m.dirty
不为nil
,同时这个entry
不存在于m.dirty
中。 -
entry
是一个正常的值
方法
Store
- 新增元素,写操作。
Store
可能会在某些情况下下,比如初始化或m.dirty
刚被提升后,从m.read
中复制数据,若此时m.read
中数据量比较大,则可能会影响性能。
//写操作
func (m *Map) Store(key, value interface{})
Load
- 读操作,提供一个键
key
来查找对应的值value
,若不存在则通过ok
反映。
// 读操作
func (m *Map) Load(key interface{}) (value interface{}, ok bool)
Delete
由于数据可能存在两份,因此删除时必须考虑
- 若
read map
和dirty map
都有,则认为是干净数据。 - 若
read map
没有而dirty map
中有,则dirty map
中存在还未升级的藏数据。 - 若
read map
存在而dirty map
中不存在,则标记为待删除数据。
删除一个键值,删除操作是从m.read
中开始的若此时entity
中不存在于m.read
中,同时m.dirty
中存在新数据,则会加锁并尝试从m.dirty
中删除元素。
删除操作是需要经过双检查的,从m.dirty
中直接删除即可,就当它没有存在过。但如果从m.read
中删除则并不会直接删除而会打标记。
Range
由于for...range
是Golang内建的语言特性,所有没有办法使用for...range
遍历sync.Map
,但可以使用它的Range
方法通过回调的方式来遍历。
有疑问加站长微信联系(非本文作者)