查看原文
其他

使用 eBPF 实现 OFF CPU Time 记录并生成火焰图 —— 内核部分

曹海宁 Linux内核之旅 2023-09-14



当开发者开发了一个 CPU 程序,开发者的期望肯定是性能越高越好。为了探查程序性能瓶颈问题,通常会使用各种工具来排查性能瓶颈,如 BCC。

BCC(BPF Compiler Collection)是一个开源的 eBPF(Extended Berkeley Packet Filter)工具集,用于创建高效、安全的内核和用户空间跟踪和操纵工具。

BCC 有个 offcputime 的命令工具。offcputime 工具会追踪那些处于非运行状态的时间片,也就是 CPU 空闲的时间,并且它会给出一个报告,描述了哪些函数使得任务从 CPU 上被迁移走。

这种分析可以帮助开发者理解程序的等待情况,比如程序可能在等待 I/O 操作完成,或者等待锁释放等。了解这些信息可以帮助开发者找到性能瓶颈,优化程序的运行效率。

01

offcputime
的使用

先来看一下 BCC 的 offcputime 的使用。

# 对指定pid追踪offcputime,时长5秒钟
sudo offcputime -f 5 -p {ping_pid}

这里贴出来一个样例结果,事先执行了一个 ping 命令让其一直不停地执行。

执行 sudo offcputime -f 5 -p {ping_pid} > out.txt 追踪其 5 秒钟,并将结果输出到 out.txt,查看其内容。

ping;__libc_poll;entry_SYSCALL_64_after_hwframe;do_syscall_64;__x64_sys_poll;do_sys_poll;do_poll.constprop.0;schedule_hrtimeout_range;schedule_hrtimeout_range_clock;schedule;finish_task_switch.isra.0 11615
ping;[unknown];__recvmsg;entry_SYSCALL_64_after_hwframe;do_syscall_64;__x64_sys_recvmsg;__sys_recvmsg;___sys_recvmsg;____sys_recvmsg;sock_recvmsg;inet_recvmsg;ping_recvmsg;skb_recv_datagram;__skb_recv_datagram;__skb_wait_for_more_packets;schedule_timeout;schedule;finish_task_switch.isra.0 53101
ping;[unknown];__libc_poll;entry_SYSCALL_64_after_hwframe;do_syscall_64;__x64_sys_poll;do_sys_poll;do_poll.constprop.0;schedule_hrtimeout_range;schedule_hrtimeout_range_clock;schedule;finish_task_switch.isra.0 3954380

上面的例子里,每一行信息内容从左往右分别是:

  • PID 的进程名

  • 用户态栈信息

  • 内核态栈信息

  • 该栈脱离 CPU 的总用时

  • 用户态和内核态栈信息(用-分隔)

但是显然这些输出,初看并没有帮助,所以还需要让数据可视化。使用https://github.com/brendangregg/FlameGraph 的 flamegraph.pl 脚本把例子中的数据生成火焰图。

执行 ./flamegraph.pl out.txt > out.svg,产生火焰图 out.svg 文件,如下:

而本文的目标,就是复刻上面 BCC 的 offcputime 命令工具的功能,并且把可视化部分交给现有的脚本工具完成。

02

前置知识

测试环境:

OS: Ubuntu 22.04.2 LTS
Kernel: 6.1.10-060110-generic

关于开发 eBPF 需要的工具在此就不赘述了。

需要知道如何开发 eBPF 程序,以及对应的 kprobe、tracepoint、eBPF Map、BTF 等相关知识。

03

基本原理

在着手开发以前,先要弄清楚开发流程是怎样的。offcputime 的简要原理是:

1. 当内核触发了切换任务,如果切出的 PID(prev-pid) 是被监控的,则记录下栈信息和时间。

2. 当监听到切入的 PID(next-pid),根据上次记录下来的时间,计算出距离这次切入的时间,这一段时间就是 OFF CPU 的时间。

04

开发

4.1. 选择 hook 点

上面描述操作的开始都是需要内核任务切换触发,而用 eBPF 技术可以轻易将程序 hook 到内核中的绝大部分函数和 Linux 预定义的 tracepoint 。

