多队列网卡与虚拟化

多队列网卡

之前简单介绍过多队列网卡,这里稍微展开一点。
之前介绍过,一个数据包,由某个队列处理,触发指定中断。
在这里插入图片描述
当收到报文时,通过 hash 包头的(sip, sport, dip, dport)四元组,将一条流总是收到相同队列,同时触发与该队列绑定的中断。

ls /sys/class/net/eth0/queue

结果
在这里插入图片描述

内核对多队列网卡的支持

Linux 内核中,RPS(Receive Packet Steering)在接收端提供了这样的机制。RPS 主要是把软中断的负载均衡到 CPU 的各个 core 上,网卡驱动对每个流生成一个 hash 标识,这个 hash值可以通过四元组来计算,然后由中断处理的地方根据这个 hash 标识分配到相应的 core 上去,这样就可以比较充分地发挥多核的能力了。
在这里插入图片描述

NAPI

内核使用NAPI技术来接收数据。NAPI 是 linux 上采用一种提高网络处理效率的技术,其核心概念就是不采用中断的方式读取数据,取而代之是采用中断唤醒数据接收的服务程序,然后 POLL 的方式轮询数据。为什么不使用中断来直接读取数据呢?因为网络上数据包很多,如果来数据就中断,效率会很低,如果数据包少,这个方法还可行。
NAPI 的优点:

  1. 中断缓和,在日常使用中,网卡产生的高达几 k/s,每次中断都需要系统来处理,是一个很大的压力,而 NAPI 使用轮询是禁止了网卡接收中断,减小处理中断的压力。
  2. 数据包节流,NAPI 之前的 NIC 总在接收数据包之后产生一个 IRQ,接着在中断服务函数将 skb 加入本地 softnet,然后触发本地 NET_RX_SOFTIRQ 软中断后续处理。如果包速过高,因为 IRQ 的优先级高于 SoftIRQ,导致系统的大部分资源都在响应中断,但 softnet 的队列大小有限,接收到的超额数据包也只能丢掉,所以这时这个模型是在用宝贵的系统资源做无用功。而 NAPI 则在这样的情况下,直接把包丢掉,不会继续将需要丢掉的数据包扔给内核去处理,这样,网卡将需要丢掉的数据包尽可能的早丢弃掉,内核将不可见需要丢掉的数据包,这样也减少了内核的压力。

NAPI 的缺点:

  1. 对于上层的应用程序而言,系统不能在每个数据包接收到的时候都可以及时地去处理它,而且随着传输速度增加,累计的数据包将会耗费大量的内存。
  2. 另外一个问题是对于大的数据包处理比较困难,原因是大的数据包传送到网络层上的时候耗费的时间比短数据包长很多(即使是采用 DMA 方式)。所以,NAPI 技术适用于对高速率的短长度数据包的处理。

QDisc

内核使用QDisc技术来发送数据。QDisc 是 queueing discipline 的简写,它是理解流量(traffic control)的基础。无论何时,内核如果需要通过某个网络接口发送数据包,需要按照这个接口配置的 qdisc(排队规则)把数据包加入队列。然后,内核会尽可能多的从 qdisc 里面取出数据包,把它们交给网络适配器模块。最简单的 QDisc 是 pfifo,他不对进入的数据包做任何处理,数据包采用先入先出的方式。
在这里插入图片描述

具体实现

接收

接收数据由netif_rx()函数实现。主要工作:

  • 初始化skb_buff数据结构的某些数据域,如接受数据帧的时间
  • 将接受到的数据帧放入CPU的私有输入队列,通过标记通知内核有数据帧到达
  • 更新CPU的接受统计信息
int netif_rx(struct sk_buff *skb)
{
	trace_netif_rx_entry(skb);
 
	return netif_rx_internal(skb);
}

跟一下netif_rx_internal(),如果数据帧成功放入到CPU的输入队列就返回NET_RX_SUCCESS;如果数据帧被扔掉就返回NET_RX_DROP。主要工作:

  • 设置skb的接受时间
  • 获取当前CPU的标识ID
  • 调用enqueue_to_backlog继续处理
