蛮荆

Go 语言如何获取 CPU 利用率

2023-09-18

概述

Go 语言标准库没有提供获取 CPU 利用率的方法,如果业务开发中需要用到一些服务器性能指标数据,必须由开发者自己实现。

本文主要介绍在 Linux 中如何获取 CPU 利用率,笔者的示例代码运行环境为 go1.20 linux/amd64

命令行工具

Linux 中常见的命令如 tophtopsar, 可以非常方便地获取和显示 CPU 利用率等数据,下面是 top 命令的结果输出。

top 命令输出

我们可以很容易想到,利用 Go 语言标准库中的 exec.Command 方法结合 top 命令,获取 CPU 的利用率,下面是对应的代码。

package main

import (
	"bytes"
	"fmt"
	"log"
	"os/exec"
)

func main() {
	var output bytes.Buffer

	cmd := exec.Command("top", "-b", "-n", "1")
	cmd.Stdout = &output
	err := cmd.Run()
	if err != nil {
		log.Fatal(err)
	} else {
		fmt.Printf("top result: \n%v\n", output.String())
	}
}

执行命令:

$ go run main.go

top result:                                                                    
top - 15:07:24 up 1 day,  1:13,  0 users,  load average: 0.00, 0.00, 0.00      
Tasks:  24 total,   1 running,  23 sleeping,   0 stopped,   0 zombie           
%Cpu(s):  0.1 us,  0.1 sy,  0.0 ni, 99.8 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  8056768 total,  5245460 free,   750332 used,  2060976 buff/cache    
KiB Swap:  2097152 total,  2097152 free,        0 used.  7003500 avail Mem     
                                                                               
  PID USER        PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND    
    1 root        20   0    2324   1708   1600 S   0.0  0.0   0:01.13 init(Ubunt+
    4 root        20   0    2948    308     68 S   0.0  0.0   6:34.42 init       

...

20997 someone     20   0   19680   9512   5420 S   0.0  0.1   0:00.30 zsh        
29176 someone     20   0 1610576  21080   9272 S   0.0  0.3   0:00.09 go         
29268 someone     20   0  711488   3008    872 S   0.0  0.0   0:00.00 main       
29273 someone     20   0   29444   3656   3228 R   0.0  0.0   0:00.00 top     

从输出的结果中可以看到,虽然上面的方法可以获取到 CPU 相关数据,但是输出结果仅仅只是便于人眼阅读,如果我们希望将相关数据值单独取出来在程序中使用, 就需要基于结果字符串进行解析操作,这个过程就会非常麻烦而且容易出错,所以我们需要一个更好的方案。


CPU 数据相关文件

Linux 一切皆文件 一文中提到,Linux 将资源抽象为文件表示,那么和 CPU 相关的数据是否也会被抽象为文件,进而保存在某个文件中呢?

通过查找 Linux 开发在线文档,可以发现和 CPU 相关的数据主要分布于 /proc 目录下的几个文件中:

/proc/stat

提供了内核统计数据,当然也包括了 CPU 的数据。

/proc/cpuinfo

提供了有关 CPU 的详细数据,包括 CPU 型号、核心数量等。

/proc/<PID>/stat

/proc/stat 提供的数据类似,但是数据对应的是单个进程。

/proc/stat

因为我们希望看到系统全局 CPU 利用率,所以这里选择基于 /proc/stat 文件中的内容进行解析来获取数据。

首先来看下 /proc/stat 的文件内容:

$ cat /proc/stat

# 笔者的测试机器输出如下
cpu  40493 6954 65486 76990150 9113 0 25483 0 0 0
cpu0 5181 995 9564 9615416 1201 0 20111 0 0 0

...

cpu7 4382 609 7231 9627991 626 0 203 0 0 0
intr 11137544 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1310 0 181 1 1 10 0 3 0 3 0 77853 0 1408 0 1408 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
ctxt 37107675
btime 1694670818
processes 276043
procs_running 1
procs_blocked 0
softirq 21005941 0 2478101 7 16554 0 0 3790140 6921044 0 7800095

然后再对照看一下 /proc/stat 对应的文档:

/proc/stat 文档描述

结合上面的文档描述,/proc/stat 文件的输内容表示如下:

  • 第一行输出系统全局 CPU 使用情况
  • 从第二行开始,依次输出单个逻辑 CPU 的使用情况

CPU 使用情况数据按照空格划分,一共有 10 列 (也就是图中画红线的部分),每一列表示的含义如下。

列序号 名称 描述
1 user 用户态 CPU 时间
2 nice 低优先级用户态 CPU 时间 (进程的 nice 值被调整为 1-19 之间)
3 system 内核态 CPU 时间
4 idle CPU 空闲时间 (不包括 IO 等待时间)
5 iowait 等待 I/O 的 CPU 时间
6 irq 处理硬中断的 CPU 时间
7 softirq 处理软中断的 CPU 时间
8 steal 当系统运行在虚拟机中的时候,被其他虚拟机占用的 CPU 时间
9 guest 通过虚拟化运行其他操作系统的时间
10 guest_nice 低优先级运行虚拟机的时间

文件内容剩下的字段本文暂时用不到,不过这里还是简单提一下。

字段 作用
intr 系统中断相关数据
ctxt 系统上下文切换次数
btime 系统启动以来的时间
processes 创建的进程数量
procs_running 运行进程数量
procs_blocked 阻塞进程数量
softirq 不同类型软中断的处理次数

计算利用率

CPU 利用率 = CPU 使用时间 / (CPU 闲置时间 + CPU 使用时间)

CPU 使用时间 = user + nice + system + irq + softirq + steal

CPU 闲置时间 = idle + iowait

在上面的公式中,我们将 iowait 字段算作 CPU 空闲时间,这一点可能存在争议 (因为 CPU 在等待 IO 时可能会去执行其他进程任务),这里我们暂且跳过这个争议, 先实现一个最小版本 (避免在细节上面浪费过多时间),然后找一个成熟的开源组件,对比一下计算方法即可。

代码实现

有了上面的理论基础之后,现在可以写代码来实现功能,核心的思路是: 通过读取 /proc/stat 文件内容解析出对应的 CPU 指标数据完成采样,然后通过多次采样数据对比计算出 CPU 的利用率

package main

import (
	"fmt"
	"log"
	"math"
	"math/rand"
	"os"
	"strconv"
	"strings"
	"time"
)

const (
	cpuStatFile = "/proc/stat"
)

// 采样结果对象
type result struct {
	used uint64 // CPU 使用时间
	idle uint64 // CPU 闲置时间
}

// CPU 指标采样函数
func sample() (*result, error) {
	data, err := os.ReadFile(cpuStatFile)
	if err != nil {
		return nil, err
	}

	res := &result{}

	lines := strings.Split(string(data), "\n")
	for _, line := range lines {
		fields := strings.Fields(line)
		// 为了简化演示
		// 这里只取所有 CPU 总的统计数据
		if len(fields) == 0 || fields[0] != "cpu" {
			continue
		}

		// 将第一行数据分割为数组
		n := len(fields)
		for i := 1; i < n; i++ {
			if i > 8 {
				continue
			}

			// 解析每一列的数值
			val, err := strconv.ParseUint(fields[i], 10, 64)
			if err != nil {
				return nil, err
			}

			// 第 4 列表示 CPU 空闲时间
			// 第 5 列表示 等待 I/O 的 CPU 时间
			if i == 4 || i == 5 {
				res.idle += val
			} else {
				res.used += val
			}
		}

		return res, nil
	}

	return res, nil
}

func main() {
	// 获取第一次采样结果
	first, err := sample()
	if err != nil {
		log.Fatal(err)
	}

	// 模拟一些 CPU 密集型任务
	rand.Seed(time.Now().UnixNano())
	for i := 0; i < 10000; i++ {
		_ = math.Sqrt(rand.Float64())
	}

	// 获取第二次采样结果
	second, err := sample()
	if err != nil {
		log.Fatal(err)
	}

	// 计算两次采样期间 CPU 的空闲时间
	idle := float64(second.idle - first.idle)
	// 计算两次采样期间 CPU 的使用时间
	used := float64(second.used - first.used)
	// CPU 利用率 = CPU 使用时间 / (CPU 闲置时间 + CPU 使用时间)
	var usage float64
	if idle+used > 0 {
		usage = used / (idle + used) * 100
	}

	fmt.Printf("CPU usage is %f%%\n", usage)
}

运行上面的代码

$ go run main.go

CPU usage is 32.558140%

上面的代码演示了如何获取系统中所有 CPU 总的利用率,感兴趣的读者可以在这个代码基础上进行改进,实现获取单个 CPU 的利用率。


对比验证

现在找一个 Go 语言的开源组件,对比和验证一下刚才的实现代码是否存在问题,避免闭门造车,一叶障目。

笔者选择的组件是 gopsutil,下面是使用该组件获取 CPU 利用率对应的代码。

组件实现代码

package main

import (
	"fmt"
	"github.com/shirou/gopsutil/v3/cpu"
	"log"
	"math"
	"math/rand"
	"time"
)

func main() {
	done := make(chan struct{})

	go func() {
		for i := 0; i < 5; i++ {
			// 获取 CPU 利用率 (每 100 毫秒获取一次)
			percent, err := cpu.Percent(100*time.Millisecond, false)
			if err != nil {
				log.Fatalf("get CPU usage: %v\n", err)
				return
			}

			for _, v := range percent {
				fmt.Printf("CPU usage is %.2f%%\n", v)
			}
		}

		// 模拟程序结束后通过 channel 发送通知
		done <- struct{}{}
	}()

	// 模拟一些 CPU 密集型任务
	rand.Seed(time.Now().UnixNano())
	for i := 0; i < 10000000; i++ {
		_ = math.Sqrt(rand.Float64())
	}

	<-done
	close(done)
}

运行上面的代码

$ go run main.go

CPU usage is 32.558140%
CPU usage is 12.35%
CPU usage is 5.00%
CPU usage is 0.00%
CPU usage is 0.00%
CPU usage is 0.00%

最后,我们追踪下 gopsutil 源代码的调用链路,学习一下内部的实现细节。

TimesStat 对象

TimesStat 表示 CPU 指标数据集合对象。

type TimesStat struct {
	CPU       string  `json:"cpu"`
	User      float64 `json:"user"`
	System    float64 `json:"system"`
	Idle      float64 `json:"idle"`
	Nice      float64 `json:"nice"`
	Iowait    float64 `json:"iowait"`
	Irq       float64 `json:"irq"`
	Softirq   float64 `json:"softirq"`
	Steal     float64 `json:"steal"`
	Guest     float64 `json:"guest"`
	GuestNice float64 `json:"guestNice"`
}

Percent 方法

// https://github.com/shirou/gopsutil/blob/2fabf15a16dca198f735a5de2722158576e986a9/cpu/cpu.go#L148
// Percent 方法计算 CPU 利用率
//   可以计算总的 CPU 利用率,也可以计算单个 CPU 的利用率 (取决于第二个参数)
//   在刚才的例子中,我们计算的是总的 CPU 利用率
func Percent(interval time.Duration, percpu bool) ([]float64, error) {
	return PercentWithContext(context.Background(), interval, percpu)
}

// Percent 方法的内部具体实现
func PercentWithContext(ctx context.Context, interval time.Duration, percpu bool) ([]float64, error) {
	...

	// 第一次采样
	cpuTimes1, err := TimesWithContext(ctx, percpu)
	if err != nil {
		return nil, err
	}

	// 获取指标的间隔时间
	// 期间直接进入休眠
	if err := common.Sleep(ctx, interval); err != nil {
		return nil, err
	}

	// 第二次采样
	cpuTimes2, err := TimesWithContext(ctx, percpu)
	if err != nil {
		return nil, err
	}

	// 根据两次采样数据计算出 CPU 利用率
	return calculateAllBusy(cpuTimes1, cpuTimes2)
}

TimesWithContext

该方法主要用于获取 CPU 指标数据采样。

func TimesWithContext(ctx context.Context, percpu bool) ([]TimesStat, error) {
	// 获取对应的指标数据文件名称,也就是 /proc/stat
	filename := common.HostProc("stat")
	lines := []string{}
	if percpu {
		// 获取单个 CPU 数据
		...
	} else {
		// 获取总的 CPU 数据
		lines, _ = common.ReadLinesOffsetN(filename, 0, 1)
	}

	ret := make([]TimesStat, 0, len(lines))

	for _, line := range lines {
		// 将 /proc/stat 文件中的单行文本数据解析为 TimesStat 指标对象
		ct, err := parseStatLine(line)
		if err != nil {
			continue
		}
		ret = append(ret, *ct)

	}
	return ret, nil
}

calculateAllBusy

该方法根据两个 TimesStat 采样数据对象,计算出 CPU 利用率,具体的计算方法委托给 calculateBusy 方法。

func calculateAllBusy(t1, t2 []TimesStat) ([]float64, error) {
	...

	ret := make([]float64, len(t1))
	for i, t := range t2 {
		ret[i] = calculateBusy(t1[i], t)
	}
	return ret, nil
}

func calculateBusy(t1, t2 TimesStat) float64 {
	t1All, t1Busy := getAllBusy(t1)
	t2All, t2Busy := getAllBusy(t2)

	...
	
	return math.Min(100, math.Max(0, (t2Busy-t1Busy)/(t2All-t1All)*100))
}

func getAllBusy(t TimesStat) (float64, float64) {
    busy := t.User + t.System + t.Nice + t.Iowait + t.Irq +
        t.Softirq + t.Steal
    return busy + t.Idle, busy
}

从上面的代码可以看到,calculateBusy 方法内部的计算公式为:

CPU 利用率 = CPU 使用时间 / (CPU 闲置时间 + CPU 使用时间)

CPU 使用时间 = user + nice + system + iowait + irq + softirq + steal

CPU 闲置时间 = idle


小结

本文主要介绍了在 Linux 系统中,如何使用 Go 语言获取 CPU 利用率,我们首先介绍了在 Linux 中获取 CPU 利用率时涉及到的文件和具体方法, 然后通过自己手动实现了一个简单的版本,最后通过开源组件 gopsutil 的内部实现和自己手动实现进行对比和验证, 发现了计算细节的差异:

  • 自己手动实现的版本中,iowait 列的数据作为 CPU 闲置时间
  • gopsutil 实现的版本中,iowait 列的数据作为 CPU 使用时间

此外,笔者查看了 htop 命令 对应的源代码,发现 htop 也是将 iowait 列的数据作为 CPU 闲置时间,下面是具体的代码链接和截图。

htop 命令源代码

htop 命令 iowait 计算方式

针对上面的差异情况,笔者 (不成熟的) 的建议如下:

  • 如果技术栈为 Go 语言,直接使用 gopsutil 组件即可
  • 如果技术栈为其他语言,使用该语言中对应的成熟组件
  • 如果上述两种情况都不符合,或者必须自己实现获取 CPU 利用率的功能,可以根据业务场景来决定
    1. 对于 CPU 密集型场景,将 iowait 列的数据作为 CPU 计算时间
    2. 对于 IO 密集型场景,将 iowait 列的数据作为 CPU 闲置时间

扩展阅读

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。