土法炼钢兴趣小组的算法知识备份

【Linux 网络子系统深度拆解】net_device 与网卡驱动模型:从硬件到内核的接口契约

文章导航

分类入口
linuxnetworking
标签入口
#net_device#linux-kernel#napi#nic-driver#ring-buffer#dma#multi-queue#network-stack

目录

上一篇我们拆解了 sk_buff——内核网络栈的通用包容器。但 sk_buff 不会凭空出现。在收包路径中,是网卡驱动负责从硬件拿到原始数据、分配 sk_buff、填充字段,然后交给协议栈。在发包路径中,是网卡驱动负责把协议栈构造好的 sk_buff 映射到 DMA 地址、写入硬件描述符、触发网卡发送。

驱动和内核网络栈之间的接口契约就是 struct net_devicestruct net_device_ops。Linux 内核中每一个网络设备——不管是物理的 Intel X710、虚拟的 veth pair、还是隧道的 VXLAN——都是一个 net_device 实例。

本文拆解三个核心问题:

  1. net_device 有哪些关键字段,内核网络栈怎么通过它找到驱动的操作函数?
  2. 网卡驱动的收包模型(NAPI)是怎么在中断和轮询之间切换的?
  3. 多队列网卡的 TX/RX 队列在内核中怎么组织?

本文基于 Linux 6.6 LTS 内核源码(include/linux/netdevice.h)。6.8 的差异会标注。


一、net_device 全景:一个超大结构体

struct net_device 定义在 include/linux/netdevice.h,是内核中最大的结构体之一。在 6.8 内核中定义起始于约第 2103 行,字段数量超过 100 个。

这不是设计失误,而是历史积累——网络设备需要承载的信息确实很多:设备名、MAC 地址、MTU、硬件特性、统计计数器、TX/RX 队列、NAPI 列表、XDP 程序、Traffic Control 钩子……所有这些都挂在一个 net_device 上。

为了在高速路径上减少 cache miss,6.x 内核把 net_device 的字段分成了几个 cacheline 对齐的组:

// include/linux/netdevice.h(简化,展示结构分组)
struct net_device {
    // --- TX 热路径字段 ---
    struct_group(net_device_read_tx,
        const struct net_device_ops *netdev_ops;    // 驱动操作表
        struct netdev_queue     *_tx;               // TX 队列数组
        unsigned int            num_tx_queues;      // TX 队列总数
        unsigned int            real_num_tx_queues; // 活跃 TX 队列数
        unsigned int            tx_queue_len;       // 默认 TX 队列长度
        // ...
    );

    // --- TX/RX 共享热路径字段 ---
    struct_group(net_device_read_txrx,
        unsigned long           state;              // 设备状态标志
        unsigned int            flags;              // IFF_UP, IFF_PROMISC 等
        unsigned short          hard_header_len;    // L2 头长度
        netdev_features_t       features;           // 硬件特性掩码
        // ...
    );

    // --- RX 热路径字段 ---
    struct_group(net_device_read_rx,
        struct bpf_prog __rcu   *xdp_prog;          // XDP 程序
        struct netdev_rx_queue  *_rx;                // RX 队列数组
        unsigned int            real_num_rx_queues;  // 活跃 RX 队列数
        unsigned int            gro_max_size;        // GRO 最大聚合大小
        // ...
    );

    // --- 非热路径字段 ---
    char                    name[IFNAMSIZ];         // 设备名(如 "eth0")
    unsigned int            mtu;                    // 最大传输单元
    unsigned char           addr_len;               // MAC 地址长度
    unsigned char           dev_addr[MAX_ADDR_LEN]; // MAC 地址
    struct list_head        napi_list;              // 所有 NAPI 实例
    struct net_device_stats stats;                  // 传统统计(已弃用)
    struct net_device_core_stats __percpu *core_stats; // per-CPU 统计
    // ... 还有几十个字段
};

这个 cacheline 分组设计意味着:在发包路径中,CPU 只需要加载 net_device_read_tx 这个 cacheline 组就能找到 netdev_ops 和 TX 队列;在收包路径中,只需要加载 net_device_read_rx 就能找到 RX 队列和 XDP 程序。减少不必要的 cache 污染,在高 PPS 场景下影响显著。