static int netif_rx_internal(struct sk_buff *skb)
{
	int ret;
 
	net_timestamp_check(netdev_tstamp_prequeue, skb); //设置数据包的时间
 
	trace_netif_rx(skb);
#ifdef CONFIG_RPS
	if (static_key_false(&rps_needed)) {
		struct rps_dev_flow voidflow, *rflow = &voidflow;
		int cpu;
 
		preempt_disable();
		rcu_read_lock();
 
		cpu = get_rps_cpu(skb->dev, skb, &rflow);  //取当前cpu的id
		if (cpu < 0)
			cpu = smp_processor_id();
 
		ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail); //继续处理
 
		rcu_read_unlock();
		preempt_enable();
	} else
#endif
	{
		unsigned int qtail;
		ret = enqueue_to_backlog(skb, get_cpu(), &qtail);
		put_cpu();
	}
	return ret;
}

到这里 ,通过enqueue_to_backlog就能看出是队列了,就不再跟了。

发送

实际是通过__dev_queue_xmit实现的,需要注意的几点:

  1. 设备在调用这个函数之前,必须设置设备优先级 和缓冲区buffer
  2. 如果此函数发送失败,会返回一个负数的Error number,不过即使返回正数,也不一定保证发送成功,封包也许会被网络拥塞给drop掉
  3. 这个函数也可以从队列规则中返回error,NET_XMIT_DROP, 这个错误是一个整数,所以错误也有可能是整数,也验证了点2 ,所以在协议栈的上一层使用这个函数的时候,可能需要注意error的处理部分
  4. 不管这个函数返回什么值,这个skb最终的宿命就是被consume,也就是free掉了… 所以这个时候上层不要尝试重发了… 除非有协议栈的重传, 要不skb已经被free了,再去重新去调用,不要命了…此时kernel就挂掉了…
  5. 在调用这个函数的时候,必须打开中断
