Go sync.Map

JunChow520 · · 131 次点击 · · 开始浏览    

  • 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中的表级锁

采用嵌入structmap增加读写锁

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.Mapsync.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的优化,解决并发提升的性能的呢?

  • 优化的方向:把锁的粒度尽可能降低来提高运行速度
  • 空间换时间,通过冗余的两个数据结构readdirty实现加锁对性能的影响。
  • 使用只读数据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的数据结构很简单,包含四个字段mureaddirtymisses,它使用了冗余的数据结构readdirtydirty中会包含read中未删除的实体,新增的实体会加入到dirty中。

实现

sync.Map的实现思路可概括为:读写分离、空间换时间

  • 通过readdirty字段将读写分离,读的数据只存在只读字段read上,最新写入的数据则存在dirty字段上。
  • 读取时会先检查read字段是否存在,若不存在则查询dirty字段。
  • 写入时则只会写入到dirty字段
  • 读取read字段并不需要加锁,但从dirty字段读取或写入时都需要加锁。
  • 另外还提供了misses字段来统计read字段被穿透的次数,所谓的被穿透也就是说需要读取dirty字段的情况,当超过一定次数时则将dirty字段数据同步到read字段上。
  • 删除数据时则直接通过标记来延迟删除
sync.Map

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 mapdirty 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的修改是通过原子操作进行的。

虽然readdirty存在冗余数据,但这些数据是通过指针指向同一个数据,所以尽管Mapvalue会很大,但冗余空间占用的还是很有限的。

entry

readOnly.mMap.dirty存储的值类型是*entry,它包含一个指针p,指向用户存储的value值。

type entry struct{
  p unsafe.Pointer //*interface{}
}

指针p具有三种值

  • nilentry已经被删除同时m.dirtynil
  • expungedentry已被删除且m.dirty不为nil,同时这个entry不存在于m.dirty中。
  • entry是一个正常的值

方法

Store

  • 新增元素,写操作。Store可能会在某些情况下下,比如初始化或m.dirty刚被提升后,从m.read中复制数据,若此时m.read中数据量比较大,则可能会影响性能。
//写操作
func (m *Map) Store(key, value interface{})
Store

Load

  • 读操作,提供一个键key来查找对应的值value,若不存在则通过ok反映。
// 读操作
func (m *Map) Load(key interface{}) (value interface{}, ok bool)
Load

Delete

Delete

由于数据可能存在两份,因此删除时必须考虑

  • read mapdirty 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方法通过回调的方式来遍历。


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:JunChow520

查看原文:Go sync.Map

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:701969077

131 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传