Golang sync.Map 原理(两个map实现 读写分离、适用读多写少场景)

news2024/11/18 3:37:17

参考:

  • 由浅入深聊聊Golang的sync.Map 通过对源码的逐行分析,清晰易懂
  • Golang sync.Map原理 通过向 sync.Map 中增删改查来介绍sync.Map的底层原理
  • Golang中sync.Map的实现原理是什么 很好的概括了sync.Map的原理
  • 手摸手Go 深入理解sync.Map 知乎大佬

大家都知道go中的原生map是非线程安全的,多个协程并发读map常常会出现这样的问题 fatal error: concurrent map writes,所以一般为了使map能够做到线程安全,都会采取以下两种方式实现:

  • map + mutex (读多写少的场景下,锁的粒度太大存在效率问题:影响其他的元素操作)
  • sync.Map(减少加锁时间:读写分离,降低锁粒度,空间换时间,降低影响范围)

那么问题来了,sync.Map是如何做到线程安全的呢?一起来了解下~

sync.Map原理解析:

原理

sync.Map底层使用了两个原生map,一个叫read,仅用于读;一个叫dirty,用于在特定情况下存储最新写入的key-value数据:
在这里插入图片描述
read好比整个sync.Map的一个“高速缓存”,当goroutine从sync.Map中读数据时,sync.Map会首先查看read这个缓存层是否有用户需要的数据(key是否命中),如果有(key命中),则通过原子操作将数据读取并返回,这是sync.Map推荐的快路径(fast path),也是sync.Map的读性能极高的原因。

  • 操作:直接写入dirty(负责写的map)
  • 操作:先读read(负责读操作的map),没有再读dirty(负责写操作的map)
    在这里插入图片描述
    sync.Map 的实现原理可概括为:
  1. 通过 read 和 dirty 两个字段实现数据的读写分离,读的数据存在只读字段 read 上,将最新写入的数据则存在 dirty 字段上
  2. 读取时会先查询 read,不存在再查询 dirty,写入时则只写入 dirty
  3. 读取 read 并不需要加锁,而读或写 dirty 则需要加锁
  4. 另外有 misses 字段来统计 read 被穿透的次数(被穿透指需要读 dirty 的情况),超过一定次数则将 dirty 数据更新到 read 中(触发条件:misses=len(dirty))
    在这里插入图片描述

优缺点

  • 优点:Go官方所出;通过读写分离,降低锁时间来提高效率;
  • 缺点:不适用于大量写的场景,这样会导致 read map 读不到数据而进一步加锁读取,同时dirty map也会一直晋升为read map,整体性能较差,甚至没有单纯的 map+metux 高。
  • 适用场景:读多写少的场景。

可见,通过这种读写分离的设计,解决了并发场景下的写入安全,又使读取速度在大部分情况可以接近内建 map,非常适合读多写少的情况。

以上就是sync.Map的基本实现原理了,如果想要从源码角度去了解更多的底层实现细节,就继续往下学习~


sync.Map的 核心数据结构 及 源码解析

// sync.Map的核心数据结构
type Map struct {
	mu Mutex						// 对 dirty 加锁保护,线程安全
	read atomic.Value 				// readOnly 只读的 map,充当缓存层
	dirty map[interface{}]*entry 	// 负责写操作的 map,当misses = len(dirty)时,将其赋值给read
	misses int						// 未命中 read 时的累加计数,每次+1
}

// 上面read字段的数据结构
type readOnly struct {
    m  map[interface{}]*entry // 
    amended bool // Map.dirty的数据和这里read中 m 的数据不一样时,为true
}

// 上面m字段中的entry类型
type entry struct {
    // 可见value是个指针类型,虽然read和dirty存在冗余情况(amended=false),但是由于是指针类型,存储的空间应该不是问题
    p unsafe.Pointer // *interface{}
}

在 sync.Map 中常用的有以下方法:
- Load():读取指定 key 返回 value
- Delete(): 删除指定 key
- Store(): 存储(新增或修改)key-value

下面分别从这三种方法出发来理清底层代码逻辑:

Load()查询操作:

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // 因read只读,线程安全,优先读取
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    
    // 如果read没有,并且dirty有新数据,那么去dirty中查找(read.amended=true:dirty和read数据不一致)
    if !ok && read.amended {
        m.mu.Lock()
        // 双重检查(原因是前文的if判断和加锁非原子的,害怕这中间发生故事)
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        
        // 如果read中还是不存在,并且dirty中有新数据
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // m计数+1
            m.missLocked()
        }
        
        m.mu.Unlock()
    }
    
    // !ok && read.amended=false:dirty和read数据是一致的,read 和 dirty 中都不存在,返回nil
    if !ok {
        return nil, false
    }
	
	// ok && read.amended=true:dirty和read数据不一致,dirty存在但read不存在该key,直接返回dirty中数据~
    return e.load()
}

