eBPF for Scheduler Visibility: Production Techniques for Diagnosing CPU Contention and Runqueue Latency

eBPF for Scheduler Visibility: Production Techniques for Diagnosing CPU Contention and Runqueue Latency

在现代数据中心中,CPU 调度器延迟是影响服务尾延迟(tail latency)的关键因素之一。传统的性能分析工具如 perf 和 ftrace 虽然强大,但在生产环境中往往受限于采样频率和性能开销。eBPF(Extended Berkeley Packet Filter)的出现彻底改变了这一局面,让我们能够以极低开销在运行时动态注入观测逻辑,实时追踪内核调度器的每一个关键事件。本文将深入探讨如何利用 eBPF 技术诊断 CPU 争用和运行队列延迟问题,涵盖从工具链选择到自定义探针部署的完整实战流程。

一、Linux CFS 调度器与延迟根源

Linux 的完全公平调度器(CFS)通过虚拟运行时间(vruntime)来决定线程调度顺序。当系统负载较高时,线程在运行队列中的等待时间(runqueue latency)会显著增加,这就是所谓的调度延迟。在容器化和微服务架构中,多个服务共享同一组 CPU 核心,NUMA 拓扑不合理的任务分配、CPU throttling、以及 noisy neighbor 效应都会导致不可预测的延迟尖刺。

要诊断这些问题,我们需要回答几个核心问题:

  1. 线程在就绪队列中平均等待了多久?
  2. 有多少线程在争用同一颗 CPU 核心?
  3. 上下文切换的频率和原因是什么?
  4. 哪些进程被频繁抢占,违反了延迟目标?

传统的 perf sched 工具虽然可以记录调度事件,但其基于 tracepoint 的机制会产生海量数据,对高吞吐系统而言开销不可忽视。eBPF 提供了一种更优雅的解决方案。

二、BCC 工具链中的调度器观测工具

BCC(BPF Compiler Collection)提供了一系列开箱即用的调度器观测工具,这些工具基于 eBPF 实现,默认开销极低。

2.1 runqlat:运行队列延迟直方图

runqlat 是最常用的调度延迟观测工具,它通过在 sched_wakeup 和 sched_switch 两个 tracepoint 上挂载探针,计算线程从被唤醒到实际获得 CPU 执行的等待时间。

# 运行 10 秒,以毫秒为单位输出延迟直方图
$ sudo runqlat 10 1

Tracing run queue latency... Hit Ctrl-C to end.

     usecs               : count     distribution
         0 -> 1          : 5347     |****************************************|
         2 -> 3          : 892      |******                                  |
         4 -> 7          : 451      |***                                     |
        8 -> 15          : 213      |*                                       |
       16 -> 31          : 187      |*                                       |
       32 -> 63         : 156      |*                                       |
       64 -> 127        : 89       |                                         |
      128 -> 255        : 34       |                                         |
      256 -> 511        : 12       |                                         |
      512 -> 1023       : 3        |                                         |

上述输出中可以看到,绝大多数(5347 次)的调度延迟在 1 微秒以内,但仍有 12 次延迟超过了 256 微秒。这种长尾分布正是 P99 延迟恶化的根源。

2.2 runqlen:运行队列长度监控

runqlen 展示的是每个 CPU 核心上就绪队列的实时长度。当队列长度持续大于 CPU 核心数时,说明系统处于过载状态。

# 每 1 秒采样一次,输出平均队列长度
$ sudo runqlen 5 1

Sampling run queue... Hit Ctrl-C to end.

     avg_pid            : count     distribution
         0              : 48       |****************************************|
         1              : 12       |**********                              |
         2              : 3        |***                                     |
         3              : 1        |*                                       |

CPU 001: 8 sampled, average = 4.25
CPU 002: 8 sampled, average = 3.87
CPU 003: 8 sampled, average = 5.12    <-- 过载核心
CPU 004: 8 sampled, average = 2.50

2.3 offcputime:Off-CPU 时间分析

offcptoe 用于追踪线程不在 CPU 上执行的时间,配合调用栈信息可以精确定位线程阻塞的原因(如等待锁、I/O 或睡眠)。

