eBPF 教程:使用 fsession 追踪慢速 vfs_read 调用

当一个文件服务出现读延迟尖峰时,应用层计时只能告诉你请求变慢了,却无法区分是内核在 I/O 上阻塞还是用户态逻辑耗时。真正有用的问题是:哪个线程发起了读操作、请求了多少字节、调用返回了什么、这一次 vfs_read 花了多长时间?

本教程展示如何使用 Linux 7.0 引入的 fsession 机制测量 vfs_read 调用延迟。fsession 是一种新的 eBPF 程序类型,它在函数进入时执行一次、返回时再执行一次,并提供内置的调用级存储来关联这两个阶段。我们构建的工具会在函数进入时记录时间戳、返回时计算延迟,按进程和阈值过滤后,通过 ring buffer 上报慢读事件。

完整源码:github.com/eunomia-bpf...

问题:如何关联函数的进入和返回

要测量一个内核函数的执行时间,需要在它开始时记录时间戳,在它返回时计算差值。这听起来很简单,但 eBPF 中的传统方案都有各自的缺陷。

传统方案:两个程序配合哈希表

最常见的做法是使用两个独立的 BPF 程序,一个附加到函数进入(fentry),一个附加到函数返回(fexit)。进入程序把时间戳存入一个以线程 ID 为键的哈希表;返回程序查找时间戳、计算延迟、删除条目:

markdown 复制代码
进入程序:                              返回程序:
1. 获取线程 ID                         1. 获取线程 ID
2. 获取时间戳                          2. 从哈希表查找时间戳
3. 存入哈希表                          3. 计算延迟
                                       4. 删除哈希表条目
                                       5. 上报事件

这个方案能工作,但有几个问题:

  • 哈希表开销 :每次函数调用都需要在进入时插入、在返回时查找并删除。对于像 vfs_read 这样的高频函数,开销会累积。
  • 状态泄漏 :如果线程在进入后、返回前被杀死(比如 kill -9),哈希表条目永远不会被删除,造成内存泄漏。
  • 没有内在关联:两个程序完全独立,唯一的联系是外部哈希表,没有任何保证它们追踪的是同一次函数调用。

kprobe/kretprobe:相同模式,更高开销

kprobe 机制也是两个程序的结构,同样需要外部哈希表来关联。此外,kprobe 通过软件断点机制工作(用中断指令替换第一条指令),开销比 fentry 更大,后者使用内核的 ftrace 基础设施和 JIT 优化的调用序列。

用户态采样:统计性的,不是精确的

perf record 这样的工具周期性采样堆栈,可以构建时间花在哪里的统计概貌。然而,采样无法测量单次函数调用的延迟。如果你需要捕获尾部延迟事件(偶尔出现的 100ms 读操作导致超时),统计采样可能完全错过它们。

fsession 的解决方案

Linux 7.0 引入了 fsession ,在内核层面解决了关联问题。当你用 SEC("fsession/vfs_read") 声明一个 BPF 程序时,内核会:

  1. vfs_read 进入时调用你的程序
  2. 分配一个 8 字节的暂存区("session cookie"),绑定到这次特定的调用
  3. vfs_read 返回时再次调用你的程序
  4. 释放 session cookie

关键在于 session cookie 是自动管理的,作用域精确到一次函数调用。程序用 bpf_session_is_return(ctx) 区分进入和返回,用 bpf_session_cookie(ctx) 读写 cookie。

在传统方案中,你需要一个这样的哈希表:

c 复制代码
// 传统方案:以线程 ID 为键的哈希表
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u64);    // pid_tgid
    __type(value, u64);  // timestamp
} start SEC(".maps");

用了 fsession,整个哈希表都不需要了。时间戳直接存在 session cookie 里:

c 复制代码
// fsession:调用级 cookie,不需要 map
__u64 *started = bpf_session_cookie(ctx);
*started = bpf_ktime_get_ns();

cookie 从进入到返回一直存在,之后自动清理,不可能泄漏。

返回阶段可以访问函数参数

fsession 的另一个优势:返回阶段的上下文同时包含原始函数参数和返回值。在传统方案中,如果你需要在返回时访问函数参数(比如在事件中包含请求的字节数),必须在进入时把它们存到哈希表里。用 fsession,参数直接可用:

c 复制代码
SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
             loff_t *pos, ssize_t ret)
{
    // 返回时,'count' 和 'ret' 都可以直接访问
    // 不需要在进入时存储 'count'
}

