Redis 高负载下的中断优化

背景

本来稳定的环境也由于请求量的上涨带来了不少不稳定的因素,其中一直困扰咱们的就是网卡丢包问题。起初线上存在部分Redis节点还在使用千兆网卡的老旧服务器,而缓存服务每每须要承载极高的查询量,并要求毫秒级的响应速度,如此一来千兆网卡很快就出现了瓶颈。通过整治,咱们将千兆网卡服务器替换为了万兆网卡服务器,本觉得能够高枕无忧,可是没想到,在业务高峰时段,机器也居然出现了丢包问题,而此时网卡带宽使用还远远没有达到瓶颈。html

定位网络丢包的缘由

从异常指标入手

首先,咱们在系统监控的net.if.in.dropped指标中,看到有大量数据丢包异常,那么第一步就是要了解这个指标表明什么。前端

这个指标的数据源,是读取/proc/net/dev中的数据,监控Agent作简单的处理以后上报。如下为/proc/net/dev的一个示例,能够看到第一行Receive表明in,Transmit表明out,第二行即各个表头字段,再日后每一行表明一个网卡设备具体的值。node

其中各个字段意义以下:linux

字段 解释
bytes The total number of bytes of data transmitted or received by the interface.
packets The total number of packets of data transmitted or received by the interface.
errs The total number of transmit or receive errors detected by the device driver.
drop The total number of packets dropped by the device driver.
fifo The number of FIFO buffer errors.
frame The number of packet framing errors.
colls The number of collisions detected on the interface.
compressed The number of compressed packets transmitted or received by the device driver. (This appears to be unused in the 2.2.15 kernel.)
carrier The number of carrier losses detected by the device driver.
multicast The number of multicast frames transmitted or received by the device driver.

经过上述字段解释,咱们能够了解丢包发生在网卡设备驱动层面;可是想要了解真正的缘由,须要继续深刻源码。算法

/proc/net/dev的数据来源,根据源码文件net/core/net-procfs.c,能够知道上述指标是经过其中的dev_seq_show()函数和dev_seq_printf_stats()函数输出的:api

static int dev_seq_show(struct seq_file *seq, void *v)
{
    if (v == SEQ_START_TOKEN)
        /* 输出/proc/net/dev表头部分   */
        seq_puts(seq, "Inter-| Receive "
                  " | Transmit\n"
                  " face |bytes packets errs drop fifo frame "
                  "compressed multicast|bytes packets errs "
                  "drop fifo colls carrier compressed\n");
    else
        /* 输出/proc/net/dev数据部分   */
        dev_seq_printf_stats(seq, v);
    return 0;
}
  
static void dev_seq_printf_stats(struct seq_file *seq, struct net_device *dev)
{
    struct rtnl_link_stats64 temp;
  
    /* 数据源从下面的函数中取得   */
    const struct rtnl_link_stats64 *stats = dev_get_stats(dev, &temp);
 
    /* /proc/net/dev 各个字段的数据算法   */
    seq_printf(seq, "%6s: %7llu %7llu %4llu %4llu %4llu %5llu %10llu %9llu "
           "%8llu %7llu %4llu %4llu %4llu %5llu %7llu %10llu\n",
           dev->name, stats->rx_bytes, stats->rx_packets,
           stats->rx_errors,
           stats->rx_dropped + stats->rx_missed_errors,
           stats->rx_fifo_errors,
           stats->rx_length_errors + stats->rx_over_errors +
            stats->rx_crc_errors + stats->rx_frame_errors,
           stats->rx_compressed, stats->multicast,
           stats->tx_bytes, stats->tx_packets,
           stats->tx_errors, stats->tx_dropped,
           stats->tx_fifo_errors, stats->collisions,
           stats->tx_carrier_errors +
            stats->tx_aborted_errors +
            stats->tx_window_errors +
            stats->tx_heartbeat_errors,
           stats->tx_compressed);
}
复制代码

dev_seq_printf_stats()函数里,对应drop输出的部分,能看到由两块组成:stats->rx_dropped+stats->rx_missed_errors缓存

继续查找dev_get_stats函数可知,rx_droppedrx_missed_errors都是从设备获取的,而且须要设备驱动实现。bash

/**
 *  dev_get_stats   - get network device statistics
 *  @dev: device to get statistics from
 *  @storage: place to store stats
 *
 *  Get network statistics from device. Return @storage.
 *  The device driver may provide its own method by setting
 *  dev->netdev_ops->get_stats64 or dev->netdev_ops->get_stats;
 *  otherwise the internal statistics structure is used.
 */
struct rtnl_link_stats64 *dev_get_stats(struct net_device *dev,
                    struct rtnl_link_stats64 *storage)
{
    const struct net_device_ops *ops = dev->netdev_ops;
    if (ops->ndo_get_stats64) {
        memset(storage, 0, sizeof(*storage));
        ops->ndo_get_stats64(dev, storage);
    } else if (ops->ndo_get_stats) {
        netdev_stats_to_stats64(storage, ops->ndo_get_stats(dev));
    } else {
        netdev_stats_to_stats64(storage, &dev->stats);
    }   
    storage->rx_dropped += (unsigned long)atomic_long_read(&dev->rx_dropped);
    storage->tx_dropped += (unsigned long)atomic_long_read(&dev->tx_dropped);
    storage->rx_nohandler += (unsigned long)atomic_long_read(&dev->rx_nohandler);
    return storage;
}
复制代码