func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    
    // 将dirty置给read,因为穿透概率太大了(原子操作,耗时很小)
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

在这里插入图片描述

当Load方法在read map中没有命中(miss)目标key时,该方法会再次尝试在dirty中继续匹配key;无论dirty中是否匹配到,Load方法都会在锁保护下调用missLocked方法增加misses的计数(+1);当计数器misses值到达len(dirty)阈值时,则将dirty中的元素整体更新到read,且dirty自身变为nil。

注意点:

  • 阈值:misses == len(dirty)
  • 写操作仅针对dirty(负责写操作的map),所以dirty是包含read的,最新且全量的数据。

Delete()删除操作:

func (m *Map) Delete(key interface{}) {
    // 读出read,断言为readOnly类型
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    // 如果read中没有,并且dirty中有新元素,那么就去dirty中去找。这里用到了amended,当read与dirty不同时为true,说明dirty中有read没有的数据。
    
    if !ok && read.amended {
        m.mu.Lock()
        // 再检查一次,因为前文的判断和锁不是原子操作,防止期间发生了变化。
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        
        if !ok && read.amended {
            // 直接删除
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
    
    if ok {
    // 如果read中存在该key,则将该value 赋值nil(采用标记的方式删除!)
        e.delete()
    }
}

func (e *entry) delete() (hadValue bool) {
    for {
    	// 再次加载数据的指针,如果指针为空或已被标记删除,那么返回false,删除失败
        p := atomic.LoadPointer(&e.p)
        if p == nil || p == expunged {
            return false
        }
        
        // 原子操作
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return true
        }
    }
}

在这里插入图片描述
注意点:

  • delete(m.dirty, key)这里采用直接删除dirty中的元素,而不是先查再删:
    这样的删除成本低。读一次需要寻找,删除也需要寻找,无需重复操作。
  • 通过延迟删除对read中的值域先进行标记
    将read中目标key对应的value值置为nil(e.delete()→将read=map[interface{}]*entry中的值域*entry置为nil)

Store(): 新增/修改 操作

func (m *Map) Store(key, value interface{}) {
    // 如果m.read存在这个key,并且没有被标记删除,则尝试更新。
    read, _ := m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }
    
    // 如果read不存在或者已经被标记删除
    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
   
    if e, ok := read.m[key]; ok { // read 存在该key
    // 如果read值域中entry已删除且被标记为expunge,则表明dirty没有key,可添加入dirty,并更新entry
        if e.unexpungeLocked() { 
            // 加入dirty中,这里是指针
            m.dirty[key] = e
        }
        // 更新value值
        e.storeLocked(&value) 
        
    } else if e, ok := m.dirty[key]; ok { // dirty 存在该 key,更新
        e.storeLocked(&value)
        
    } else { // read 和 dirty都没有
        // 如果read与dirty相同,则触发一次dirty刷新(因为当read重置的时候,dirty已置为 nil了)
        if !read.amended { 
            // 将read中未删除的数据加入到dirty中
            m.dirtyLocked() 
            // amended标记为read与dirty不相同,因为后面即将加入新数据。
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        m.dirty[key] = newEntry(value) 
    }
    m.mu.Unlock()
}

// 将read中未删除的数据加入到 dirty中
func (m *Map) dirtyLocked() {
    if m.dirty != nil {
        return
    }
    
    read, _ := m.read.Load().(readOnly)
    m.dirty = make(map[interface{}]*entry, len(read.m))
    
    // 遍历read。
    for k, e := range read.m {
        // 通过此次操作,dirty中的元素都是未被删除的,可见标记为expunged的元素不在dirty中!!!
        if !e.tryExpungeLocked() {
            m.dirty[k] = e
        }
    }
}

// 判断entry是否被标记删除,并且将标记为nil的entry更新标记为expunge
func (e *entry) tryExpungeLocked() (isExpunged bool) {
    p := atomic.LoadPointer(&e.p)
    
    for p == nil {
        // 将已经删除标记为nil的数据标记为expunged
        if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
    }
    return p == expunged
}

// 对entry尝试更新 (原子cas操作)
func (e *entry) tryStore(i *interface{}) bool {
    p := atomic.LoadPointer(&e.p)
    if p == expunged {
        return false
    }
    for {
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
        if p == expunged {
            return false
        }
    }
}

// read里 将标记为expunge的更新为nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
    return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}

// 更新entry
func (e *entry) storeLocked(i *interface{}) {
    atomic.StorePointer(&e.p, unsafe.Pointer(i))
}

