go语言加锁的粒度 go语言 锁

如何实现支持数亿用户的长连消息系统此文是根据周洋在【高可用架构群】中的分享内容整理而成,转发请注明出处 。
周洋,360手机助手技术经理及架构师,负责360长连接消息系统,360手机助手架构的开发与维护 。
不知道咱们群名什么时候改为“Python高可用架构群”了,所以不得不说,很荣幸能在接下来的一个小时里在Python群里讨论golang....
360消息系统介绍
360消息系统更确切的说是长连接push系统,目前服务于360内部多个产品,开发平台数千款app,也支持部分聊天业务场景,单通道多app复用,支持上行数据,提供接入方不同粒度的上行数据和用户状态回调服务 。
目前整个系统按不同业务分成9个功能完整的集群,部署在多个idc上(每个集群覆盖不同的idc),实时在线数亿量级 。通常情况下,pc,手机,甚至是智能硬件上的360产品的push消息 , 基本上是从我们系统发出的 。
关于push系统对比与性能指标的讨论
很多同行比较关心go语言在实现push系统上的性能问题,单机性能究竟如何,能否和其他语言实现的类似系统做对比么?甚至问如果是创业,第三方云推送平台,推荐哪个?
其实各大厂都有类似的push系统,市场上也有类似功能的云服务 。包括我们公司早期也有erlang,nodejs实现的类似系统,也一度被公司要求做类似的对比测试 。我感觉在讨论对比数据的时候,很难保证大家环境和需求的统一,我只能说下我这里的体会,数据是有的 , 但这个数据前面估计会有很多定语~
第一个重要指标:单机的连接数指标
做过长连接的同行,应该有体会,如果在稳定连接情况下,连接数这个指标,在没有网络吞吐情况下对比,其实意义往往不大 , 维持连接消耗cpu资源很小 , 每条连接tcp协议栈会占约4k的内存开销,系统参数调整后,我们单机测试数据,最高也是可以达到单实例300w长连接 。但做更高的测试,我个人感觉意义不大 。
因为实际网络环境下,单实例300w长连接,从理论上算压力就很大:实际弱网络环境下,移动客户端的断线率很高 , 假设每秒有1000分之一的用户断线重连 。300w长连接,每秒新建连接达到3w,这同时连入的3w用户,要进行注册,加载离线存储等对内rpc调用,另外300w长连接的用户心跳需要维持,假设心跳300s一次 , 心跳包每秒需要1w tps 。单播和多播数据的转发 , 广播数据的转发 , 本身也要响应内部的rpc调用 , 300w长连接情况下 , gc带来的压力,内部接口的响应延迟能否稳定保障 。这些集中在一个实例中,可用性是一个挑战 。所以线上单实例不会hold很高的长连接,实际情况也要根据接入客户端网络状况来决定 。
第二个重要指标:消息系统的内存使用量指标
这一点上,使用go语言情况下,由于协程的原因,会有一部分额外开销 。但是要做两个推送系统的对比 , 也有些需要确定问题 。比如系统从设计上是否需要全双工(即读写是否需要同时进行)如果半双工,理论上对一个用户的连接只需要使用一个协程即可(这种情况下,对用户的断线检测可能会有延时),如果是全双工,那读/写各一个协程 。两种场景内存开销是有区别的 。
另外测试数据的大小往往决定我们对连接上设置的读写buffer是多大,是全局复用的,还是每个连接上独享的,还是动态申请的 。另外是否全双工也决定buffer怎么开 。不同的策略 , 可能在不同情况的测试中表现不一样 。
第三个重要指标:每秒消息下发量
这一点上,也要看我们对消息到达的QoS级别(回复ack策略区别),另外看架构策略,每种策略有其更适用的场景,是纯粹推?还是推拉结合?甚至是否开启了消息日志?日志库的实现机制、以及缓冲开多大?flush策略……这些都影响整个系统的吞吐量 。
另外为了HA , 增加了内部通信成本,为了避免一些小概率事件,提供闪断补偿策略 , 这些都要考虑进去 。如果所有的都去掉,那就是比较基础库的性能了 。
所以我只能给出大概数据,24核,64G的服务器上,在QoS为message at least,纯粹推,消息体256B~1kB情况下,单个实例100w实际用户(200w )协程,峰值可以达到2~5w的QPS...内存可以稳定在25G左右 , gc时间在200~800ms左右(还有优化空间) 。
我们正常线上单实例用户控制在80w以内,单机最多两个实例 。事实上,整个系统在推送的需求上,对高峰的输出不是提速 , 往往是进行限速,以防push系统瞬时的高吞吐量,转化成对接入方业务服务器的ddos攻击所以对于性能上 , 我感觉大家可以放心使用,至少在我们这个量级上,经受过考验,go1.5到来后,确实有之前投资又增值了的感觉 。
消息系统架构介绍
下面是对消息系统的大概介绍,之前一些同学可能在gopher china上可以看到分享,这里简单讲解下架构和各个组件功能,额外补充一些当时遗漏的信息:
架构图如下,所有的service都 written by golang.
几个大概重要组件介绍如下:
dispatcher service根据客户端请求信息 , 将应网络和区域的长连接服务器的,一组IP传送给客户端 。客户端根据返回的IP,建立长连接 , 连接Room service.
room Service , 长连接网关,hold用户连接,并将用户注册进register service,本身也做一些接入安全策略、白名单、IP限制等 。
register service是我们全局session存储组件,存储和索引用户的相关信息,以供获取和查询 。
coordinator service用来转发用户的上行数据,包括接入方订阅的用户状态信息的回调,另外做需要协调各个组件的异步操作,比如kick用户操作,需要从register拿出其他用户做异步操作.
saver service是存储访问层,承担了对redis和mysql的操作 , 另外也提供部分业务逻辑相关的内存缓存,比如广播信息的加载可以在saver中进行缓存 。另外一些策略,比如客户端sdk由于被恶意或者意外修改 , 每次加载了消息,不回复ack,那服务端就不会删除消息 , 消息就会被反复加载,形成死循环,可以通过在saver中做策略和判断 。(客户端总是不可信的) 。
center service提供给接入方的内部api服务器,比如单播或者广播接口,状态查询接口等一系列api,包括运维和管理的api 。
举两个常见例子 , 了解工作机制:比如发一条单播给一个用户,center先请求Register获取这个用户之前注册的连接通道标识、room实例地址,通过room service下发给长连接 Center Service比较重的工作如全网广播,需要把所有的任务分解成一系列的子任务,分发给所有center,然后在所有的子任务里 , 分别获取在线和离线的所有用户,再批量推到Room Service 。通常整个集群在那一瞬间压力很大 。
deployd/agent service用于部署管理各个进程,收集各组件的状态和信息,zookeeper和keeper用于整个系统的配置文件管理和简单调度
关于推送的服务端架构
常见的推送模型有长轮训拉取 , 服务端直接推送(360消息系统目前主要是这种),推拉结合(推送只发通知,推送后根据通知去拉取消息).
拉取的方式不说了,现在并不常用了,早期很多是nginx lua redis,长轮训 , 主要问题是开销比较大,时效性也不好 , 能做的优化策略不多 。
直接推送的系统,目前就是360消息系统这种,消息类型是消耗型的,并且对于同一个用户并不允许重复消耗,如果需要多终端重复消耗 , 需要抽象成不同用户 。
推的好处是实时性好 , 开销小,直接将消息下发给客户端,不需要客户端走从接入层到存储层主动拉取.
但纯推送模型 , 有个很大问题,由于系统是异步的,他的时序性无法精确保证 。这对于push需求来说是够用的,但如果复用推送系统做im类型通信,可能并不合适 。
对于严格要求时序性,消息可以重复消耗的系统,目前也都是走推拉结合的模型,就是只使用我们的推送系统发通知,并附带id等给客户端做拉取的判断策略,客户端根据推送的key,主动从业务服务器拉取消息 。并且当主从同步延迟的时候,跟进推送的key做延迟拉取策略 。同时也可以通过消息本身的QoS,做纯粹的推送策略,比如一些“正在打字的”低优先级消息,不需要主动拉取了,通过推送直接消耗掉 。
哪些因素决定推送系统的效果?
首先是sdk的完善程度,sdk策略和细节完善度 , 往往决定了弱网络环境下最终推送质量.
SDK选路策略,最基本的一些策略如下:有些开源服务可能会针对用户hash一个该接入区域的固定ip,实际上在国内环境下不可行 , 最好分配器(dispatcher)是返回散列的一组,而且端口也要参开,必要时候,客户端告知是retry多组都连不上,返回不同idc的服务器 。因为我们会经常检测到一些case,同一地区的不同用户,可能对同一idc内的不同ip连通性都不一样,也出现过同一ip不同端口连通性不同,所以用户的选路策略一定要灵活,策略要足够完善.另外在选路过程中,客户端要对不同网络情况下的长连接ip做缓存,当网络环境切换时候(wifi、2G、3G),重新请求分配器,缓存不同网络环境的长连接ip 。
客户端对于数据心跳和读写超时设置,完善断线检测重连机制
针对不同网络环境,或者客户端本身消息的活跃程度 , 心跳要自适应的进行调整并与服务端协商,来保证链路的连通性 。并且在弱网络环境下,除了网络切换(wifi切3G)或者读写出错情况 , 什么时候重新建立链路也是一个问题 。客户端发出的ping包,不同网络下 , 多久没有得到响应,认为网络出现问题 , 重新建立链路需要有个权衡 。另外对于不同网络环境下,读取不同的消息长度 , 也要有不同的容忍时间 , 不能一刀切 。好的心跳和读写超时设置,可以让客户端最快的检测到网络问题 , 重新建立链路,同时在网络抖动情况下也能完成大数据传输 。
结合服务端做策略
另外系统可能结合服务端做一些特殊的策略,比如我们在选路时候,我们会将同一个用户尽量映射到同一个room service实例上 。断线时,客户端尽量对上次连接成功的地址进行重试 。主要是方便服务端做闪断情况下策略,会暂存用户闪断时实例上的信息,重新连入的 时候,做单实例内的迁移,减少延时与加载开销.
客户端保活策略
很多创业公司愿意重新搭建一套push系统,确实不难实现,其实在协议完备情况下(最简单就是客户端不回ack不清数据),服务端会保证消息是不丢的 。但问题是为什么在消息有效期内,到达率上不去?往往因为自己app的push service存活能力不高 。选用云平台或者大厂的,往往sdk会做一些保活策略,比如和其他app共生,互相唤醒,这也是云平台的push service更有保障原因 。我相信很多云平台旗下的sdk,多个使用同样sdk的app,为了实现服务存活,是可以互相唤醒和保证活跃的 。另外现在push sdk本身是单连接,多app复用的 , 这为sdk实现 , 增加了新的挑战 。
综上,对我来说,选择推送平台 , 优先会考虑客户端sdk的完善程度 。对于服务端 , 选择条件稍微简单,要求部署接入点(IDC)越要多,配合精细的选路策略,效果越有保证,至于想知道哪些云服务有多少点,这个群里来自各地的小伙伴们,可以合伙测测 。
go语言开发问题与解决方案
下面讲下,go开发过程中遇到挑战和优化策略,给大家看下当年的一张图,在第一版优化方案上线前一天截图~
可以看到 , 内存最高占用69G,GC时间单实例最高时候高达3~6s.这种情况下 , 试想一次悲剧的请求,经过了几个正在执行gc的组件 , 后果必然是超时... gc照成的接入方重试,又加重了系统的负担 。遇到这种情况当时整个系统最差情况每隔2,3天就需要重启一次~
当时出现问题,现在总结起来,大概以下几点
1.散落在协程里的I/O,Buffer和对象不复用 。
当时(12年)由于对go的gc效率理解有限 , 比较奔放,程序里大量short live的协程,对内通信的很多io操作,由于不想阻塞主循环逻辑或者需要及时响应的逻辑,通过单独go协程来实现异步 。这回会gc带来很多负担 。
针对这个问题,应尽量控制协程创建,对于长连接这种应用,本身已经有几百万并发协程情况下 , 很多情况没必要在各个并发协程内部做异步io,因为程序的并行度是有限,理论上做协程内做阻塞操作是没问题 。
如果有些需要异步执行,比如如果不异步执行,影响对用户心跳或者等待response无法响应,最好通过一个任务池,和一组常驻协程,来消耗 , 处理结果,通过channel再传回调用方 。使用任务池还有额外的好处,可以对请求进行打包处理 , 提高吞吐量 , 并且可以加入控量策略.
2.网络环境不好引起激增
go协程相比较以往高并发程序,如果做不好流控,会引起协程数量激增 。早期的时候也会发现,时不时有部分主机内存会远远大于其他服务器 , 但发现时候,所有主要profiling参数都正常了 。
后来发现,通信较多系统中 , 网络抖动阻塞是不可免的(即使是内网),对外不停accept接受新请求,但执行过程中,由于对内通信阻塞 , 大量协程被 创建,业务协程等待通信结果没有释放,往往瞬时会迎来协程暴涨 。但这些内存在系统稳定后,virt和res都并没能彻底释放 , 下降后,维持高位 。
处理这种情况,需要增加一些流控策略,流控策略可以选择在rpc库来做,或者上面说的任务池来做 , 其实我感觉放在任务池里做更合理些,毕竟rpc通信库可以做读写数据的限流,但它并不清楚具体的限流策略,到底是重试还是日志还是缓存到指定队列 。任务池本身就是业务逻辑相关的 , 它清楚针对不同的接口需要的流控限制策略 。
【go语言加锁的粒度 go语言 锁】3.低效和开销大的rpc框架
早期rpc通信框架比较简单,对内通信时候使用的也是短连接 。这本来短连接开销和性能瓶颈超出我们预期 , 短连接io效率是低一些,但端口资源够,本身吞吐可以满足需要,用是没问题的,很多分层的系统 , 也有http短连接对内进行请求的
但早期go版本,这样写程序 , 在一定量级情况,是支撑不住的 。短连接大量临时对象和临时buffer创建,在本已经百万协程的程序中,是无法承受的 。所以后续我们对我们的rpc框架作了两次调整 。
第二版的rpc框架,使用了连接池,通过长连接对内进行通信(复用的资源包括client和server的:编解码Buffer、Request/response),大大改善了性能 。
但这种在一次request和response还是占用连接的,如果网络状况ok情况下,这不是问题,足够满足需要了 , 但试想一个room实例要与后面的数百个的register,coordinator,saver,center,keeper实例进行通信,需要建立大量的常驻连接,每个目标机几十个连接,也有数千个连接被占用 。
非持续抖动时候(持续逗开多少无解),或者有延迟较高的请求时候,如果针对目标ip连接开少了 , 会有瞬时大量请求阻塞,连接无法得到充分利用 。第三版增加了Pipeline操作,Pipeline会带来一些额外的开销,利用tcp的全双特性 , 以尽量少的连接完成对各个服务集群的rpc调用 。
4.Gc时间过长
Go的Gc仍旧在持续改善中,大量对象和buffer创建,仍旧会给gc带来很大负担,尤其一个占用了25G左右的程序 。之前go team的大咖邮件也告知我们,未来会让使用协程的成本更低,理论上不需要在应用层做更多的策略来缓解gc.
改善方式,一种是多实例的拆分 , 如果公司没有端口限制 , 可以很快部署大量实例,减少gc时长,最直接方法 。不过对于360来说,外网通常只能使用80和433 。因此常规上只能开启两个实例 。当然很多人给我建议能否使用SO_REUSEPORT , 不过我们内核版本确实比较低,并没有实践过 。
另外能否模仿nginx , fork多个进程监控同样端口 , 至少我们目前没有这样做,主要对于我们目前进程管理上,还是独立的运行的,对外监听不同端口程序,还有配套的内部通信和管理端口,实例管理和升级上要做调整 。
解决gc的另两个手段,是内存池和对象池,不过最好做仔细评估和测试,内存池、对象池使用,也需要对于代码可读性与整体效率进行权衡 。
这种程序一定情况下会降低并行度,因为用池内资源一定要加互斥锁或者原子操作做CAS , 通常原子操作实测要更快一些 。CAS可以理解为可操作的更细行为粒度的锁(可以做更多CAS策略,放弃运行,防止忙等) 。这种方式带来的问题是 , 程序的可读性会越来越像C语言,每次要malloc,各地方用完后要free , 对于对象池free之前要reset,我曾经在应用层尝试做了一个分层次结构的“无锁队列”
上图左边的数组实际上是一个列表 , 这个列表按大小将内存分块 , 然后使用atomic操作进行CAS 。但实际要看测试数据了,池技术可以明显减少临时对象和内存的申请和释放,gc时间会减少,但加锁带来的并行度的降低,是否能给一段时间内的整体吞吐量带来提升 , 要做测试和权衡…
在我们消息系统,实际上后续去除了部分这种黑科技,试想在百万个协程里面做自旋操作申请复用的buffer和对象,开销会很大,尤其在协程对线程多对多模型情况下,更依赖于golang本身调度策略,除非我对池增加更多的策略处理 , 减少忙等,感觉是在把runtime做的事情,在应用层非常不优雅的实现 。普遍使用开销理论就大于收益 。
但对于rpc库或者codec库,任务池内部,这些开定量协程,集中处理数据的区域,可以尝试改造~
对于有些固定对象复用 , 比如固定的心跳包什么的,可以考虑使用全局一些对象 , 进行复用,针对应用层数据,具体设计对象池 , 在部分环节去复用,可能比这种无差别的设计一个通用池更能进行效果评估.
消息系统的运维及测试
下面介绍消息系统的架构迭代和一些迭代经验,由于之前在其他地方有过分享,后面的会给出相关链接 , 下面实际做个简单介绍,感兴趣可以去链接里面看
架构迭代~根据业务和集群的拆分,能解决部分灰度部署上线测试,减少点对点通信和广播通信不同产品的相互影响,针对特定的功能做独立的优化.
消息系统架构和集群拆分,最基本的是拆分多实例,其次是按照业务类型对资源占用情况分类 , 按用户接入网络和对idc布点要求分类(目前没有条件,所有的产品都部署到全部idc)
系统的测试go语言在并发测试上有独特优势 。
对于压力测试,目前主要针对指定的服务器,选定线上空闲的服务器做长连接压测 。然后结合可视化,分析压测过程中的系统状态 。但压测早期用的比较多,但实现的统计报表功能和我理想有一定差距 。我觉得最近出的golang开源产品都符合这种场景,go写网络并发程序给大家带来的便利,让大家把以往为了降低复杂度 , 拆解或者分层协作的组件,又组合在了一起 。
QA
Q1:协议栈大小 , 超时时间定制原则?
移动网络下超时时间按产品需求通常2g,3G情况下是5分钟,wifi情况下5~8分钟 。但对于个别场景,要求响应非常迅速的场景,如果连接idle超过1分钟 , 都会有ping , pong,来校验是否断线检测 , 尽快做到重新连接 。
Q2:消息是否持久化?
消息持久化,通常是先存后发 , 存储用的redis,但落地用的mysql 。mysql只做故障恢复使用 。
Q3:消息风暴怎么解决的?
如果是发送情况下,普通产品是不需要限速的 , 对于较大产品是有发送队列做控速度,按人数,按秒进行控速度发放,发送成功再发送下一条 。
Q4:golang的工具链支持怎么样?我自己写过一些小程序千把行之内,确实很不错,但不知道代码量上去之后,配套的debug工具和profiling工具如何,我看上边有分享说golang自带的profiling工具还不错,那debug呢怎么样呢,官方一直没有出debug工具 , gdb支持也不完善,不知你们用的什么?
是这样的,我们正常就是println , 我感觉基本上可以定位我所有问题 , 但也不排除由于并行性通过println无法复现的问题,目前来看只能靠经验了 。只要常见并发尝试,经过分析是可以找到的 。go很快会推出调试工具的~
Q5:协议栈是基于tcp吗?
是否有协议拓展功能?协议栈是tcp,整个系统tcp长连接,没有考虑扩展其功能~如果有好的经验,可以分享~
Q6:问个问题,这个系统是接收上行数据的吧,系统接收上行数据后是转发给相应系统做处理么,是怎么转发呢,如果需要给客户端返回调用结果又是怎么处理呢?
系统上行数据是根据协议头进行转发,协议头里面标记了产品和转发类型,在coordinator里面跟进产品和转发类型 , 回调用户,如果用户需要阻塞等待回复才能后续操作,那通过再发送消息,路由回用户 。因为整个系统是全异步的 。
Q7:问个pushsdk的问题 。pushsdk的单连接 , 多app复用方式,这样的情况下以下几个问题是如何解决的:1)系统流量统计会把所有流量都算到启动连接的应用吧?而启动应用的连接是不固定的吧?2)同一个pushsdk在不同的应用中的版本号可能不一样,这样暴露出来的接口可能有版本问题 , 如果用单连接模式怎么解决?
流量只能算在启动的app上了,但一般这种安装率很高的app承担可能性大,常用app本身被检测和杀死可能性较少,另外消息下发量是有严格控制 的 。整体上用户还是省电和省流量的 。我们pushsdk尽量向上兼容 , 出于这个目的 , push sdk本身做的工作非常有限,抽象出来一些常见的功能,纯推的系统,客户端策略目前做的很少,也有这个原因 。
Q8:生产系统的profiling是一直打开的么?
不是一直打开,每个集群都有采样 , 但需要开启哪个可以后台控制 。这个profling是通过接口调用 。
Q9:面前系统中的消息消费者可不可以分组?类似于Kafka 。
客户端可以订阅不同产品的消息,接受不同的分组 。接入的时候进行bind或者unbind操作
Q10:为什么放弃erlang,而选择go,有什么特别原因吗?我们现在用的erlang?
erlang没有问题,原因是我们上线后,其他团队才做出来,经过qa一个部门对比测试,在没有显著性能提升下,选择继续使用go版本的push,作为公司基础服务 。
Q11:流控问题有排查过网卡配置导致的idle问题吗?
流控是业务级别的流控,我们上线前对于内网的极限通信量做了测试,后续将请求在rpc库内,控制在小于内部通信开销的上限以下.在到达上限前作流控 。
Q12:服务的协调调度为什么选择zk有考虑过raft实现吗?golang的raft实现很多啊,比如Consul和ectd之类的 。
3年前,还没有后两者或者后两者没听过应该 。zk当时公司内部成熟方案,不过目前来看,我们不准备用zk作结合系统的定制开发 , 准备用自己写的keeper代替zk , 完成配置文件自动转数据结构,数据结构自动同步指定进程,同时里面可以完成很多自定义的发现和控制策略,客户端包含keeper的sdk就可以实现以上的所有监控数据,profling数据收集,配置文件更新,启动关闭等回调 。完全抽象成语keeper通信sdk,keeper之间考虑用raft 。
Q13:负载策略是否同时在服务侧与CLIENT侧同时做的 (DISPATCHER 会返回一组IP)?另外,ROOM SERVER/REGISTER SERVER连接状态的一致性|可用性如何保证? 服务侧保活有无特别关注的地方? 安全性方面是基于TLS再加上应用层加密?
会在server端做,比如重启操作前,会下发指令类型消息,让客户端进行主动行为 。部分消息使用了加密策略,自定义的rsa des,另外满足我们安全公司的需要,也定制开发很多安全加密策略 。一致性是通过冷备解决的,早期考虑双写,但实时状态双写同步代价太高而且容易有脏数据,比如register挂了,调用所有room,通过重新刷入指定register来解决 。
Q14:这个keeper有开源打算吗?
还在写,如果没耦合我们系统太多功能,一定会开源的,主要这意味着,我们所有的bind在sdk的库也需要开源~
Q15:比较好奇lisence是哪个如果开源?
Go语言中恰到好处的内存对齐 在开始之前,希望你计算一下Part1共占用的大小是多少呢?
输出结果:
这么一算,Part1这一个结构体的占用内存大小为 1 4 1 8 1 = 15 个字节 。相信有的小伙伴是这么算的,看上去也没什么毛病
真实情况是怎么样的呢?我们实际调用看看,如下:
输出结果:
最终输出为占用 32 个字节 。这与前面所预期的结果完全不一样 。这充分地说明了先前的计算方式是错误的 。为什么呢?
在这里要提到 “内存对齐” 这一概念,才能够用正确的姿势去计算,接下来我们详细的讲讲它是什么
有的小伙伴可能会认为内存读取,就是一个简单的字节数组摆放
上图表示一个坑一个萝卜的内存读取方式 。但实际上 CPU 并不会以一个一个字节去读取和写入内存 。相反 CPU 读取内存是 一块一块读取 的,块的大小可以为 2、4、6、8、16 字节等大小 。块大小我们称其为 内存访问粒度。如下图:
在样例中 , 假设访问粒度为 4 。CPU 是以每 4 个字节大小的访问粒度去读取和写入内存的 。这才是正确的姿势
另外作为一个工程师,你也很有必要学习这块知识点哦 :)
在上图中 , 假设从 Index 1 开始读?。岢鱿趾鼙览5奈侍?。因为它的内存访问边界是不对齐的 。因此 CPU 会做一些额外的处理工作 。如下:
从上述流程可得出,不做 “内存对齐” 是一件有点 "麻烦" 的事 。因为它会增加许多耗费时间的动作
而假设做了内存对齐,从 Index 0 开始读取 4 个字节,只需要读取一次,也不需要额外的运算 。这显然高效很多,是标准的 空间换时间 做法
在不同平台上的编译器都有自己默认的 “对齐系数”,可通过预编译命令#pragma pack(n)进行变更,n 就是代指 “对齐系数” 。一般来讲 , 我们常用的平台的系数如下:
另外要注意,不同硬件平台占用的大小和对齐值都可能是不一样的 。因此本文的值不是唯一的,调试的时候需按本机的实际情况考虑
输出结果:
在 Go 中可以调用unsafe.Alignof来返回相应类型的对齐系数 。通过观察输出结果,可得知基本都是2^n ,最大也不会超过 8 。这是因为我手提(64 位)编译器默认对齐系数是 8 , 因此最大值不会超过这个数
在上小节中,提到了结构体中的成员变量要做字节对齐 。那么想当然身为最终结果的结构体,也是需要做字节对齐的
接下来我们一起分析一下,“它” 到底经历了些什么,影响了 “预期” 结果
在每个成员变量进行对齐后,根据规则 2,整个结构体本身也要进行字节对齐,因为可发现它可能并不是2^n ,不是偶数倍 。显然不符合对齐的规则
根据规则 2,可得出对齐值为 8 。现在的偏移量为 25,不是 8 的整倍数 。因此确定偏移量为 32 。对结构体进行对齐
Part1 内存布局:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx
通过本节的分析,可得知先前的 “推算” 为什么错误?
是因为实际内存管理并非 “一个萝卜一个坑” 的思想 。而是一块一块 。通过空间换时间(效率)的思想来完成这块读取、写入 。另外也需要兼顾不同平台的内存操作情况
在上一小节,可得知根据成员变量的类型不同 , 其结构体的内存会产生对齐等动作 。那假设字段顺序不同,会不会有什么变化呢?我们一起来试试吧 :-)
输出结果:
通过结果可以惊喜的发现,只是 “简单” 对成员变量的字段顺序进行改变,就改变了结构体占用大小
接下来我们一起剖析一下Part2,看看它的内部到底和上一位之间有什么区别,才导致了这样的结果?
符合规则 2 , 不需要额外对齐
Part2 内存布局:ecax|bbbb|dddd|dddd
通过对比Part1和Part2的内存布局,你会发现两者有很大的不同 。如下:
仔细一看,Part1存在许多 Padding 。显然它占据了不少空间 , 那么 Padding 是怎么出现的呢?
通过本文的介绍,可得知是由于不同类型导致需要进行字节对齐,以此保证内存的访问边界
那么也不难理解 , 为什么 调整结构体内成员变量的字段顺序 就能达到缩小结构体占用大小的疑问了,是因为巧妙地减少了 Padding 的存在 。让它们更 “紧凑” 了 。这一点对于加深 Go 的内存布局印象和大对象的优化非常有帮
(十一)golang 内存分析编写过C语言程序的肯定知道通过malloc()方法动态申请内存,其中内存分配器使用的是glibc提供的ptmalloc2 。除了glibc,业界比较出名的内存分配器有Google的tcmalloc和Facebook的jemalloc 。二者在避免内存碎片和性能上均比glic有比较大的优势 , 在多线程环境中效果更明显 。
Golang中也实现了内存分配器,原理与tcmalloc类似,简单的说就是维护一块大的全局内存 , 每个线程(Golang中为P)维护一块小的私有内存,私有内存不足再从全局申请 。另外,内存分配与GC(垃圾回收)关系密切,所以了解GC前有必要了解内存分配的原理 。
为了方便自主管理内存,做法便是先向系统申请一块内存,然后将内存切割成小块,通过一定的内存分配算法管理内存 。以64位系统为例,Golang程序启动时会向系统申请的内存如下图所示:
预申请的内存划分为spans、bitmap、arena三部分 。其中arena即为所谓的堆区 , 应用中需要的内存从这里分配 。其中spans和bitmap是为了管理arena区而存在的 。
arena的大小为512G,为了方便管理把arena区域划分成一个个的page,每个page为8KB,一共有512GB/8KB个页;
spans区域存放span的指针 , 每个指针对应一个page,所以span区域的大小为(512GB/8KB)乘以指针大小8byte = 512M
bitmap区域大小也是通过arena计算出来,不过主要用于GC 。
span是用于管理arena页的关键数据结构,每个span中包含1个或多个连续页,为了满足小对象分配,span中的一页会划分更小的粒度 , 而对于大对象比如超过页大小,则通过多页实现 。
根据对象大?。?划分了一系列class,每个class都代表一个固定大小的对象,以及每个span的大小 。如下表所示:
上表中每列含义如下:
class: class ID , 每个span结构中都有一个class ID, 表示该span可处理的对象类型
bytes/obj:该class代表对象的字节数
bytes/span:每个span占用堆的字节数,也即页数乘以页大小
objects: 每个span可分配的对象个数 , 也即(bytes/spans)/(bytes/obj)waste
bytes: 每个span产生的内存碎片,也即(bytes/spans)%(bytes/obj)上表可见最大的对象是32K大?。?超过32K大小的由特殊的class表示,该class ID为0,每个class只包含一个对象 。
span是内存管理的基本单位,每个span用于管理特定的class对象, 跟据对象大小 , span将一个或多个页拆分成多个块进行管理 。src/runtime/mheap.go:mspan定义了其数据结构:
以class 10为例,span和管理的内存如下图所示:
spanclass为10,参照class表可得出npages=1,nelems=56,elemsize为144 。其中startAddr是在span初始化时就指定了某个页的地址 。allocBits指向一个位图,每位代表一个块是否被分配 , 本例中有两个块已经被分配,其allocCount也为2 。next和prev用于将多个span链接起来,这有利于管理多个span , 接下来会进行说明 。
有了管理内存的基本单位span,还要有个数据结构来管理span , 这个数据结构叫mcentral,各线程需要内存时从mcentral管理的span中申请内存,为了避免多线程申请内存时不断的加锁,Golang为每个线程分配了span的缓存,这个缓存即是cache 。src/runtime/mcache.go:mcache定义了cache的数据结构
alloc为mspan的指针数组,数组大小为class总数的2倍 。数组中每个元素代表了一种class类型的span列表,每种class类型都有两组span列表,第一组列表中所表示的对象中包含了指针,第二组列表中所表示的对象不含有指针 , 这么做是为了提高GC扫描性能,对于不包含指针的span列表,没必要去扫描 。根据对象是否包含指针,将对象分为noscan和scan两类,其中noscan代表没有指针,而scan则代表有指针,需要GC进行扫描 。mcache和span的对应关系如下图所示:
mchache在初始化时是没有任何span的 , 在使用过程中会动态的从central中获取并缓存下来,跟据使用情况 , 每种class的span个数也不相同 。上图所示,class 0的span数比class1的要多 , 说明本线程中分配的小对象要多一些 。
cache作为线程的私有资源为单个线程服务 , 而central则是全局资源,为多个线程服务,当某个线程内存不足时会向central申请,当某个线程释放内存时又会回收进central 。src/runtime/mcentral.go:mcentral定义了central数据结构:
lock: 线程间互斥锁,防止多线程读写冲突
spanclass : 每个mcentral管理着一组有相同class的span列表
nonempty: 指还有内存可用的span列表
empty: 指没有内存可用的span列表
nmalloc: 指累计分配的对象个数线程从central获取span步骤如下:
将span归还步骤如下:
从mcentral数据结构可见,每个mcentral对象只管理特定的class规格的span 。事实上每种class都会对应一个mcentral,这个mcentral的集合存放于mheap数据结构中 。src/runtime/mheap.go:mheap定义了heap的数据结构:
lock: 互斥锁
spans: 指向spans区域,用于映射span和page的关系
bitmap:bitmap的起始地址
arena_start: arena区域首地址
arena_used: 当前arena已使用区域的最大地址
central: 每种class对应的两个mcentral
从数据结构可见 , mheap管理着全部的内存,事实上Golang就是通过一个mheap类型的全局变量进行内存管理的 。mheap内存管理示意图如下:
系统预分配的内存分为spans、bitmap、arean三个区域 , 通过mheap管理起来 。接下来看内存分配过程 。
针对待分配对象的大小不同有不同的分配逻辑:
(0, 16B) 且不包含指针的对象: Tiny分配
(0, 16B) 包含指针的对象:正常分配
[16B, 32KB] : 正常分配
(32KB, -) : 大对象分配其中Tiny分配和大对象分配都属于内存管理的优化范畴,这里暂时仅关注一般的分配方法 。
以申请size为n的内存为例,分配步骤如下:
Golang内存分配是个相当复杂的过程,其中还掺杂了GC的处理,这里仅仅对其关键数据结构进行了说明,了解其原理而又不至于深陷实现细节 。1、Golang程序启动时申请一大块内存并划分成spans、bitmap、arena区域
2、arena区域按页划分成一个个小块 。
3、span管理一个或多个页 。
4、mcentral管理多个span供线程申请使用
5、mcache作为线程私有资源,资源来源于mcentral 。
go语言的map多协程访问时需要加锁吗go语言的map多协程访问时需要加锁
支持==和!=操作就可以做key,实际上只有function、map、slice三个kind不支持作为key,因为只能和nil比较不能和另一个值比较 。布尔、整型、浮点、复数、字符串、指针、channel等都可以做key 。
struct能不能做key要看每一个字段,如果所有字段都可以做key,那这个struct就可以 。有一个字段不能做key,这个struct就不能做key 。array也是,元素类型能做key,那这个array就可以 。
例如:
type Foo map[struct {
Bbool
Iint
Ffloat64
Ccomplex128
Sstring
P*Foo
Ch chan Foo
}]bool
每一个字段都可以做key,Foo就可以做key 。再如:
type Foo map[struct {
Fn func() Foo
Mmap[*Foo]int
S[]Foo
}]bool
有一个字段不能做key、Foo就不允许做key,而这三个字段都不能 。
字段是递归检查的:
type Foo map[struct {
Sub struct {
M map[*Foo]bool
}
}]bool
Sub的M字段不能做key , Sub就不能做key,Foo也就不能做key 。
总之想把一个数据结构用于map的key,就不能包含function、map和slice 。
彻底理解Golang Map 本文目录如下,阅读本文后 , 将一网打尽下面Golang Map相关面试题
Go中的map是一个指针,占用8个字节,指向hmap结构体;源码 src/runtime/map.go 中可以看到map的底层结构
每个map的底层结构是hmap,hmap包含若干个结构为bmap的bucket数组 。每个bucket底层都采用链表结构 。接下来,我们来详细看下map的结构
bmap就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶 , 是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和插入中详细说明 。在桶内 , 又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置) 。
bucket内存数据结构可视化如下:
注意到 key 和 value 是各自放在一起的,并不是key/value/key/value/...这样的形式 。源码里说明这样的好处是在某些情况下可以省略掉 padding字段 , 节省内存空间 。
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap 。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的 , 破坏了 bmap 不含指针的设想 , 这时会把 overflow 移动到 extra 字段来 。
map是个指针,底层指向hmap,所以是个引用类型
golang 有三个常用的高级类型 slice 、map、channel,它们都是 引用类型,当引用类型作为函数参数时,可能会修改原内容数据 。
golang 中没有引用传递,只有值和指针传递 。所以 map 作为函数实参传递时本质上也是值传递,只不过因为 map 底层数据结构是通过指针指向实际的元素存储空间,在被调函数中修改 map,对调用者同样可见,所以 map 作为函数实参传递时表现出了引用传递的效果 。
因此,传递 map 时,如果想修改map的内容而不是map本身,函数形参无需使用指针
map底层数据结构是通过指针指向实际的元素 存储空间,这种情况下 , 对其中一个map的更改 , 会影响到其他map
map 在没有被修改的情况下,使用 range 多次遍历 map 时输出的 key 和 value 的顺序可能不同 。这是 Go 语言的设计者们有意为之,在每次 range 时的顺序被随机化,旨在提示开发者们,Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序 。
map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map 。
map默认是并发不安全的,原因如下:
Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问) , 导致大部分程序付出加锁代价(性能) , 决定了不支持 。
场景:2个协程同时读和写 , 以下程序会出现致命错误:fatal error: concurrent map writes
如果想实现map线程安全,有两种方式:
方式一:使用读写锁mapsync.RWMutex
方式二:使用golang提供的sync.Map
sync.map是用读写分离实现的 , 其思想是空间换时间 。和map RWLock的实现方式相比,它做了一些优化:可以无锁访问read map,而且会优先操作read map , 倘若只操作read map就可以满足要求(增删改查遍历),那就不用去操作write map(它的读写都要加锁),所以在某些特定场景中它发生锁竞争的频率会远远小于map RWLock的实现方式 。
golang中map是一个kv对集合 。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv 。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持 , 则使用 aes hash,否则使用 memhash 。
map有3钟初始化方式,一般通过make方式创建
map的创建通过生成汇编码可以知道,make创建map时调用的底层函数是 runtime.makemap。如果你的map初始容量小于等于8会发现走的是 runtime.fastrand 是因为容量小于8时不需要生成多个桶,一个桶的容量就可以满足
makemap函数会通过fastrand创建一个随机的哈希种子,然后根据传入的hint计算出需要的最小需要的桶的数量,最后再使用makeBucketArray 创建用于保存桶的数组 , 这个方法其实就是根据传入的B计算出的需要创建的桶数量在内存中分配一片连续的空间用于存储数据,在创建桶的过程中还会额外创建一些用于保存溢出数据的桶,数量是2^(B-4)个 。初始化完成返回hmap指针 。
找到一个 B , 使得 map 的装载因子在正常范围内
Go 语言中读取 map 有两种语法:带 comma 和 不带 comma 。当要查询的 key 不在 map 里 , 带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值 。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串 。
map的查找通过生成汇编码可以知道 , 根据 key 的不同类型,编译器会将查找函数用更具体的函数替换,以优化效率:
函数首先会检查 map 的标志位 flags 。如果 flags 的写标志位此时被置 1 了,说明有其他协程在执行“写”操作,进而导致程序 panic 。这也说明了 map 对协程是不安全的 。
key经过哈希函数计算后,得到的哈希值如下(主流64位机下共 64 个 bit 位):
m: 桶的个数
从buckets 通过 hashm 得到对应的bucket,如果bucket正在扩容,并且没有扩容完成,则从oldbuckets得到对应的bucket
计算hash所在桶编号:
用上一步哈希值最后的 5 个 bit 位,也就是01010,值为 10,也就是 10 号桶(范围是0~31号桶)
计算hash所在的槽位:
用上一步哈希值哈希值的高8个bit 位,也就是 10010111,转化为十进制,也就是151,在 10 号 bucket 中寻找** tophash 值(HOB hash)为 151* 的 槽位**,即为key所在位置,找到了 2 号槽位,这样整个查找过程就结束了 。
如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket 。
通过上面找到了对应的槽位,这里我们再详细分析下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 数量多 , 包括大量的 overflow bucket)
不难想像造成这种情况的原因:不停地插入、删除元素 。先插入很多元素 , 导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况 。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触发第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散 , 查找插入效率低得吓人,因此出台第 2 点规定 。这就像是一座空城,房子很多 , 但是住户很少,都分散了,找起人来很困难
对于条件 2,其实元素没那么多 , 但是 overflow bucket 数特别多,说明很多 bucket 都没装满 。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密 。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来 。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升 。
由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁 , 会非常影响性能 。因此 Go map 的扩容采取了一种称为“渐进式”的方式,原有的 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 对了 。
golang sync.pool对象复用 并发原理 缓存池 在go http每一次go serve(l)都会构建Request数据结构 。在大量数据请求或高并发的场景中go语言加锁的粒度,频繁创建销毁对象go语言加锁的粒度 , 会导致GC压力 。解决办法之一就是使用对象复用技术 。在http协议层之下,使用对象复用技术创建Request数据结构 。在http协议层之上,可以使用对象复用技术创建(w,*r,ctx)数据结构 。这样即可以回快TCP层读包之后的解析速度,也可也加快请求处理的速度 。
先上一个测试:
结论是这样的:
貌似使用池化,性能弱爆了???这似乎与net/http使用sync.pool池化Request来优化性能的选择相违背 。这同时也说明了一个问题,好的东西,如果滥用反而造成了性能成倍的下降 。在看过pool原理之后,结合实例 , 将给出正确的使用方法,并给出预期的效果 。
sync.Pool是一个 协程安全 的 临时对象池。数据结构如下:
local 成员的真实类型是一个 poolLocal 数组,localSize 是数组长度 。这涉及到Pool实现,pool为每个P分配了一个对象,P数量设置为runtime.GOMAXPROCS(0) 。在并发读写时,goroutine绑定的P有对象,先用自己的,没有去偷其它P的 。go语言将数据分散在了各个真正运行的P中,降低了锁竞争 , 提高了并发能力 。
不要习惯性地误认为New是一个关键字,这里的New是Pool的一个字段,也是一个闭包名称 。其API:
如果不指定New字段,对象池为空时会返回nil,而不是一个新构建的对象 。Get()到的对象是随机的 。
原生sync.Pool的问题是,Pool中的对象会被GC清理掉,这使得sync.Pool只适合做简单地对象池 , 不适合作连接池 。
pool创建时不能指定大小,没有数量限制 。pool中对象会被GC清掉,只存在于两次GC之间 。实现是pool的init方法注册了一个poolCleanup()函数,这个方法在GC之前执行,清空pool中的所有缓存对象 。
为使多协程使用同一个POOL 。最基本的想法就是每个协程,加锁去操作共享的POOL,这显然是低效的 。而进一步改进,类似于ConcurrentHashMap(JDK7)的分Segment,提高其并发性可以一定程度性缓解 。
注意到pool中的对象是无差异性的,加锁或者分段加锁都不是较好的做法 。go的做法是为每一个绑定协程的P都分配一个子池 。每个子池又分为私有池和共享列表 。共享列表是分别存放在各个P之上的共享区域,而不是各个P共享的一块内存 。协程拿自己P里的子池对象不需要加锁,拿共享列表中的就需要加锁了 。
Get对象过程:
Put过程:
如何解决Get最坏情况遍历所有P才获取得对象呢:
方法1止前sync.pool并没有这样的设置 。方法2由于goroutine被分配到哪个P由调度器调度不可控,无法确保其平衡 。
由于不可控的GC导致生命周期过短,且池大小不可控,因而不适合作连接池 。仅适用于增加对象重用机率,减少GC负担 。2
执行结果:
单线程情况下 , 遍历其它无元素的P,长时间加锁性能低下 。启用协程改善 。
结果:
测试场景在goroutines远大于GOMAXPROCS情况下,与非池化性能差异巨大 。
测试结果
可以看到同样使用*sync.pool,较大池大小的命中率较高,性能远高于空池 。
结论:pool在一定的使用条件下提高并发性能 , 条件1是协程数远大于GOMAXPROCS,条件2是池中对象远大于GOMAXPROCS 。归结成一个原因就是使对象在各个P中均匀分布 。
池pool和缓存cache的区别 。池的意思是,池内对象是可以互换的,不关心具体值,甚至不需要区分是新建的还是从池中拿出的 。缓存指的是KV映射,缓存里的值互不相同,清除机制更为复杂 。缓存清除算法如LRU、LIRS缓存算法 。
池空间回收的几种方式 。一些是GC前回收 , 一些是基于时钟或弱引用回收 。最终确定在GC时回收Pool内对象,即不回避GC 。用java的GC解释弱引用 。GC的四种引用:强引用、弱引用、软引用、虚引用 。虚引用即没有引用,弱引用GC但有空间则保留,软引用GC即清除 。ThreadLocal的值为弱引用的例子 。
regexp 包为了保证并发时使用同一个正则,而维护了一组状态机 。
fmt包做字串拼接,从sync.pool拿[]byte对象 。避免频繁构建再GC效率高很多 。
go语言加锁的粒度的介绍就聊到这里吧,感谢你花时间阅读本站内容,更多关于go语言 锁、go语言加锁的粒度的信息别忘了在本站进行查找喔 。

    推荐阅读