结构体 rtnl_link_stats64 的定义在 /usr/include/linux/if_link.h 中:服务器

/* The main device statistics structure */
struct rtnl_link_stats64 {
    __u64   rx_packets;     /* total packets received   */
    __u64   tx_packets;     /* total packets transmitted    */
    __u64   rx_bytes;       /* total bytes received     */
    __u64   tx_bytes;       /* total bytes transmitted  */
    __u64   rx_errors;      /* bad packets received     */
    __u64   tx_errors;      /* packet transmit problems */
    __u64   rx_dropped;     /* no space in linux buffers    */
    __u64   tx_dropped;     /* no space available in linux  */
    __u64   multicast;      /* multicast packets received   */
    __u64   collisions;
 
    /* detailed rx_errors: */
    __u64   rx_length_errors;
    __u64   rx_over_errors;     /* receiver ring buff overflow  */
    __u64   rx_crc_errors;      /* recved pkt with crc error    */
    __u64   rx_frame_errors;    /* recv'd frame alignment error */ __u64 rx_fifo_errors; /* recv'r fifo overrun      */
    __u64   rx_missed_errors;   /* receiver missed packet   */
 
    /* detailed tx_errors */
    __u64   tx_aborted_errors;
    __u64   tx_carrier_errors;
    __u64   tx_fifo_errors;
    __u64   tx_heartbeat_errors;
    __u64   tx_window_errors;
 
    /* for cslip etc */
    __u64   rx_compressed;
    __u64   tx_compressed;
};
复制代码

至此,咱们知道rx_dropped是Linux中的缓冲区空间不足致使的丢包,而rx_missed_errors则在注释中写的比较笼统。有资料指出,rx_missed_errors是fifo队列(即rx ring buffer)满而丢弃的数量,但这样的话也就和rx_fifo_errors等同了。后来公司内网络内核研发大牛王伟给了咱们点拨:不一样网卡本身实现不同,好比Intel的igb网卡rx_fifo_errorsmissed的基础上,还加上了RQDPC计数,而ixgbe就没这个统计。RQDPC计数是描述符不够的计数,missedfifo满的计数。因此对于ixgbe来讲,rx_fifo_errorsrx_missed_errors确实是等同的。网络

经过命令ethtool -S eth0能够查看网卡一些统计信息,其中就包含了上文提到的几个重要指标rx_droppedrx_missed_errorsrx_fifo_errors等。但实际测试后,我发现不一样网卡型号给出的指标略有不一样,好比Intel ixgbe就能取到,而Broadcom bnx2/tg3则只能取到rx_discards(对应rx_fifo_errors)、rx_fw_discards(对应rx_dropped)。这代表,各家网卡厂商设备内部对这些丢包的计数器、指标的定义略有不一样,但经过驱动向内核提供的统计数据都封装成了struct rtnl_link_stats64定义的格式。

在对丢包服务器进行检查后,发现rx_missed_errors为0,丢包所有来自rx_dropped。说明丢包发生在Linux内核的缓冲区中。接下来,咱们要继续探索究竟是什么缓冲区引发了丢包问题,这就须要完整地了解服务器接收数据包的过程。

了解接收数据包的流程

接收数据包是一个复杂的过程,涉及不少底层的技术细节,但大体须要如下几个步骤:

  1. 网卡收到数据包。
  2. 将数据包从网卡硬件缓存转移到服务器内存中。
  3. 通知内核处理。
  4. 通过TCP/IP协议逐层处理。
  5. 应用程序经过read()socket buffer读取数据。

将网卡收到的数据包转移到主机内存(NIC与驱动交互)

NIC在接收到数据包以后,首先须要将数据同步到内核中,这中间的桥梁是rx ring buffer。它是由NIC和驱动程序共享的一片区域,事实上,rx ring buffer存储的并非实际的packet数据,而是一个描述符,这个描述符指向了它真正的存储地址,具体流程以下:

  1. 驱动在内存中分配一片缓冲区用来接收数据包,叫作sk_buffer
  2. 将上述缓冲区的地址和大小(即接收描述符),加入到rx ring buffer。描述符中的缓冲区地址是DMA使用的物理地址;
  3. 驱动通知网卡有一个新的描述符;
  4. 网卡从rx ring buffer中取出描述符,从而获知缓冲区的地址和大小;
  5. 网卡收到新的数据包;
  6. 网卡将新数据包经过DMA直接写到sk_buffer中。

当驱动处理速度跟不上网卡收包速度时,驱动来不及分配缓冲区,NIC接收到的数据包没法及时写到sk_buffer,就会产生堆积,当NIC内部缓冲区写满后,就会丢弃部分数据,引发丢包。这部分丢包为rx_fifo_errors,在/proc/net/dev中体现为fifo字段增加,在ifconfig中体现为overruns指标增加。