代码实现

本工具由三个文件组成:

  • fsession_latency.h:BPF 和用户空间共享的数据结构
  • fsession_latency.bpf.c:测量延迟的 BPF 程序
  • fsession_latency.c:管理 BPF 生命周期并打印结果的用户空间加载器

共享头文件

fsession_latency.h 定义了通过 ring buffer 发送的事件结构和聚合统计结构:

c 复制代码
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
#ifndef __FSESSION_LATENCY_H
#define __FSESSION_LATENCY_H

#define FSESSION_COMM_LEN 16

struct latency_event {
	unsigned int pid;
	unsigned int tgid;
	unsigned long long requested;
	long long result;
	unsigned long long latency_ns;
	unsigned int device_major;
	unsigned int device_minor;
	unsigned long long inode;
	unsigned int mode;
	char comm[FSESSION_COMM_LEN];
};

struct latency_stats {
	unsigned long long calls;
	unsigned long long slow;
	unsigned long long errors;
	unsigned long long dropped;
};

#endif /* __FSESSION_LATENCY_H */

四个计数器分别记录:

  • calls:观察到的 vfs_read 调用总数
  • slow:达到或超过延迟阈值的调用数
  • errorsvfs_read 返回负数错误码的调用数
  • dropped:因 ring buffer 满而无法提交的事件数

FSESSION_COMM_LEN 设为 16,与内核的 TASK_COMM_LEN 一致。

每个事件还携带 device_majordevice_minorinodemode。BPF 程序把内核原始 s_dev 编码拆成 12 位主设备号和 20 位次设备号,再结合 i_ino 得到稳定的 VFS 对象身份;用户态根据 i_mode 输出 regularfifocharacter 等对象类型。

BPF 程序

fsession_latency.bpf.c 是工具的核心。下面是完整的内核态程序:

c 复制代码
// SPDX-License-Identifier: GPL-2.0
#define bpf_session_is_return bpf_session_is_return_vmlinux_snapshot
#define bpf_session_cookie bpf_session_cookie_vmlinux_snapshot
#include "vmlinux.h"
#undef bpf_session_is_return
#undef bpf_session_cookie
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "fsession_latency.h"

#define KERNEL_MINOR_BITS 20
#define KERNEL_MINOR_MASK ((1U << KERNEL_MINOR_BITS) - 1)

char LICENSE[] SEC("license") = "GPL";

const volatile __u64 threshold_ns;
const volatile __u32 target_tgid;

struct latency_stats stats;

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
} events SEC(".maps");

/*
 * The repository vmlinux.h snapshot predates the ctx argument on these
 * kfunc prototypes. Rename those stale declarations while including the
 * snapshot, then provide the Linux 7.0 signatures below.
 */
extern bool bpf_session_is_return(void *ctx) __ksym;
extern __u64 *bpf_session_cookie(void *ctx) __ksym;

SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
	     loff_t *pos, ssize_t ret)
{
	__u64 pid_tgid = bpf_get_current_pid_tgid();
	__u64 *started = bpf_session_cookie(ctx);
	struct latency_event *event;
	struct inode *inode;
	__u32 device;
	__u64 latency;

	if (!bpf_session_is_return(ctx)) {
		if (target_tgid && pid_tgid >> 32 != target_tgid) {
			*started = 0;
			return 0;
		}
		*started = bpf_ktime_get_ns();
		return 0;
	}

	if (!*started)
		return 0;

	latency = bpf_ktime_get_ns() - *started;
	__sync_fetch_and_add(&stats.calls, 1);
	if (ret < 0)
		__sync_fetch_and_add(&stats.errors, 1);
	if (latency < threshold_ns)
		return 0;

	__sync_fetch_and_add(&stats.slow, 1);
	event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
	if (!event) {
		__sync_fetch_and_add(&stats.dropped, 1);
		return 0;
	}

	__builtin_memset(event, 0, sizeof(*event));
	event->pid = (__u32)pid_tgid;
	event->tgid = pid_tgid >> 32;
	event->requested = count;
	event->result = ret;
	event->latency_ns = latency;
	inode = BPF_CORE_READ(file, f_inode);
	if (inode) {
		device = BPF_CORE_READ(inode, i_sb, s_dev);
		event->device_major = device >> KERNEL_MINOR_BITS;
		event->device_minor = device & KERNEL_MINOR_MASK;
		event->inode = BPF_CORE_READ(inode, i_ino);
		event->mode = BPF_CORE_READ(inode, i_mode);
	}
	bpf_get_current_comm(event->comm, sizeof(event->comm));
	bpf_ringbuf_submit(event, 0);
	return 0;
}