# 追踪进程号为 185 的线程,捕获大于 1ms 的 off-CPU 事件
$ sudo offcptime -p 185 1

    finish_task_switch
    schedule
    futex_wait_queue_me
    futex_wait
    do_futex
    SyS_futex
    entry_SYSCALL_64_fastpath
    -                mysqld (185)
        1234567 us

    finish_task_switch
    schedule
    io_schedule
    blk_mq_get_tag
    __blk_mq_alloc_request
    blk_mq_alloc_request
    -                mysqld (185)
        892345 us

通过火焰图(Flame Graph)可视化这些调用栈,可以快速定位延迟瓶颈是 CPU 调度、互斥锁、还是块设备 I/O。

三、自定义 eBPF 探针深入调度器

BCC 提供的现成工具虽然方便,但在生产环境中往往需要更精细的控制。下面展示如何从头编写自定义 eBPF 探针,捕获调度延迟的详细指标。

3.1 使用 bpftrace 进行快速原型

bpftrace 是基于 eBPF 的高级追踪语言,非常适合快速验证观测思路:

// 追踪所有进程的调度延迟,按进程名聚合
kprobe:sched_wakeup {
    @start[tid] = nsecs;
}

kprobe:sched_wakeup_new {
    @start[tid] = nsecs;
}

kretprobe:sched_wakeup /@start[tid]/ {
    $latency = (nsecs - @start[tid]) / 1000;
    @usecs = hist($latency);
    @avg_usecs = avg($latency);
    delete(@start[tid]);
}

// 每 5 秒输出一次统计
interval:s:5 {
    print(@usecs);
    print(@avg_usecs);
    clear(@usecs);
    clear(@avg_usecs);
}

3.2 完整的 C + libbpf 探针实现

对于生产环境部署,推荐使用 libbpf 编写的 CO-RE(Compile Once, Run Everywhere)程序,无需在目标机器上编译即可运行。

// sched_latency.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

#define MAX_CPUS 128
#define MAX_PIDS 65536

struct event {
    u32 pid;
    u32 tgid;
    u64 wait_us;
    u64 delta_vruntime;
    u32 cpu;
    char comm[16];
};

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, MAX_PIDS);
    __type(key, u32);
    __type(value, u64);
} start SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");

// 就绪队列最大长度统计
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, MAX_CPUS);
    __type(key, u32);
    __type(value, u64);
} rq_length SEC(".maps");

SEC("tp_btf/sched_wakeup")
int BPF_PROG(trace_sched_wakeup, struct task_struct *p) {
    u32 pid = BPF_CORE_READ(p, pid);
    u64 ts = bpf_ktime_get_ns();

    if (pid == 0)
        return 0;

    bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);
    return 0;
}

SEC("tp_btf/sched_switch")
int BPF_PROG(trace_sched_switch, bool preempt, 
             struct task_struct *prev, struct task_struct *next) {
    u32 pid = BPF_CORE_READ(prev, pid);
    u64 *tsp, delta_us;
    u64 now = bpf_ktime_get_ns();

    tsp = bpf_map_lookup_elem(&start, &pid);
    if (!tsp)
        return 0;

    delta_us = (now - *tsp) / 1000;

    // 只记录超过阈值的延迟事件
    if (delta_us > 100) {
        struct event e = {};
        e.pid = pid;
        e.tgid = BPF_CORE_READ(prev, tgid);
        e.wait_us = delta_us;
        e.cpu = bpf_get_smp_processor_id();
        bpf_get_current_comm(&e.comm, sizeof(e.comm));
        bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e));
    }

    bpf_map_delete_elem(&start, &pid);

    // 更新新唤醒进程的时间戳
    u32 next_pid = BPF_CORE_READ(next, pid);
    if (next_pid) {
        bpf_map_update_elem(&start, &next_pid, &now, BPF_ANY);
    }

    return 0;
}

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

3.3 用户空间程序

// sched_latency.c (用户空间)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "sched_latency.skel.h"

static volatile bool running = true;

static void sig_handler(int sig) {
    running = false;
}

static int handle_event(void *ctx, void *data, size_t sz) {
    struct event *e = data;
    printf("PID=%-8d TGID=%-8d COMM=%-16s CPU=%-3d WAIT=%llu us\n",
           e->pid, e->tgid, e->comm, e->cpu, e->wait_us);
    return 0;
}

