如何准确的获取CPU占用率[Linux][Go]

如果我们想要获取系统的 CPU 占用率,首先,Go 语言本身是没有帮我们封装这样的 API 的,所以我们只能自己通过其他方式直接向操作系统要,而不同的操作系统“要”的方式都不太一样,我们这里主要基于 Linux 场景来分析。

虽然有现成的 ps 和 top 等工具我们可以读到现成的值,但是这些工具也是基于 proc 文件解析的,对于人眼可读性较友好,但是代码解析时就不那么方便了。

PROCESS 文件系统

在许多 Unix 类系统中,都存在一个 procfs (Process File System) 进程文件系统的概念,用于将内核中的一些信息通过文件的形式开放出来供用户使用,不过这个文件并不是我们平常指的狭义上的文件,这些信息是只存在于内存中的,所以也不用担心性能问题,类似内存文件系统的还有 sysfs、tmpfs 等。

我们可以通过 df 命令查看一个目录所使用的文件系统: image-20201230185420142

而在 Linux 系统,这个文件系统默认挂载在 /proc 目录下,除了进程信息 Linux 还塞了很多诸如IO、总线设备、内存、CPU、cgroups 等其他系统信息在里面,这并不是我们关心的重点,就不详细展开了。

在这个目录下的 stat文件中,则保存着一些内核的统计数据,其中就有我们关心的 CPU 运行统计数据。

这个文件里面有一些数字,在分析这些数字之前我们需要有一些关于CPU的基本概念。

CPU运行统计

CPU 本质上就是一个指令执行器,我们写好的代码经由编译器编译成对应 CPU 架构的指令,然后我们委托操作系统把这些指令给到 CPU 去执行,CPU 内部经过 取值、译码、执行、访存、写回 等流程就完成了一条指令然后取下一条,同时给CPU 配上一些内存、输入输出设备来供其差遣,这就是一个计算机运行的基本模型。 img

这个指令执行器是只存在两种状态的,在执行指令 or 没有在执行指令,不存在像内存这种用了一半、用了百分之多少的说法。

而我们通常看到的百分之多少的 CPU 使用,其实都省略了一个条件,采样周期,虽然 CPU 在一个特定的时刻只有 0 和 1 两种状态,但是如果我们定一个周期,比如 1秒钟,我们统计这一秒钟内 CPU 有多少时间在运行,除以总的时间单位数量就能得出一个比较直观的百分数字,这就是所有 CPU 使用率统计的原理。

在 Linux 中,内核会将时间划分成一个最小单元:jiffy,直译成中文是瞬间,形容非常非常短,受 USER_HZ(频率)参数控制jiffy = 1 / USER_HZ,通常 USER_HZ 是 100,而对应的 jiffy 则是 10ms

PROCESS STATISTICS

再回到我们的 /proc/stat文件,一个 /proc/stat文件实例如下(红字是我标注的): image-20201230223811735

关于 Linux proc 文件系统的目录和文件结构描述我们可以在 Linux 的在线文档站找到:https://man7.org/linux/man-pages/man5/procfs.5.html ,其中也包括关于 /proc/stat 的详细描述。

这个文件前面 cpu 开头的几行是代表 CPU 相关的一些信息:

  • 第一行 cpu 是代表总体的 CPU 使用情况
  • 之后的 cpuN 则是代表具体某一个核心的情况

每一行后面都有一些数字,这些数字则是我们上面提到的 jiffies,分别描述了 CPU 累计在各种状态下的时间,具体从左到右依次是(6-10是内核 2.6 版本加进去的):

  1. user 用户态
  2. nice 低优先级用户态(nice 的进程)
  3. system内核态(特权模式)
  4. idle空闲
  5. iowaitIO等待
  6. irq硬中断 (Interrupt request)
  7. softirq软中断
  8. steal 虚拟化环境下其他系统的运行时间
  9. guest 运行虚拟宿主机
  10. guest_nice虚拟宿主机nice

在网上流传的很多 CPU 利用率计算的公式中,要么就是版本太老只有前五项或者前七项,要么就是粗暴的把所有数字加到一起然后用 idle相除,但是这些其实都不准确,比如在内核代码中统计虚拟宿主机的guest_niceguest时间时(截图如下),对应的user_niceuser值也会增加,那么全部加一块儿很明显就会导致 CPU 占用率偏高,这并不科学。

image-20201230183043703

参考这个帖子以及htop的实现:

  1. guest 和 guest_nice 的时间片已经算在用户态时间中了
  2. iowait 的时候 CPU 是不干活的所以算空闲状态(这个值可能不太精确,因为CPU可能在等待 IO 时会执行其他进程)
  3. 系统态和中断时 CPU 都是运行状态

准确的 CPU 累计占用率计算方式应该是:

work = user +  nice + system + irq + softirq + steal
idle = idle + iowait
usage = work / (work + idle)

当然这是累计的值,如果我们要取实时值的话,采样一小段时间取差值即可。

Go 语言实现

那么在 Go 语言里面,比较完备的实现则是这样的(注:内核版本 >= 2.6.33):

整体版

点击展开代码详情

const (
	user = iota
	nice
	system
	idle
	iowait
	irq
	softirq
	steal
	guest
	guest_nice
)

