当一个文件服务出现读延迟尖峰时,应用层计时只能告诉你请求变慢了,却无法区分是内核在 I/O 上阻塞还是用户态逻辑耗时。真正有用的问题是:哪个线程发起了读操作、请求了多少字节、调用返回了什么、这一次 vfs_read 花了多长时间?
本教程展示如何使用 Linux 7.0 引入的 fsession 机制测量 vfs_read 调用延迟。fsession 是一种新的 eBPF 程序类型,它在函数进入时执行一次、返回时再执行一次,并提供内置的调用级存储来关联这两个阶段。我们构建的工具会在函数进入时记录时间戳、返回时计算延迟,按进程和阈值过滤后,通过 ring buffer 上报慢读事件。
问题:如何关联函数的进入和返回
要测量一个内核函数的执行时间,需要在它开始时记录时间戳,在它返回时计算差值。这听起来很简单,但 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 程序时,内核会:
- 在
vfs_read进入时调用你的程序 - 分配一个 8 字节的暂存区("session cookie"),绑定到这次特定的调用
- 在
vfs_read返回时再次调用你的程序 - 释放 session cookie
关键在于 session cookie 是自动管理的,作用域精确到一次函数调用。程序用 bpf_session_is_return(ctx) 区分进入和返回,用 bpf_session_cookie(ctx) 读写 cookie。
Session 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:达到或超过延迟阈值的调用数errors:vfs_read返回负数错误码的调用数dropped:因 ring buffer 满而无法提交的事件数
FSESSION_COMM_LEN 设为 16,与内核的 TASK_COMM_LEN 一致。
每个事件还携带 device_major、device_minor、inode 和 mode。BPF 程序把内核原始 s_dev 编码拆成 12 位主设备号和 20 位次设备号,再结合 i_ino 得到稳定的 VFS 对象身份;用户态根据 i_mode 输出 regular、fifo、character 等对象类型。
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_return 和 bpf_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 读取对象身份并提交。
注意 count 和 file 都可以直接访问,不需要在进入时存储。这就是 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);
关闭顺序对正确性很重要:
- 解除 BPF 程序的附加(停止产生新事件)
- 排空 ring buffer 中剩余的事件
- 读取最终的计数器值(现在稳定了,因为程序已解除附加)
这确保 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_write、vfs_fsync或其他 VFS 操作 - 文件路径过滤 :使用
file->f_path按特定文件或挂载点过滤 - 堆栈追踪 :添加
bpf_get_stackid()捕获慢调用的内核和/或用户态堆栈 - 直方图 :用
BPF_MAP_TYPE_ARRAY的延迟直方图替代逐事件上报 - cgroup 过滤:添加 cgroup ID 检查实现容器感知的追踪
dropped 计数器仍然是 ring buffer 压力的信号。如果它在增长,要么增大缓冲区大小,要么提高阈值减少事件量。
总结
本教程展示了如何使用 Linux 7.0 的 fsession 机制测量内核函数延迟。相比传统的 fentry/fexit 配合哈希表的方案,主要优势:
- 单个程序 :一个 BPF 程序处理进入和返回,用
bpf_session_is_return()区分阶段 - 内置关联:8 字节的 session cookie 替代外部哈希表在两个阶段间传递数据
- 无状态泄漏:内核管理 cookie 生命周期,不需要清理,不可能泄漏
- 返回时可访问参数:函数参数在返回阶段仍然可用,无需显式存储
在这个 vfs_read 示例中,最后一点还让返回阶段可以报告 VFS 对象的设备号、inode 和类型。应把结果理解为通用 VFS 调用延迟,并用对象身份继续定位,而不是假设每个慢事件都是磁盘问题。
模式很简单:检查 bpf_session_is_return(),用 bpf_session_cookie() 管理调用级状态,直接访问参数和返回值。这个模式适用于任何需要关联函数进入和退出的场景。
如果你想深入了解 eBPF,请查看我们的教程代码仓库 github.com/eunomia-bpf... 或访问我们的网站 eunomia.dev/tutorials/。