当前位置: 首页 > 科技观察

从入门到掉坑:Go内存池-对象池技术介绍

时间:2023-03-14 20:15:45 科技观察

作者:deryzhou,腾讯PCG后台开发工程师Go中内存池如何实现,能不能直接用map?公共库中的GroupCache和BigCache的内存池也是如何实现的呢?有什么陷阱吗?什么是对象池?想看重点的同学可以直接看Section2.0中GroupCache的总结。前言:之前推出了tcmalloc和GoC++服务,性能优化肯定会涉及到Google大名鼎鼎的tcmalloc。与glibc相比,tcmalloc在多线程方面有一个巨大的优势:vstcmalloc使用了内存池技术。想了解tcmalloc的细节,偷图解TCMalloc中一张比较经典的结构图:图解TCMalloc作为Google的得意之作,Golang自然而然地使用了tcmalloc的内存池03技术。因此,我们在正常使用Golang时,并不需要关注内存分配的性能。1、关于map你需要知道的事情由于Go本身的内存已经由tcmalloc管理,所以我们可以想到实现缓存的就是map吧?(不过仔细想想,map不需要加锁吗?不加锁是不是用sync.Map更好)坑一:为什么不用sync.Map2020-05-09补充:很多同学也提到bigcache测试是不公平。查看issues,有人测试了map+lock和sync.Map,性能确实低了(单锁情况下)https://github.com/golang/go/issues/28938#issuecomment-441737879但是如果当shardsmap+lock和sync.Map的读写比不同时(比如读多写少,超时才更新),很难判断哪个实现更好。有兴趣的同学可以尝试深挖下(而且doyenli也提到了sync.Mapappendonlyinternal)用过map的同学应该都知道map不是线程安全的。当多个协程同步更新地图时,有概率会丢失程序的内核。那我们为什么不用sync.Map呢?当然不是因为go版本太旧不支持这个表面原因。https://github.com/allegro/bigcache-bench中有一份对比数据。纯写map比sync.Map快很多,读也有一定的优势。考虑到大多数场景下读多写少,我们只需要在map上加一个读写锁,异步写的问题就解决了(不会损失太多性能)。mapvssync.Map除了读写锁,我们还可以使用shardmap的分布式锁来继续提升并发(后面会介绍bigcache部分),所以在最后的缓存库中可以看到,没有人用sync.Map,它是用map+读写锁来实现存储的。坑2:用map做内存池可以吗?不可以。地图存储键也有限制。当map中的key数量超过千万级时,可能会造成性能瓶颈。这是我在之前的业务中实际遇到的。当时在服务中使用GroupCache作为缓存,导致部分在线请求超时(超时率0.08%左右)。这个问题暂且搁置一旁,先搞清楚原因再介绍这里的区别。找资料后发现Go在2014年有个issue提到了LargemapscausesignificantGCpauses的问题。简单来说,当map中key数量较多时,GC扫描map带来的停顿是不容忽视的。好消息是,在2015年,Go开发者对map中没有指针的情况进行了优化:GCignoremapswithnopointers。我们参考里面的代码,写一个GC测试程序来验证:packagemainimport("fmt""os""runtime""time")//Resultsofthisprogrammonmymachine:////fortin12345;dogorunmaps.go$t;done////Higherparallelismdoeshelp,tosomeextent:////fortin12345;doGOMAXProcS=8gorunmaps.go$t;done////Output(go1.14)://Withmap[int32]*int32,GCtook456.159324ms//Withmap[int32]int32,GCtook10.644116ms//Withmapshards([]map[int32]*int32),GCtook383.296446ms//Withmapshards([]map[int32]int32),GCtook1.023655ms//Withaplainslice([]main.t),GCtook172.776μsfuncmain(){constN=5e7//5000wiflen(os.Args)!=2{fmt.Printf("usage:%s[1234]\n(numberselectsthetest)\n",os.Args[0])return}switchos.Args[1]{case"1"://Bigmapwithapointerinthevaluem:=make(map[int32]*int32)fori:=0;i1762->1220MB,3195MB目标,12P(强制)map[]int:gc10@9.357s0%:0.003+14+0.004ms时钟,0.046+0/14/13+0.054mscpu,1183->1183->746MB,2147MB??goal,12P(forced)输出各个字段的含义,可以看GODEBUG的gctrace干货分析,这里我们只关注0.055+0/1306/3899inthecpu+0.049mscpu本节解释:MarkPrepare(STW)-0.055表示整个过程处于markphaseSTWpausetimeMarking-0/1306/3899三段信息,其中0为mutatorassist耗时,1306为dedicatedmarkworkers+fractionalmarkworker耗时,3899为idlemarkworker耗时(虽然拆分为3个不同的gcworker,扫描的P在此过程中仍将暂停,并且注意这里的时间是所有P)MarkTermination(STW)-0.049表示整个过程在markTermination阶段的STW停顿时间只有Mark前后两个阶段会导致Stop-The-World(STW),和中间的Marking过程是并行的。这里1306ms是因为我们已经启动了12个P,1306ms和3899ms是所有P消耗时间的综合。虽然说Marking是平行的,但是扫描出来的P还是会悬空的。所以这个时间最终体现在业务程序上,就是某个P处理的请求在GC的时候耗时突然增加(不稳定),不能简单的忽略。回到上面的问题,shardsmap的性能如何?得到提升(近10倍)?//Withmap[int32]int32,GCtook11.285541msgc1@0.001s7%:0.010+2.1+0.012msclock,0.12+0.99/2.1/1.2+0.15mscpu,4->6->6MB,5MBgoal,12P...gc8@2.374s0%:0.003+3.9+0.018msclock,0.042+0.31/6.7/3.1+0.21mscpu,649->649->537MB,650MBgoal,12Pgc9@4.834s0%:0.003+7.5+0.021msclock,0.040+0/14/5.1+0.25mscpu,1298->1298->1073MB,1299MBgoal,12Pgc10@9.188s0%:0.003+26+0.004msclock,0.045+0/26/0.35+0.053mscpu,1183->1183->746MB,2147MBgoal,12P(forced)gc11@9.221s0%:0.018+9.4+0.003msclock,0.22+0/17/5.0+0.043mscpu,746->746->746MB,1492MBgoal,12P(forced)//Withmapshards([]map[int32]int32),GCtook1.017494msgc1@0.001s7%:0.010+2.9+0.048msclock,0.12+0.26/3.6/4.1+0.57mscpu,4->7->6MB,5MBgoal,12P...gc12@3.924s0%:0.003+3.2+0.004msclock,0.040+1.2/7.5/14+0.048mscpu,822->827->658MB,840MBgoal,12Pgc13@8.096s0%:0.003+6.1+0.004msclock,0.044+6.0/14/32+0.053mscpu,1290->1290->945MB,1317MBgoal,12Pgc14@11.619s0%:0.003+1.2+0.004msclock,0.045+0/2.5/3.7+0.056mscpu,1684->1684->1064MB,1891MBgoal,12P(强制)gc28@11.6s0%:0.003+0.91+0.004msclock,0.038+0/2.3/3.6+0.057mscpu,1064->1064->1064MB,21从倒数第二轮内存最大的时候算起,GCworker耗时为close;唯一比较大的区别是markTermination阶段的STW时间,在shard模式下少了1/10,所以推测和这个阶段的优化有关。至于为什么这个时间可以减少,我也不知道为什么(这个坑挖得太深了,只能等会儿找资料补上了。。。)2.GroupCache恢复正常(大家:什么?!写了这么多你还没输入文字我:咳咳..咳咳..),我们用map来总结一下内存池要点:内存池用map代替sync.Map;map要加读写锁,尽量存储非指针(key和value不包含指针)。指针存储在map中,需要注意key过多导致的GC暂停问题。shardsmap然后再看GroupCache的实现方法,在lru/lru.go中定义://CacheisanLRUcache.Itisnotsafeforconcurrentaccess.typeCachestruct{cachemap[interface{}]*list.Element}从cache的定义来看,可以是看到这是我们说的map包含指针的情况,还是不管shards。所以如果你的单机GroupCache中的key过多,使用时要注意。注:截至1.14,map包含指针时idleworker的耗时问题还没有定论。有兴趣的可以参考go1.14rc1中GC延迟10ms-26ms的例子,可能是因为'GC(idle)'workandPhenomenon。3、与分布式场景下的GroupCache相比,如果你本地还有千万级别的key,建议使用BigCache。无数经验证明,大map内存池带来的GC延迟可以通过切掉bigcache来解决。那么bigcache是??怎么做到的呢?简单的说:shardsmap+map[uint]uint+[]byte+freelink=BigCache定义了shardscache,避免锁粒度过大。map中只存uint,避免指针实现一个队列结构(其实就是[]byte,通过uint下标分配)自由链机制,删除保留空洞,最后一起回收(这个逻辑相当比较复杂,先留个小坑吧。。。)它的内存池定义如下:typecacheShardstruct{hashmapmap[uint64]uint32//key在表项entriesqueue.BytesQueue中的位置//其实就是[]byte,等新数据来了再copy到末尾}这样GC就变成了没有指针的map+[]扫描字节结构有问题,所以性能会高很多。坑4:两种方式(GroupCache和BigCache)对具体业务的影响有多大?以上只是对map实现的内存池的模拟分析,以及两个典型的Cache库的对比。如果你和我一样问自己“两种特定类型的Cache对业务的影响有多大”?那我只能开心的对你说:欢迎来到坑底-_-我们需要1000万左右的单机缓存在线key。首先,我尝试模拟业务,分别向两个缓存中插入1000w数据来测试GC暂停。但是因为实验代码或者其他未知的陷阱,我最终认为这种方法不适合讨论。我觉得还是用老办法,用Prometheus的直方图统计耗时分布。我们先统计底层存储(Redis)的耗时分布,再统计写入500w数据后BigCache和GroupCache的实际情况。分析结论:从redis数据来看,40ms以上的请求占比0.08%;40ms以上的BigCache请求占0.04%(即超过一半的超时请求被Cache阻塞),GroupCache为0.2%。长期请求翻了一倍多(估计和map的锁机制有关)。redis本身的10ms-40ms请求占段请求的24.11%;BigCache只有15.51%,相当于挡住了大约33%的高延迟请求(证明热点Cache还是有效的)GroupCache占了21.55%的段请求,比直接用redis要好。详细数据分布:redis[0.1]0.00%redis[0.5]0.38%redis[1]3.48%redis[5]71.94%redis[10]22.90%redis[20]1.21%redis[40]0.07%redis[+Inf]0.01%bigcache[0.1]0.40%bigcache[0.5]16.16%bigcache[1]14.82%bigcache[5]53.07%bigcache[10]14.85%bigcache[20]0.66%bigcache[40]0.03%bigcache[+Inf]0.01%组缓存[0.1]0.24%组缓存[0.5]9.59%组缓存[1]9.69%组缓存[5]]58.74%组缓存[10]19.10%组缓存[20]2.45%组缓存[40]0.17%组缓存[+Inf]0.03%但是,经过测试,我们只能粗略知道本地使用GroupCache不如使用500万个keyBigCache稳定(即使GroupCache实现了LRU淘汰,但实际上因为Hot/MainCache的存在,内存利用效率不如BigCache)。在分布式情况下,GroupCache和BigCache有多大区别?坑就等着大家一起跳了。4、对象池和零拷贝在实际业务中,map中往往不会存储5000w级别的key。如果我们只有50w个键,GC暂停将减少到大约4ms(gcworker也会并行工作以避免STW)。比如无极(腾讯内部的配置服务)等配置服务(或者其他高频数据查询场景),往往需要Get(key)来获取对应的结构化数据。从BigCache中找到CPU消耗(如图所示)。对比网络IO和Protobuf分析,Get占0.78%,Set占0.9%,基本可以忽略不计:CPUprofile优化思路也很清晰。我们参考GroupCache的lru实现,提前解析JSON,在业务端获取的时候直接返回struct的指针。具体过程并不复杂,直接ppt截图:零拷贝我们把接口设计成一个注册方法(注册需要解析JSON数据的结构体),然后在Get的时候返回该结构体的指针,实现零拷贝。下面的benchmarks可以反映性能差异和内存分配(Client_Get是实时JSON解析,Filter_Get是优化对象池API),实际上可以看到0allocs/op:goos:linuxgoarch:amd64pkg:open-wuji/go-sdk/wujiclientBenchmarkClient_Get-810000001154ns/op1.00hits87B/op3allocs/opBenchmarkFilter_Get-84899364302ns/op1.00hits7B/op1allocs/opBenchmarkClient_GetParallel-88383149162ns/op1.00hits80B/op2allocs/opBenchmarkFilter_GetParallel-81305368091.4ns/op1.00hits0B/op0allocs/opPASSokopen-wuji/go-sdk/wujiclient93.494sSuccess:基准测试通过。无极还没有开源。对具体实现感兴趣的同学可以看gist中filterAPI的实现代码