核心思想
RPS补丁要解决的问题是单队列网卡如何在多CPU机器上实现更高的接收速率。考虑传统的接收方式:软中断上下文进行数据包的接收,而软中断由哪个CPU来调度是由硬中断决定的,硬中断往往具有随机性,如果软中断总是在一个繁忙的CPU上被运行,那么CPU的处理速度有可能会成为接收瓶颈,为了实现负载均衡,如果能够将数据包分别由多个CPU来处理,那么肯定可以提升性能,当然要注意同一个流的数据包应该总是由同一个CPU处理,否则会出现接收乱序,这对于TCP这样的协议来讲是绝对不可以接受的。
数据结构
网卡结构中的新增字段
struct net_device {
...
// 对应/sys/class/net/<device>/queues目录
struct kset *queues_kset;
// RPS接收队列
struct netdev_rx_queue *_rx;
/* Number of RX queues allocated at alloc_netdev_mq() time */
unsigned int num_rx_queues;
...
}
CPU接收队列中的新增字段
struct softnet_data {
...
/* Elements below can be accessed between CPUs for RPS */
// 该结构和IPI机制有关,用于一个CPU向另一个CPU发送通知
struct call_single_data csd ____cacheline_aligned_in_smp;
// input_pkt_queue不算新增,之前就有,只是挪了下地方
struct sk_buff_head input_pkt_queue;
}
skb中的新增字段
struct sk_buff {
...
// 保存skb的哈希结果,根据该哈希结果选择应该由哪个CPU来处理该数据包
__u32 rxhash;
}
RPS接收队列
/*
* This structure holds an RPS map which can be of variable length. The
* map is an array of CPUs.
*/
struct rps_map {
// cpus数组中有效元素个数,也表示该队列可以被几个CPU接收处理
unsigned int len;
struct rcu_head rcu;
// cpus[0]代表的是第一个有效CPU的cpu id,cpus[1]代表的是第二个有效CPU的cpu id,以此类推
u16 cpus[0];
};
/* This structure contains an instance of an RX queue. */
struct netdev_rx_queue {
// 根据/sys/class/net/<device>/queues/rx-<n>/rps_cpus文件分配并初始化该map
struct rps_map *rps_map;
// 对应/sys/class/net/<device>/queues/rx-<n>目录
struct kobject kobj;
// 因为可以创建多个RPS接收队列,下面的字段永远指向第一个队列
struct netdev_rx_queue *first;
// RPS队列个数
atomic_t count;
} ____cacheline_aligned_in_smp;
队列中的rps_map成员至关重要,它决定了当前队列的数据包能够分发到哪些CPU上面处理。
关键流程分析
CPU接收队列初始化
在网络设备接口层初始化过程中,会对每个CPU的接收队列进行初始化,RPS相关内容如下:
static int __init net_dev_init(void) {
...
queue->csd.func = trigger_softirq;
queue->csd.info = queue;
queue->csd.flags = 0;
...
}
网络设备结构分配
在分配网络设备时,增加了分配RPS队列的逻辑:
struct net_device *alloc_netdev_mq(int sizeof_priv, const char *name,
void (*setup)(struct net_device *), unsigned int queue_count)
{
...
struct netdev_rx_queue *rx;
int i;
// 按照队列个数分配RPS接收队列,驱动程序可以指定队列个数
rx = kcalloc(queue_count, sizeof(struct netdev_rx_queue), GFP_KERNEL);
if (!rx) {
printk(KERN_ERR "alloc_netdev: Unable to allocate rx queues.\n");
goto free_tx;
}
// 将RPS队列个数记录到rx[0].count中,下面也有记录到dev->num_rx_queues中
atomic_set(&rx->count, queue_count);
// 多队列场景,每一个队列都会有一个指向第一个队列的指针
for (i = 0; i < queue_count; i++)
rx[i].first = rx;
// 设置RPS接收队列到网卡结构中
dev->_rx = rx;
dev->num_rx_queues = queue_count;
...
}
类似的,在网络设备的注册接口register_netdevice()中如果发现dev->num_rx_queues为0,那么为了兼容RPS的处理流程,总是会为网络设备分配1个接收队列的。代码和上面的逻辑非常类似,不在赘述。
控制参数rps_cpus
如前面介绍,引入RPS特性时,在sys文件系统中增加了目录/sys/class/net//queues/rx-,该目录只有一个属性文件:rps_cpus,它是一个CPU掩码值,内核会使用该掩码,下面看该文件的写操作,从写操作的实现中,可以理解该参数控制了什么。
// rps_cpus属性,对应/sys/class/net/<device>/queues/rx-<n>/rps_cpus文件
static struct rx_queue_attribute rps_cpus_attribute = __ATTR(rps_cpus, S_IRUGO | S_IWUSR, show_rps_map, store_rps_map);
ssize_t store_rps_map(struct netdev_rx_queue *queue, struct rx_queue_attribute *attribute, const char *buf, size_t len)
{
struct rps_map *old_map, *map;
cpumask_var_t mask;
int err, cpu, i;
static DEFINE_SPINLOCK(rps_map_lock);
if (!capable(CAP_NET_ADMIN))
return -EPERM;
// 分配一个cpu掩码变量,其类型是体系结构相关的
if (!alloc_cpumask_var(&mask, GFP_KERNEL))
return -ENOMEM;
// 根据设置的值将mask中指定bit置位
err = bitmap_parse(buf, len, cpumask_bits(mask), nr_cpumask_bits);
if (err) {
free_cpumask_var(mask);
return err;
}
// 根据掩码分配rps_map,该掩码的值会影响rps_map中cpu映射数组的大小
map = kzalloc(max_t(unsigned, RPS_MAP_SIZE(cpumask_weight(mask)), L1_CACHE_BYTES), GFP_KERNEL);
if (!map) {
free_cpumask_var(mask);
return -ENOMEM;
}
// 参数指定的掩码和cpu_online_mask(表示对应的cpu是否可以工作)相与后,将其中为1的cpu编号记录到map->cpus数组中
i = 0;
for_each_cpu_and(cpu, mask, cpu_online_mask)
map->cpus[i++] = cpu;
// 显然,map->len的含义表示最后该队列可以映射到的CPU的个数。如果该队列没有任何一个CPU可以接收,
// 那么将map为NULL,这种情况下对于该队列将不会启用RPS
if (i)
map->len = i;
else {
kfree(map);
map = NULL;
}
// 更新队列的rps_map
spin_lock(&rps_map_lock);
old_map = queue->rps_map;
rcu_assign_pointer(queue->rps_map, map);
spin_unlock(&rps_map_lock);
// 释放旧的rps_map
if (old_map)
call_rcu(&old_map->rcu, rps_map_release);
free_cpumask_var(mask);
return len;
}
从上面的流程中可以看出,控制参数rps_cpus将决定该队列的数据可以由哪些CPU处理,只有哪些rps_cpus对应bit置1的CPU才能处理该队列的数据包。
数据包接收
我们知道,无论是NAPI还是非NAPI流程(下面介绍的是NAPI方式,对于非NAPI方式,仅仅是在netif_rx()中有修改,目的是少绕一个圈子),最后设备接口层都是通过netif_receive_skb()将数据包递交给协议栈的,引入RPS后,这部分逻辑变更如下:
int netif_receive_skb(struct sk_buff *skb)
{
int cpu;
// 计算该skb应该由哪个CPU处理
cpu = get_rps_cpu(skb->dev, skb);
if (cpu < 0)
// 如果没有映射的CPU,那么走原来的流程,在当前CPU上将数据包递交给协议栈,这可以兼容以前的逻辑。
// 一般RPS未开启或者有什么其他异常时属于这种情况
return __netif_receive_skb(skb);
else
// 将数据包放入指定cpu的input_pkt_queue中,激活指定CPU的软中断完成数据包的接收(如果需要)
return enqueue_to_backlog(skb, cpu);
}
决定数据包归属CPU
如上,RPS接收过程中的第一个核心逻辑就是确定一个数据包应该由哪个CPU来接收,get_rps_cpu()的实现如下:
/*
* get_rps_cpu is called from netif_receive_skb and returns the target
* CPU from the RPS map of the receiving queue for a given skb.
*/
static int get_rps_cpu(struct net_device *dev, struct sk_buff *skb)
{
struct ipv6hdr *ip6;
struct iphdr *ip;
struct netdev_rx_queue *rxqueue;
struct rps_map *map;
int cpu = -1;
u8 ip_proto;
u32 addr1, addr2, ports, ihl;
rcu_read_lock();
if (skb_rx_queue_recorded(skb)) {
// 如果驱动程序已经通过skb->queue_mapping指定了该数据包应该由哪个CPU处理,那么尊重其选择
u16 index = skb_get_rx_queue(skb);
if (unlikely(index >= dev->num_rx_queues)) {
if (net_ratelimit()) {
netdev_warn(dev, "received packet on queue %u, but number of RX queues is %u\n",
index, dev->num_rx_queues);
}
goto done;
}
rxqueue = dev->_rx + index;
} else
// 如果驱动没有选择,则选择第一个队列
rxqueue = dev->_rx;
// rps_map为空,表示不启用RPS,控制参数rps_cpus的值如果时0x0那么就是这种情况
if (!rxqueue->rps_map)
goto done;
// 下面要根据skb的报头信息计算哈希值,如果网卡已经计算了,那么这里不用多次一举了
if (skb->rxhash)
goto got_hash; /* Skip hash computation on packet header */
// 网卡没有计算,那么自己计算,影响哈希值的因子有: L4端口号、源IP地址、目的IP地址和一个系统初始化时产生的随机值
switch (skb->protocol) {
case __constant_htons(ETH_P_IP):
if (!pskb_may_pull(skb, sizeof(*ip)))
goto done;
ip = (struct iphdr *) skb->data;
ip_proto = ip->protocol;
addr1 = ip->saddr;
addr2 = ip->daddr;
ihl = ip->ihl;
break;
case __constant_htons(ETH_P_IPV6):
if (!pskb_may_pull(skb, sizeof(*ip6)))
goto done;
ip6 = (struct ipv6hdr *) skb->data;
ip_proto = ip6->nexthdr;
addr1 = ip6->saddr.s6_addr32[3];
addr2 = ip6->daddr.s6_addr32[3];
ihl = (40 >> 2);
break;
default:
goto done;
}
ports = 0;
switch (ip_proto) {
case IPPROTO_TCP:
case IPPROTO_UDP:
case IPPROTO_DCCP:
case IPPROTO_ESP:
case IPPROTO_AH:
case IPPROTO_SCTP:
case IPPROTO_UDPLITE:
if (pskb_may_pull(skb, (ihl * 4) + 4))
ports = *((u32 *) (skb->data + (ihl * 4)));
break;
default:
break;
}
// 计算哈希值
skb->rxhash = jhash_3words(addr1, addr2, ports, hashrnd);
if (!skb->rxhash)
skb->rxhash = 1;
got_hash:
map = rcu_dereference(rxqueue->rps_map);
if (map) {
// 不理解这个转换关系
u16 tcpu = map->cpus[((u64) skb->rxhash * map->len) >> 32];
// 目标CPU必须在工作
if (cpu_online(tcpu)) {
cpu = tcpu;
goto done;
}
}
done:
rcu_read_unlock();
return cpu;
}
如上,关键是哈希值的计算,由于使用的是数据包的源IP、目的IP、L4协议号和一个系统级别的随机数,所以属于同一条流的数据包一定可以映射到同一个CPU上面处理,这一点非常的关键。
将数据包放入目标CPU队列
RPS方式接收数据包的第二个关键步骤就是将数据包放入目标CPU的处理队列中,enqueue_to_backlog()的实现如下:
/*
* 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)
{
struct softnet_data *queue;
unsigned long flags;
// 找到目标CPU的数据包接收队列
queue = &per_cpu(softnet_data, cpu);
local_irq_save(flags);
__get_cpu_var(netdev_rx_stat).total++;
// 下面的逻辑将把数据包放入目标CPU接收队列中的input_pkt_queue中
spin_lock(&queue->input_pkt_queue.lock);
if (queue->input_pkt_queue.qlen <= netdev_max_backlog) {
// 不能超过该队列的接收上限
if (queue->input_pkt_queue.qlen) {
// 如果不是第一个放入该队列的数据包,那么只需要放入就可以了
enqueue:
__skb_queue_tail(&queue->input_pkt_queue, skb);
spin_unlock_irqrestore(&queue->input_pkt_queue.lock, flags);
return NET_RX_SUCCESS;
}
// 对于第一次放入skb,需要激活软中断来处理input_pkt_queue
if (napi_schedule_prep(&queue->backlog)) {
if (cpu != smp_processor_id()) {
// 如果目标CPU不是当前CPU,那么设置标记,在软中断中会检查该标记,然后使用IPI机制激活目标CPU
struct rps_remote_softirq_cpus *rcpus = &__get_cpu_var(rps_remote_softirq_cpus);
cpu_set(cpu, rcpus->mask[rcpus->select]);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
} else
// 目标CPU就是当前CPU,直接激活软中断即可
__napi_schedule(&queue->backlog);
}
goto enqueue;
}
// 队列已满,丢包
spin_unlock(&queue->input_pkt_queue.lock);
__get_cpu_var(netdev_rx_stat).dropped++;
local_irq_restore(flags);
kfree_skb(skb);
return NET_RX_DROP;
}
软中断下半部的处理
从上面的实现看,开启RPS后,会将数据包放入目标CPU的input_pkt_queue中激活软中断后继续接收,我们知道,input_pkt_queue队列的处理,是在process_backlog()中处理的,下面看软中断接收过程的实现。
首先还是接收软中断net_rx_action():
/*
* net_rps_action sends any pending IPI's for rps. This is only called from
* softirq and interrupts must be enabled.
*/
static void net_rps_action(cpumask_t *mask)
{
int cpu;
/* Send pending IPI's to kick RPS processing on remote cpus. */
for_each_cpu_mask_nr(cpu, *mask) {
struct softnet_data *queue = &per_cpu(softnet_data, cpu);
if (cpu_online(cpu))
__smp_call_function_single(cpu, &queue->csd, 0);
}
cpus_clear(*mask);
}
static void net_rx_action(struct softirq_action *h)
{
...
int select;
struct rps_remote_softirq_cpus *rcpus;
// 处理目标CPU和当前CPU不一致的场景,IPI机制激活目标CPU
rcpus = &__get_cpu_var(rps_remote_softirq_cpus);
select = rcpus->select;
rcpus->select ^= 1;
local_irq_enable();
net_rps_action(&rcpus->mask[select]);
...
}
process_backlog()的处理如下:
static int process_backlog(struct napi_struct *napi, int quota)
{
...
// 调用的不再是netif_receive_skb()
__netif_receive_skb(skb);
}