static int __dev_queue_xmit(struct sk_buff *skb, void *accel_priv)
{
	struct net_device *dev = skb->dev;
	struct netdev_queue *txq;
	struct Qdisc *q;
	int rc = -ENOMEM;
 
	skb_reset_mac_header(skb);
 
	if (unlikely(skb_shinfo(skb)->tx_flags & SKBTX_SCHED_TSTAMP))
		__skb_tstamp_tx(skb, NULL, skb->sk, SCM_TSTAMP_SCHED);
 
	/* Disable soft irqs for various locks below. Also
	 * stops preemption for RCU.
	 */
	rcu_read_lock_bh();
 
	skb_update_prio(skb);
 
	/* If device/qdisc don't need skb->dst, release it right now while
	 * its hot in this cpu cache.
	 */
	/*这个地方看netdevcie的flag是否要去掉skb DST相关的信息,一般情况下这个flag是默认被设置的
	 *在alloc_netdev_mqs的时候,已经默认给设置了,其实个人认为这个路由信息也没有太大作用了...
	 *dev->priv_flags = IFF_XMIT_DST_RELEASE | IFF_XMIT_DST_RELEASE_PERM;
	 */
	if (dev->priv_flags & IFF_XMIT_DST_RELEASE)
		skb_dst_drop(skb);
	else
		skb_dst_force(skb);
		
        /*此处主要是取出此netdevice的txq和txq的Qdisc,Qdisc主要用于进行拥塞处理,一般的情况下,直接将
         *数据包发送给driver了,如果遇到Busy的状况,就需要进行拥塞处理了,就会用到Qdisc*/
	txq = netdev_pick_tx(dev, skb, accel_priv);
	q = rcu_dereference_bh(txq->qdisc);
 
#ifdef CONFIG_NET_CLS_ACT
	skb->tc_verd = SET_TC_AT(skb->tc_verd, AT_EGRESS);
#endif
	trace_net_dev_queue(skb);
	
	/*如果Qdisc有对应的enqueue规则,就会调用__dev_xmit_skb,进入带有拥塞的控制的Flow,注意这个地方,虽然是走拥塞控制的
	 *Flow但是并不一定非得进行enqueue操作啦,只有Busy的状况下,才会走Qdisc的enqueue/dequeue操作进行
	 */
	if (q->enqueue) {
		rc = __dev_xmit_skb(skb, q, dev, txq);
		goto out;
	}
 
	/* The device has no queue. Common case for software devices:
	   loopback, all the sorts of tunnels...
	   Really, it is unlikely that netif_tx_lock protection is necessary
	   here.  (f.e. loopback and IP tunnels are clean ignoring statistics
	   counters.)
	   However, it is possible, that they rely on protection
	   made by us here.
	   Check this and shot the lock. It is not prone from deadlocks.
	   Either shot noqueue qdisc, it is even simpler 8)
	 */
	 
	/*此处是设备没有Qdisc的,实际上没有enqueue/dequeue的规则,无法进行拥塞控制的操作,
	 *对于一些loopback/tunnel interface比较常见,判断下设备是否处于UP状态*/
	if (dev->flags & IFF_UP) {
		int cpu = smp_processor_id(); /* ok because BHs are off */
 
		if (txq->xmit_lock_owner != cpu) {
 
			if (__this_cpu_read(xmit_recursion) > RECURSION_LIMIT)
				goto recursion_alert;
			skb = validate_xmit_skb(skb, dev);
			if (!skb)
				goto drop;
 
			HARD_TX_LOCK(dev, txq, cpu);
                       /*这个地方判断一下txq不是stop状态,那么就直接调用dev_hard_start_xmit函数来发送数据*/
			if (!netif_xmit_stopped(txq)) {
				__this_cpu_inc(xmit_recursion);
				skb = dev_hard_start_xmit(skb, dev, txq, &rc);
				__this_cpu_dec(xmit_recursion);
				if (dev_xmit_complete(rc)) {
					HARD_TX_UNLOCK(dev, txq);
					goto out;
				}
			}
			HARD_TX_UNLOCK(dev, txq);
			net_crit_ratelimited("Virtual device %s asks to queue packet!\n",
					     dev->name);
		} else {
			/* Recursion is detected! It is possible,
			 * unfortunately
			 */
recursion_alert:
			net_crit_ratelimited("Dead loop on virtual device %s, fix it urgently!\n",
					     dev->name);
		}
	}
 
	rc = -ENETDOWN;
drop:
	rcu_read_unlock_bh();
 
	atomic_long_inc(&dev->tx_dropped);
	kfree_skb_list(skb);
	return rc;
out:
	rcu_read_unlock_bh();
	return rc;
}

int dev_queue_xmit(struct sk_buff *skb)
{
	return __dev_queue_xmit(skb, NULL);
}

dpdk的支持

之前写过的代码,直接贴过来,也是用了队列。

 if (rte_eth_rx_queue_setup(g_dpdkPortId, DPDK_QUEUE_ID_RX, RX_RING_SIZE,
            rte_eth_dev_socket_id(g_dpdkPortId), &rxq_conf, mbuf_pool) < 0) {
        rte_exit(EXIT_FAILURE, "Couldn't setup RX queue.\n");
    }
	
	// Set up TX queue.
	struct rte_eth_txconf txq_conf = dev_info.default_txconf;
	txq_conf.offloads = local_port_conf.txmode.offloads;
	if (rte_eth_tx_queue_setup(g_dpdkPortId, 0, nb_txd,
            rte_eth_dev_socket_id(g_dpdkPortId), &txq_conf) < 0) {
        rte_exit(EXIT_FAILURE, "Couldn't setup TX queue.\n");
    }

虚拟化

虚拟化是资源的逻辑表示,不受物理设备的约束。虚拟化技术的实现形式是在系统中加入一个虚拟化层,虚拟化层将下层的资源抽象成另一种形式的资源,提供给上层使用。

CPU 虚拟化

CPU虚拟化分为三种,给三张图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
vmware是完全虚拟化,vsphere是辅助级虚拟化,docker是半虚拟化。

内存虚拟化