二、net_device_ops:驱动操作表

net_device_ops 是网卡驱动向内核注册的”方法表”。内核网络栈通过这张表调用驱动的具体实现,不需要知道底层硬件细节。

// include/linux/netdevice.h, 约第 1437 行
struct net_device_ops {
    int     (*ndo_open)(struct net_device *dev);
    int     (*ndo_stop)(struct net_device *dev);
    netdev_tx_t (*ndo_start_xmit)(struct sk_buff *skb,
                                   struct net_device *dev);
    u16     (*ndo_select_queue)(struct net_device *dev,
                                struct sk_buff *skb,
                                struct net_device *sb_dev);
    void    (*ndo_set_rx_mode)(struct net_device *dev);
    int     (*ndo_set_mac_address)(struct net_device *dev, void *addr);
    int     (*ndo_change_mtu)(struct net_device *dev, int new_mtu);
    void    (*ndo_tx_timeout)(struct net_device *dev,
                              unsigned int txqueue);
    void    (*ndo_get_stats64)(struct net_device *dev,
                               struct rtnl_link_stats64 *storage);
    int     (*ndo_setup_tc)(struct net_device *dev,
                            enum tc_setup_type type, void *type_data);
    // ... 还有几十个可选操作
};

核心操作

操作 调用时机 含义
ndo_open ip link set dev eth0 up 初始化硬件、分配 ring buffer、注册 NAPI、使能中断
ndo_stop ip link set dev eth0 down 停止硬件、释放资源、注销 NAPI
ndo_start_xmit 每个发送包 发包的核心入口——把 sk_buff 交给硬件
ndo_select_queue 发包时选队列 决定这个包走哪个 TX 队列
ndo_get_stats64 读取统计信息 ip -s link show 等命令读取时调用
ndo_change_mtu 修改 MTU ip link set dev eth0 mtu 9000
ndo_set_rx_mode 配置接收过滤 混杂模式、组播过滤等
ndo_tx_timeout TX 超时 watchdog 发现某个 TX 队列卡死时调用

ndo_start_xmit:发包的关键路径

ndo_start_xmit 是内核网络栈和网卡驱动之间最关键的接口。每个要发送的 sk_buff 最终都会到达这个函数。驱动在这里做的事情:

  1. sk_buff 的数据地址映射到 DMA 地址(dma_map_single / dma_map_page
  2. 填写硬件 TX 描述符(descriptor),包含 DMA 地址、长度、offload 标志
  3. 更新 TX ring buffer 的 producer 指针
  4. 通知网卡有新的描述符可以发送(写 doorbell 寄存器)
// 典型的 ndo_start_xmit 骨架(简化)
static netdev_tx_t mynic_start_xmit(struct sk_buff *skb,
                                     struct net_device *dev)
{
    struct mynic_tx_ring *ring = &priv->tx_ring[skb_get_queue_mapping(skb)];

    // 检查是否有空闲描述符
    if (mynic_tx_avail(ring) < skb_shinfo(skb)->nr_frags + 1) {
        netif_stop_queue(dev);
        return NETDEV_TX_BUSY;
    }

    // DMA 映射
    dma_addr = dma_map_single(dev->dev.parent, skb->data,
                              skb_headlen(skb), DMA_TO_DEVICE);

    // 填写 TX 描述符
    desc = &ring->desc[ring->next_to_use];
    desc->addr = dma_addr;
    desc->len = skb_headlen(skb);
    desc->cmd = TX_CMD_EOP | TX_CMD_RS;

    // 处理 fragment
    for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
        // 每个 fragment 也要 DMA 映射 + 填描述符
    }

    // 更新 producer 指针,通知硬件
    ring->next_to_use = next;
    writel(ring->next_to_use, ring->tail_reg);

    return NETDEV_TX_OK;
}

返回值 NETDEV_TX_OK 表示驱动已经接管了这个 sk_buffNETDEV_TX_BUSY 表示队列已满,内核会暂停这个队列的发送。


三、NAPI:中断与轮询的动态切换

NAPI 收包模型:中断到轮询到协议栈

为什么需要 NAPI

