RPS特性

核心思想

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);
}

参考资料

  1. RPS补丁;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值