虚拟机本质上是 Host 机上的一个进程,按理说应该可以使用 Host 机的虚拟地址空间,但由于在虚拟化模式下,虚拟机处于非 Root 模式,无法直接访问 Root 模式下的 Host 机上的内存。
这个时候就需要 VMM 的介入,VMM 需要 intercept (截获)虚拟机的内存访问指令,然后 virtualize(模拟)Host 上的内存,相当于 VMM 在虚拟机的虚拟地址空间和 Host 机的虚拟地址空间中间增加了一层,即虚拟机的物理地址空间,也可以看作是 Qemu 的虚拟地址空间(稍微有点绕,但记住一点,虚拟机是由 Qemu 模拟生成的就比较清楚了)。所以,内存软件虚拟化的目标就是要将虚拟机的虚拟地址(Guest Virtual Address, GVA)转化为 Host 的物理地址(Host Physical Address, HPA),中间要经过虚拟机的物理地址(Guest Physical Address, GPA)和 Host 虚拟地址(Host Virtual Address)的转化,即:
GVA -> GPA -> HVA -> HPA
其中前两步由虚拟机的系统页表完成,中间两步由 VMM 定义的映射表(由数据结构kvm_memory_slot 记录)完成,它可以将连续的虚拟机物理地址映射成非连续的 Host 机虚拟地址,后面两步则由 Host 机的系统页表完成。如下图所示。
在这里插入图片描述
这样做得目的有两个:

  1. 提供给虚拟机一个从零开始的连续的物理内存空间。
  2. 在各虚拟机之间有效隔离、调度以及共享内存资源。

影子页表技术

接上图,我们可以看到,传统的内存虚拟化方式,虚拟机的每次内存访问都需要 VMM 介入,并由软件进行多次地址转换,其效率是非常低的。因此才有了影子页表技术和 EPT 技术。
影子页表简化了地址转换的过程,实现了 Guest 虚拟地址空间到 Host 物理地址空间的直接映射。
要实现这样的映射,必须为 Guest 的系统页表设计一套对应的影子页表,然后将影子页表装入 Host 的 MMU 中,这样当 Guest 访问 Host 内存时,就可以根据 MMU 中的影子页表映射关系,完成 GVA 到 HPA 的直接映射。而维护这套影子页表的工作则由 VMM 来完成。
由于 Guest 中的每个进程都有自己的虚拟地址空间,这就意味着 VMM 要为 Guest 中的每个进程页表都维护一套对应的影子页表,当 Guest 进程访问内存时,才将该进程的影子页表装入 Host 的 MMU 中,完成地址转换。
我们也看到,这种方式虽然减少了地址转换的次数,但本质上还是纯软件实现的,效率还是不高,而且 VMM 承担了太多影子页表的维护工作,设计不好。为了改善这个问题,就提出了基于硬件的内存虚拟化方式,将这些繁琐的工作都交给硬件来完成,从而大大提高了效率。

EPT 技术

这方面 Intel 和 AMD 走在了最前面,Intel 的 EPT 和 AMD 的 NPT 是硬件辅助内存虚拟化的代表,两者在原理上类似,本文重点介绍一下 EPT 技术。
如下图是 EPT 的基本原理图示,EPT 在原有 CR3 页表地址映射的基础上,引入了 EPT 页表来实现另一层映射,这样,GVA->GPA->HPA 的两次地址转换都由硬件来完成。
在这里插入图片描述
这里举一个小例子来说明整个地址转换的过程。假设现在 Guest 中某个进程需要访问内存,CPU 首先会访问 Guest 中的 CR3 页表来完成 GVA 到 GPA 的转换,如果 GPA 不为空,则 CPU 接着通过 EPT 页表来实现 GPA 到 HPA 的转换(实际上,CPU 会首先查看硬件 EPT TLB 或者缓存,如果没有对应的转换,才会进一步查看 EPT 页表),如果 HPA 为空呢,则 CPU 会抛出 EPT Violation 异常由 VMM 来处理。如果 GPA 地址为空,即缺页,则 CPU 产生缺页异常,注意,这里,如果是软件实现的方式,则会产生 VM-exit,但是硬件实现方式,并不会发生 VM-exit,而是按照一般的缺页中断处理,这种情况下,也就是交给 Guest 内 核 的 中 断 处 理 程 序 处 理 。 在 中 断 处 理 程 序 中 会 产 生EXIT_REASON_EPT_VIOLATION,Guest 退出,VMM 截获到该异常后,分配物理地址并建立GVA 到 HPA 的映射,并保存到 EPT 中,这样在下次访问的时候就可以完成从 GVA 到HPA 的转换了。

