知识卡片
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本身也会带来一些额外开销,是在开销和连接数之间做的又一层权衡)。