func getCPUUseRate(sampleTime time.Duration) (float64, error) {
	workPre, totalPre, err := jiffies()
	if err != nil {
		return 0, err
	}

	time.Sleep(sampleTime)

	workAfter, totalAfter, err := jiffies()
	if err != nil {
		return 0, err
	}

	work := workAfter - workPre
	total := totalAfter - totalPre

	return float64(work) / float64(total), nil
}

var ErrInvalidStatFile = errors.New("invalid statistic file")

func jiffies() (workTime, totalTime int, err error) {
	contents, err := ioutil.ReadFile("/proc/stat")
	if err != nil {
		return 0, 0, err
	}

	lines := strings.SplitN(string(contents), "\n", 2)
	if len(lines) == 0 {
		return 0, 0, ErrInvalidStatFile
	}

	fields := strings.Fields(lines[0])
	if len(fields) != 11 || fields[0] != "cpu" {
		return 0, 0, ErrInvalidStatFile
	}

	v := make([]int, len(fields))
	for i := 1; i < len(fields); i++ {
		val, err := strconv.Atoi(fields[i])
		if err != nil {
			return 0, 0, fmt.Errorf("%w: %v", ErrInvalidStatFile, err)
		}
		v[i] = val
	}

	workTime = v[user] + v[nice] + v[system] + v[irq] + +v[softirq] + v[steal]
	idleTime := v[idle] + v[iowait]

	return workTime, workTime + idleTime, nil
}

分核版

如果我们想要分别获取 CPU 每个核心的使用情况:

点击展开代码

const (
	user = iota
	nice
	system
	idle
	iowait
	irq
	softirq
	steal
	guest
	guest_nice
)

func jiffiesPerCore() (workTimes, totalTimes []int, err error) {
	contents, err := ioutil.ReadFile("/proc/stat")
	if err != nil {
		return nil, nil, err
	}

	lines := strings.Split(string(contents), "\n")
	if len(lines) == 0 {
		return nil, nil, ErrInvalidStatFile
	}
	
	coreValues := make([][]int, 0)
	for _, line := range lines[1:] {
		if !strings.HasPrefix(line, "cpu") {
			continue
		}
		fields := strings.Fields(line)
		if len(fields) != 11 {
			return nil, nil, ErrInvalidStatFile
		}
	
		v := make([]int, len(fields))
		for i := 1; i < len(fields); i++ {
			val, err := strconv.Atoi(fields[i])
			if err != nil {
				return nil, nil, fmt.Errorf("%w: %v", ErrInvalidStatFile, err)
			}
			v[i] = val
		}
		coreValues = append(coreValues, v)
	}
	
	for _, v := range coreValues {
		workTime := v[user] + v[nice] + v[system] + v[irq] + +v[softirq] + v[steal]
		idleTime := v[idle] + v[iowait]
		workTimes = append(workTimes, workTime)
		totalTimes = append(totalTimes, workTime+idleTime)
	}
	
	return workTimes, totalTimes, nil
}

func getCPUUseRatePerCore(sampleTime time.Duration) (rates []float64, err error) {
	workPre, totalPre, err := jiffiesPerCore()
	if err != nil {
		return nil, err
	}

	time.Sleep(sampleTime)
	
	workAfter, totalAfter, err := jiffiesPerCore()
	if err != nil {
		return nil, err
	}
	
	if len(workPre) != len(workAfter) {
		return nil, errors.New("unexpected jiffies")
	}
	
	for i, _ := range workPre {
		work := workAfter[i] - workPre[i]
		total := totalAfter[i] - totalPre[i]
		rate := float64(work) / float64(total)
		rates = append(rates, rate)
	}
	
	return rates, nil
}

验证

我们可以写一个简单的小程序来验证一下我们代码的正确性。

这台服务器拥有 两个 Intel(R) Xeon(R) Gold 6148CPU核心,主频都是 2.40GHz。上面没跑什么其他任务,正常情况下服务器的 CPU 占用情况都只有百分之几。

为了测试,我写了一段死循环累加代码,在另一个协程中执行,然后主协程中调用了我们上面写的多核版 CPU 占用率计算函数:

func main() {
	go func() {
		for i := 0; ; {i++}
	}()
	for {
		rates, err := getCPUUseRatePerCore(1 * time.Second)
		if err != nil {
			fmt.Println(err.Error())
		}

		for i, rate := range rates {
			fmt.Printf("当前CPU %d 利用率:%f%%\n", i+1, rate*100)
		}
		fmt.Println()
	}
}

由于不存在任何的 IO、锁以及任何条件的等待,那么这段死循环累加会一直执行下去,持续不断的提供指令让 CPU 执行。因为没有什么其他任务会来抢占 CPU,所以预期这个程序会吃掉一整个核的 CPU,至于为什么不是两个核都吃满,因为我段代码是线性的,这意味着同一时间只会有一条指令在被执行。

我们知道在 Go 语言的协程调度机制中,协程会被绑定在线程上运行,而在 Linux 下线程则使用了 CFS 可抢占支持优先级时间片动态轮转调度算法 来决策哪个线程将要运行,每个核心都有一条调度队列,当我们的代码在一个核上被其他进程抢占了之后,在没有设置 CPU 核心亲和度(affinity)绑定核心的时候是有可能被移到另一个核上执行的。

因此预期的现象就是,虽然代码能跑满一个核,但是有可能前半段跑满了第一个核,然后后半段是跑满了第二个核,两个 CPU 利用率总和加起来一直在一百多一点徘徊,实际测试结果,基本符合预期:

image-20201230222426596