下面继续逐段解释同一个程序。

c 复制代码
// SPDX-License-Identifier: GPL-2.0
#define bpf_session_is_return bpf_session_is_return_vmlinux_snapshot
#define bpf_session_cookie bpf_session_cookie_vmlinux_snapshot
#include "vmlinux.h"
#undef bpf_session_is_return
#undef bpf_session_cookie
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "fsession_latency.h"

#define KERNEL_MINOR_BITS 20
#define KERNEL_MINOR_MASK ((1U << KERNEL_MINOR_BITS) - 1)

char LICENSE[] SEC("license") = "GPL";

开头的宏处理是一个兼容性变通。仓库的 vmlinux.h 快照是在 Linux 7.0 给 bpf_session_is_returnbpf_session_cookie 加上 ctx 参数之前生成的。宏在 include 时重命名旧声明,然后我们在下面提供正确的签名。从 7.0 以上内核重新生成的 vmlinux.h 不需要这个处理。

c 复制代码
const volatile __u64 threshold_ns;
const volatile __u32 target_tgid;

struct latency_stats stats;

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
} events SEC(".maps");

BPF 程序中的 const volatile 变量有特殊语义。它们被放在 .rodata 段,用户空间可以在打开 skeleton 之后、加载之前设置。一旦加载,验证器把它们当作编译期常量,可以做死代码消除等优化(比如当 target_tgid 为 0 时)。

stats.bss 段的全局变量,程序运行后用户空间可以直接读取。

ring buffer(events)大小是 256 KB,足够容纳数千个事件才会溢出。

c 复制代码
/*
 * The repository vmlinux.h snapshot predates the ctx argument on these
 * kfunc prototypes. Rename those stale declarations while including the
 * snapshot, then provide the Linux 7.0 signatures below.
 */
extern bool bpf_session_is_return(void *ctx) __ksym;
extern __u64 *bpf_session_cookie(void *ctx) __ksym;

这是 kfunc 声明,导出给 BPF 程序调用的内核函数。__ksym 属性告诉验证器在加载时从运行中的内核解析这些符号,而不是期望它们在 BPF 对象中定义。

c 复制代码
SEC("fsession/vfs_read")
int BPF_PROG(measure_vfs_read, struct file *file, char *buf, size_t count,
	     loff_t *pos, ssize_t ret)
{
	__u64 pid_tgid = bpf_get_current_pid_tgid();
	__u64 *started = bpf_session_cookie(ctx);
	struct latency_event *event;
	struct inode *inode;
	__u32 device;
	__u64 latency;

SEC("fsession/vfs_read") 告诉内核这是一个附加到 vfs_read 的 fsession 程序。BPF_PROG 宏展开后设置标准的追踪上下文;ctx 隐式可用,可以传给 kfunc。

函数签名列出 vfs_read 的参数,最后是返回值。进入时,ret 是未定义的;返回时,所有参数和返回值都有效。

c 复制代码
	if (!bpf_session_is_return(ctx)) {
		if (target_tgid && pid_tgid >> 32 != target_tgid) {
			*started = 0;
			return 0;
		}
		*started = bpf_ktime_get_ns();
		return 0;
	}

进入阶段 :首先检查是否应该过滤这次调用。如果设置了 target_tgid(非零)且当前进程的 TGID 不匹配,在 cookie 中写入 0 表示"跳过",然后返回。否则,把当前单调时间戳写入 cookie。

TGID 在 bpf_get_current_pid_tgid() 返回值的高 32 位;低 32 位是线程 ID(内核术语中的 PID)。

c 复制代码
	if (!*started)
		return 0;

	latency = bpf_ktime_get_ns() - *started;
	__sync_fetch_and_add(&stats.calls, 1);
	if (ret < 0)
		__sync_fetch_and_add(&stats.errors, 1);
	if (latency < threshold_ns)
		return 0;

返回阶段 :如果 cookie 是 0,说明进入阶段已经过滤了这次调用,直接返回。否则计算延迟并更新聚合计数器。__sync_fetch_and_add 提供原子更新,因为多个 CPU 可能同时执行这个程序。

如果延迟低于阈值,到此为止,调用被计数但不产生事件。这让 ring buffer 只关注慢调用。

c 复制代码
	__sync_fetch_and_add(&stats.slow, 1);
	event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
	if (!event) {
		__sync_fetch_and_add(&stats.dropped, 1);
		return 0;
	}

	__builtin_memset(event, 0, sizeof(*event));
	event->pid = (__u32)pid_tgid;
	event->tgid = pid_tgid >> 32;
	event->requested = count;
	event->result = ret;
	event->latency_ns = latency;
	inode = BPF_CORE_READ(file, f_inode);
	if (inode) {
		device = BPF_CORE_READ(inode, i_sb, s_dev);
		event->device_major = device >> KERNEL_MINOR_BITS;
		event->device_minor = device & KERNEL_MINOR_MASK;
		event->inode = BPF_CORE_READ(inode, i_ino);
		event->mode = BPF_CORE_READ(inode, i_mode);
	}
	bpf_get_current_comm(event->comm, sizeof(event->comm));
	bpf_ringbuf_submit(event, 0);
	return 0;
}