通知系统内核处理(驱动与Linux内核交互)

这个时候,数据包已经被转移到了sk_buffer中。前文提到,这是驱动程序在内存中分配的一片缓冲区,而且是经过DMA写入的,这种方式不依赖CPU直接将数据写到了内存中,意味着对内核来讲,其实并不知道已经有新数据到了内存中。那么如何让内核知道有新数据进来了呢?答案就是中断,经过中断告诉内核有新数据进来了,并须要进行后续处理。

提到中断,就涉及到硬中断和软中断,首先须要简单了解一下它们的区别:

  • 硬中断: 由硬件本身生成,具备随机性,硬中断被CPU接收后,触发执行中断处理程序。中断处理程序只会处理关键性的、短期内能够处理完的工做,剩余耗时较长工做,会放到中断以后,由软中断来完成。硬中断也被称为上半部分。
  • 软中断: 由硬中断对应的中断处理程序生成,每每是预先在代码里实现好的,不具备随机性。(除此以外,也有应用程序触发的软中断,与本文讨论的网卡收包无关。)也被称为下半部分。

当NIC把数据包经过DMA复制到内核缓冲区sk_buffer后,NIC当即发起一个硬件中断。CPU接收后,首先进入上半部分,网卡中断对应的中断处理程序是网卡驱动程序的一部分,以后由它发起软中断,进入下半部分,开始消费sk_buffer中的数据,交给内核协议栈处理。

经过中断,可以快速及时地响应网卡数据请求,但若是数据量大,那么会产生大量中断请求,CPU大部分时间都忙于处理中断,效率很低。为了解决这个问题,如今的内核及驱动都采用一种叫NAPI(new API)的方式进行数据处理,其原理能够简单理解为 中断+轮询,在数据量大时,一次中断后经过轮询接收必定数量包再返回,避免产生屡次中断。

整个中断过程的源码部分比较复杂,而且不一样驱动的厂商及版本也会存在必定的区别。 如下调用关系基于Linux-3.10.108及内核自带驱动drivers/net/ethernet/intel/ixgbe

注意到,enqueue_to_backlog函数中,会对CPU的softnet_data实例中的接收队列(input_pkt_queue)进行判断,若是队列中的数据长度超过netdev_max_backlog ,那么数据包将直接丢弃,这就产生了丢包。netdev_max_backlog是由系统参数net.core.netdev_max_backlog指定的,默认大小是 1000。

/*
 * enqueue_to_backlog is called to queue an skb to a per CPU backlog
 * queue (may be a remote CPU queue).
 */
static int enqueue_to_backlog(struct sk_buff *skb, int cpu,
                  unsigned int *qtail)
{
    struct softnet_data *sd;
    unsigned long flags;
 
    sd = &per_cpu(softnet_data, cpu);
 
    local_irq_save(flags);
 
    rps_lock(sd);
  
    /* 判断接收队列是否满,队列长度为 netdev_max_backlog  */ 
    if (skb_queue_len(&sd->input_pkt_queue) <= netdev_max_backlog) {
  
         
        if (skb_queue_len(&sd->input_pkt_queue)) {
enqueue:
            /*  队列若是不会空,将数据包添加到队列尾  */
            __skb_queue_tail(&sd->input_pkt_queue, skb);
            input_queue_tail_incr_save(sd, qtail);
            rps_unlock(sd);
            local_irq_restore(flags);
            return NET_RX_SUCCESS;
        }   
 
        /* Schedule NAPI for backlog device
         * We can use non atomic operation since we own the queue lock
         */
        /*  队列若是为空,回到 ____napi_schedule加入poll_list轮询部分,并从新发起软中断  */ 
        if (!__test_and_set_bit(NAPI_STATE_SCHED, &sd->backlog.state)) {
            if (!rps_ipi_queued(sd))
                ____napi_schedule(sd, &sd->backlog);
        }   
        goto enqueue;
    }
 
    /* 队列满则直接丢弃,对应计数器 +1 */ 
    sd->dropped++;
    rps_unlock(sd);
 
    local_irq_restore(flags);
 
    atomic_long_inc(&skb->dev->rx_dropped);
    kfree_skb(skb);
    return NET_RX_DROP;
}
复制代码

内核会为每一个CPU Core都实例化一个softnet_data对象,这个对象中的input_pkt_queue用于管理接收的数据包。假如全部的中断都由一个CPU Core来处理的话,那么全部数据包只能经由这个CPU的input_pkt_queue,若是接收的数据包数量很是大,超过中断处理速度,那么input_pkt_queue中的数据包就会堆积,直至超过netdev_max_backlog,引发丢包。这部分丢包能够在cat /proc/net/softnet_stat的输出结果中进行确认:

其中每行表明一个CPU,第一列是中断处理程序接收的帧数,第二列是因为超过 netdev_max_backlog而丢弃的帧数。 第三列则是在net_rx_action函数中处理数据包超过netdev_budge指定数量或运行时间超过2个时间片的次数。在检查线上服务器以后,发现第一行CPU。硬中断的中断号及统计数据能够在/proc/interrupts中看到,对于多队列网卡,当系统启动并加载NIC设备驱动程序模块时,每一个RXTX队列会被初始化分配一个惟一的中断向量号,它通知中断处理程序该中断来自哪一个NIC队列。在默认状况下,全部队列的硬中断都由CPU 0处理,所以对应的软中断逻辑也会在CPU 0上处理,在服务器 TOP 的输出中,也能够观察到 %si 软中断部分,CPU 0的占比比其余core高出一截。

到这里其实有存在一个疑惑,咱们线上服务器的内核版本及网卡都支持NAPI,而NAPI的处理逻辑是不会走到enqueue_to_backlog中的,enqueue_to_backlog主要是非NAPI的处理流程中使用的。对此,咱们以为可能和当前使用的Docker架构有关,事实上,咱们经过net.if.dropped指标获取到的丢包,都发生在Docker虚拟网卡上,而非宿主机物理网卡上,所以极可能是Docker虚拟网桥转发数据包以后,虚拟网卡层面产生的丢包,这里因为涉及虚拟化部分,就不进一步分析了。

驱动及内核处理过程当中的几个重要函数:

(1)注册中断号及中断处理程序,根据网卡是否支持MSI/MSIX,结果为:MSIXixgbe_msix_clean_ringsMSIixgbe_intr,都不支持 → ixgbe_intr

/**
 * 文件:ixgbe_main.c
 * ixgbe_request_irq - initialize interrupts
 * @adapter: board private structure
 *
 * Attempts to configure interrupts using the best available
 * capabilities of the hardware and kernel.
 **/
static int ixgbe_request_irq(struct ixgbe_adapter *adapter)
{
    struct net_device *netdev = adapter->netdev;
    int err;
 
    /* 支持MSIX,调用 ixgbe_request_msix_irqs 设置中断处理程序*/
    if (adapter->flags & IXGBE_FLAG_MSIX_ENABLED)
        err = ixgbe_request_msix_irqs(adapter);
    /* 支持MSI,直接设置 ixgbe_intr 为中断处理程序 */
    else if (adapter->flags & IXGBE_FLAG_MSI_ENABLED)
        err = request_irq(adapter->pdev->irq, &ixgbe_intr, 0,
                  netdev->name, adapter);
    /* 都不支持的状况,直接设置 ixgbe_intr 为中断处理程序 */
    else 
        err = request_irq(adapter->pdev->irq, &ixgbe_intr, IRQF_SHARED,
                  netdev->name, adapter);
 
    if (err)
        e_err(probe, "request_irq failed, Error %d\n", err);
 
    return err;
}
  
/**
 * 文件:ixgbe_main.c
 * ixgbe_request_msix_irqs - Initialize MSI-X interrupts
 * @adapter: board private structure
 *
 * ixgbe_request_msix_irqs allocates MSI-X vectors and requests
 * interrupts from the kernel.
 **/
static int (struct ixgbe_adapter *adapter)
{
    …
    for (vector = 0; vector < adapter->num_q_vectors; vector++) {
        struct ixgbe_q_vector *q_vector = adapter->q_vector[vector];
        struct msix_entry *entry = &adapter->msix_entries[vector];
 
        /* 设置中断处理入口函数为 ixgbe_msix_clean_rings */
        err = request_irq(entry->vector, &ixgbe_msix_clean_rings, 0,
                  q_vector->name, q_vector);
        if (err) {
            e_err(probe, "request_irq failed for MSIX interrupt '%s' "
                  "Error: %d\n", q_vector->name, err);
            goto free_queue_irqs;
        }
    …
    }
}
复制代码

(2)线上的多队列网卡均支持MSIX,中断处理程序入口为ixgbe_msix_clean_rings,里面调用了函数napi_schedule(&q_vector->napi)

/**
 * 文件:ixgbe_main.c
 **/
static irqreturn_t ixgbe_msix_clean_rings(int irq, void *data)
{
    struct ixgbe_q_vector *q_vector = data;
 
    /* EIAM disabled interrupts (on this vector) for us */
 
    if (q_vector->rx.ring || q_vector->tx.ring)
        napi_schedule(&q_vector->napi);
 
    return IRQ_HANDLED;
}
复制代码

(3)以后通过一些列调用,直到发起名为NET_RX_SOFTIRQ的软中断。到这里完成了硬中断部分,进入软中断部分,同时也上升到了内核层面。

/**
 * 文件:include/linux/netdevice.h
 *  napi_schedule - schedule NAPI poll
 *  @n: NAPI context
 *
 * Schedule NAPI poll routine to be called if it is not already
 * running.
 */
static inline void napi_schedule(struct napi_struct *n)
{
    if (napi_schedule_prep(n))
    /*  注意下面调用的这个函数名字前是两个下划线 */
        __napi_schedule(n);
}
 