熟悉 Linux 或 看过 BCC 的 offcputime 工具源码的同学可能很快知道直接 kprobe 到 finish_task_switch 就能 hook 到任务切换的地方,但是本文介绍的是不使用 kprobe 的方式,而是使用 tracepoint 变种:  tp_btf 。

不使用 krpobe 的原因是 Linux 内核的函数签名并不是稳定的,如在支持的内核版本上,应该尽量使用 tracepoint 。

例如我们仿照 BCC 中用 kprobe 到 finish_task_switch 实现在高版本的 Linux 内核上无法正常启动。搜索一下内核的函数:

me@alice:~$ sudo cat /proc/kallsyms | grep finish_task_switch
ffffffff97132380 t finish_task_switch.isra.0

这个.isra.0 后缀是什么东西?再看看 Linux 的源代码:

static struct rq *finish_task_switch(struct task_struct *prev)
__releases(rq->lock)
{
...
}

函数名居然变了?明明 Linux 源代码还是 finish_task_switch,怎么查到的就是 finish_task_switch.isra.0 了。

这是因为 Linux 内核经过编译器的编译,编译器可能会对优化过的函数更改名称。详情可以看这个链接:

What does the GCC function suffix "isra" mean?:https://stackoverflow.com/questions/13963150/what-does-the-gcc-function-suffix-isra-mean

BCC 也是针对这种情况做了一个正则匹配的特殊处理:

# initialize BPF
b = BPF(text=bpf_text)
b.attach_kprobe(event_re="^finish_task_switch$|^finish_task_switch\.isra\.\d$",
fn_name="oncpu")
matched = b.num_open_kprobes()

但是既然是复刻,有 tracepoint 能让代码更简洁。上面关于任务切换的 tracepoint,经过一番搜索,使用 sched_switch 即可实现。

4.2. tracepoint 和 tp_btf

上面提到了 tp_btf,它和 tracepoint 的区别如下 (摘自网络):

所谓的 btf raw tracepoint 指的是 BTF-powered raw tracepoint (tp_btf) 或者说是 BTF-enabled raw tracepoint 。

btf raw tracepoint 跟常规 raw tracepoint 有一个 最主要的区别 是:btf 版本可以直接在 ebpf 程序中访问内核内存, 不需要像常规 raw tracepoint 一样需要借助类似 bpf_core_read 或 bpf_probe_read_kernel 这样 的辅助函数才能访问内核内存。

关于 tp_btf 的参数,一般是在 vmlinux.h 文件中以 btf_trace_ 开头定义的函数。比如下面就是 sched_switch 在 vmlinux.h 中的函数签名:

typedef void (*btf_trace_sched_switch)(void *, bool, struct task_struct *, struct task_struct *, unsigned int);

4.3. 定义 eBPF 程序入口

说了这么多,本文终于要写下第一行 eBPF 程序代码,首先定义一个 eBPF 入口函数,用 SEC 宏声明使用 tp_btf/sched_switch ,参数则跟上面的 btf_trace_sched_switch 保持一致。
这里只需要用到 prev 和 next 这两个 task_struct ,而从中获取的也仅仅是 PID 和 TGID 而已。

SEC("tp_btf/sched_switch")
int BPF_PROG(sched_switch, bool preempt, struct task_struct *prev, struct task_struct *next) {
pid_t prev_pid = prev->pid;
pid_t prev_tgid = prev->tgid;
 
pid_t next_pid = next->pid;
pid_t next_tgid = next->tgid;

// TODO
return 0;
}
到这里我们就轻松拿到了任务切换时 切出 (prev)/ 切入 (next) 任务的 PID 和 TGID:

什么是 TGID?为什么要拿 TGID?

在 Linux 系统中,每个运行的进程或线程都被分配一个唯一的 PID。这个 ID 用于识别和跟踪系统中的各种进程。如果你在系统中运行 ps 或 top 命令,你会看到一系列的 PID,这些就是系统中正在运行的进程和线程的标识。

每个进程可以有一个或多个线程,所有属于同一个进程的线程组成了一个线程组,TGID 就是这个线程组的 ID。换句话说,TGID 实际上就是主线程(也就是创建其他线程的线程)的 PID。

而开发者在常见的语境下,会统一把 TGID 称为 PID。比如我们运行了一个 Java Server 程序,会产生 GC 线程、IO 线程、计算线程等,以上所产生的所有线程在内核中都会新分配一个 PID。开发者如果想 kill 这个程序,更习惯说 kill 这个 pid 为 xxx 的进程。