在千兆网卡时代,每收到一个包都触发一次硬件中断还勉强能接受。到了万兆网卡、每秒数百万包的场景,纯中断模型会导致 interrupt storm(中断风暴)——CPU 把绝大部分时间花在中断处理上,反而没时间处理包数据。

NAPI(New API)的核心思想是:低负载时用中断驱动(低延迟),高负载时切换到轮询模式(高吞吐)。

低负载:
  包到达 → 硬件中断 → 内核处理包 → 等待下一个中断

高负载:
  包到达 → 硬件中断 → 关闭中断 → 轮询处理一批包 → 还有包?继续轮询
                                                    → 没包了?重新开中断

napi_struct

每个 NAPI 实例用 struct napi_struct 表示:

// include/linux/netdevice.h, 约第 352 行
struct napi_struct {
    struct list_head    poll_list;       // 挂在 per-CPU 轮询列表上
    unsigned long       state;           // 状态标志
    int                 weight;          // 每次轮询最多处理的包数
    int                 (*poll)(struct napi_struct *, int);  // 轮询回调

    struct net_device   *dev;            // 所属设备
    struct gro_list     gro_hash[GRO_HASH_BUCKETS]; // GRO 聚合桶
    struct sk_buff      *skb;            // 待处理的 skb
    struct list_head    rx_list;         // 待交付的 GRO_NORMAL 包列表
    int                 rx_count;        // rx_list 长度

    unsigned int        napi_id;         // 唯一 ID(busy polling 用)
    struct hrtimer      timer;           // 高精度定时器
    struct task_struct  *thread;         // 线程化 NAPI 的内核线程
    struct list_head    dev_list;        // 设备的 NAPI 列表
    int                 irq;             // 关联的中断号
};

weight 是每次轮询的”预算”——NAPI 回调最多处理 weight 个包就必须让出 CPU。默认值通常是 64。如果一次轮询处理了 64 个包还有剩余,NAPI 会被重新调度;如果处理不到 64 个,说明包已经处理完了,NAPI 重新开启中断。

NAPI 的生命周期

驱动在 ndo_open 时注册 NAPI 实例:

// 驱动初始化(ndo_open 中)
netif_napi_add(dev, &priv->napi, mynic_poll);
napi_enable(&priv->napi);

收包中断发生时,驱动调用 napi_schedule 把 NAPI 实例挂到 per-CPU 的轮询列表上:

// 中断处理函数(硬中断上下文)
static irqreturn_t mynic_interrupt(int irq, void *data)
{
    struct mynic_priv *priv = data;

    // 关闭这个队列的中断
    mynic_disable_irq(priv);

    // 调度 NAPI 轮询
    napi_schedule(&priv->napi);

    return IRQ_HANDLED;
}

napi_schedule() 会触发 NET_RX_SOFTIRQ 软中断。在软中断处理函数 net_rx_action() 中,内核遍历 per-CPU 的 NAPI 列表,调用每个 NAPI 实例的 poll 回调:

// 驱动的 NAPI poll 回调(简化)
static int mynic_poll(struct napi_struct *napi, int budget)
{
    int work_done = 0;

    while (work_done < budget) {
        // 从 RX ring buffer 取一个描述符
        desc = &ring->desc[ring->next_to_clean];
        if (!desc->done)
            break;  // 没有更多完成的包了

        // 分配 sk_buff,把数据从 DMA buffer 拷贝或映射过来
        skb = mynic_build_skb(ring, desc);

        // 交给 NAPI GRO 处理
        napi_gro_receive(napi, skb);

        work_done++;
        ring->next_to_clean = next;
    }

    if (work_done < budget) {
        // 处理完了,退出轮询,重新开启中断
        napi_complete_done(napi, work_done);
        mynic_enable_irq(priv);
    }

    return work_done;
}

关键逻辑:

NAPI 状态标志

enum {
    NAPI_STATE_SCHED,       // 已被调度,等待轮询
    NAPI_STATE_MISSED,      // 在完成过程中有新包到达,需要重新调度
    NAPI_STATE_DISABLE,     // 正在被禁用
    NAPI_STATE_THREADED,    // 在专用内核线程中运行
    NAPI_STATE_IN_BUSY_POLL,// sk_busy_loop() 正在占用
};

