在《Futex 概述》中已经介绍了 Futex 的用户态快速路径,以及发生竞争后进入内核等待、唤醒的过程。本文继续讨论一个更偏实践的问题:怎样利用 eBPF 观测 Futex,并从采集结果中识别锁竞争和调度延迟。
本文关注观测方案、指标和实验结果。libbpf 程序加载、attach、Map 读取以及 probe 没有触发等问题,单独整理在《使用 libbpf 调试 Futex eBPF 程序》中。
一、观测目标与追踪点
Futex 没有竞争时主要在用户态通过原子操作完成,只有等待或唤醒等慢路径才会进入内核。因此,仅追踪 Futex 系统调用无法得到所有加锁操作,却很适合回答以下问题:
- 哪些线程频繁进入
FUTEX_WAIT? - 一次等待从进入内核到返回经历了多长时间?
FUTEX_WAKE实际唤醒了多少线程,是否存在无效唤醒?- 哪些 Futex 地址形成热点?
- 锁竞争是否导致延迟关键任务长时间等待?
Tracepoint
优先检查系统是否提供 syscalls:sys_enter_futex 和 syscalls:sys_exit_futex。进入时保存 pid/tid、uaddr、futex_op 和时间戳;退出时找回开始时间并结合返回值计算耗时。
Tracepoint 接口相对稳定,适合先做系统调用级观测。
fentry/fexit 或 kprobe/kretprobe
如果需要区分 futex_wait、futex_wake 和 PI Futex 等内核路径,可以追踪对应内核函数。该方案能取得更贴近内核实现的参数,但函数名和签名可能随内核版本变化。
SEC("fentry/__futex_wait")
int BPF_PROG(trace_futex_wait, u32 *uaddr, unsigned int flags, u32 val)
{
/* 保存地址、线程和开始时间 */
return 0;
}
Uprobe
Uprobe 可以观察 pthread_mutex_lock() 等用户态函数,理论上能够补充用户态快速路径,但依赖具体 libc 或应用二进制。对于高频锁操作,其观测开销也更值得警惕,因此本文暂不把它作为默认方案。
当前开销记录
下面的数据来自现有实验笔记。发表前必须补充测试命令、内核版本、重复次数和统计方法,并重新验证。
| 技术 | 当前记录的单次开销 | 稳定性 | 用途 |
|---|---|---|---|
| Tracepoint | 约 130 ns | 高 | 系统调用级观测 |
| fentry/fexit | fentry 约 48 ns,fexit 约 76 ns | 取决于内核函数 | 内核函数级观测 |
| Uprobe | 尚无可靠结果 | 依赖用户态符号 | 补充用户态快速路径 |
二、从事件中得到哪些指标
等待次数与等待延迟
对每个 FUTEX_WAIT 记录进入和返回时间,统计等待次数、平均值、P50、P95、P99 和最大等待时间。相比平均值,长尾分位数更容易暴露少量但严重的锁竞争。
唤醒次数与返回值
对 FUTEX_WAKE 记录请求唤醒数量和系统调用返回值。返回值表示实际唤醒的线程数,可以识别无效唤醒、惊群风险以及 wait/wake 数量长期不匹配的场景。
热点 Futex
将进程与 uaddr 组合成 key,按地址累计等待次数、总延迟和直方图。直接记录地址时要考虑地址复用和信息暴露;跨进程共享 Futex 也不能仅凭虚拟地址判断是否对应同一个内核 Futex key。
struct futex_key {
__u32 tgid;
__u64 uaddr;
};
struct hist {
char comm[16];
__u64 slots[MAX_SLOTS];
__u64 count;
__u64 total_ns;
};
PI Futex 与优先级反转
对 FUTEX_LOCK_PI、FUTEX_UNLOCK_PI 单独分类,观察高优先级线程等待低优先级持锁线程的时间。仅凭 wait/wake 事件无法准确恢复全部 owner 关系,还需要结合调度事件或 PI Futex 内核状态验证。
三、Map、数据处理与观测开销
一类 Map 保存线程进入 Futex 时的时间,另一类 Map 保存按锁地址聚合的统计结果。
start = bpf_ktime_get_ns();
bpf_map_update_elem(&starts, &tid, &start, BPF_ANY);
startp = bpf_map_lookup_elem(&starts, &tid);
if (startp) {
delta = bpf_ktime_get_ns() - *startp;
/* 更新 count、total_ns 和 log2 histogram */
bpf_map_delete_elem(&starts, &tid);
}
bpf_map_update_elem() 的标志含义如下:
| 标志 | 行为 |
|---|---|
BPF_ANY |
key 存在时覆盖,不存在时插入 |
BPF_NOEXIST |
仅在 key 不存在时插入 |
BPF_EXIST |
仅在 key 已存在时更新 |
开始时间通常允许覆盖,使用 BPF_ANY 更符合“保存最近一次进入时间”的语义;如果业务要求发现重复进入,可使用 BPF_NOEXIST 并单独统计冲突。
控制观测开销
Futex 是高频事件,观测工具本身可能改变系统行为:
- 尽早按
tgid、cgroup 或操作类型过滤。 - 在 BPF Map 中聚合直方图,避免逐条发送事件。
- 只在定位问题时采集调用栈。
- 对极高频地址进行抽样。
- 记录丢失事件,避免把不完整数据当成真实结果。
参考实现可以查看 BCC/libbpf-tools 中的 futexctn。
四、实验设计、结果与调度联系
发表前需要补齐 CPU 型号、SMT 状态、内核配置、libbpf/clang/bpftool 版本、测试负载、采样时长、绑核方式和重复次数。
建议至少比较:不加载工具的基线、只加载不输出、Map 聚合、Ring Buffer 逐条输出,以及开启/关闭 PID 或 cgroup 过滤。
| 场景 | Futex 调用次数 | 平均等待 | P99 等待 | 工具 CPU 开销 | 丢失事件 |
|---|---|---|---|---|---|
| 基线 | 待补 | 待补 | 待补 | 待补 | - |
| Map 聚合 | 待补 | 待补 | 待补 | 待补 | 待补 |
| Ring Buffer 输出 | 待补 | 待补 | 待补 | 待补 | 待补 |
与 LAVD 的联系
如果能可靠识别锁持有者,可以尝试让 LAVD 优先调度阻塞延迟关键任务的持锁线程。LAVD 的用户态控制流程可以参考《sched_ext 与 scx_lavd 用户态前端解析》。不过,普通 Futex 很难只依靠 wait/wake 恢复 owner,仍需结合用户态锁语义、调度事件和历史获取模式。
重点评估:Futex holder boost 是否降低关键任务 P99/P999 等待时间,以及它是否导致普通任务吞吐下降或产生新的优先级反转。
五、小结
Futex 性能观测的重点不是简单统计系统调用次数,而是建立等待线程、Futex 地址、唤醒结果和调度延迟之间的关系。下一步需要补齐可复现实验数据,再判断 Futex holder boost 对 LAVD 是否确实有效。
参考资料
- Brendan Gregg,《Systems Performance》
- Brendan Gregg,《BPF Performance Tools》及中文版《BPF 之巅》
- David Calavera、Lorenzo Fontana,《Linux Observability with BPF》及中文版《Linux 内核观测技术 BPF》
- Brendan Gregg 个人网站
- eBPF 官方网站
- IO Visor 项目
- awesome-ebpf 资料索引
- The BSD Packet Filter: A New Architecture for User-level Packet Capture