在这里插入图片描述
注意点:

  • m.dirtyLocked()通过迭代的方式,将read中未删除的数据加入到 dirty 中
    是一个整体的指针交换操作。
    当之前执行Load()方法且满足条件misses=len(dirty)时,会将dirty数据整体迁移到read中。sync.Map直接将原dirty指针store给read并将dirty自身也置为nil。
    因此sync.Map若想要保证在 amended=true(read和dirty中数据不一致),并且下一次发生数据迁移时(dirty → read)不会丢失数据,dirty中就必须拥有整个Map的全量数据才行。所以这里m.dirtyLocked()又会【将read中未删除的数据加入到 dirty中】。
    不过dirtyLocked()是通过一个迭代实现的元素从read到dirty的复制,如果Map中元素数量很大,这个过程付出的损耗将很大,并且这个过程是在保护下的。这里迭代遍历复制的方式可能会存在性能问题
  • 惰性删除:
    和仅在read中的情况不同(仅将value设置为nil),仅存在于dirty中的key被删除后,该key就不再存在了。这里还有一点值得注意的是:当向dirty写入一个新的key时,dirty会复制read中未被删除的元素,已经被删除key对应的value会先标记为哨兵*(expunged:算是一个标记,表示dirty map中对应的值已经被干掉了)并延迟删除,并且该key不会被加入到dirty中。直到下一次promote全量更新read时,该key才会被回收(因为read被交换指向新的dirty,原read指向的内存将被GC)。

总结:

通过阅读源码我们发现sync.Map是通过冗余的两个数据结构(read、dirty),实现性能的提升。为了提升性能,load、delete、store等操作尽量使用只读的read;为了提高read的key击中概率,采用动态调整,将dirty数据提升为read;对于数据的删除,采用延迟标记删除法只有在提升dirty的时候才删除

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/63304.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

CentOS7.9系统部署(nginx+uwsgi+flask)项目

一、概述 上次&#xff0c;我们介绍了如何将CentOS服务器自带的Python3.6.8版本升级到Python3.8.0版本&#xff0c;现在我们开始介绍如何将flask项目部署的CentOS7.9版本的Linux服务器上。 二、环境准备 2.1安装git 我们通常会将自己的项目托管在Github或者gitee平台&#…

地址锁存器,总线控制器,双向总线驱动器(数据缓冲器),时钟发生器。8088最小工作模式。

这几个芯片你知道它叫什么干什么用&#xff0c;跟CPU怎么接就可以。一般就是考填空 目录 这几个芯片你知道它叫什么干什么用&#xff0c;跟CPU怎么接就可以。一般就是考填空 地址锁存器&#xff08;74LS373&#xff0c;8282&#xff09; 数据缓冲器 8286&#xff0c;74LS24…

Spring MVC 常用注解的使用

ResponseBody 由于 Spring MVC 是基于 MVC 这个设计模式的&#xff0c;所以在不加上注解的情况下&#xff0c;页面和前端交互的时候返回的默认是一个视图 View&#xff0c;或者说静态页面&#xff0c;而实际上用的比较多的是将处理完的数据发送给前端&#xff0c;所以我们可以…

第五章. 可视化数据分析分析图表—常用图表的绘制2—直方图,饼形图

第五章. 可视化数据分析分析图 5.3 常用图表的绘制2—直方图&#xff0c;饼形图 本节主要介绍常用图表的绘制&#xff0c;主要包括直方图&#xff0c;饼形图。 1.直方图&#xff08;matplotlib.pyplot.hist&#xff09; 直方图&#xff0c;又称质量分布图&#xff0c;一般用横…

[附源码]JAVA毕业设计农产品的物流信息服务平台(系统+LW)

[附源码]JAVA毕业设计农产品的物流信息服务平台&#xff08;系统LW&#xff09; 项目运行 环境项配置&#xff1a; Jdk1.8 Tomcat8.5 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 …

MyBatis开发的详细步骤

推荐教程&#xff1a;SSM框架 一、什么是Mybatis 1.mybatis 是一个优秀的基于java的持久层框架&#xff0c;它内部封装了jdbc&#xff0c;使开发者只需要关注sql语句本身&#xff0c;而不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程。 2.mybatis通过x…

Windows下使用labelme标注图像

安装参考链接&#xff1a;https://github.com/wkentaro/labelme 一、安装Anaconda Windows下安装labelme需要借助Anaconda环境&#xff0c;安装很简单 https://www.anaconda.com/download/ 先进入官网&#xff0c;然后点击Windows系统版本 下载完成之后&#xff0c;就按照提…

[附源码]计算机毕业设计社区生活废品回收APPSpringboot程序

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis Maven Vue 等等组成&#xff0c;B/S模式 M…

网站都变成灰色,其实几行代码就搞定了!

最近&#xff0c;全站和各个App的内容都变成了灰色&#xff0c;包括按钮、图片等等。 这时候我们可能会好奇这是怎么做到的呢&#xff1f; 有人会以为所有的内容都统一换了一个 CSS 样式&#xff0c;图片也全换成灰色的了&#xff0c;按钮等样式也统一换成了灰色样式。但你想想…

