Java/Go双修 - Go并发安全sync.Map原理

237 阅读8分钟

sync.Map是什么

Go语言map不支持并发读写操作(线程不安全),否则会出现致命的错误导致程序直接终止(fatal error无法被defer+recover捕获)

但Go官方也为我们提供了一个并发安全的sync.Map,Go语言的并发安全map本质是采用空间换时间的思想

这里我们可能会想到,我们可以对map加一个锁来让其的每一次操作都受到保护,这样就实现了并发安全,但性能很低

sync.Map内部使用两个map之间的相互配合(一个read map 和 dirty map),来为我们提供一个拥有并发读写能力的hash表结构

在并发处理上,相比于我们前面提到的普通map的无脑加锁操作,sync.Map将读和写分开

读操作优先从read中读取,read操作是不会加锁的,当read读取不到才会去dirty读

写操作只会在dirty写,只有对dirty操作的时候才需要加锁,这样区分加锁时机,提升并发性能

sync.Map的数据结构

sync.Map的结构定义,sync.Map定义在源文件src/sync/map.go里面

type Map struct {
    mu    Mutex           // 用于保护dirty字段的锁
    read  atomic.Value    // 只读字段,其实际的数据类型是一个readOnly结构
    dirty map[interface{}]*entry //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素和新加入的元素
    misses int            // 计数器,记录在read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read
}

misses的作用我们后续会提到,现在只需要知道有这个变量就行

// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
    m    map[interface{}]*entry // key为任意可比较类型, value位为entry指针的一个map
    amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中存在
}

我们看一下entry的结构,entry作为dirty这个map的value类型以及readOnly结构体中map对应的value类型

type entry struct {
		p unsafe.Pointer  // p指向真正的value所在的地址
}

sync.Map的设计其实很巧妙,在dirty和readOnly里面的两个map的value并不是一个对象,而是一个指向任意类型的对象指针

所以,在这两个map都非空的情况下,map的read字段和dirty字段都会包含相同的数据项,如果通过read字段更改了这个项的值

dirty字段会读取到这个项的新值,因为它们都指向的同一个地址,sync.Map的底层结构如下图:

image.png

sync.Map流程及双向数据流转机制

image.png

对于读取,删除,更新这三种操作,会先找到read,如果read无法解决,再找dirty,写入操作直接找dirty,可以理解为另一种读写分离

接下来聊一下双向数据流转机制,什么是数据流转机制?为什么需要这个机制?什么时候会触发呢?

简单讲,双向数据流转机制就是:

  1. dirty把全量数据交给read
  2. read把全量数据交给dirty

dirty -> read

我们知道read可以为dirty尽量挡住读请求的流量,但如果read很多次没有挡住请求,让读请求访问到了dirty意味着什么呢?

代表read的责任削弱了,把压力给到了dirty,一旦dirty感受到压力超过了一个阈值(上文提到的misses),就会做出一个决定

这个决定就是将dirty的数据全部交给read,这样dirty的压力就减少了(read map获得dirty map全量数据)

image.png

image.png

read -> dirty

在上面的流程我们知道dirty->read,并且将dirty重置为nil,在这之后,如果有新的写请求进来,我们需要初始化dirty

这个初始化的过程也就是将read map里面的逻辑上存在的数据拷贝到dirty map(以O(N)的时间复杂度将数据拷贝一份给dirty)

什么叫逻辑上存在,能从sync.Map中读出的数据

什么叫逻辑上不存在,可能在sync.Map中物理内存上有这份数据,但无法读取到(后文讲entry的状态再分析)

image.png

image.png

sync.Map双向数据流转思考

这样的数据流转操作是否是多余的呢?dirty把数据给read,自己重置为nil,然后read又把数据给dirty,这算是踢皮球吗?

  • dirty把数据全给read,为什么自己要重置为nil呢?

    如果dirty不重置为nil,就会出现read和dirty使用同一个底层map,无法做到数据隔离的目的

image.png

  • 为什么dirty不能拷贝一份全量的数据给read?而是要直接改变指针指向?

    不能,因为如果是写数据全部给read,那么对于访问read的操作就全都要加锁(读、删、写要互斥)

    那么在dirty -> read的这个过程中,整个sync.Map将处于报废的状态

