知识卡片

Go协程GC优化的三个教训

普通读书笔记卡

内容

360消息系统早期上线时曾经历过严重的GC问题——内存最高占用69G,单实例GC时间最高达3-6秒,一次请求如果经过几个正在执行GC的组件就会超时,超时又引发接入方重试、进一步加重系统负担,最差情况下整个系统每隔两三天就要重启一次。复盘后总结出三个关键教训。一,大量I/O导致Buffer和对象不复用:早期对Go的GC效率理解有限,代码里有大量短生命周期的协程,很多对内通信的I/O操作都为了不阻塞主循环而单独起协程做异步,这给GC带来了很大负担;教训是应该尽量控制协程创建,长连接应用本身已经有几百万并发协程,在各个并发协程内部再做异步I/O几乎没必要——因为程序的并行度本身有限,理论上在协程内做阻塞操作是没问题的,真正需要异步执行的场景(比如不异步就会影响心跳响应),更合理的做法是用一个任务池加一组常驻协程消耗处理结果、再通过channel把结果传回调用方,任务池还有额外好处:可以对请求打包处理提高吞吐量,并能加入控量策略。二,网络环境不好引起协程数量激增:通信较多的系统里网络抖动阻塞不可避免,如果对外持续接受新请求、但对内通信因抖动而阻塞,就会瞬间创建大量协程等待通信结果、内存瞬时飙升,且这些内存在系统稳定后也未必能彻底释放,会维持在高位;解决办法是增加流控策略,且流控放在业务相关的任务池里做比放在RPC通信库里更合理,因为RPC库只能做读写限流、不清楚具体该重试、记日志还是缓存到队列,而任务池天然了解不同接口各自需要的限流策略。三,低效开销大的RPC框架:早期对内通信用短连接,短连接带来的大量临时对象和buffer创建,在已经有百万协程的程序里根本扛不住;团队先后做了两版调整——第二版换成长连接连接池、复用编解码Buffer和Request/Response,但一次request/response仍占用整条连接,一旦目标机的连接开少了、网络延迟稍高就会有大量请求瞬间阻塞;第三版进一步加入Pipeline操作,利用TCP全双工特性、用尽量少的连接完成对各服务集群的RPC调用(Pipeline本身也会带来一些额外开销,是在开销和连接数之间做的又一层权衡)。

参考来源

- 位置:《高可用架构(第1卷)》第1章《高可用架构案例精选》"1.4 如何实现支持数亿用户的长连消息系统:Golang高并发案例"节,"1.4.4 GO语言开发问题与解决方案"(源文件:_epub-src/OEBPS/Text/Chapter1_4_5.xhtml) - 结论依据:原文说明"由于当时(2012年)我对GO的GC效率理解有限……程序里有大量short live的协程……第2版的RPC框架使用了连接池……第3版增加了Pipeline操作",完整描述了从问题发现到三轮RPC框架迭代的过程,直接支撑本卡片结论。 - 原始内容:应尽量控制协程创建,对于长连接应用这种本身已经有几百万并发协程情况,几乎没必要在各个并发协程内部做异步I/O……第2版的RPC框架使用了连接池,通过长连接对内进行通信……第3版增加了Pipeline操作。