尽管 Intel x86-64 架构仍是服务器领域的主流选择,但 ARM 架构正日益受到关注,许多企业已开始将产品从 x86-64 迁移至 ARM。在 PingCAP,我们希望 TiDB 能够良好地运行于 ARM 平台,因此决定在 AWS 的 ARM 实例上对其性能展开评估。
TiKV 是 TiDB 的分布式事务型键值存储引擎。当我们在 AWS ARM 服务器上测试 TiKV 时,已参照 AWS 官方文档对网络队列进行了调优,但仍观察到两个 CPU 核心的软中断(softirq)使用率持续偏高,构成显著性能瓶颈。进一步排查发现,这两个高负载软中断实为tasklet 类型软中断。
我们知道,相同类型的 tasklet 始终以串行方式执行,无法在多个 CPU 上并发运行。该现象与我们所遇到的问题高度吻合,因此我们猜测:该 tasklet 函数可能由 ENA 网卡驱动实现。然而,网络子系统对时延极为敏感,且Linux 内核中已明定义了两个专门用于网络的软中断:NET_TX与NET_RX。这与“由网卡驱动使用 tasklet 处理网络请求”的猜测相悖。那这两个 tasklet 究竟在做什么?为此,我们重新搭建环境进行深入排查。
系统配置
操作系统
Red Hat Enterprise Linux Server 发行版 7.6(Maipo)
内核
4.14.0 - 115.2.2.el7a.aarch64
网卡信息
以太网控制器:亚马逊公司的弹性网络适配器(ENA) 两个 RX/TX 通道,环大小为 1024
问题复现
为稳定复现该现象,我们执行了以下操作:
- 设置中断亲和性和 RPS。
# 获取 eth0 网卡的中断号
grep eth0 /proc/interrupts | awk '{print $1}' | tr -d :
44
45
# 将中断 44 绑定至 CPU 0,中断 45 绑定至 CPU 8
sudo sh -c "echo 0 > /proc/irq/44/smp_affinity_list"
sudo sh -c "echo 8 > /proc/irq/45/smp_affinity_list"
# 配置 RPS:RX-0 队列由 CPU 1–7 处理,RX-1 由 CPU 9–15 处理(mask `fe`=0b11111110, `fe00`=0b1111111000000000)
sudo sh -c 'echo fe > /sys/class/net/eth0/queues/rx-0/rps_cpus'
sudo sh -c 'echo fe00 > /sys/class/net/eth0/queues/rx-1/rps_cpus'
- 使用 wrk + nginx 生成工作负载。
- 通过监控 CPU 和中断情况确认问题是否成功复现。
watch -d cat /proc/softirq
mpstat -P ALL 2
根因分析
我们知道,tasklet 需要通过 tasklet_schedule 来触发调度。为了获取其运行时调用链,我们计划编写一个跟踪工具。结合内核代码,这应能帮助我们找到小任务函数的定义。由于我们使用的 Linux 是 4.14 内核,BPF 编译器集合(BCC)似乎是合适选择。
然而,我们安装的 BCC 无法运行。查看内核配置文件后,我们发现 CONFIG_BPF_SYSCALL 并未启用。目前尚不清楚该版本内核的 BPF 是否支持 Aarch64 平台。因此,我们决定直接编写 kprobe 内核模块来捕获 tasklet_schedule 函数,以了解 tasklet 是在哪个进程上下文中被唤醒的。以下是跟踪日志。
Jul 17 07:30:50 ip-172-31-4-116 kernel: CPU: 0 PID: 7 Comm: ksoftirqd/0 Kdump: loaded Tainted: G OE ------------ 4.14.0-115.2.2.el7a.aarch64 #1
...
Jul 17 07:30:50 ip-172-31-4-116 kernel: [<ffff0000087522c0>] tcp_wfree+0x15c/0x168
...
Jul 17 07:30:50 ip-172-31-4-116 kernel: [<ffff0000008e4f2c>] ena_io_poll+0x54c/0x8f0 [ena]
Jul 17 07:30:50 ip-172-31-4-116 kernel: [<ffff0000086e6df0>] net_rx_action+0x314/0x470
...
Jul 17 07:30:50 ip-172-31-4-116 kernel: ctx: bh
从跟踪信息中可以看出,在中断下半部运行的 ksoftirqd 线程正在处理 NET_RX 软中断,并尝试调度小任务。让我们来看看网卡驱动代码(未找到 2.0.1k 版本,因此这里使用 2.0.2 版本)。
static int ena_io_poll(struct napi_struct *napi, int budget)
{
... ...
tx_work_done = ena_clean_tx_irq(tx_ring, tx_budget);
rx_work_done = ena_clean_rx_irq(rx_ring, napi, budget);
... ...
}
static int ena_clean_tx_irq(struct ena_ring *tx_ring, u32 budget)
{
... ...
dev_kfree_skb(skb);
... ...
}
可以注意到,网卡驱动通过复用 NET_RX 来响应传输完成中断和数据接收中断,而捕获到的上下文处于传输完成中断的下半部。网卡驱动在进行传输后清理时,会调用 dev_kfree_skb 宏(#define dev_kfree_skb(a) consume_skb(a))来释放 sk_buffer。然后在执行 tcp_wfree 函数时,会检查 TSQ_THROTTLED 标志,以确定是否有数据在等待队列规则(qdisc)空间。如果有任何数据包在等待,与该数据包对应的 sock 对象将被添加到当前 CPU 的 TCP 小队列(TSQ)中,如下所示:
struct tsq_tasklet {
struct tasklet_struct tasklet;
struct list_head head; /* queue of tcp sockets */
};
static DEFINE_PER_CPU(struct tsq_tasklet, tsq_tasklet);
struct tcp_sock *tp = tcp_sk(sk);
... ...
list_add(&tp->tsq_node, &tsq->head);
然后调度 TSQ 小任务,并在下一个软中断的上下文中发送 sock 中的数据包。相应的内核代码如下:
/* 在 kfree_skb 时自动调用的 skb 缓冲区析构函数。
* 注意:在此上下文中不得尝试发送新 skb,
* 原因是我们可能已持有 qdisc 锁(避免死锁)。
*/
void tcp_wfree(struct sk_buff *skb)
{
struct sock *sk = skb->sk;
struct tcp_sock *tp = tcp_sk(sk);
/* 检查是否之前因队列满而被 TSQ 限流(throttled),
* 如果是,且尚未加入 TSQ 处理队列(避免重复入队) */
if (test_and_clear_bit(TSQ_THROTTLED, &tp->tsq_flags) &&
!test_and_set_bit(TSQ_QUEUED, &tp->tsq_flags)) {
unsigned long flags;
struct tsq_tasklet *tsq;
/* 保留一个 sock 引用计数(refcount)。
* 减去 (skb->truesize - 1) 是因为 skb 本身即将被释放,
* 此处“+1”是为 TSQ 处理期间额外持有的引用;
* 该引用最终将在 tcp_tasklet_func() 中释放。
*/
atomic_sub(skb->truesize - 1, &sk->sk_wmem_alloc);
/* 将此 socket 加入本 CPU 的 TSQ tasklet 队列 */
local_irq_save(flags);
tsq = &__get_cpu_var(tsq_tasklet);
list_add(&tp->tsq_node, &tsq->head);
tasklet_schedule(&tsq->tasklet);
local_irq_restore(flags);
} else {
sock_wfree(skb);
}
}
现在我们可以查看 tsq_tasklet_func 的定义:
/* 每个 CPU 绑定一个 tasklet,用于尝试发送更多 skb。
* 我们运行在 tasklet 上下文(软中断下半部),
* 但在转移 tsq->head 链表时仍需关中断,因为:
* —— tcp_wfree() 可能从中断上下文(如非 NAPI 驱动)调用,
* —— 导致并发修改链表(race condition)。
*/
static void tcp_tasklet_func(unsigned long data)
{
struct tsq_tasklet *tsq = (struct tsq_tasklet *)data;
LIST_HEAD(list);
unsigned long flags;
struct list_head *q, *n;
struct tcp_sock *tp;
struct sock *sk;
local_irq_save(flags);
list_splice_init(&tsq->head, &list);
local_irq_restore(flags);
list_for_each_safe(q, n, &list) {
tp = list_entry(q, struct tcp_sock, tsq_node);
list_del(&tp->tsq_node);
sk = (struct sock *)tp;
bh_lock_sock(sk);
if (!sock_owned_by_user(sk)) {
/* 若用户进程未持有 sock(即不在 copy_to_user 等路径),则立即处理 */
tcp_tsq_handler(sk);// 核心发送逻辑:尝试 enqueue skb 至 qdisc
} else {
/* 若 sock 被用户态进程占用(如 recv/send 系统调用中),
* 则推迟处理:标记 deferred bit,等待 release_sock() 时回调 */
/* defer the work to tcp_release_cb() */
set_bit(TCP_TSQ_DEFERRED, &tp->tsq_flags);
}
bh_unlock_sock(sk);
clear_bit(TSQ_QUEUED, &tp->tsq_flags);
sk_free(sk);
}
}
核心逻辑如下:
if (!sock_owned_by_user(sk)) {
tcp_tsq_handler(sk);
} else {
/* defer the work to tcp_release_cb() */
set_bit(TCP_TSQ_DEFERRED, &tp->tsq_flags);
}
如果 sock 被另一个用户进程持有,则延迟 sock 处理;当 sock 被释放时,调用 tcp_tsq_handler 来发送数据。
void tcp_release_cb(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
unsigned long flags, nflags;
... ...
if (flags & (1UL << TCP_TSQ_DEFERRED))
tcp_tsq_handler(sk);
... ...
}
否则,直接调用 tcp_tsq_handler 来发送数据。
static void tcp_tsq_handler(struct sock *sk)
{
if ((1 << sk->sk_state) &
(TCPF_ESTABLISHED | TCPF_FIN_WAIT1 | TCPF_CLOSING |
TCPF_CLOSE_WAIT | TCPF_LAST_ACK))
tcp_write_xmit(sk, tcp_current_mss(sk), tcp_sk(sk)->nonagle,
0, GFP_ATOMIC);
}
我们修改了跟踪模块以跟踪 tcp_tasklet_func,以确保它确实在 CPU 0 上被唤醒并运行(日志略)。
综上所述,以上执行流程总结如下:
Transmit completion IRQ handler (IRQ ctx on CPU 0)
→ raise napi_shedule (SOFTIRQ ctx on CPU 0)
→ NET_RX (SOFTIRQ ctx on CPU 0)
→ ena_io_poll (driver in SOFTIRQ ctx on CPU 0)
→ ena_clean_tx_irq (driver in SOFTIRQ ctx on CPU 0)
→ dev_kfree_skb (in SOFTIRQ ctx on CPU 0)
→ tcp_wfree (in SOFTIRQ ctx on CPU 0 DO TSQ flag CHECK)
→ get TSQ tasklet from CPU 0 (per-cpu val)
→ raise tsq tasklet (on CPU 0)
下次 CPU 0 的软中断调度时:
→ 运行 tsq_tasklet_func(在 CPU 0 上)
ENA 网卡的硬件队列仅包含 2 个硬中断,这意味着按照上述流程,仅允许两个 TSQ 小任务工作。因此,当网络负载较高时,相应的 CPU 核心就会成为性能瓶颈,这在强调高性能和低延迟的云环境中尤为关键。
结论
基于以上观察和分析,我们可以得出结论:对于运行在支持 TSQ 的 Linux 内核上的网卡,如果硬件队列的数量远小于 CPU 核心的数量,那么在网卡发生中断时,相应的 CPU 很可能会成为瓶颈。
参考资料