int main(int argc, char **argv) {
    struct sched_latency_bskel *skel;
    struct ring_buffer *rb;
    int err;

    signal(SIGINT, sig_handler);
    signal(SIGTERM, sig_handler);

    skel = sched_latency_bskel__open_and_load();
    if (!skel) {
        fprintf(stderr, "Failed to load BPF skeleton\n");
        return 1;
    }

    err = sched_latency_bskel__attach(skel);
    if (err) {
        fprintf(stderr, "Failed to attach BPF programs\n");
        goto cleanup;
    }

    rb = ring_buffer__new(bpf_map__fd(skel->maps.events), handle_event, NULL, NULL);
    printf("Tracing scheduling latency... Press Ctrl-C to stop.\n");

    while (running) {
        err = ring_buffer__poll(rb, 100);
        if (err == -EINTR) break;
        if (err < 0) {
            fprintf(stderr, "Polling error: %d\n", err);
            break;
        }
    }

cleanup:
    sched_latency_bskel__destroy(skel);
    return err != 0;
}

编译并运行:

$ clang -O2 -g -target bpf -c sched_latency.bpf.c -o sched_latency.bpf.o
$ clang -O2 -g sched_latency.c -lbpf -lelf -lz -o sched_latency
$ sudo ./sched_latency
PID=3425    TGID=3012    COMM=nginx         CPU=3   WAIT=1256 us
PID=3426    TGID=3012    COMM=nginx         CPU=7   WAIT=892 us
PID=1052    TGID=1052    COMM=redis-server  CPU=12  WAIT=2103 us

四、高级技巧:NUMA 感知调度分析

在 NUMA 架构中,进程被调度到非本地 NUMA 节点的核心上会导致远端内存访问,使内存延迟增加 2-3 倍。下面展示如何通过 eBPF 追踪 NUMA 相关的调度事件。

4.1 追踪 NUMA 页面迁移与任务迁移

#!/usr/bin/env python3
# numa_sched_trace.py - 使用 BCC 追踪 NUMA 任务迁移

from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

struct numa_event {
    u32 pid;
    u32 src_cpu;
    u32 dst_cpu;
    u64 timestamp;
    char comm[16];
};

BPF_PERF_OUTPUT(numa_events);

// 追踪迁移请求设置
TRACEPOINT_PROBE(kernel, migrate_task_to) {
    struct numa_event e = {};
    e.pid = bpf_get_current_pid_tgid() >> 32;
    e.dst_cpu = args->dest_cpu;
    e.src_cpu = bpf_get_smp_processor_id();
    e.timestamp = bpf_ktime_get_ns();
    bpf_get_current_comm(&e.comm, sizeof(e.comm));
    numa_events.perf_submit(args, &e, sizeof(e));
    return 0;
}
"""

b = BPF(text=prog)
print("Tracing NUMA task migration events...")

def print_event(cpu, data, size):
    event = b["numa_events"].event(data)
    ts = event.timestamp / 1e9
    print(f"[{ts:.6f}] PID={event.pid} COMM={event.comm.decode()} "
          f"MIGRATE: CPU {event.src_cpu} -> {event.dst_cpu}")

b["numa_events"].open_perf_buffer(print_event)
while True:
    b.perf_buffer_poll()

4.2 使用 runqlat 的 NUMA 感知模式

可以通过结合 NUMA 拓扑信息和 runqlat 输出来定位延迟核心:

$ lscpu | grep NUMA
NUMA node0 CPU(s):   0-31
NUMA node1 CPU(s):   32-63

# 针对 NUMA 节点 0 的 CPU 核心单独采样
$ sudo runqlat -C 0-31 5

usecs               : count     distribution
    0 -> 1          : 1247     |****************************************|
    2 -> 3          : 156      |*****                                   |
    ...

五、生产环境实战:定位 P99 延迟尖刺

某金融交易系统的 P99 延迟从 200us 飙升至 15ms,持续约 50ms 后恢复正常。使用 eBPF 逐步排查的完整过程如下:

5.1 第一步:全局延迟概览

$ sudo runqlat -m 30 3

通过按核心分组输出,发现 CPU 12 和 CPU 14 存在显著延迟异常。

5.2 第二步:锁定问题核心上的进程

$ sudo runqlat -p $(pgrep -d, trading-engine) 10 1

确认 trading-engine 进程的平均调度延迟为 45us,但 P99 达到 2.3ms。

5.3 第三步:追踪上下文切换链路

使用自定义 BCC 脚本捕获完整调度链:

# sched_chain.py - 追踪长延迟调度的完整调用链
from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/sched/wait.h>

struct wakeup_event {
    u64 ts;
    u32 pid;
};

BPF_HASH(wakeup_times, u32, struct wakeup_event);
BPF_HISTOGRAM(latency_hist, u64);
BPF_STACK_TRACE(stacks, 1024);

TRACEPOINT_PROBE(sched, sched_wakeup) {
    struct wakeup_event we = {};
    we.ts = bpf_ktime_get_ns();
    we.pid = args->pid;
    wakeup_times.update(&args->pid, &we);
    return 0;
}

TRACEPOINT_PROBE(sched, sched_switch) {
    u64 latency, key = 0;
    struct wakeup_event *we;

    if (args->prev_state == TASK_RUNNING)
        return 0;

    we = wakeup_times.lookup(&args->prev_pid);
    if (!we)
        return 0;

    latency = (bpf_ktime_get_ns() - we->ts) / 1000;
    latency_hist.increment(bpf_log2l(latency));

    if (latency > 5000) {  // > 5ms
        struct chain键 = {
            .pid = args->prev_pid,
            .latency = latency,
        };
        stack_traces.submit(args, stacks.get_stackid(args, 0));
    }

    wakeup_times.delete(&args->prev_pid);
    return 0;
}
"""