/**
 * 文件:net/core/dev.c
 * __napi_schedule - schedule for receive
 * @n: entry to schedule
 *
 * The entry's receive function will be scheduled to run. * Consider using __napi_schedule_irqoff() if hard irqs are masked. */ void __napi_schedule(struct napi_struct *n) { unsigned long flags; /* local_irq_save用来保存中断状态,并禁止中断 */ local_irq_save(flags); /* 注意下面调用的这个函数名字前是四个下划线,传入的 softnet_data 是当前CPU */ ____napi_schedule(this_cpu_ptr(&softnet_data), n); local_irq_restore(flags); } /* Called with irq disabled */ static inline void ____napi_schedule(struct softnet_data *sd, struct napi_struct *napi) { /* 将 napi_struct 加入 softnet_data 的 poll_list */ list_add_tail(&napi->poll_list, &sd->poll_list); /* 发起软中断 NET_RX_SOFTIRQ */ __raise_softirq_irqoff(NET_RX_SOFTIRQ); } 复制代码

(4)NET_RX_SOFTIRQ对应的软中断处理程序接口是net_rx_action()

/*
 *  文件:net/core/dev.c
 *  Initialize the DEV module. At boot time this walks the device list and
 *  unhooks any devices that fail to initialise (normally hardware not
 *  present) and leaves us with a valid list of present and active devices.
 *
 */
 
/*
 *       This is called single threaded during boot, so no need
 *       to take the rtnl semaphore.
 */
static int __init net_dev_init(void)
{
    …
    /*  分别注册TX和RX软中断的处理程序 */
    open_softirq(NET_TX_SOFTIRQ, net_tx_action);
    open_softirq(NET_RX_SOFTIRQ, net_rx_action);
    …
}
复制代码

(5)net_rx_action功能就是轮询调用poll方法,这里就是ixgbe_poll。一次轮询的数据包数量不能超过内核参数net.core.netdev_budget指定的数量(默认值300),而且轮询时间不能超过2个时间片。这个机制保证了单次软中断处理不会耗时过久影响被中断的程序。

/* 文件:net/core/dev.c  */
static void net_rx_action(struct softirq_action *h)
{
    struct softnet_data *sd = &__get_cpu_var(softnet_data);
    unsigned long time_limit = jiffies + 2;
    int budget = netdev_budget;
    void *have;
 
    local_irq_disable();
 
    while (!list_empty(&sd->poll_list)) {
        struct napi_struct *n;
        int work, weight;
 
        /* If softirq window is exhuasted then punt.
         * Allow this to run for 2 jiffies since which will allow
         * an average latency of 1.5/HZ.
         */
  
        /* 判断处理包数是否超过 netdev_budget 及时间是否超过2个时间片 */
        if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
            goto softnet_break;
 
        local_irq_enable();
 
        /* Even though interrupts have been re-enabled, this
         * access is safe because interrupts can only add new
         * entries to the tail of this list, and only ->poll()
         * calls can remove this head entry from the list.
         */
        n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
 
        have = netpoll_poll_lock(n);
 
        weight = n->weight;
 
        /* This NAPI_STATE_SCHED test is for avoiding a race
         * with netpoll's poll_napi(). Only the entity which * obtains the lock and sees NAPI_STATE_SCHED set will * actually make the ->poll() call. Therefore we avoid * accidentally calling ->poll() when NAPI is not scheduled. */ work = 0; if (test_bit(NAPI_STATE_SCHED, &n->state)) { work = n->poll(n, weight); trace_napi_poll(n); } …… } } 复制代码

(6)ixgbe_poll以后的一系列调用就不一一详述了,有兴趣的同窗能够自行研究,软中断部分有几个地方会有相似if (static_key_false(&rps_needed))这样的判断,会进入前文所述有丢包风险的enqueue_to_backlog函数。 这里的逻辑为判断是否启用了RPS机制,RPS是早期单队列网卡上将软中断负载均衡到多个CPU Core的技术,它对数据流进行hash并分配到对应的CPU Core上,发挥多核的性能。不过如今基本都是多队列网卡,不会开启这个机制,所以走不到这里,static_key_false是针对默认为falsestatic key的优化判断方式。这段调用的最后,deliver_skb会将接收的数据传入一个IP层的数据结构中,至此完成二层的所有处理。

/**
 *  netif_receive_skb - process receive buffer from network
 *  @skb: buffer to process
 *
 *  netif_receive_skb() is the main receive data processing function.
 *  It always succeeds. The buffer may be dropped during processing
 *  for congestion control or by the protocol layers.
 *
 *  This function may only be called from softirq context and interrupts
 *  should be enabled.
 *
 *  Return values (usually ignored):
 *  NET_RX_SUCCESS: no congestion
 *  NET_RX_DROP: packet was dropped
 */
int netif_receive_skb(struct sk_buff *skb)
{
    int ret;
 
    net_timestamp_check(netdev_tstamp_prequeue, skb);
 
    if (skb_defer_rx_timestamp(skb))
        return NET_RX_SUCCESS;
 
    rcu_read_lock();
 
#ifdef CONFIG_RPS
    /* 判断是否启用RPS机制 */
    if (static_key_false(&rps_needed)) {
        struct rps_dev_flow voidflow, *rflow = &voidflow;
        /* 获取对应的CPU Core */
        int cpu = get_rps_cpu(skb->dev, skb, &rflow);
 
        if (cpu >= 0) {
            ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
            rcu_read_unlock();
            return ret;
        }
    }
#endif
    ret = __netif_receive_skb(skb);
    rcu_read_unlock();
    return ret;
}
复制代码