entry的状态

在上面,我们聊了sync.Map的结构,这里还有一个非常重要的结构需要单独拿出来讲讲--entry

我们都知道map是k-v(key,value)键值对的哈希表结构,而在sync.Map中,read map和dirty map各自的map结构对应的是entry

type entry struct {
		p unsafe.Pointer  // p指向真正的value所在的地址
}

对于entrty,这里p有三种取值:

  • p为正常值:entry表示正常的value
  • p为nil,我称之为软删除态,代表数据逻辑上不存在了(物理上仍存在,read和dirty都有这份数据)
  • p为expunged,我称之为硬删除态(物理上dirty没有这份数据)

nil状态

这里我们可能会想,entry不就是key对应value吗?不就指向真正的物理内存就可以了吗,为什么还需要nil和expunged?

我们来分析为什么需要这两种状态,首先我们知道了read map是一个可以通过无锁操作来帮助我们消耗请求sync.Map整体的流量

这样就可以不需要加锁,换句话说我们思考为什么写操作要加锁访问dirty,而更新和删除操作不需要进行加锁访问dirty呢?

前面我们提到dirty保存了全量的数据,那么写操作肯定是要到dirty的,dirty要有全量数据,并且保证线程安全要加锁

  • 那为什么更新操作可以不走dirty呢?我们不是要保证dirty要有最全且最新的数据吗?

    因为底层read和dirty两个map的指向都是同一个entry,所以更新read,就自然的更新了dirty

image.png

  • 那为什么删除操作可以不走dirty呢?如果说没有nil和expunged状态,会有什么问题?

    会发现我们的dirty中仍然有着key2,并没有删除干净

image.png

那么既然删除操作走read删除不干净,那就应该走read的同时也走dirty不是吗?

那要删除dirty的key-value,那是不是意味着要给dirty上锁?这也就是为什么entry会有nil和expunged的状态

归根结底就是:通过read map来消耗删除流量,尽可能的减少加锁的操作,实现无锁删除

当这个entry对应的p变成nil后,我们再来看读写的情况

读:p变成了nil,最后返回的value是nil (着个key-entry对被隐藏了,访问不到了)

写:因为key-entry对,仍然存在于read map和dirty中,可以通过CAS,将nil值改为value值,完成更新操作,而不是完成写操作,无需加锁访问dirty,这句话是最关键的

其实nil状态的设计就是为了做到:"对同一个key先删后写的场景的优化"

image.png

image.png

expunged状态

那么expunged状态有什么作用呢?

我们回顾 read -> dirty的流程,有提到read只会将逻辑上存在的数据Copy给dirty,那么这里有个问题:

read中为nil的数据没有拷贝给dirty(因为逻辑上不存在),那么下一次进行写的时候,我们如何判断是直接更新写到read还是dirty呢?

  • 如果只有nil状态,那么此时进行写操作,就会导致dirty丢失了这个数据key3-v3

image.png

  • 所以我们需要一个额外的状态来标识 --- "dirty和read是否正在共享 这个逻辑删除的key-entry"

image.png

entry状态总结

不管是nil状态还是expunged状态,都代表逻辑上,key-entry从sync.Map中已经删除了

  • nil状态:我们将其称之为软删除,但是read map和dirty map中物理上仍然有这个key-entry对

    同时我们可以基于CAS让read消耗 对p为nil的key-entry的写流量(优化对同一个key先删后写的场景)

  • expunged状态:我们将其称之为硬删除,物理上dirty map中已经没有key-entry对了,它能帮助我们标识出dirty map

    当前有没有这个逻辑删除的key-entry(如果标识dirty map中没有,我们就需要访问dirty map)

思考

  • sync.Map有啥不足的吗?

    sync.Map不适用于写多的场景,因为写操作足够多的话,sync.Map相当于Mutex + map

    而且sync.Map中存在一个将read map数据流转到dirty map的过程,这个过程是线性时间复杂度(On)的

    当map中key-value数据较多的时候,容易导致程序性能抖动,比如想要访问sync.Map拿锁操作goroutine要一直等待O(n)

  • sync.Map的底层原理是什么?

    空间换时间、数据的状态流转、entry状态的设计