上文说到网卡一般通过PCIe总线与系统相连,PCIe总线一般使用msi和msix中断进行通知。中断机制可以让CPU对外部事件作出及时的响应,但是当网卡处于大量收发包的状态中,会不断触发中断,这会导致系统只顾得上响应中断,而无法做其他事。
面对上述情景,可以在中断函数中处理非常紧急的事,而不紧急的事就交给中断下半部来做,这也是中断下半部出现的原因。但在网卡的场景中,尤其是随着当前网卡带宽越来越打,这样仍然存在问题,当中断下半部仍在处理网卡的收发包时,网卡仍会源源不断的接收包的到来,这可能会导致中断下半部来不及接收,从而会有大量的包堵塞到硬件上。
因此早在linux2.6,就引入了NAPI机制,NAPI是linux上采用的一种提高网络处理效率的方法。它不采用中断方式读取数据,而是唤醒软中断接收的服务程序(软中断),然后POLL的方法轮询数据。并且在没有处理完数据,它仍能激活软中断,重新进行收发。
但从笔者网卡性能调试的过程中,采用中断、软中断以及NAPI机制相结合的方法,仍然无法最大化的发挥出网卡的性能,还需要一种名为中断聚合的技术,这可以让中断在合适的时机触发,既不影响CPU处理收发包,又不会让包因来不及接收而堵在硬件上。
1. 软中断
软中断是linux使用中断下半部引用最多的技术。尤其是在网卡的场景中,一般都使用软中断进行收发包的处理。NR_SOFTIRQS是内核支持得最大软中断个数,目前5.15内核已经支持10个软中断,内核支持的软中断如下:
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;
enum
{
HI_SOFTIRQ=0, /*高优先级tasklet*/
TIMER_SOFTIRQ, /*定时器软中断*/
NET_TX_SOFTIRQ,/*网络收发包*/
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ, /*处理与块设备相关的任务*/
IRQ_POLL_SOFTIRQ,/**/
TASKLET_SOFTIRQ,/*常规软中断*/
SCHED_SOFTIRQ,/*处理与进程调度和负载均衡相关的任务*/
HRTIMER_SOFTIRQ,/*处理与高精度定时器(hrtimer)相关的任务*/
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */
NR_SOFTIRQS
};
softirq_action是软中断的向量表。open_softirq会向内核注册一个软中断,本质是设置中断向量表,注册其处理函数。以网络收发包为例,通过open_softirq函数就将net_tx_action与软中断向量NET_TX_SOFTIRQ绑定在了一起。rx方向也是如此。
struct softirq_action
{
void (*action)(struct softirq_action *);
};
void open_softirq(int nr, void (*action)(struct softirq_action *))
{
softirq_vec[nr].action = action;
}
ndev_dev_init()
{
......
open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);
......
}
当需要调用软中断时,就要调用raise_softirq函数激活软中断。激活的过程其实就是标记_softirq_pending记录过程,将__softirq_pending中对应位置1,这样在软中断处理函数中,发现某一位被置1的情况下,就会去调用注册的对应处理函数进行处理。可以看到,or_softirq_pending(x)函数实现是CPU相关的,也就是说每一个CPU都保存了一份map表,这样,每个CPU都可以根据各自的map表去执行各自的软中断处理函数。
void raise_softirq(unsigned int nr)
{
unsigned long flags;
local_irq_save(flags);
raise_softirq_irqoff(nr);
local_irq_restore(flags);
}
void __raise_softirq_irqoff(unsigned int nr)
{
lockdep_assert_irqs_disabled();
trace_softirq_raise(nr);
or_softirq_pending(1UL << nr);
}
#define or_softirq_pending(x) (__this_cpu_or(local_softirq_pending_ref, (x)))
软中断处理函数的核心逻辑在__do_softirq中。
asmlinkage __visible void __softirq_entry __do_softirq(void)
{
unsigned long end = jiffies + MAX_SOFTIRQ_TIME;
unsigned long old_flags = current->flags;
int max_restart = MAX_SOFTIRQ_RESTART;
struct softirq_action *h;
bool in_hardirq;
__u32 pending;
int softirq_bit;
/*
* Mask out PF_MEMALLOC as the current task context is borrowed for the
* softirq. A softirq handled, such as network RX, might set PF_MEMALLOC
* again if the socket is related to swapping.
*/
current->flags &= ~PF_MEMALLOC;
/*保存位图*/
pending = local_softirq_pending();
softirq_handle_begin();
in_hardirq = lockdep_softirq_start();
account_softirq_enter(current);
restart:
/* Reset the pending bitmask before enabling irqs */
/*清楚位图*/
set_softirq_pending(0);
/*锁中断,只是为了保持位图的互斥,位图处理完毕。后面的代码可以直接使用保存的pending,
而中断处理程序在激活的时候,也可以放心地使用irq_stat.__softirq_pending。
所以,可以开中断了*/
local_irq_enable();
/*获取中断向量*/
h = softirq_vec;
/*查找pending中按位不是0的位置*/
while ((softirq_bit = ffs(pending))) {
unsigned int vec_nr;
int prev_count;
h += softirq_bit - 1;
vec_nr = h - softirq_vec;
prev_count = preempt_count();
kstat_incr_softirqs_this_cpu(vec_nr);
trace_softirq_entry(vec_nr);
/*执行处理函数*/
h->action(h);
trace_softirq_exit(vec_nr);
if (unlikely(prev_count != preempt_count())) {
pr_err("huh, entered softirq %u %s %p with preempt_count %08x, exited with %08x?\n",
vec_nr, softirq_to_name[vec_nr], h->action,
prev_count, preempt_count());
preempt_count_set(prev_count);
}
h++;
pending >>= softirq_bit;
}
if (!IS_ENABLED(CONFIG_PREEMPT_RT) &&
__this_cpu_read(ksoftirqd) == current)
rcu_softirq_qs();
//当软中断处理完毕后,因为前面已经开了中断了,所以有可能新的软中断已经又被设置,
//软中断调度程序会尝试重新软中断,其最大重启次数由max_restart决定。
//所以,这里必须再次关闭中断,再来一次……
local_irq_disable();
pending = local_softirq_pending();
if (pending) {
if (time_before(jiffies, end) && !need_resched() &&
--max_restart)
goto restart;
wakeup_softirqd();
}
account_softirq_exit(current);
lockdep_softirq_end(in_hardirq);
softirq_handle_end();
current_restore_flags(old_flags, PF_MEMALLOC);
}
那么软中断线程又是如何注册的呢
static struct smp_hotplug_thread softirq_threads = {
.store = &ksoftirqd,
.thread_should_run = ksoftirqd_should_run,
.thread_fn = run_ksoftirqd,
.thread_comm = "ksoftirqd/%u",
};
如果需要获取软中断次数,以及时间的话,可以分别用以下命令获取
cat /proc/softirqs 可以用于查看软中断激发的数量。
top命令可以查看CPU中软中断消耗的百分比
2. napi机制
先看net_softirq软中断触发后,执行net_rx_action.tx方向也同理。net_rx_action函数的执行流程主要是:
1.获取设置好的用于轮询的budget,用于可处理的包的数量,以及设置超时的时间
2.从当前CPU的softnet_data poll_list中获取每个napi结构,执行napi_poll函数。napi_poll函数会调用work = n->poll(n, weight)函数。该函数是初始化napi结构时需要绑定的网卡处理函数。
3.napi_poll函数处理完之后,就从总的budget中减去每次poll,消费的budget值,如果消费掉总的设置的budget值,或者超市,则退出软中断处理;
4.将repoll放到链表尾部,如果repoll不为空,则重新激活软中断,进行处理;
5.最后使能中断,这表明cpu能收到网卡触发的中断了。
那么在网卡中,又是如何使用napi机制的呢?下面以stmmac网卡驱动为例
1.网卡初始化的过程中,会将poll与实际网卡处理函数相绑定。
static void stmmac_napi_add(struct net_device *dev)
{
struct stmmac_priv *priv = netdev_priv(dev);
u32 queue, maxq;
maxq = max(priv->plat->rx_queues_to_use, priv->plat->tx_queues_to_use);
for (queue = 0; queue < maxq; queue++) {
struct stmmac_channel *ch = &priv->channel[queue];
ch->priv_data = priv;
ch->index = queue;
spin_lock_init(&ch->lock);
if (queue < priv->plat->rx_queues_to_use) {
netif_napi_add(dev, &ch->rx_napi, stmmac_napi_poll_rx,
NAPI_POLL_WEIGHT);
}
if (queue < priv->plat->tx_queues_to_use) {
netif_tx_napi_add(dev, &ch->tx_napi,
stmmac_napi_poll_tx,
NAPI_POLL_WEIGHT);
}
if (queue < priv->plat->rx_queues_to_use &&
queue < priv->plat->tx_queues_to_use) {
netif_napi_add(dev, &ch->rxtx_napi,
stmmac_napi_poll_rxtx,
NAPI_POLL_WEIGHT);
}
}
}
2.在每个中断处理函数中,会先去检查napi是否能够被调度,如果可以,则关闭dma中断,同时调用__napi_schedule函数,该函数作用就是激发软中断处理函数。这样就可以进行中断下半部的处理。
static irqreturn_t stmmac_msi_intr_rx(int irq, void *data)
{
......
stmmac_napi_check(priv, chan, DMA_DIR_RX);
return IRQ_HANDLED;
}
static int stmmac_napi_check(struct stmmac_priv *priv, u32 chan, u32 dir)
{
......
if ((status & handle_rx) && (chan < priv->plat->rx_queues_to_use)) {
if (napi_schedule_prep(rx_napi)) {
spin_lock_irqsave(&ch->lock, flags);
stmmac_disable_dma_irq(priv, priv->ioaddr, chan, 1, 0);
spin_unlock_irqrestore(&ch->lock, flags);
__napi_schedule(rx_napi);
}
}
......
return status;
}
3.在napi机制工作之前,需要使能napi。stmmac_enable_all_queues在stmmac_open函数调用。如果工作正常的话,在执行了stmmac_open函数后,就可以开始收发包了。
static void stmmac_enable_all_queues(struct stmmac_priv *priv)
{
u32 rx_queues_cnt = priv->plat->rx_queues_to_use;
u32 tx_queues_cnt = priv->plat->tx_queues_to_use;
u32 maxq = max(rx_queues_cnt, tx_queues_cnt);
u32 queue;
for (queue = 0; queue < maxq; queue++) {
struct stmmac_channel *ch = &priv->channel[queue];
if (stmmac_xdp_is_enabled(priv) &&
test_bit(queue, priv->af_xdp_zc_qps)) {
napi_enable(&ch->rxtx_napi);
continue;
}
if (queue < rx_queues_cnt)
napi_enable(&ch->rx_napi);
if (queue < tx_queues_cnt)
napi_enable(&ch->tx_napi);
}
}
3.当不需要收发包时,需要将napi disable。同时如果驱动卸载,还需要将napi资源释放。
static void stmmac_disable_all_queues(struct stmmac_priv *priv)
{
u32 rx_queues_cnt = priv->plat->rx_queues_to_use;
struct stmmac_rx_queue *rx_q;
u32 queue;
/* synchronize_rcu() needed for pending XDP buffers to drain */
for (queue = 0; queue < rx_queues_cnt; queue++) {
rx_q = &priv->rx_queue[queue];
if (rx_q->xsk_pool) {
synchronize_rcu();
break;
}
}
__stmmac_disable_all_queues(priv);
}
static void stmmac_napi_del(struct net_device *dev)
{
struct stmmac_priv *priv = netdev_priv(dev);
u32 queue, maxq;
maxq = max(priv->plat->rx_queues_to_use, priv->plat->tx_queues_to_use);
for (queue = 0; queue < maxq; queue++) {
struct stmmac_channel *ch = &priv->channel[queue];
if (queue < priv->plat->rx_queues_to_use)
netif_napi_del(&ch->rx_napi);
if (queue < priv->plat->tx_queues_to_use)
netif_napi_del(&ch->tx_napi);
if (queue < priv->plat->rx_queues_to_use &&
queue < priv->plat->tx_queues_to_use) {
netif_napi_del(&ch->rxtx_napi);
}
}
}
3.中断聚合
文章开始说到,尽管使用了中断+napi机制,但对于现在网卡越来越大的带宽之后,仍然稍显不够,无法发挥网卡最大性能。因此,中断聚合应运而生。所谓中断聚合,就是网卡中断可以根据收发包以及触发时间动态调整。这样,网卡中断的上送就能根据实际情况而灵活变动。
中断聚合可以分为硬件中断聚合和软件中断聚合。本质都是设置一个定时器,在该定时器之内根据收发包的数量或者是否超出定时器设置的时间从而选择是否上报中断。
以stmmac网卡驱动为例,该驱动同时使用了两种中断聚合的方式,分别是rx方向使用硬件定时器,而tx中断则使用了软件定时器的方式。但stmmac驱动采用了固定frame和时间的中断触发,只能通过ethtool的方式进行修改。
先看rx方向中断聚合.rx中断聚合在stmmac_rx_refill函数中进行,该函数由stmmac_rx调用,用于当rx 描述符资源被释放了之后,需要重新申请。接着就是中断聚合的部分,实际上这部分代码是有问题的,代码原理是计算rx_count_frames,然后与设置的rx_coal_frames值比较,如果值大于设置的rx_coal_frames,则触发上送中断。
static inline void stmmac_rx_refill(struct stmmac_priv *priv, u32 queue)
{
......
rx_q->rx_count_frames++;
rx_q->rx_count_frames += priv->rx_coal_frames[queue];
if (rx_q->rx_count_frames > priv->rx_coal_frames[queue])
rx_q->rx_count_frames = 0;
use_rx_wd = !priv->rx_coal_frames[queue];
use_rx_wd |= rx_q->rx_count_frames > 0;
if (!priv->use_riwt)
use_rx_wd = false;
dma_wmb();
stmmac_set_rx_owner(priv, p, use_rx_wd);
......
}
但stmmac这部分代码有2个问题(备注:stmmac 驱动bug蛮多的):
1.代码逻辑是收到包就会主动上送中断,原因是rx_q->rx_count_frames一直为0,从而use_rx_wd为正值,然后通过stmmac_set_rx_owner设置描述符的中断标记位,也就是说收到一个包就上报中断,不科学;
2.更好的方式理论上是根据一段时间内收包的数量和大小,去决定是否上送中断。这点intel就做的比较好。感兴趣的可以看看ixgbe中的函数ixgbe_update_itr的实现。
另外什么时候设置的硬件定时器呢?stmmac_hw_setup函数会去设置硬件定时器。其原理是以上次上送的rx中断为开始时间,如果在设置的定时器时间硬件有包,但没上送中断,则上送中断,如果没有包,则不触发中断。如果有限时间内已经上送了中断,则又以上送的中断的时间为起始时间。
static int stmmac_hw_setup(struct net_device *dev, bool init_ptp)
{
......
if (priv->use_riwt) {
u32 queue;
for (queue = 0; queue < rx_cnt; queue++) {
if (!priv->rx_riwt[queue])
priv->rx_riwt[queue] = DEF_DMA_RIWT;
stmmac_rx_watchdog(priv, priv->ioaddr,
priv->rx_riwt[queue], queue);
}
}
......
}
再看stmmac tx方向的中断聚合,它采用软件定时器的方式。
stmmac会在stmmac_xmit函数进行两个方面的中断聚合。一方面会根据统计发包的数量,如果发包数量大于设置的tx_coal_frames,或者统计的总的发包数量取余小于现阶段的发包数量,则仍然设置tx描述符的中断位。
static netdev_tx_t stmmac_xmit(struct sk_buff *skb, struct net_device *dev)
{
.......
tx_packets = (entry + 1) - first_tx;
tx_q->tx_count_frames += tx_packets;
if ((skb_shinfo(skb)->tx_flags & SKBTX_HW_TSTAMP) && priv->hwts_tx_en)
set_ic = true;
else if (!priv->tx_coal_frames[queue])
set_ic = false;
else if (tx_packets > priv->tx_coal_frames[queue])
set_ic = true;
else if ((tx_q->tx_count_frames %
priv->tx_coal_frames[queue]) < tx_packets)
set_ic = true;
else
set_ic = false;
if (set_ic) {
if (likely(priv->extend_desc))
desc = &tx_q->dma_etx[entry].basic;
else if (tx_q->tbs & STMMAC_TBS_AVAIL)
desc = &tx_q->dma_entx[entry].basic;
else
desc = &tx_q->dma_tx[entry];
tx_q->tx_count_frames = 0;
stmmac_set_tx_ic(priv, desc);
priv->xstats.tx_set_ic_bit++;
}
.......
stmmac_tx_timer_arm(priv, queue);
}
另一方面,还会启动定时器,如果到达限定的时间后,则会主动进行调度。同时在tx_clean函数中当dirty_tx不等于cur_tx时,表明还有包未发送,则启动定时器函数主动进行调度。
static enum hrtimer_restart stmmac_tx_timer(struct hrtimer *t)
{
struct stmmac_tx_queue *tx_q = container_of(t, struct stmmac_tx_queue, txtimer);
struct stmmac_priv *priv = tx_q->priv_data;
struct stmmac_channel *ch;
struct napi_struct *napi;
ch = &priv->channel[tx_q->queue_index];
napi = tx_q->xsk_pool ? &ch->rxtx_napi : &ch->tx_napi;
if (likely(napi_schedule_prep(napi))) {
unsigned long flags;
spin_lock_irqsave(&ch->lock, flags);
stmmac_disable_dma_irq(priv, priv->ioaddr, ch->index, 0, 1);
spin_unlock_irqrestore(&ch->lock, flags);
__napi_schedule(napi);
}
return HRTIMER_NORESTART;
}
当然stmmac_xmit函数中使用定时器还是需要重视的,需要尽可能减少定时器带来的影响,在性能优化中,可以根据发包的数量来决定是否启用定时器。
中断聚合的效果是不言而喻的,不仅最大程度发挥了网卡的性能,同时还减小了latency。在网卡性能优化中,这是极其重要的一环。