而要追踪进程所有的线程,使用 PID 显然不合适,这里应该是 TGID 作为进程的唯一标识。为了避免混淆,统一在 eBPF 内核态代码中严格区分 PID 和 TGID。

如无另外说明,以下的 PID 指内核线程 PID,TGID 为线程组 ID。

4.4. 任务切出处理

任务的切出意味着任务开始 OFF CPU 了,也是要 eBPF 程序追踪的开始。

eBPF 程序是基于事件触发的程序,当 hook 到某一点时,内核中关于该点的所有事件都会触发 eBPF 程序,显然并不需要追踪所有的 TGID,需要增加一个判断来过滤事件。

在 C 文件中定义一个 listen_tgid,先写死值,等后续完善程序后再实现动态修改:

const volatile u32 listen_tgid = 10086;

定义一个函数,该函数的作用是针对 切出任务 (prev_task) 的处理,如果是追踪的 TGID,则记录下信息,目前仅实现 TGID 的过滤:

// 尝试记录 OFF CPU 的开始时间
inline void try_record_start(void *ctx, u32 prev_pid, u32 prev_tgid) {
if (prev_tgid == 0 || prev_pid == 0 || prev_tgid != listen_tgid) {
return;
}
// TODO
}

把记录的信息需直接存储在 eBPF 的 Map 中,首先定义一个 type 为 BPF_MAP_TYPE_HASH 的 Map 作为缓存。该 Map 并不与用户空间交互。

使用 temp_key_t 作为 Map 的 key,temp_value_t 作为 Map 的 value。

Key 使用 TGID 和 PID 组合,下次该任务切入的时候可以方便索引查找。

Value 信息包含了:

  • start_time:触发当前 PID 切出的时间。

  • user_stack_id: 当前 PID 的用户态栈信息的 ID 值。

  • kernel_stack_id:当前 PID 的内核态栈信息的 ID 值。

  • comm[16]: PID 的名称。

// 用于暂存到map的struct
struct temp_key_t {
u32 tgid;
u32 pid;
};

struct temp_value_t {
u64 start_time;
u64 user_stack_id;
u64 kernel_stack_id;
u8 comm[16];
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, struct temp_key_t);
__type(value, struct temp_value_t);
} temp_pid_status SEC(".maps");

上面提到了用户态和内核态栈信息的 ID,eBPF 提供了可以直接获取栈信息的方式,需要定义的也是一个 Map,不过类型是 BPF_MAP_TYPE_STACK_TRACE 。

使用方式也很简单,使用 eBPF 提供的 helper 函数 bpf_get_stackid 就可以获取栈信息对应的 ID 值,当要获取栈信息时,直接使用该 ID 作为 KEY 从该 BPF_MAP_TYPE_STACK_TRACE 中获取栈信息即可。

所以该 Map 是需要暴露给用户空间的,用户空间的程序通过该 Map 获取栈信息。

#define PERF_MAX_STACK_DEPTH 127
 
struct {
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__type(key, u32);
__uint(value_size, PERF_MAX_STACK_DEPTH * sizeof(u64));
__uint(max_entries, 4096);
} stack_traces SEC(".maps");
接下来要写的就是填充上面的定义 temp_key_t 和 temp_value_t 并放到 temp_pid_status 缓存中。

// 尝试记录offcputime开始时间
inline void try_record_start(void *ctx, u32 prev_pid, u32 prev_tgid) {
// 过滤
if (prev_tgid == 0 || prev_pid == 0 || prev_tgid != listen_tgid) {
return;
}
// 获取 stack 信息,填充数据
struct temp_value_t value = {};
value.start_time = bpf_ktime_get_ns();
value.user_stack_id = bpf_get_stackid(ctx, &stack_traces, BPF_F_USER_STACK);
value.kernel_stack_id = bpf_get_stackid(ctx, &stack_traces, 0);
bpf_get_current_comm(&value.comm, sizeof(value.comm));
struct temp_key_t key = {
.pid = prev_pid,
.tgid = prev_tgid
};
// 更新到缓存中
bpf_map_update_elem(&temp_pid_status, &key, &value, BPF_ANY);
}