NAPI_STATE_MISSED 值得注意:在 napi_complete_done() 执行过程中,如果有新包到达触发了中断,驱动调用 napi_schedule() 时发现 NAPI 还在 complete 过程中,就会设置 MISSED 标志。napi_complete_done() 检测到 MISSED 后会重新调度自己,避免包延迟。

线程化 NAPI

传统 NAPI 在 softirq 上下文中运行,受 netdev_budget(默认 300)和 time_squeeze 限制。6.x 内核支持线程化 NAPI(NAPI_STATE_THREADED),把轮询放到专用内核线程中,可以被 cgroup 调度、设置 CPU 亲和性、用 nice 值控制优先级:

# 开启线程化 NAPI
echo 1 > /sys/class/net/eth0/threaded

# 查看 NAPI 线程
ps aux | grep napi
# root  12345  0.0  0.0  napi/eth0-rx-0

四、多队列架构:TX 队列与 RX 队列

现代网卡(万兆及以上)普遍支持多个 TX/RX 队列。每个队列绑定一个中断,通过 IRQ 亲和性绑定到不同 CPU,实现真正的并行收发包。

TX 队列(netdev_queue)

// include/linux/netdevice.h
struct netdev_queue {
    struct net_device       *dev;
    struct Qdisc __rcu      *qdisc;         // TC qdisc
    struct napi_struct      *napi;           // TX completion NAPI
    spinlock_t              _xmit_lock;      // 发送锁
    unsigned long           state;           // 队列状态
    unsigned long           trans_start;     // 最后发送时间
    struct dql              dql;             // 动态队列限制
};

dql(Dynamic Queue Limits)是一个自适应机制——它根据网卡的实际处理速度动态调整每个 TX 队列允许排队的字节数。避免”队列太深导致延迟高”和”队列太浅导致吞吐低”的两难问题。

发包时,内核通过 ndo_select_queue 或默认的 netdev_pick_tx() 选择一个 TX 队列:

// 默认 TX 队列选择(简化)
u16 netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
    // 优先使用 XPS(Transmit Packet Steering)映射
    // 否则根据 skb hash 选择队列
    int queue_index = skb_tx_hash(dev, skb);
    return queue_index;
}

RX 队列(netdev_rx_queue)

// include/net/netdev_rx_queue.h
struct netdev_rx_queue {
    struct xdp_rxq_info         xdp_rxq;       // XDP 元数据
    struct rps_map __rcu        *rps_map;       // RPS CPU 映射
    struct rps_dev_flow_table __rcu *rps_flow_table; // RPS 流表
    struct napi_struct          *napi;           // 关联的 NAPI
    struct xsk_buff_pool        *pool;           // AF_XDP 缓冲池
    struct net_device           *dev;
};

每个 RX 队列关联一个 NAPI 实例和一个硬件中断。通过 RSS(Receive Side Scaling),网卡硬件根据包的五元组 hash 将包分发到不同 RX 队列,不同 CPU 并行处理。

rps_maprps_flow_table 是软件 RPS/RFS 的配置——在网卡不支持硬件 RSS 的情况下,用软件实现类似的 CPU 分发。

pool 是 AF_XDP 的零拷贝缓冲池——当 XDP socket 绑定到这个队列时,收包直接写入用户态共享内存,绕过整个协议栈。


五、Ring Buffer 与 DMA:硬件到内核的数据传递

描述符 Ring Buffer

物理网卡和驱动之间通过描述符环形缓冲区(Descriptor Ring Buffer)交换数据。这不是 net_devicenetdevice.h 中定义的通用结构,而是每个驱动自己实现的,但模式高度一致:

     +---+---+---+---+---+---+---+---+
     | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |  ← 描述符数组(固定大小)
     +---+---+---+---+---+---+---+---+
       ^               ^
       |               |
    consumer        producer
    (driver)        (hardware/driver)

RX Ring Buffer

  1. 驱动预先分配一批 DMA buffer,把它们的地址写入描述符
  2. 网卡收到包后,通过 DMA 把数据写入 buffer,在描述符中标记”已完成”
  3. 驱动在 NAPI poll 中遍历描述符,取出完成的包,构建 sk_buff
  4. 驱动重新分配 buffer,填入空闲描述符,让网卡可以继续写入