I/O 虚拟化

先给一张图
在这里插入图片描述

I/O 全虚拟化

如上图中间部分。
这种方式比较好理解,简单来说,就是通过纯软件的形式来模拟虚拟机的 I/O 请求。以qemu-kvm 来举例,内核中的 kvm 模块负责截获 I/O 请求,然后通过事件通知告知给用户空间的设备模型 qemu,qemu 负责完成本次 I/O 请求的模拟。
优点:不需要对操作系统做修改,也不需要改驱动程序,因此这种方式对于多种虚拟化技术的「可移植性」和「兼容性」比较好。
缺点:纯软件形式模拟,自然性能不高,另外,虚拟机发出的 I/O 请求需要虚拟机和 VMM 之间的多次交互,产生大量的上下文切换,造成巨大的开销。

I/O 半虚拟化

如上图左侧。 针对 I/O 全虚拟化纯软件模拟性能不高这一点,I/O 半虚拟化前进了一步。它提供了一种机制,使得 Guest 端与 Host 端可以建立连接,直接通信,摒弃了截获模拟这种方式,从而获得较高的性能。
值得注意的有两点:

  • 采用 I/O 环机制,使得 Guest 端和 Host 端可以共享内存,减少了虚拟机与 VMM 之间的交互
  • 采用事件和回调的机制来实现 Guest 与 Host VMM 之间的通信。这样,在进行中断处理时,就可以直接采用事件和回调机制,无需进行上下文切换,减少了开销。

要实现这种方式, Guest 端和 Host 端需要采用类似于 C/S 的通信方式建立连接,这也就意味着要修改 Guest 和 Host 端操作系统内核相应的代码,使之满足这样的要求。为了描述方便,我们统称 Guest 端为前端,Host 端为后端。前后端通常采用的实现方式是驱动的方式,即前后端分别构建通信的驱动模块,前端实现在内核的驱动程序中,后端实现在 qemu 中,然后前后端之间采用共享内存的方式传递数据。
关于这方面一个比较好的开源实现是 virtio,后面会介绍。
半虚拟化优点:性能较 I/O 全虚拟化有了较大的提升
半虚拟化缺点:要修改操作系统内核以及驱动程序,因此会存在移植性和适用性方面的问题,导致其使用受限。

I/O 直通或透传技术

如上图右侧。
上面两种虚拟化方式,还是从软件层面上来实现,性能自然不会太高。最好的提高性能的方式还是从硬件上来解决。如果让虚拟机独占一个物理设备,像宿主机一样使用物理设备,那无疑性能是最好的。
I/O 直通技术就是提出来完成这样一件事的。它通过硬件的辅助可以让虚拟机直接访问物理设备,而不需要通过 VMM 或被 VMM 所截获。
由于多个虚拟机直接访问物理设备,会涉及到内存的访问,而内存又是共享的,那怎么来隔离各个虚拟机对内存的访问呢,这里就要用到一门技术——IOMMU,简单说,IOMMU 就是用来隔离虚拟机对内存资源访问的。
I/O 直通技术需要硬件支持才能完成,这方面首选是 Intel 的 VT-d 技术,它通过对芯片级的改造来达到这样的要求,这种方式固然对性能有着质的提升,不需要修改操作系统,移植性也好。但该方式也是有一定限制的,这种方式仅限于物理资源丰富的机器,因为这种方式仅仅能满足一个设备分配给一个虚拟机,一旦一个设备被虚拟机占用了,其他虚拟机时无法使用该设备的。
虚拟机中的磁盘,是全虚拟化的方式;网络,是直通的方式。

Virtio