Java入门教程(11) ——基本数据类型

文章目录1.数据类型22.1 整型2.2 浮点型2.3 字符型2.4 布尔型1.数据类型 分为基本数据类型和引用数据类型 如图示&#xff1a; 2 2.1 整型 byte 1字节 short 2 字节 int 4字节 long 8字节. Java 整型常数默认为 int 型&#xff0c;声明 long 型常量可以后加‘ l ’或‘ L ’…

kube-OVN总体架构

本文档将介绍 Kube-OVN 的总体架构&#xff0c;和各个组件的功能以及其之间的交互。 总体来看&#xff0c;Kube-OVN 作为 Kubernetes 和 OVN 之间的一个桥梁&#xff0c;将成熟的 SDN 和云原生相结合。 这意味着 Kube-OVN 不仅通过 OVN 实现了 Kubernetes 下的网络规范&#x…

热销产品缺货,滞销产品积压?WMS系统如何打造智能仓储

仓库是企业物流系统中的一个关键环节&#xff0c;涵盖出库、入库、质检等各个流程。传统的仓储模式单一、反应迟钝&#xff0c;难以适应企业的数字化经营要求。 如何在最小的人力资源下&#xff0c;最大限度地发挥仓库的价值&#xff0c;在最小的成本下&#xff0c;最大限度地利…

【服务器数据恢复】Zfs文件系统误删除数据的数据恢复案例

服务器故障&#xff1a; 一台zfs文件系统服务器&#xff0c;运维人员误操作删了服务器上的数据&#xff0c;用户联系到我们数据恢复中心要求恢复数据。 服务器数据恢复过程&#xff1a; 1、服务器数据恢复工程师对故障服务器所有硬盘进行扇区级镜像备份&#xff0c;后续的数据…

Kafka核心技术与实战 04

Kafka 不再是一个单纯的消息引擎系统&#xff0c;而是能够实现精确一次&#xff08;Exactly-once&#xff09;处理语义的实时流处理平台。 Kafka版本 Apache Kafka&#xff0c;也称社区版 Kafka。优势在于迭代速度快&#xff0c;社区响应度高&#xff0c;使用它可以让你有更高…

美团一面:为什么线程崩溃崩溃不会导致 JVM 崩溃

网上看到一个很有意思的美团面试题&#xff1a;为什么线程崩溃崩溃不会导致 JVM 崩溃&#xff0c;这个问题我看了不少回答&#xff0c;但发现都没答到根上&#xff0c;所以决定答一答&#xff0c;相信大家看完肯定会有收获&#xff0c;本文分以下几节来探讨 线程崩溃&#xff0…

公众号美食文案怎么写?怎么写才能吸引人?

美食类公众号的文案还是比较难写的&#xff0c;毕竟文案没有图片那么直观&#xff0c;让用户看着就有食欲。 公众号美食文案怎么写&#xff1f;怎么写才能吸引人&#xff1f;怎么写才能在字里行间透露着美食的诱惑力&#xff1f; 作为一个有着十年丰富经验的文案人&#xff0c…

Java编程最常见的208道面试题,一文解析

相比与这些问题&#xff0c;我的这 208 道面试题具备以下优点&#xff1a; 披沙拣金提炼出每个 Java 模块中最经典的面试题&#xff1b;答案准确&#xff0c;每个题目都是我仔细校对过的&#xff1b;接近最真实的企业面试&#xff0c;题目实用有效果&#xff1b;难懂的题目&am…

SAP IDoc状态70 - This IDoc is saved as the original of an edited document.

SAP IDoc状态70 - This IDoc is saved as the original of an edited document. 根据SAP的标准逻辑&#xff0c;一个IDoc一旦被修改了&#xff0c;SAP系统会自动创建一个新的IDoc(状态70)来存储IDoc修改日志。 比如idoc # IDoc 208828452&#xff0c;由于库存不够&#xff0c;所…

SQL注入【SQLi-LABS Page-1(Basic Challenges Less1-Less22)】

文章目录前言sqlmapless-1&#xff08;基于错误的GET单引号字符型注入&#xff09;less-2&#xff08;基于错误的GET整型注入&#xff09;less-3&#xff08;基于错误的GET单引号变形注入&#xff09;less4&#xff08;基于错误的GET双引号字符型注入&#xff09;less5&#xf…

Docker的资源管理控制(CPU、内存、磁盘IO配额)

目录 一、CPU 资源控制 1、设置CPU使用率上限 &#xff08;1&#xff09;查看CPU使用率 &#xff08;2&#xff09;进行CPU压力测试 &#xff08;3&#xff09;设置CPU使用率 2、设置CPU资源占用比&#xff08;设置多个容器时才有效&#xff09; 3、设置容器绑定指定的CP…