TX Ring Buffer

  1. 驱动在 ndo_start_xmit 中把 sk_buff 的 DMA 地址写入描述符
  2. 通知网卡(写 doorbell/tail 寄存器)
  3. 网卡读取描述符,DMA 读取数据,发送到网线
  4. 发送完成后网卡标记描述符为”已完成”,驱动在 TX completion 中回收 sk_buff

Ring Buffer 大小调优

# 查看当前 ring buffer 大小
ethtool -g eth0

# 输出示例:
# Ring parameters for eth0:
# Pre-set maximums:
# RX:     4096
# TX:     4096
# Current hardware settings:
# RX:     256
# TX:     256

# 调大 ring buffer(减少高负载下的丢包)
ethtool -G eth0 rx 4096 tx 4096

Ring buffer 越大,在流量突发时越不容易丢包,但也意味着更大的延迟和更多的内存消耗。这是一个 latency-throughput tradeoff。


六、设备注册与生命周期

注册流程

网卡驱动加载时,通过 register_netdev() 向内核注册一个 net_device

// 驱动探测函数(简化)
static int mynic_probe(struct pci_dev *pdev, ...)
{
    struct net_device *dev;

    // 分配 net_device + 驱动私有数据
    dev = alloc_etherdev_mqs(sizeof(struct mynic_priv),
                             num_tx_queues, num_rx_queues);

    // 设置操作表
    dev->netdev_ops = &mynic_netdev_ops;

    // 设置硬件特性
    dev->features |= NETIF_F_SG | NETIF_F_IP_CSUM |
                     NETIF_F_TSO | NETIF_F_GRO;

    // 设置 MAC 地址
    eth_hw_addr_set(dev, hw_addr);

    // 注册到内核
    register_netdev(dev);

    return 0;
}

alloc_etherdev_mqs() 一次性分配 net_device 结构体、驱动私有数据、TX/RX 队列数组。私有数据通过 netdev_priv(dev) 访问。

features:硬件特性掩码

net_device.features 是一个位掩码,标识网卡支持的硬件卸载能力:

Feature 含义
NETIF_F_SG Scatter-Gather DMA(发送 fragment 列表)
NETIF_F_IP_CSUM IPv4 TCP/UDP 校验和卸载
NETIF_F_HW_CSUM 通用校验和卸载
NETIF_F_TSO TCP Segmentation Offload
NETIF_F_GRO Generic Receive Offload
NETIF_F_GSO Generic Segmentation Offload
NETIF_F_RXCSUM RX 校验和卸载
NETIF_F_HW_VLAN_CTAG_RX 硬件 VLAN 标签剥离
NETIF_F_LOOPBACK 硬件 loopback
# 查看网卡硬件特性
ethtool -k eth0 | head -20

# 关闭 TSO(调试用)
ethtool -K eth0 tso off

协议栈在发包时会检查 dev->features,决定是否使用硬件卸载。例如,如果网卡支持 NETIF_F_TSO,TCP 层就不需要按 MSS 分段,而是构造一个大 sk_buffskb_shinfo(skb)->gso_size = MSS),让网卡硬件来分段。


七、可观测性:追踪 net_device 和 NAPI

追踪 NAPI 调度与完成

# 统计 NAPI poll 频率和每次处理的包数
bpftrace -e '
kprobe:__napi_poll {
    @sched = count();
}
kretprobe:__napi_poll /retval > 0/ {
    @pkts = hist(retval);
}
interval:s:1 { print(@sched); print(@pkts); clear(@sched); clear(@pkts); }
'

观察中断分布

# 查看网卡中断在各 CPU 上的分布
grep eth0 /proc/interrupts

# 典型输出(多队列网卡):
#            CPU0       CPU1       CPU2       CPU3
#  45:     123456          0          0          0  eth0-TxRx-0
#  46:          0     234567          0          0  eth0-TxRx-1
#  47:          0          0     345678          0  eth0-TxRx-2
#  48:          0          0          0     456789  eth0-TxRx-3

每个队列的中断只在绑定的 CPU 上触发。如果分布不均匀,可以调整 IRQ 亲和性:

# 设置中断 45 的 CPU 亲和性(绑定到 CPU 0)
echo 1 > /proc/irq/45/smp_affinity