virtio 是一种 I/O 半虚拟化解决方案,是一套通用 I/O 设备虚拟化的程序,是对半虚拟化 Hypervisor 中的一组通用 I/O 设备的抽象。一句话,主要是针对外设,提供了一套上层应用与各Hypervisor 虚拟化设备(KVM,Xen,VMware 等)之间的通信框架和编程接口,减少跨平台所带来的兼容性问题,大大提高驱动程序开发效率。
在完全虚拟化的解决方案中,guest VM 要使用底层 host 资源,需要 Hypervisor 来截获所有的请求指令,然后模拟出这些指令的行为,这样势必会带来很多性能上的开销。半虚拟化通过底层硬件辅助的方式,将部分没必要虚拟化的指令通过硬件来完成,Hypervisor 只负责完成部分指令的虚拟化,要做到这点,需要 guest 来配合,guest 完成不同设备的前端驱动程序,Hypervisor 配合 guest 完成相应的后端驱动程序,这样两者之间通过某种交互机制就可以实现高效的虚拟化过程。由于不同 guest 前端设备其工作逻辑大同小异(如块设备、网络设备、PCI 设备、balloon驱动等),单独为每个设备定义一套接口实属没有必要,而且还要考虑扩平台的兼容性问题,
另外,不同后端 Hypervisor 的实现方式也大同小异(如 KVM、Xen 等),这个时候,就需要一套通用框架和标准接口(协议)来完成两者之间的交互过程,virtio 就是这样一套标准,它极大地解决了这些不通用的问题。
在这里插入图片描述
从总体上看,virtio 可以分为四层,包括前端 guest 中各种驱动程序模块,后端 Hypervisor (实现在 Qemu 上)上的处理程序模块,中间用于前后端通信的 virtio 层和 virtio-ring 层,virtio 这一层实现的是虚拟队列接口,算是前后端通信的桥梁,而 virtio-ring 则是该桥梁的具体实现,它实现了两个环形缓冲区,分别用于保存前端驱动程序和后端处理程序执行的信息。
在这里插入图片描述
vring 主要通过两个环形缓冲区来完成数据流的转发,此外还有一个描述符数组 desc,可用的 available ring 和使用过的 used ring。
在这里插入图片描述

Vhost

vhost 是 virtio 的一种后端实现方案,在 virtio 简介中,我们已经提到 virtio 是一种半虚拟化的实现方案,需要虚拟机端和主机端都提供驱动才能完成通信,通常,virtio 主机端的驱动是实现在用户空间的 qemu 中,而 vhost 是实现在内核中,是内核的一个模块 vhost-net.ko,主要是去适配所有操作系统。
在 virtio 的机制中,guest 与 用户空间的 Hypervisor 通信,会造成多次的数据拷贝和 CPU 特权级的上下文切换。例如 guest 发包给外部网络,首先,guest 需要切换到host kernel,然后 host kernel 会切换到 qemu 来处理 guest 的请求, Hypervisor 通过系统调用将数据包发送到外部网络后,会切换回 host kernel , 最后再切换回guest。这样漫长的路径无疑会带来性能上的损失。
vhost 正是在这样的背景下提出的一种改善方案,它是位于 host kernel 的一个模块,用于和 guest 直接通信,数据交换直接在 guest 和 host kernel 之间通过 virtqueue 来进行,qemu 不参与通信,但也没有完全退出舞台,它还要负责一些控制层面的事情,比如和 KVM 之间的控制指令的下发等。
下图左半部分是 vhost 负责将数据发往外部网络的过程,右半部分是 vhost 大概的数据交互流程图。其中,qemu 还是需要负责 virtio 设备的适配模拟,负责用户空间某些管理控制事件的处理,而 vhost 实现较为纯净,以一个独立的模块完成 guest 和 host kernel 的数据交换过程。
在这里插入图片描述
vhost 与 virtio 前端的通信主要采用一种事件驱动 eventfd 的机制来实现,guest 通知 vhost 的事件要借助 kvm.ko 模块来完成,vhost 初始化期间,会启动一个工作线程work 来监听 eventfd,一旦 guest 发出对 vhost 的 kick event,kvm.ko 触发ioeventfd 通知到 vhost,vhost 通过 virtqueue 的 avail ring 获取数据,并设置used ring。同样,从 vhost 工作线程向 guest 通信时,也采用同样的机制,只不过这种情况发的是一个回调的 call envent,kvm.ko 触发 irqfd 通知 guest。

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值