谁偷了1/3个CPU - 诡异Go性能问题追根问底
看到过不少文章介绍自己CPU占用恶高甚至接近100%,其实那到反而清楚无遗漏了,无非哪个busy loop卡住了。这里为大家描述一个近期遇到的Go程序在空闲时候依旧在top命令里总报告30%左右CPU占用的问题,这样的性能问题更隐蔽更难琢磨。
问题发生在我自己做的高性能多组Raft库Dragonboat里,这是一个Apache2开源的Go实现的多组Raft库,它的主打就是吞吐性能吊打竟品几十倍。因为性能是核心卖点,因此每个函数的CPU耗费都了如指掌,直到有一天突然发现系统空载的时候占用一个CPU核的30%的负载,如下图:
服务器程序空闲的时候,top中看到的cpu负载通常应该是个位数低位。深入分析以后发现是一个较深的Go调度实现的问题。
观察
看到上述top的结果,strace -c 看了一下,很多futex。多启动几个这样的空闲进程,抓个火焰图看,它是这样的:
必须得假设您对Go近期版本(如1.8-1.11)的调度有一定基本了解,了解M、P、G三者的意义和作用。如暂时不了解,可参考本文或者该文的中文翻译。
火焰图中可以观察到几点:
- 让当前M去sleep的操作挺重的,它由tickWorkerMain试图去读一个channel引发
- runtime.futex的数据和上面提到的strace所报告的情况吻合
查看代码,Dragonboat库tickWorkerMain中有一个1khz的ticker,等于每秒读ticker.C这个channel 1000次。当时第一感觉是有些疑惑,因为常识告诉我,一个简单的非严格1khz的低频ticker,在3Ghz左右的服务器处理器上,cpu占用应该在1-2%这样极低的占用才合理。只是希望程序某个函数被一秒调用1000次,怎么就占用掉近30%的cpu?
先不管成因,孤立出这个问题点来看看。为了实现“程序某个函数被一秒调用1000次”这点,在Go中用ticker可以这么做:
package main
import (
"time"
)
func main() {
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for range ticker.C {
}
}
结果上述程序top报告25%的%CPU值。是Go的ticker实现的问题吗?换sleep循环看看:
package main
import (
"time"
)
func main() {
for {
time.Sleep(time.Millisecond)
}
}
运行上述time.Sleep程序,top报告15%的%CPU。这和同样一个sleep循环的C++在1%的%CPU有天壤之别。于是开始倾向于是Go的调度器的锅。
分析
再回到上述火焰图,park_m()后一连串操作很显眼。我们已经知道M表示Machine,通常认为是一个OS Thread,park_m()后续stopm()顾名思义就是这个把当前M给停用掉,告诉系统这个M暂时不用。
一切似乎开始明朗了。每次tickWorkerMain开始等下一个tick的时候,也就是去读ticker.C这个channel的时候都因为时间没到channel为空,当前goroutine会被park掉,这个操作很轻,只需要更改一个标志。而此时因为系统空闲,并没有别的goroutine可供调度,Go的scheduler就必须让这个M去sleep,而这个操作是较重的且有锁,最终futex的syscall被调用。更具体的,这还和Go的后台timer实现、system monitor实现有关(注意火焰图中runtime.sysmon),这里不展开。人人都会告诉你协程调度是很轻的操作,这当然没错。但他们都没有告诉你更重要的一点:协程调度反复高频出现没有goroutine可供调度的代价在Go的当前实现里是显著的。
必须指出,本问题是因为系统空闲没有goroutine可以调度造成的。显然的,系统繁忙的时候,即CPU资源真正体现价值时,上述30%的%CPU的overhead并不存在,因为大概率下会有goroutine可供调度,无需去做让M去sleep这个很重的操作。
而这一问题的影响是具体客观的:
- 用户会反复提问为何系统空闲时占30%的%CPU,空闲时进程在top里始终排顶部不是个好事
- 在一个慢速的用电池的ARM核上有这东西就麻烦了
cgo解决
我们也已经知道,C/C++做同样的事代价很轻。如果从不改业务逻辑出发,首先想到的就是不让M去sleep,不发生无goroutine可调度的情况。比如,可以用一个OS线程通过cgo独立于scheduler地去产生这个1kHz的tick,每秒从C代码去调用1000次所需的Go函数。这个思路很容易实现,无非是从Go代码里调用一个C函数,这个C函数每秒1000次的从sleep中醒来去调用Go里的1khz的tick的处理函数,具体就不贴具体代码了。
用这个思路修改了Dragonboat的代码一跑,空闲时的cpu负载大幅降低:
结果
上述workaround已经让这一问题对自己软件的影响极大降低了。去golang-nuts吐槽一下,再报golang的issue tracker,根本的问题还是Go Scheduler的实现。用户用1kHz的ticker不应该是这样大费周章,标准库、runtime上直接提供更高效的实现才是真正解决方案。
在Dragonboat的开发中,这样的performance regression几乎每周都发生。从一秒十万次吞吐到一秒一千万吞吐的进化,是算法协议不断理解的深入,也是对Go runtime习性的不断熟悉的一个过程。后面陆续会风向大量这样的性能优化实践知识,均以目前互联网后台最热门Golang为语言,素材均为任何应用均会涉及的通用场景。作为最好的教材,欢迎大家试用Dragonboat,也请大家点Star支持它的持续开发。
![](/img/my/wx.png)
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
iOS开发之CoreMotion框架的应用
iOS开发之CoreMotion框架的应用 我们知道,现在智能手机手机的功能已经越来越强大。小小的手机中集成了众多的传感器配件。通过这些传感器可以获取到手机甚至用户的状态信息。 在iOS5之前,加速度传感器的相关信息封装在UIAccelerometer这个类中,其主要用来获取设备在三维空间中的状态信息,之后,加速度传感器以及螺旋仪传感器的相关信息都封装在了CoreMotion这个框架中,这个框架对加速度,磁力以及螺旋仪传感器信息进行统一管理,并封装了许多强大的计算方法帮助开发者获取设备的空间状态。 之前有写过一篇关于UIAccelerometer与CoreMotion简单使用的博客,比较偏用法介绍,并不系统,本篇博客是针对CoreMotion的完善与补充。 https://my.oschina.net/u/2340880/blog/543434 一、CoreMotion框架整体结构 在学习这个框架之前,首先需要对框架中类的关系与作用有个整体的了解。下图展示了CoreMotion框架的整体结构: 从上图中可以看出,CoreMotion框架中主要分为3大块,一部分是用...
- 下一篇
长连接的心跳及重连设计
前言 说道“心跳”这个词大家都不陌生,当然不是指男女之间的心跳,而是和长连接相关的。 顾名思义就是证明是否还活着的依据。 什么场景下需要心跳呢? 目前我们接触到的大多是一些基于长连接的应用需要心跳来“保活”。 由于在长连接的场景下,客户端和服务端并不是一直处于通信状态,如果双方长期没有沟通则双方都不清楚对方目前的状态;所以需要发送一段很小的报文告诉对方“我还活着”。 同时还有另外几个目的: 服务端检测到某个客户端迟迟没有心跳过来可以主动关闭通道,让它下线。 客户端检测到某个服务端迟迟没有响应心跳也能重连获取一个新的连接。 正好借着在 cim有这样两个需求来聊一聊。 心跳实现方式 心跳其实有两种实现方式: TCP 协议实现(keepalive 机制)。 应用层自己实现。 由于 TCP 协议过于底层,对于开发者来说维护性、灵活度都比较差同时还依赖于操作系统。 所以我们这里所讨论的都是应用层的实现。 如上图所示,在应用层通常是由客户端发送一个心跳包 ping 到服务端,服务端收到后响应一个 pong 表明双方都活得好好的。 一旦其中一端延迟 N 个时间窗口没有收到消息则进行不同的处理。 客户...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- SpringBoot2初体验,简单认识spring boot2并且搭建基础工程
- CentOS7,8上快速安装Gitea,搭建Git服务器
- Linux系统CentOS6、CentOS7手动修改IP地址
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- MySQL8.0.19开启GTID主从同步CentOS8
- Red5直播服务器,属于Java语言的直播服务器
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- SpringBoot2整合Redis,开启缓存,提高访问速度
- SpringBoot2整合Thymeleaf,官方推荐html解决方案
- CentOS7,CentOS8安装Elasticsearch6.8.6