查看 ring buffer 和队列状态

# 查看 ring buffer 使用情况
ethtool -S eth0 | grep -E 'rx_queue|tx_queue|drop'

# 查看每个队列的统计
ls /sys/class/net/eth0/queues/
# rx-0  rx-1  rx-2  rx-3  tx-0  tx-1  tx-2  tx-3

cat /sys/class/net/eth0/queues/rx-0/rps_cpus
cat /sys/class/net/eth0/queues/tx-0/xps_cpus

追踪 TX 队列停止/恢复

# 追踪 TX 队列满导致的停止事件
bpftrace -e '
kprobe:netif_stop_queue {
    @stop[comm] = count();
}
kprobe:netif_wake_queue {
    @wake[comm] = count();
}
interval:s:5 { print(@stop); print(@wake); clear(@stop); clear(@wake); }
'

如果 netif_stop_queue 频繁触发,说明 TX ring buffer 太小或者网卡发送速度跟不上协议栈的发包速度。


八、小结

net_devicenet_device_ops 定义了内核网络栈和网卡驱动之间的接口契约。几个关键判断:

  1. net_device 的 cacheline 分组是性能关键。 TX 热路径只需加载 TX 组字段,RX 热路径只需加载 RX 组字段。在每秒千万包场景下,一次额外的 cache miss 就是几纳秒的延迟累加。

  2. NAPI 不是简单的”中断合并”。 它是一个在中断和轮询之间动态切换的状态机。低负载时中断驱动保证低延迟,高负载时切换到轮询保证高吞吐。NAPI_STATE_MISSED 机制保证切换过程不丢包。

  3. 多队列 + IRQ 亲和性是万兆网卡的性能基础。 没有多队列,所有包都在一个 CPU 上处理,无论 CPU 多快都会成为瓶颈。RSS/RPS 把不同流分到不同 CPU,是实现线性扩展的前提。

  4. Ring Buffer 大小是 latency-throughput 的 tradeoff。 太小容易在突发流量时丢包,太大增加缓冲延迟。ethtool -G 和 DQL 是两个调优手段。

下一篇我们将跟着一个收到的网络包,走完从网卡中断到 socket 接收队列的完整路径——把 sk_buffnet_device、NAPI 这些零件串成完整的收包链路。


参考文献

  1. Linux 内核源码,include/linux/netdevice.h,6.6 LTS / 6.8
  2. Linux 内核源码,net/core/dev.c,6.6 LTS
  3. Linux 内核文档,Documentation/networking/napi.rst
  4. Linux 内核文档,Documentation/networking/scaling.rst(RSS/RPS/RFS/XPS)

上一篇sk_buff 全解:内核网络包的终极容器

下一篇收包路径全解:从 NIC 中断到 socket 接收队列

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】收包路径全解:从 NIC 中断到 socket 接收队列

一个网络包从网卡 DMA 到用户态 recvmsg(),要走过硬中断、NAPI 轮询、GRO 聚合、协议分发、IP 路由、Netfilter 钩子、TCP/UDP 处理、socket 队列八个阶段。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 RX 收包路径,量化每一跳的 CPU 开销,并用 bpftrace 实测各阶段延迟分布。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】sk_buff 全解:内核网络包的终极容器

sk_buff 是 Linux 内核网络栈的通用货币——每一个收到或发出的网络包,都必须装在这个容器里走完全程。本文从 Linux 6.6 内核源码出发,拆解 sk_buff 的内存布局、四大指针操作、clone 与 copy 的代价差异、skb_shared_info 的 fragment 机制,并用 bpftrace 实测 sk_buff 分配热点和生命周期。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】软中断与 ksoftirqd:网络包处理的调度引擎

网络包到达网卡后,真正消耗 CPU 的处理全部发生在软中断上下文。本文从 Linux 6.6 内核源码出发,拆解 softirq 10 向量优先级体系、__do_softirq() 主循环与 MAX_SOFTIRQ_RESTART 放弃策略、ksoftirqd 调度时机、Threaded NAPI 替代方案,以及 CONFIG_PREEMPT_RT 下的行为变化。最后用 bpftrace/perf 实测软中断延迟和 time_squeeze 饥饿。


By .