对于慢调用,递增 slow 计数器并尝试在 ring buffer 中预留空间。如果预留失败(缓冲区满),递增 dropped 让用户知道有事件丢失。成功后先清零事件,复制调用字段,再从 file->f_inode 读取对象身份并提交。

注意 countfile 都可以直接访问,不需要在进入时存储。这就是 fsession 的优势:函数参数在返回阶段仍然可用。工具故意不解析路径,因为路径可能改名或有多个别名。对于普通文件,可先用设备号确定挂载点,再按 inode 搜索,例如 find /mount -xdev -inum INODE -print

用户空间加载器

fsession_latency.c 处理命令行解析、BPF 生命周期管理和事件消费。关键部分:

通过只读数据配置

c 复制代码
skel->rodata->threshold_ns = env.threshold_us * 1000;
skel->rodata->target_tgid = env.pid;

打开 skeleton 之后、加载之前,用户空间把阈值(从微秒转换为纳秒)和目标 TGID 写入 .rodata 段。这些在 BPF 程序中成为常量。

Ring buffer 消费

c 复制代码
static int handle_event(void *context, void *data, size_t size)
{
	const struct latency_event *event = data;

	printf("EVENT comm=%-16s tgid=%u pid=%u object=%u:%u:%llu type=%s "
	       "requested=%llu result=%lld latency_us=%llu\n",
	       event->comm, event->tgid, event->pid,
	       event->device_major, event->device_minor, event->inode,
	       file_type(event->mode), event->requested, event->result,
	       event->latency_ns / 1000);
	events_printed++;
	return 0;
}

每个事件打印进程名、ID、VFS 身份和类型、请求字节数、返回值和延迟(微秒)。

干净关闭序列

c 复制代码
fsession_latency_bpf__detach(skel);

err = ring_buffer__consume(ring);
// ... 错误处理 ...

printf("SUMMARY calls=%llu slow=%llu errors=%llu dropped=%llu events=%llu\n",
       skel->bss->stats.calls, skel->bss->stats.slow,
       skel->bss->stats.errors, skel->bss->stats.dropped, events_printed);

关闭顺序对正确性很重要:

  1. 解除 BPF 程序的附加(停止产生新事件)
  2. 排空 ring buffer 中剩余的事件
  3. 读取最终的计数器值(现在稳定了,因为程序已解除附加)

这确保 events_printed 与实际通过 ring buffer 投递的事件数一致。

编译与运行

从源码构建(仓库内置 libbpf 1.7.0 和 bpftool v7.7.0):

bash 复制代码
cd src/52-fsession-latency
make clean
make -j2

追踪指定服务进程 30 秒,上报 10 ms 及以上的读操作:

bash 复制代码
SERVICE_PID=$(pgrep -n my-service)
sudo ./fsession_latency --pid "$SERVICE_PID" --threshold-us 10000 --duration 30

关于 PID 命名空间--pid 选项比较的是宿主机(初始)PID 命名空间中的 TGID。如果目标运行在有自己 PID 命名空间的容器中,你需要找到它在宿主机可见的 TGID。在容器内进程可能是 PID 1,但从宿主机看可能是 PID 12345。在宿主机上使用 pgrep 或检查 /proc/<pid>/status 中的 NSpid 行。

命令行选项

text 复制代码
Usage: ./fsession_latency [--threshold-us USEC] [--duration SEC] [--pid TGID] [--verbose]