上面函数做的事情也比较简单,就三步:过滤、填充数据、更新到缓存中。

到此,追踪 TGID 的切出部分就完成。

4.5. 任务切入处理

任务的切入意味着任务准备 ON CPU 了,也就是说这次的 OFF CPU 已经结束,只要获取到上面定义的 start_time , 就可以计算这次任务 OFF CPU 的总耗时。

跟上面一样,定义一个处理切入的函数,除去最后一行 increment_ns ,逻辑就是:

  • 过滤 TGID。

  • 获取缓存,如果获取不到直接返回。

  • 根据从缓存拿到上次 OFF CPU 时的数据,计算出 PID 的 OFF CPU 耗时。
// 尝试记录offcputime结束并计算时间
inline void try_record_end(u32 next_pid, u32 next_tgid) {
if (next_tgid == 0 || next_pid == 0 || next_tgid != listen_tgid) {
return;
}
struct temp_key_t key = {
.pid = next_pid,
.tgid = next_tgid
};
struct temp_value_t *temp_value = NULL;
temp_value = bpf_map_lookup_elem(&temp_pid_status, &key);
if (temp_value == NULL) {
// 找不到直接return
return;
}
u64 end_time = bpf_ktime_get_ns();
// 计算出使用的时间,微秒
u64 usage_us = (end_time - temp_value->start_time) / 1000;
increment_ns(next_pid, next_tgid, usage_us, temp_value);
}

以上的代码就可以计算出 PID 的 OFF CPU 时间。但是程序在运行过程中,一段代码通常不只运行一次的,还需要将栈 OFF CPU 的时间累加。

定义一个暂存信息的 eBPF Map,命名为 pid_stack_counter,该 Map 每一条数据都代表着上面用 BCC offcputime 输出数据一条数据。

以TGID、PID、用户态栈 ID、内核态栈 ID、PID 名称 的组合作为 KEY,用总耗时作为 value,存入 pid_stack_counter 中。

struct key_t {
u32 tgid;
u32 pid;
u64 user_stack_id;
u64 kernel_stack_id;
u8 comm[16];
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, struct key_t);
__type(value, u64);
} pid_stack_counter SEC(".maps");

void increment_ns(u32 pid, u32 tgid, u64 usage_us, struct temp_value_t *temp_value) {
struct key_t key = {};
key.tgid = tgid;
key.pid = pid;
// 填充key数据
key.user_stack_id = temp_value->user_stack_id;
key.kernel_stack_id = temp_value->kernel_stack_id;
__builtin_memcpy(&key.comm, &temp_value->comm, sizeof(key.comm));
// 拿到之前暂存的耗时数据
u64 *total_usage_us = bpf_map_lookup_elem(&pid_stack_counter, &key);
u64 result = 0;
if (total_usage_us == NULL) {
result = usage_us;
} else {
// 如果有数据,直接累加数据
result = usage_us + *total_usage_us;
}
// 更新 pid_stack_counter Map的暂存数据
bpf_map_update_elem(&pid_stack_counter, &key, &result, BPF_ANY);
}

上面 increment_ns 函数是 try_record_end 函数最后一行的实现逻辑:

  • 填充 key 数据。

  • 根据 key 拿到之前暂存的总耗时数据,总耗时数据没有则意味着第一次累加。

  • 将该条数据的耗时累加,并更新到 pid_stack_counter Map 中。

05

总结

至此,TGID 切入任务的处理就已经完成了。当每次触发监听的 TGID 的切出和切入,最后汇总的数据都会写入到的 pid_stack_counter 。

接下来 eBPF 内核空间程序要做的就是不停地记录信息,到用户空间的程序计时停止,然后用户空间拿到 pid_stack_counter Map 的数据并处理。

06

下一篇

offcputime 的内核空间程序到此就已经写好了,目前已经实现了从内核中读取和暂存要追踪 TGID 的 OFF CPU 时间。

下一篇,将会开始编写用户空间是如何处理捕获的数据,以及如何根据栈信息找到对应的函数名称等,最终生成火焰图,从而真正实现一个 offcputime 工具。


完整 eBPF 程序内核部分代码

GitHub 地址: offcputime.bpf.c

https://github.com/jelipo/ebpf_rs/blob/master/offcputime/src/offcputime.bpf.c

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存