TCP/IP协议栈逐层处理,最终交给用户空间读取

数据包进到IP层以后,通过IP层、TCP层处理(校验、解析上层协议,发送给上层协议),放入socket buffer,在应用程序执行read() 系统调用时,就能从socket buffer中将新数据从内核区拷贝到用户区,完成读取。

这里的socket buffer大小即TCP接收窗口,TCP因为具有流量控制功能,能动态调整接收窗口大小,所以数据传输阶段不会出现因为socket buffer接收队列空间不足而丢包的状况(但UDP及TCP握手阶段仍会有)。涉及TCP/IP协议的部分不是这次丢包问题的研究重点,所以这里再也不赘述。

网卡队列

查看网卡型号

# lspci -vvv | grep Eth
01:00.0 Ethernet controller: Intel Corporation Ethernet Controller 10-Gigabit X540-AT2 (rev 03)
        Subsystem: Dell Ethernet 10G 4P X540/I350 rNDC
01:00.1 Ethernet controller: Intel Corporation Ethernet Controller 10-Gigabit X540-AT2 (rev 03)
        Subsystem: Dell Ethernet 10G 4P X540/I350 rNDC

  
# lspci -vvv
07:00.0 Ethernet controller: Intel Corporation I350 Gigabit Network Connection (rev 01)
        Subsystem: Dell Gigabit 4P X540/I350 rNDC
        Control: I/O- Mem+ BusMaster+ SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR- FastB2B- DisINTx+
        Status: Cap+ 66MHz- UDF- FastB2B- ParErr- DEVSEL=fast >TAbort- <TAbort- <MAbort- >SERR- <PERR- INTx-
        Latency: 0, Cache Line Size: 128 bytes
        Interrupt: pin D routed to IRQ 19
        Region 0: Memory at 92380000 (32-bit, non-prefetchable) [size=512K]
        Region 3: Memory at 92404000 (32-bit, non-prefetchable) [size=16K]
        Expansion ROM at 92a00000 [disabled] [size=512K]
        Capabilities: [40] Power Management version 3
                Flags: PMEClk- DSI+ D1- D2- AuxCurrent=0mA PME(D0+,D1-,D2-,D3hot+,D3cold+)
                Status: D0 NoSoftRst+ PME-Enable- DSel=0 DScale=1 PME-
        Capabilities: [50] MSI: Enable- Count=1/1 Maskable+ 64bit+
                Address: 0000000000000000  Data: 0000
                Masking: 00000000  Pending: 00000000
        Capabilities: [70] MSI-X: Enable+ Count=10 Masked-
                Vector table: BAR=3 offset=00000000
                PBA: BAR=3 offset=00002000

复制代码

能够看出,网卡的中断机制是MSI-X,即网卡的每一个队列均可以分配中断(MSI-X支持2048个中断)。

网卡队列

...
 #define IXGBE_MAX_MSIX_VECTORS_82599 0x40