选项:
  -t, --threshold-us USEC  慢读阈值(默认:1000 微秒)
  -d, --duration SEC       追踪时长,1-86400(默认:10 秒)
  -p, --pid TGID           追踪指定进程 ID(默认:所有进程)
  -v, --verbose            打印 libbpf 诊断信息
  -h, --help               显示帮助

输出示例

例如,一个 Python 服务等待 FIFO,而写入端在 50 ms 后响应时,会产生下面的输出:

console 复制代码
Tracing vfs_read for 1 seconds; threshold=10000 us; pid=selected
EVENT comm=python3          tgid=1245 pid=1245 object=0:16:784 type=fifo requested=1 result=1 latency_us=50246
SUMMARY calls=66 slow=1 errors=0 dropped=0 events=1

SUMMARY 行显示:

  • 观察到 66 次 vfs_read 调用
  • 1 次是慢的(达到阈值)
  • 0 次返回错误
  • 0 个事件被丢弃
  • 1 个事件被打印

环境要求

要求 详情
内核版本 Linux 7.0+(fsession 首次引入)
BTF 必须启用(CONFIG_DEBUG_INFO_BTF=y
内核配置 CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y, CONFIG_BPF_EVENTS=y, CONFIG_DEBUG_INFO_BTF=y, CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=y
BPF JIT 运行时必须启用
架构 已在 x86_64 上测试
权限 root

上游合并提交为 f17b474e36647c23801ef8fdaf2255ab66dd2973

扩展方向

这里展示的 fsession 模式适用于任何需要关联进入和返回的内核函数。一些扩展方向:

  • 其他函数 :附加到 vfs_writevfs_fsync 或其他 VFS 操作
  • 文件路径过滤 :使用 file->f_path 按特定文件或挂载点过滤
  • 堆栈追踪 :添加 bpf_get_stackid() 捕获慢调用的内核和/或用户态堆栈
  • 直方图 :用 BPF_MAP_TYPE_ARRAY 的延迟直方图替代逐事件上报
  • cgroup 过滤:添加 cgroup ID 检查实现容器感知的追踪

dropped 计数器仍然是 ring buffer 压力的信号。如果它在增长,要么增大缓冲区大小,要么提高阈值减少事件量。

总结

本教程展示了如何使用 Linux 7.0 的 fsession 机制测量内核函数延迟。相比传统的 fentry/fexit 配合哈希表的方案,主要优势:

  1. 单个程序 :一个 BPF 程序处理进入和返回,用 bpf_session_is_return() 区分阶段
  2. 内置关联:8 字节的 session cookie 替代外部哈希表在两个阶段间传递数据
  3. 无状态泄漏:内核管理 cookie 生命周期,不需要清理,不可能泄漏
  4. 返回时可访问参数:函数参数在返回阶段仍然可用,无需显式存储

在这个 vfs_read 示例中,最后一点还让返回阶段可以报告 VFS 对象的设备号、inode 和类型。应把结果理解为通用 VFS 调用延迟,并用对象身份继续定位,而不是假设每个慢事件都是磁盘问题。

模式很简单:检查 bpf_session_is_return(),用 bpf_session_cookie() 管理调用级状态,直接访问参数和返回值。这个模式适用于任何需要关联函数进入和退出的场景。

如果你想深入了解 eBPF,请查看我们的教程代码仓库 github.com/eunomia-bpf... 或访问我们的网站 eunomia.dev/tutorials/

参考资料

相关推荐
A_humble_scholar1 小时前
Linux 网络基础(三)下的 HTTP 核心详解:从请求响应到状态管
linux·网络·http
Albart5751 小时前
Docker Desktop最新版安装踩坑全记录(Windows_Mac_Linux)【2026 4.74.0 终版】
linux·windows·macos·docker·环境搭建·踩坑记录
举手2 小时前
Epoll模型
linux·c++·学习
Escalating_xu2 小时前
【Linux】基础 I/O 深度解析:FILE、文件描述符、open/read/write、重定向与缓冲区
java·linux·服务器
qetfw2 小时前
Debian 部署 Discuz! 论坛:Nginx、PHP 与 MariaDB 配置
linux·运维·nginx·debian·php·discuz
烛之武3 小时前
Linux 命令速查表
linux·运维·服务器
小马同学-3 小时前
Keepaloved+LVS(DR) +MariaDN主主
linux·运维·lvs
进击的荆棘3 小时前
Linux系统——进程概念(上)
linux·运维·进程
深念Y3 小时前
Windows → WSL2 全面迁移:工具链、Docker、OpenCode
linux·运维·windows·docker·容器·环境·wsl