5.4 第四步:火焰图可视化

# 将捕获的调用栈转换为火焰图
$ sudo profile -F 99 -f 30 > out.stacks
$ ./FlameGraph/flamegraph.pl --color=java out.stacks > sched_latency.svg

火焰图显示 hugetlb_fault 函数占主导,定位到大页分配导致的锁竞争问题。

六、与 Prometheus + Grafana 集成

将 eBPF 采集到的调度延迟指标导出为 Prometheus 格式,实现持续监控:

package main

import (
    "log"
    "net/http"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    schedLatency = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "sched_runqueue_latency_seconds",
            Help:    "Scheduling runqueue latency distribution",
            Buckets: prometheus.ExponentialBuckets(0.00001, 2, 20),
        },
        []string{"cpu", "comm"},
    )

    contextSwitchTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "sched_context_switches_total",
            Help: "Total context switches",
        },
        []string{"cpu"},
    )
)

func init() {
    prometheus.MustRegister(schedLatency)
    prometheus.MustRegister(contextSwitchTotal)
}

func main() {
    http.Handle("/metrics", promhttp.Handler())
    log.Fatal(http.ListenAndServe(":9400", nil))
}

配合 Grafana 面板可以实时展示调度器延迟的 P50/P95/P99 分位数,并在异常时触发告警。

七、性能开销与优化

在生产环境部署 eBPF 探针时,必须关注以下几点:

  1. Tracepoint vs Kprobe:优先使用 Tracepoint(如 tp_btf/sched_wakeup),其 ABI 稳定且性能开销最小。
  2. 采样频率:对高频事件使用采样(每 N 次事件记录 1 次),而非全量采集。
  3. 内核缓冲区调优:合理设置 perf ring buffer 的大小,避免事件丢失的同时控制内存占用。
  4. 过滤条件:尽早过滤无关事件(如 PID 过滤),减少不必要的计算和缓冲区写入。
  5. CO-RE 编译:使用 libbpf CO-RE 技术确保探针跨内核版本兼容。

在一个 64 核、每秒数百万次上下文切换的高吞吐系统中,经过优化的 eBPF 调度探针开销可以控制在 1-2% 以内,远低于传统工具 5-10% 的开销。

八、总结与展望

eBPF 为 Linux 调度器观测提供了前所未有的能力。从 BCC 工具链的快速诊断,到 libbpf 生产级探针的持续监控,再到与 Prometheus/Grafana 集成的全链路观测,eBPF 正在重新定义系统性能分析的方法论。

随着 Linux 内核持续引入新的调度特性(如 sched_ext BPF 调度器、EEVDF 调度类),eBPF 将成为理解和优化调度器行为的必备工具。未来的调度器本身也将越来越多地暴露 eBPF 钩子,使得"用 eBPF 观测 eBPF 调度器"成为可能。

对于 SRE 和性能工程师而言,掌握 eBPF 调度器观测技术,意味着能够在不中断业务的前提下,对生产系统的延迟问题进行精确诊断——这在微服务和高并发场景下尤为关键。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部