...
 
  
  u16 ixgbe_get_pcie_msix_count_generic(struct ixgbe_hw *hw)
 {
     u16 msix_count;
     u16 max_msix_count;
     u16 pcie_offset;
  
     switch (hw->mac.type) {
     case ixgbe_mac_82598EB:
         pcie_offset = IXGBE_PCIE_MSIX_82598_CAPS;
         max_msix_count = IXGBE_MAX_MSIX_VECTORS_82598;
         break;
     case ixgbe_mac_82599EB:
     case ixgbe_mac_X540:
     case ixgbe_mac_X550:
     case ixgbe_mac_X550EM_x:
     case ixgbe_mac_x550em_a:
         pcie_offset = IXGBE_PCIE_MSIX_82599_CAPS;
         max_msix_count = IXGBE_MAX_MSIX_VECTORS_82599;
         break;
     default:
         return 1;
     }
 ...
复制代码

根据网卡型号肯定驱动中定义的网卡队列,能够看到X540网卡驱动中定义最大支持的IRQ Vector为0x40(数值:64)。

static int ixgbe_acquire_msix_vectors(struct ixgbe_adapter *adapter)
 {
     struct ixgbe_hw *hw = &adapter->hw;
     int i, vectors, vector_threshold;
  
     /* We start by asking for one vector per queue pair with XDP queues
      * being stacked with TX queues.
      */
     vectors = max(adapter->num_rx_queues, adapter->num_tx_queues);
     vectors = max(vectors, adapter->num_xdp_queues);
  
     /* It is easy to be greedy for MSI-X vectors. However, it really
      * doesn't do much good if we have a lot more vectors than CPUs. We'll
      * be somewhat conservative and only ask for (roughly) the same number
      * of vectors as there are CPUs.
      */
     vectors = min_t(int, vectors, num_online_cpus());
复制代码

经过加载网卡驱动,获取网卡型号和网卡硬件的队列数;可是在初始化misx vector的时候,还会结合系统在线CPU的数量,经过Sum = Min(网卡队列,CPU Core) 来激活相应的网卡队列数量,并申请Sum个中断号。

若是CPU数量小于64,会生成CPU数量的队列,也就是每一个CPU会产生一个external IRQ。

咱们线上的CPU通常是48个逻辑core,就会生成48个中断号,因为咱们是两块网卡作了bond,也就会生成96个中断号。

验证与复现网络丢包

咱们在测试环境作了测试,发现测试环境的中断确实有集中在CPU 0的状况,下面使用systemtap诊断测试环境软中断分布的方法:

global hard, soft, wq
  
probe irq_handler.entry {
hard[irq, dev_name]++;
}
  
probe timer.s(1) {
println("==irq number:dev_name")
foreach( [irq, dev_name] in hard- limit 5) {
printf("%d,%s->%d\n", irq, kernel_string(dev_name), hard[irq, dev_name]);      
}
 
println("==softirq cpu:h:vec:action")
foreach( [c,h,vec,action] in soft- limit 5) {
printf("%d:%x:%x:%s->%d\n", c, h, vec, symdata(action), soft[c,h,vec,action]);      
}
 
  
println("==workqueue wq_thread:work_func")
foreach( [wq_thread,work_func] in wq- limit 5) {
printf("%x:%x->%d\n", wq_thread, work_func, wq[wq_thread, work_func]); 
}
  
println("\n")
delete hard
delete soft
delete wq
}
  
probe softirq.entry {
soft[cpu(), h,vec,action]++;
}
  
probe workqueue.execute {
wq[wq_thread, work_func]++
}
  
  
probe begin {
println("~")
}
复制代码

下面执行i.stap的结果:

==irq number:dev_name
87,eth0-0->1693
90,eth0-3->1263
95,eth1-3->746
92,eth1-0->703
89,eth0-2->654
==softirq cpu:h:vec:action
0:ffffffff81a83098:ffffffff81a83080:0xffffffff81461a00->8928
0:ffffffff81a83088:ffffffff81a83080:0xffffffff81084940->626
0:ffffffff81a830c8:ffffffff81a83080:0xffffffff810ecd70->614
16:ffffffff81a83088:ffffffff81a83080:0xffffffff81084940->225
16:ffffffff81a830c8:ffffffff81a83080:0xffffffff810ecd70->224
==workqueue wq_thread:work_func
ffff88083062aae0:ffffffffa01c53d0->10
ffff88083062aae0:ffffffffa01ca8f0->10
ffff88083420a080:ffffffff81142160->2
ffff8808343fe040:ffffffff8127c9d0->2
ffff880834282ae0:ffffffff8133bd20->1
复制代码

下面是action对应的符号信息:

addr2line -e /usr/lib/debug/lib/modules/2.6.32-431.20.3.el6.mt20161028.x86_64/vmlinux ffffffff81461a00
/usr/src/debug/kernel-2.6.32-431.20.3.el6/linux-2.6.32-431.20.3.el6.mt20161028.x86_64/net/core/dev.c:4013
复制代码

打开这个文件,咱们发现它是在执行static void net_rx_action(struct softirq_action *h)这个函数,而这个函数正是前文提到的,NET_RX_SOFTIRQ对应的软中断处理程序。所以能够确认网卡的软中断在机器上分布很是不均,并且主要集中在CPU 0上。经过/proc/interrupts能确认硬中断集中在CPU 0上,所以软中断也都由CPU 0处理,如何优化网卡的中断成为了咱们关注的重点。

优化策略

CPU亲缘性

前文提到,丢包是由于队列中的数据包超过了netdev_max_backlog形成了丢弃,所以首先想到是临时调大netdev_max_backlog可否解决燃眉之急,事实证实,对于轻微丢包调大参数能够缓解丢包,但对于大量丢包则几乎不怎么管用,内核处理速度跟不上收包速度的问题仍是客观存在,本质仍是由于单核处理中断有瓶颈,即便不丢包,服务响应速度也会变慢。所以若是能同时使用多个CPU Core来处理中断,就能显著提升中断处理的效率,而且每一个CPU都会实例化一个softnet_data对象,队列数也增长了。

中断亲缘性设置

经过设置中断亲缘性,可让指定的中断向量号更倾向于发送给指定的CPU Core来处理,俗称“绑核”。命令grep eth /proc/interrupts的第一列能够获取网卡的中断号,若是是多队列网卡,那么就会有多行输出:

中断的亲缘性设置能够在cat /proc/irq/${中断号}/smp_affinity 或 cat /proc/irq/${中断号}/smp_affinity_list中确认,前者是16进制掩码形式,后者是以CPU Core序号形式。例以下图中,将16进制的400转换成2进制后,为 10000000000,“1”在第10位上,表示亲缘性是第10个CPU Core

那为何中断号只设置一个CPU Core呢?而不是为每个中断号设置多个CPU Core平行处理。咱们通过测试,发现当给中断设置了多个CPU Core后,它也仅能由设置的第一个CPU Core来处理,其余的CPU Core并不会参与中断处理,缘由猜测是当CPU能够平行收包时,不一样的核收取了同一个queue的数据包,但处理速度不一致,致使提交到IP层后的顺序也不一致,这就会产生乱序的问题,由同一个核来处理能够避免了乱序问题。

可是,当咱们配置了多个Core处理中断后,发现Redis的慢查询数量有明显上升,甚至部分业务也受到了影响,慢查询增多直接致使可用性下降,所以方案仍需进一步优化。

Redis进程亲缘性设置

若是某个CPU Core正在处理Redis的调用,执行到一半时产生了中断,那么CPU不得不中止当前的工做转而处理中断请求,中断期间Redis也没法转交给其余core继续运行,必须等处理完中断后才能继续运行。Redis自己定位就是高速缓存,线上的平均端到端响应时间小于1ms,若是频繁被中断,那么响应时间必然受到极大影响。容易想到,由最初的CPU 0单核处理中断,改进到多核处理中断,Redis进程被中断影响的概率增大了,所以咱们须要对Redis进程也设置CPU亲缘性,使其与处理中断的Core互相错开,避免受到影响。

使用命令taskset能够为进程设置CPU亲缘性,操做十分简单,一句taskset -cp cpu-list pid便可完成绑定。通过一番压测,咱们发现使用8个core处理中断时,流量直至打满双万兆网卡也不会出现丢包,所以决定将中断的亲缘性设置为物理机上前8个core,Redis进程的亲缘性设置为剩下的全部core。调整后,确实有明显的效果,慢查询数量大幅优化,但对比初始状况,仍然仍是高了一些些,还有没有优化空间呢?

经过观察,咱们发现一个有趣的现象,当只有CPU 0处理中断时,Redis进程更倾向于运行在CPU 0,以及CPU 0同一物理CPU下的其余核上。因而有了如下推测:咱们设置的中断亲缘性,是直接选取了前8个核心,但这8个core却多是来自两块物理CPU的,在/proc/cpuinfo中,经过字段processorphysical id 能确认这一点,那么响应慢是否和物理CPU有关呢?物理CPU又和NUMA架构关联,每一个物理CPU对应一个NUMA node,那么接下来就要从NUMA角度进行分析。

NUMA

SMP 架构

随着单核CPU的频率在制造工艺上的瓶颈,CPU制造商的发展方向也由纵向变为横向:从CPU频率转为每瓦性能。CPU也就从单核频率时代过渡到多核性能协调。

SMP(对称多处理结构):即CPU共享全部资源,例如总线、内存、IO等。

SMP 结构:一个物理CPU能够有多个物理Core,每一个Core又能够有多个硬件线程。即:每一个HT有一个独立的L1 cache,同一个Core下的HT共享L2 cache,同一个物理CPU下的多个core共享L3 cache。

下图(摘自内核月谈)中,一个x86 CPU有4个物理Core,每一个Core有两个HT(Hyper Thread)。

NUMA 架构

在前面的FSB(前端系统总线)结构中,当CPU不断增加的状况下,共享的系统总线就会由于资源竞争(多核争抢总线资源以访问北桥上的内存)而出现扩展和性能问题。

在这样的背景下,基于SMP架构上的优化,设计出了NUMA(Non-Uniform Memory Access)非均匀内存访问。

内存控制器芯片被集成处处理器内部,多个处理器经过QPI链路相连,DRAM也就有了远近之分。(以下图所示:摘自CPU Cache)

CPU 多层Cache的性能差别是很巨大的,好比:L1的访问时长1ns,L2的时长3ns…跨node的访问会有几十甚至上百倍的性能损耗。

NUMA 架构下的中断优化

这时咱们再回归到中断的问题上,当两个NUMA节点处理中断时,CPU实例化的softnet_data以及驱动分配的sk_buffer均可能是跨Node的,数据接收后对上层应用Redis来讲,跨Node访问的概率也大大提升,而且没法充分利用L二、L3 cache,增长了延时。

同时,因为Linux wake affinity特性,若是两个进程频繁互动,调度系统会以为它们颇有可能共享一样的数据,把它们放到同一CPU核心或NUMA Node有助于提升缓存和内存的访问性能,因此当一个进程唤醒另外一个的时候,被唤醒的进程可能会被放到相同的CPU core或者相同的NUMA节点上。此特性对中断唤醒进程时也起做用,在上一节所述的现象中,全部的网络中断都分配给CPU 0去处理,当中断处理完成时,因为wakeup affinity特性的做用,所唤醒的用户进程也被安排给CPU 0或其所在的numa节点上其余core。而当两个NUMA node处理中断时,这种调度特性有可能致使Redis进程在CPU core之间频繁迁移,形成性能损失。

综合上述,将中断都分配在同一NUMA Node中,中断处理函数和应用程序充分利用同NUMA下的L二、L3缓存、以及同Node下的内存,结合调度系统的wake affinity特性,可以更进一步下降延迟。

相关文章
相关标签/搜索