1.Go语言sync.Map实现原理
2.一文捋清 sync.Map的实现原理
3.浅谈Golang两种线程安全的map
4.golang面试题库?
5.深入理解Go语言sync.Map
6.Golang并发map?
Go语言sync.Map实现原理
Go语言的sync.Map是并发安全的map类型,它在Go 1.9版本引入,解决并发读写问题时无需加锁,通过read和dirty两个map实现读写分离,提升效率。
sync.Map的游戏外挂源码核心设计思想为“空间换时间”,利用冗余的数据结构减少锁的使用。read和dirty这两个map分别存放key-entry,entry指向value。read和dirty中key指向同一个value,改动entry时read和dirty会自动更新。
实现原理中,read作为并发读取安全的区域,dirty作为写入区域,通过锁机制和双检查机制确保操作正确。双检查机制在需要修改dirty前上锁,防止read中数据在检查过程中改变。延迟删除机制将删除操作标记,避免立即耗时,减少并发冲突。
read优先原则是当需要执行读、删除、更新操作时,先在read中执行,更快且更安全,若read中无法获取结果则尝试dirty。状态机机制通过entry的指针p表示三种状态,协助同步和管理数据。
sync.Map内部结构包括:readOnly、entry和sync.Map结构体。readOnly结构用于并发读取,entry数据结构用于存储指向value的指针,entry状态有三种:nil、expunged和正常。
源码解析揭示了Load、Store、Delete和Range等方法实现细节。Load方法在read map未命中时尝试dirty map,Store方法在dirty map与read map数据同步后添加新键值对,Delete方法在amended为true时,将dirty提升为read,并遍历read进行删除操作。数据迁移策略在miss计数达到一定阈值时将dirty同步到read,减少并发操作开销。
使用sync.Map时,无需担心传统并发操作的锁竞争问题,通过优化的数据结构和操作策略显著提升并发读写性能。推荐在读多写少的场景下使用sync.Map,实现高效、安全的并发管理。
一文捋清 sync.Map的实现原理
golang 内置的 map 类型不支持并发操作,若需并发读写,通常需配合锁使用。
然而,加锁操作较重,golang 官方提供了 sync.Map 类型,专门用于支持并发读写。
本文基于 go1.. linux/amd 版本的源码对 sync.Map 进行分析,旨在对 sync.Map 的原理及适用场景有更清晰的理解。
为了提高并发访问效率,通常原则是:尽量减少锁的争用,如使用原子操作替代加锁。
sync.Map 采用读写分离 + 原子操作的方式,设置了两个 map(dirty map 和 read map),read map 通过原子方式访问,dirty map 的访问需要加锁。
同步过程分为两类:
sync.Map 的数据结构定义如下:
read map 通过原子操作进行读取和写入,实际存的是 readOnly 结构。
其中字段 m 就是普通的 map,amended 用于标识 read map 的数据是否完整(dirty map 中可能写入了新的数据,此时为 true)。
read map 和 dirty map 底层的 map 中,存储的 value 都是 entry 结构。
疑问:为什么这里不直接将 m 定义为 map[any]unsafe.Pointer 类型?
其实结合下文的 entry.p 的状态可以得出结论,主要是为了并发高效地删除 key。
删除 key 时从 read map 中删除即可,但是查商品源码由于 read map 是原子操作的,因此只能整体替换整个 readOnly 结构,或者原子地将 value 中的指针置为 nil,不能直接使用 delete 关键字删除(要加锁)。
entry.p 字段在不同阶段会有不同的取值,代表不同的状态:
Store 操作:
Store 方法用于向 map 中并发安全地修改或新增数据,签名如下:
下面将源码拆成小段进行详细分析:
首先查询 read map,如果 read map 中存在该 key,则尝试写入。这里只是进行尝试,是否能写入还需看对应 entry 的状态。
如果 entry.p == expunged,则不能写入,因为已经经历过 read map 向 dirty map 的同步,read map 接下来会被直接替换掉,即使写入也没用。
运行到这里,说明要么 read map 中不存在该 key,要么 read map 中存在该 key 但 entry 为 expunged 状态(即将被物理清理)。需要在锁的保护下将数据存到 dirty map 中。
由于上一次判断到获取锁之间可能会有其他的线程修改了 read map,所以利用了 double check 再次判断 read map 是否有该 key。
情况一:read map 中存在
具体执行什么操作依赖于 entry 的状态:
注意到这里的 entry 和 entry.p 都是指针,说明如果 read map 和 dirty map 中同时存在 entry,那么数据是共享的。
情况二:read map 中不存在且 dirty 中存在
这种情况直接原子地将值存到对应的 entry 中。
情况三:read map 和 dirty map 都不存在
这种情况涉及到 read map 向 read map 的同步。
如果 read.amended == true,即 dirty map 中存在独有的 key,那么直接在 dirty map 新增 entry 即可。
如果 read.amended == false,dirty map 中可能缺失数据(比如刚经历过 dirty map 向 read map 的同步,dirty map 可能为 nil),写入之前需要将 read map 中正常的数据同步过去。这里指的正常的数据即非 nil 状态的 entry。
Load 操作:
前面说可以将 read map 视为 dirty map 的快照,由于使用原子操作可以保证并发效率,因此读取时也是优先尝试 read map。
和 Store 类似,也会有 double check 机制。
如果 read map 中不存在且 amended == false(dirty map 中没有独有的 key),说明整个 map 中不存在该 key,直接返回 false;
如果 read map 不存在且 amended == true,key 可能存在于 dirty map,因此加锁从 dirty map 获取。
由于 read map 未命中,还会将 misses 计数增加 1,如果 misses 计数达到阈值,会将 dirty map 整体替换为 read map,dirty map 置为 nil。
如果 read map 中存在 entry,则根据 entry 状态返回。nil 状态或 expunged 状态下都说明该 key 被删除,返回 false;正常状态返回 true。
Delete 操作:
逻辑总体和 Load 相似:
Range 操作:
range 操作用于遍历 sync.Map 中每个元素并执行函数。
由于 read map 和 dirty map 数据并不完全一致,且都可能有对方不存在的 key,因此需要分情况讨论:
浅谈Golang两种线程安全的map
文章标题:浅谈Golang两种线程安全的map
导语:本文将深入探讨Golang中的本地缓存库选择与对比,帮助您解决困惑。
Golang map并发读写测试:
在Golang中,原生的map在并发场景下的读写操作是线程不安全的,无论key是否相同。具体来说,当并发读写map的不同key时,运行结果会出现并发错误,因为map在读取时会检查hashWriting标志。如果存在该标志,即表示正在写入,此时会报错。在写入时,会设置该标志:h.flags |= hashWriting。设置完成后,系统会取消该标记。
使用-race编译选项可以检测并发问题,这是通过Golang的源码分析、文章解析和官方博客中详细解释的。
map+读写锁实现:
在官方sync.map库推出之前,前端dist源码推荐使用map与读写锁(RWLock)的组合。通过定义一个匿名结构体变量,包含map、RWLock,可以实现读写操作。
具体操作方法如下:从counter中读取数据,往counter中写入数据。然而,sync.map和这种实现方式有何不同?它在性能优化方面做了哪些改进?
sync.map实现:
sync.map使用读写分离策略,通过空间换取时间,优化了并发性能。相较于map+RWLock的实现,它在某些特定场景中减少锁竞争的可能性,因为可以无锁访问read map,并优先操作read map。如果仅操作read map即可满足需求(如增删改查和遍历),则无需操作write map,后者在读写时需要加锁。
sync.map的源码深入分析:
接下来,我们将着重探讨sync.Map的源码,以理解其运作原理,包括结构体Map、readOnly、entry等。
sync.Map方法介绍:
sync.Map提供了四个关键方法:Store、Load、Delete、Range。具体功能如下:
Load方法:解释Map.dirty如何提升为Map.read的机制。
Store方法:介绍tryStore函数、unexpungeLocked函数和dirtyLocked函数的实现。
Delete方法:简单总结。
Range方法:简单总结。
sync.Map总结:
sync.Map更适用于读取频率远高于更新频率的场景(appendOnly模式,尤其是key存一次,多次读取且不删除的情况),因为在key存在的情况下,读写删操作可以无锁直接访问readOnly。不建议用于频繁插入与读取新值的场景,因为这会导致dirty频繁操作,需要频繁加锁和更新read。此时,github开源库orcaman/concurrent-map可能更为合适。
设计点:expunged:
expunged是entry.p值的三种状态之一。当使用Store方法插入新key时,会加锁访问dirty,并将readOnly中未被标记为删除的所有entry指针复制到dirty。此时,之前被Delete方法标记为软删除的entry(entry.p被置为nil)都会变为expunged状态。
sync.map其他问题:
sync.map为何不实现len方法?这可能涉及成本与收益的权衡。
orcaman/concurrent-map的适用场景与实现:
orcaman/concurrent-map适用于反复插入与读取新值的场景。其实现思路是对Golang原生map进行分片加锁,降低锁粒度,从而达到最少的锁等待时间(锁冲突)。
它实现简单,部分源码如下,包括数据结构和函数介绍。
后续:
在其他业务场景中,可能需要本地kv缓存组件库,支持键过期时间设置、淘汰策略、存储优化、GC优化等功能。此时,可能需要了解freecache、gocache、fastcache、bigcache、groupcache等组件库。
参考链接:
链接1:/questions//golang-fatal-error-concurrent-map-read-and-map-write/
链接2:/golang/go/issues/
链接3:/golang/go/blob/master/src/sync/map.go
链接4:/orcaman/concurrent-map
golang面试题库?
go面试题整理(附带部分自己的解答)
原文:
如果有解答的不对的,麻烦各位在评论写出来~
go的调度原理是基于GMP模型,G代表一个goroutine,不限制数量;M=machine,代表一个线程,最大1万,所有G任务还是字符流源码在M上执行;P=processor代表一个处理器,每一个允许的M都会绑定一个G,默认与逻辑CPU数量相等(通过runtime.GOMAXPROCS(runtime.NumCPU())设置)。
go调用过程:
可以能,也可以不能。
因为go存在不能使用==判断类型:map、slice,如果struct包含这些类型的字段,则不能比较。
这两种类型也不能作为map的key。
类似栈操作,后进先出。
因为go的return是一个非原子性操作,比如语句returni,实际上分两步进行,即将i值存入栈中作为返回值,然后执行跳转,而defer的执行时机正是跳转前,所以说defer执行时还是有机会操作返回值的。
select的case的表达式必须是一个channel类型,所有case都会被求值,求值顺序自上而下,从左至右。如果多个case可以完成,则会随机执行一个case,如果有default分支,则执行default分支语句。如果连default都没有,则select语句会一直阻塞,直到至少有一个IO操作可以进行。
break关键字可跳出select的执行。
goroutine管理、信息传递。context的意思是上下文,在线程、协程中都有这个概念,它指的是程序单元的一个运行状态、现场、快照,包含。context在多个goroutine中是并发安全的。
应用场景:
例子参考:
waitgroup
channel
len:切片的长度,访问时间复杂度为O(1),go的slice底层是对数组的引用。
cap:切片的容量,扩容是以这个值为标准。默认扩容是2倍,当达到的长度后,按1.倍。
扩容:每次扩容slice底层都将先分配新的容量的内存空间,再将老的数组拷贝到新的内存空间,因为这个操作不是并发安全的。所以并发进行append操作,读到内存中的老数组可能为同一个,最终导致append的数据丢失。
共享:slice的底层是对数组的引用,因此如果两个切片引用了同一个数组片段,就会形成共享底层数组。当sliec发生内存的重新分配(如扩容)时,会对共享进行隔断。详细见下面例子:
make([]Type,len,cap)
map的底层是hashtable(hmap类型),对key值进行了hash,并将结果的低八位用于确定key/value存在于哪个bucket(bmap类型)。再将高八位与bucket的tophash进行依次比较,确定是否存在。出现hash冲撞时,会通过bucket的overflow指向另一个bucket,形成一个单向链表。每个bucket存储8个键值对。
如果要实现map的顺序读取,需要使用一个slice来存储map的key并按照顺序进行排序。
利用map,如果要求并发安全,就用sync.map
要注意下set中的delete函数需要使用delete(map)来实现,但是这个并不会释放内存,除非value也是一个子map。当进行多次delete后,app源码git可以使用make来重建map。
使用sync.Map来管理topic,用channel来做队列。
参考:
多路归并法:
preclass="vditor-reset"placeholder=""contenteditable="true"spellcheck="false"pdata-block="0"(1)假设有K路ahref=""数据流/a,流内部是有序的,且流间同为升序或降序;
/ppdata-block="0"(2)首先读取每个流的第一个数,如果已经EOF,pass;
/ppdata-block="0"(3)将有效的k(k可能小于K)个数比较,选出最小的那路mink,输出,读取mink的下一个;
/ppdata-block="0"(4)直到所有K路都EOF。
/p/pre
假设文件又1个G,内存只有M,无法将1个G的文件全部读到内存进行排序。
第一步:
可以分为段读取,每段读取M的数据并排序好写入硬盘。
假设写入后的文件为A,B,C...
第二步:
将A,B,C...的第一个字符拿出来,对这个字符进行排序,并将结果写入硬盘,同时记录被写入的字符的文件指针P。
第三步:
将刚刚排序好的9个字符再加上从指针P读取到的P+1位数据进行排序,并写入硬盘。
重复二、三步骤。
go文件读写参考:
保证排序前两个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同的排序叫稳定排序。
快速排序、希尔排序、堆排序、直接选择排序不是稳定的排序算法。
基数排序、冒泡排序、直接插入排序、折半插入排序、归并排序是稳定的排序算法。
参考:
head只请求页面的首部。多用来判断网页是否被修改和超链接的有效性。
get请求页面信息,并返回实例的主体。
参考:
:未授权的访问。
:拒绝访问。
普通的ma和不带comma。当要查询的key不在map里,带comma的用法会返回一个bool型变量提示key是否在map中;而不带comma的语句则会返回一个value类型的零值。如果value是int型就会返回0,如果value是string类型,就会返回空字符串。
map的查找通过生成汇编码可以知道,根据key的不同类型,编译器会将查找函数用更具体的函数替换,以优化效率:
函数首先会检查map的标志位flags。如果flags的写标志位此时被置1了,说明有其他协程在执行“写”操作,进而导致程序panic。这也说明了map对协程是不安全的。
key经过哈希函数计算后,得到的哈希值如下(主流位机下共个bit位):
m:桶的个数
从buckets通过hashm得到对应的bucket,如果bucket正在扩容,并且没有扩容完成,则从oldbuckets得到对应的bucket
计算hash所在桶编号:
用上一步哈希值最后的5个bit位,也就是,值为,也就是号桶(范围是0~号桶)
计算hash所在的槽位:
用上一步哈希值哈希值的高8个bit位,也就是,转化为十进制,也就是,在号bucket中寻找**tophash值(HOBhash)为*的槽位**,即为key所在位置,找到了2号槽位,这样整个查找过程就结束了。
如果在bucket中没找到,并且overflow不为空,还要继续去overflowbucket中寻找,直到找到或是所有的key槽位都找遍了,包括所有的overflowbucket。
通过上面找到了对应的槽位,这里我们再详细分析下key/value值是如何获取的:
bucket里key的起始地址就是unsafe.Pointer(b)+dataOffset。第i个key的地址就要在此基础上跨过i个key的大小;而我们又知道,value的地址是在所有key之后,因此第i个value的地址还需要加上所有key的偏移。
通过汇编语言可以看到,向map中插入或者修改key,最终调用的是mapassign函数。
实际上插入或修改key的语法是一样的,只不过前者操作的key在map中不存在,而后者操作的key存在map中。
mapassign有一个系列的函数,根据key类型的不同,编译器会将其优化为相应的“快速函数”。
我们只用研究最一般的赋值函数mapassign。
map的赋值会附带着map的扩容和迁移,map的扩容只是将底层数组扩大了一倍,并没有进行数据的转移,数据的转移是在扩容后逐步进行的,在迁移的过程中每进行一次赋值(access或者delete)会至少做一次迁移工作。
1.判断map是否为nil
每一次进行赋值/删除操作时,只要oldbuckets!=nil则认为正在扩容,会做一次迁移工作,下面会详细说下迁移过程
根据上面查找过程,查找key所在位置,如果找到则更新,没找到则找空位插入即可
经过前面迭代寻找动作,若没有找到可插入的位置,意味着需要扩容进行插入,下面会详细说下扩容过程
通过汇编语言可以看到,向map中删除key,最终调用的是mapdelete函数
删除的逻辑相对比较简单,大多函数在赋值操作中已经用到过,核心还是找到key的具体位置。寻找过程都是类似的,在bucket中挨个cell寻找。找到对应位置后,对key或者value进行“清零”操作,将count值减1,将对应位置的tophash值置成Empty
再来说触发map扩容的时机:在向map插入新key的时候,会进行条件检测,符合下面这2个条件,就会触发扩容:
1、装载因子超过阈值
源码里定义的阈值是6.5(loadFactorNum/loadFactorDen),是经过测试后取出的一个比较合理的因子
我们知道,每个bucket有8个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是8。因此当装载因子超过6.5时,表明很多bucket都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。
对于条件1,元素太多,而bucket数量太少,很简单:将B加1,bucket最大数量(2^B)直接变成原来bucket数量的2倍。于是,就有新老bucket了。注意,这时候元素都在老bucket里,还没迁移到新的bucket来。新bucket只是最大数量变为原来最大数量的2倍(2^B*2)。
2、overflow的bucket数量过多
在装载因子比较小的情况下,这时候map的查找和插入效率也很低,而第1点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即map里元素总数少,但是bucket数量多(真实分配的bucket数量多,包括大量的overflowbucket)
不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多bucket,但是装载因子达不到第1点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的overflowbucket,但就是不会触发第1点的规定,你能拿我怎么办?overflowbucket数量太多,导致key会很分散,查找插入效率低得吓人,因此出台第2点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难
对于条件2,其实元素没那么多,但是overflowbucket数特别多,说明很多bucket都没装满。解决办法就是开辟一个新bucket空间,将老bucket中的元素移动到新bucket,使得同一个bucket中的key排列地更紧密。这样,原来,在overflowbucket中的key可以移动到bucket中来。结果是节省空间,提高bucket利用率,map的查找和插入效率自然就会提升。
由于map扩容需要将原有的key/value重新搬迁到新的内存地址,如果有大量的key/value需要搬迁,会非常影响性能。因此Gomap的扩容采取了一种称为“渐进式”的方式,原有的key并不会一次性搬迁完毕,每次最多只会搬迁2个bucket。
上面说的hashGrow()函数实际上并没有真正地“搬迁”,它只是分配好了新的buckets,并将老的buckets挂到了oldbuckets字段上。真正搬迁buckets的动作在growWork()函数中,而调用growWork()函数的动作是在mapassign和mapdelete函数中。也就是插入或修改、删除key的时候,都会尝试进行搬迁buckets的工作。先检查oldbuckets是否搬迁完毕,具体来说就是检查oldbuckets是否为nil。
如果未迁移完毕,赋值/删除的时候,扩容完毕后(预分配内存),不会马上就进行迁移。而是采取增量扩容的方式,当有访问到具体bukcet时,才会逐渐的进行迁移(将oldbucket迁移到bucket)
nevacuate标识的是当前的进度,如果都搬迁完,应该和2^B的长度是一样的
在evacuate方法实现是把这个位置对应的bucket,以及其冲突链上的数据都转移到新的buckets上。
转移的判断直接通过tophash就可以,判断tophash中第一个hash值即可
遍历的过程,就是按顺序遍历bucket,同时按顺序遍历bucket中的key。
map遍历是无序的,如果想实现有序遍历,可以先对key进行排序
为什么遍历map是无序的?
如果发生过迁移,key的位置发生了重大的变化,有些key飞上高枝,有些key则原地不动。这样,遍历map的结果就不可能按原来的顺序了。
如果就一个写死的map,不会向map进行插入删除的操作,按理说每次遍历这样的map都会返回一个固定顺序的key/value序列吧。但是Go杜绝了这种做法,因为这样会给新手程序员带来误解,以为这是一定会发生的事情,在某些情况下,可能会酿成大错。
Go做得更绝,当我们在遍历map时,并不是固定地从0号bucket开始遍历,每次都是从一个**随机值序号的bucket开始遍历,并且是从这个bucket的一个随机序号的cell**开始遍历。这样,即使你是一个写死的map,仅仅只是遍历它,也不太可能会返回一个固定序列的key/value对了。
从项目的一个 panic 说起:Go 中 Sync 包的分析应用
在项目开发过程中,遇到一个常见的错误——"fatal error: concurrent map read and map write",这是由于Golang内建的map在并发环境下不安全导致的。解决这个问题的方法并不复杂,就是转向使用sync包提供的并发安全的map。
sync包在Golang 1.9之后被官方支持,其中包含了丰富的同步原语,是并发编程的关键部分。在Golang 1.9之前,解决map并发问题通常会借助sync包中的sync.RWMutex或其他锁机制。Golang作为支持用户态进程的编程语言,对并发编程的处理自然离不开锁,这是一种确保多个Goroutine在同一片内存中协同工作的同步机制。
sync包的源码目录结构清晰,包含Mutex、RWmutex、WaitGroup、Map、Once、Cond、Pool等组件。接下来,我们将逐个分析这些同步原语的用途和使用注意事项,重点讨论在项目中常见的sync.Map。
sync.Map是sync包中的一种高效并发安全的map实现,与内建map相比,它提供了Load、Store、LoadOrStore、Delete和Range等方法,并且具有更高的并发性能。虽然sync.Map没有len方法,但其内部机制使得在并发环境中的操作更加稳健。
通过结合实际项目案例和面试题中的陷阱,本文简要探讨了sync包中Mutex、RWMutex、WaitGroup、Once以及Map的使用技巧和注意事项。在实际编程中,正确使用这些同步原语对于避免并发问题至关重要。
golang本地缓存(bigcache/freecache/fastcache等)选型对比及原理总结
以下内容来自腾讯后台研发工程师jayden
导语:提到本地缓存大家都不陌生,只要是个有点经验的后台开发人员,都知道缓存的作用和弊端。本篇文章我们就来简单聊聊在golang做业务开发的过程中,本地缓存的一些可选的开源方案,分析它们的特点,以及内部的实现原理。
1.本地缓存需求分析
首先来梳理一下业务开发过程中经常面临的本地缓存的一些需求。我们一般做缓存就是为了能提高系统的读写性能,缓存的命中率越高,也就意味着缓存的效果越好。其次本地缓存一般都受限于本地内存的大小,所有全量的数据一般存不下。那基于这样的场景一方面是想缓存的数据越多,则命中率理论上也会随着缓存数据的增多而提高;另外一方面是想,既然所有的数据存不下那就想办法利用有限的内存存储有限的数据。这些有限的数据需要是经常访问的,同时有一定时效性(不会频繁改变)的。基于这两个点展开,我们一般对本地缓存会要求其满足支持过期时间、支持淘汰策略。最后再使用自动管理内存的语言例如golang等开发时,还需要考虑在加入本地缓存后引发的GC问题。
分析完我们日常本地缓存的诉求,再结合我们日常开发用到的golang语言,我们可以提炼得到golang本地缓存组件必须具备以下几个能力:
分析清楚了我们的需求,也明确了我们需要的能力。那自然优先考虑golang内置的标准库中是否存在这样的组件可以直接使用呢?很遗憾,没有。golang中内置的可以直接用来做本地缓存的无非就是map和sync.Map。而这两者中,map是非并发安全的数据结构,在使用时需要加锁;而sync.Map虽然是线程安全的。但是需要在并发读写时加锁。此外二者均无法支持数据的过期和淘汰,同时在存储大量数据时,又会产生比较频繁的GC问题,更严重的情况下导致线上服务无法稳定运行。
既然标准库中没有我们满足上述需求的本地缓存组件,那我们就想只有两种解决方案了
那首先面临的第一个问题就是方案的调研和选型,没有合适的方案时自己再来动手构建。下面我们就来给大家介绍下golang中哪些可以直接来使用的本地缓存组件吧。
2.golang本地缓存组件概览
golang中本地缓存方案可选的有如下一些:
下面通过笔者一段时间的调研和研究,将golang可选的开源本地缓存组件汇总为下表,方便大家在方案选型时作参考。
在上述方案中,freecache、bigcache、fastcache、ristretto、groupcache这几个大家根据实际的业务场景首选,offheap有定制需求时可考虑。
通过上表的总结,个人想再此再谈几点关于本地缓存组件的理解:
(1)上述本地缓存组件中,实现零GC的方案主要就两种:
a.无GC:分配堆外内存(Mmap)
b.避免GC:map非指针优化(map[uint]uint)或者采用slice实现一套无指针的map
c.避免GC:数据存入[]byte slice(可考虑底层采用环形队列封装循环使用空间)
(2)实现高性能的关键在于:
a.数据分片(降低锁的粒度)
3. 主流缓存组件实现原理剖析
在本节中我们会重点分析下freecache、bigcache、fastcache、offheap这几个组件内部的实现原理。
3.1 freecache实现原理
首先分析下freecache的内部实现原理。在freecache中它通过segment来进行对数据分片,freecache内部包含个segment,每个segment维护一把互斥锁,每一条kv数据进来后首先会根据k进行计算其hash值,然后根据hash值决定当前的这条数据落入到哪个segment中。
对于每个segment而言,它由索引、数据两部分构成。
索引:其中索引最简单的方式采用map来维护,例如map[uint]uint这种。而freecache并没有采用这种做法,而是通过采用slice来底层实现一套无指针的map,以此避免GC扫描。
数据:数据采用环形缓冲区来循环使用,底层采用[]byte进行封装实现。数据写入环形缓冲区后,记录写入的位置index作为索引,读取时首先读取数据header信息,然后再读取kv数据。
在freecache中数据的传递过程是:freecache->segment->(slot,ringbuffer) 下图是freecache的内部实现框架图。
总结: freecache通过利用数据分片减小锁的粒度,然后再存储时索引并没有采用内置的map来维护而是采用自建map减少指针来避免GC,同时数据存储时采用预先分配内存然后后边循环使用。通过上述两种方法保证了在堆上分配内存同时减少GC对系统性能的影响。
3.2 bigcache实现原理
bigcache和freecache类似,也是一个零GC、高性能的cache组件,但是它的实现和freecache还是有些差异,这儿有篇 英文博客介绍bigcache设计原理的,内容稍长感兴趣的可以阅读下,下面我们介绍一下bigcache的实现原理。
bigcache同样是采用分片的方式构成,一个bigcache对象包含2^n 个cacheShard对象,默认是个。每个cacheShard对象维护着一把sync.RWLock锁(读写锁)。所有的数据会分散到不同的cacheShard中。
每个cacheShard同样由索引和数据构成。索引采用map[uint]uint来存储,数据采用entry([]byte)环形队列存储。索引中存储的是该条数据在entryBuffer写入的位置pos。每条kv数据按照TLV的格式写入队列。
不过值得注意的是,和bigcache和freecache不同的一点在于它的环形队列可以自动扩容。同时bigcache中数据的过期是通过全局的时间窗口维护的,每个单独的kv无法设置不同的过期时间。
下面是bigcache的内容实现原理框架图。
总结:bigcache思路和freecache大体相同,只不过在索引存储时更为巧妙,直接采用内置的map结构加上基础数据类型来实现。同时底层存储数据的队列也可以根据空间大小来决定是否扩容。唯一的缺陷是无法针对每个key进行设置不同的过期时间。这个个人认为如果想用bigcache同时想要这个特性,可以进行二次开发一下。
通过 性能测试数据来看,bigcache性能要比freecache稍微好一点。大家可以思考下他们性能的差异可能会在哪里呢?
3.3 fastcache实现原理
本节介绍下fastcache的实现原理,根据fastcache官方文档介绍,它的灵感来自于bigcache。所以整体的思路和bigcache很类似,数据通过bucket进行分片。fastcache由个bucket构成。每个bucket维护一把读写锁。在bucket内部数据同理是索引、数据两部分构成。索引用map[uint]uint存储。数据采用chunks二维的切片(二维数组)存储。不过值得注意的是fastcache有一个很大的特性是,它的内存分配是在堆外分配的,而不是在堆上分配的。堆外分配的内存。这样做也就避免了golang GC的影响。下图是fastcache内部实现框架图。
总结: fastcache一方面充分利用了分片来降低锁的粒度,另一方面在索引存储时采用了对map的优化,同时在分配内存时,直接从堆外申请内存,自己实现了分配和释放内存的逻辑。通过上述手段使得GC的影响降到了最低。fastcache唯一的缺陷是官方提供的版本没有提供针对kv数据的过期时间这个特性。所以如果需要这个特性的话,需要自己动手二次开发。整体从性能上来看是比bigcache和freecache都更优。
3.4 offheap实现原理
本节介绍下offheap的相关内容,offheap其实功能就比较简单了,就是一个基于堆外内存构建的哈希表。它通过直接调用系统调用函数来分配内存。然后在内部通过数组来实现哈希表。实现过程中当发生哈希冲突时,它是采用探测法来解决。由于是在堆外分配的内存上构建的哈希表。导致它的GC开销非常的小。下图是offheap的内部实现框架图。
总结:offheap内部由于是采用探测法解决哈希冲突的,所以当哈希冲突严重时数据删除、查询都会带来非常复杂的处理流程。而且性能也会有一些损耗。可以作为学习和研究的项目还是非常不错的。
4.总结
本文主要从日常需求出发,分析了日常业务过程中对本地缓存的需求,再调研了业界可选的一些组件并进行了对比,希望对本地缓存选型上起到一些参考和帮助。最后再对其中比较重要的几个组件如freecache、bigcache、fastcache、offheap等做了内部实现的简单介绍。上述内容只是从架构层面展开介绍,后续有时间再从源码层面做一些分析。由于篇幅限制本篇内容并未对map、sync.Map、go-cache、groupcache进行介绍。感兴趣的读者可以自行搜索资料进行阅读。如果大致理解了上述原理的童鞋也可以自己动手实践起来,造个轮子看看。
5.参考资料
欢迎点赞分享,关注 @鹅厂架构师,一起探索更多业界领先产品技术。