摘要:DPDK,是由6WIND,Intel等多家公司开发,主要基于Linux系统运行,用于快速数据包处理的函数库与驱动集合,可以极大提高数据处理性能和吞吐量,是在数据平面应用中为快速的数据包处理提供一个简单而完善的架构,提高数据平面应用程序的工作效率。它专注于网络应用中数据包的高性能处理。具体体现在DPDK应用程序是运行在用户空间上利用自身提供的数据平面库来收发数据包,绕过了Linux内核协议栈对数据包处理过程。
1 DPDK简介
DPDK的网络层和我们传统的Linux网络层是有很大区别的,在传统的Linux网络层中,数据包的流程如下:
硬件中断--->取包分发至内核线程--->软件中断--->内核线程在协议栈中处理包--->处理完毕通知用户层
用户层收包-->网络层--->逻辑层--->业务层
以下则是DPDK的网络层中,数据包经过的流程:
硬件中断--->放弃中断流程
用户层通过设备映射取包--->进入用户层协议栈--->逻辑层--->业务层
从以上两个流程中,我们可以看出,在传统的Linux网络层中,数据包要到我们的应用层中间经过了很多的拷贝和硬件以及软件中断。而对于DPDK,直接放弃了采用硬件中断的方式,用户层通过映射直接从网卡取包,然后就进入了数据包的处理和使用了,中间减少了很多的中断和拷贝,因为DPDK采用了内核旁路技术以及零拷贝技术。
它有哪些优势呢?首先,因为它是在用户空间的,所以具有用户空间驱动程序的优点,比如调试方便等;其次它可以和整个C库进行连接使用;再者,它在驱动中可以使用浮点数进行计算,因为有的硬件中,可能需要使用浮点数,而Linux内核并不提供浮点数的支持,而在用户态实现驱动就可以很轻松的解决这一问题;还有,它出了什么问题,不会导致整个系统崩溃或者被挂起,因为内核态下有的错误会导致整个系统被挂起;之后,则是用户态的程序在调试方面很方便,并且可以给出封闭源码的驱动程序,不必采用GPL,更为灵活;其他的还有它采用的无拷贝收发包,减少了很多的内存拷贝,且内核空间和用户空间的内存交互式不需要进行拷贝的,只是做控制权的转移。
2 DPDK的架构
首先介绍一下DPDK的简单架构,它的架构图如图1所示:
图1 DPDK的简单架构图
从DPDK的架构图中我们可以看出,蓝色部分是DPDK的一些重要组件,其中:(1)PMD:叫做Pool Mode Driver,轮询模式驱动,主要是用轮询的方式去替代内核的中断机制,并且用数据进出应用缓冲区内存的零拷贝机制,提高发送或者接收数据的效率,提供的也是全用户态的驱动,以便通过轮询和线程绑定得到极高的网络吞吐
(2)流分类:它主要的作用是为N元组匹配和最长前缀匹配提供优化的查找算法,支持精确匹配、最长匹配和通配符匹配;
(3)无锁环队列:提供一种特殊的链表队列的管理,具有先进先出、定长、无锁、并发入或者出队的特性,通过无锁的机制,去很好的减少了系统加减锁的开销;
(4)MBUF管理:用于分配和释放缓冲区给DPDK的应用来存储缓存信息,并且存在Mempool内存池中,并通过建立MBUF对象,封装四级数据帧,供应用程序使用;
(5)EAL层:又叫环境抽象层,主要负责对计算机的底层资源的访问,比如网卡和内存,并对提供给用户的接口进行封装,它的初始化决定了如何分配这些资源给用户使用,主要功能有PMD初始化、CPU内核和DPDK线程配置和绑定、设置HugePage大页内存等初始化;
以上就是DPDK的一个简单架构的介绍,下图2则是DPDK的一个详细架构图:
图2 DPDK的详细架构图
在图中,可以看到DPDK还有一些核心组件,比如Mempool、KNI等,主要分为四个模块,其中的Core Libs库模块,主要是DPDK的关键且基础的组件,比如EAL、MBUF等,实现系统抽象、大页内存、内存池、无锁环队列等;第二个则是Extns库,这部分则负责为提供一些扩展功能,比如为建造复杂的数据包处理管道提供标准方法,或者用于流量的动态负载均衡问题等;第三个则是PMD库,它主要提供全用户态驱动,以便通过轮询和线程绑定得到极高网络吞吐,支持各种本地和虚拟网卡。第四个则是QoS库,它负责提供网络服务质量的相关组件以及限速和调度功能;第五个则是Classify库,支持精确匹配,最长匹配和通配符匹配,提供常用包处理的查表操作。
3 DPDK详解
之前介绍到了关于DPDK的一些基础知识,接下来则会对DPDK的一些关键组件进行分析。
(1)UIO (Userspace I/O)
关于DPDK实现拦截系统中断的关键组件UIO,它的实现原理框架如图3所示:
图3 DPDK的UIO实现框架
DPDK它作为一个高并发大流量的网络开发框架,它避免了很多的内核中断和数据拷贝,并且在用户空间直接和硬件进行交互,主要是通过内核旁路的方案去绕过内核将硬件操作映射到用户空间去进行的,它主要就是通过运行在内核的UIO模块来拦截内核系统中断,因为UIO是运行在用户空间的I/O技术,能够重设内核中终端回调行为从而绕过协议栈后续的处理流程,将驱动的很少在内核空间运行,更多的则在用户空间去实现,让UIO可以避免设备的驱动随着内核更新而更新。而UIO在DODK中做的工作也很简单:(1)分配和记录设备需要的资源;(2)注册UIO设备和必须再内核空间实现的小部分中断应答函数。其次则是通过read感知中断,然后通过/dev/uioX去读取中断,而mmap则是用来和外设共享内存使用的。
(2)内核网卡接口KNI(Kernel NIC Interface)
关于DPDK的内核网卡接口KNI,它的实现原理如图4所示:
图4 KNI接口的实现原理
KNI接口,它是DPDK允许用户态和内核态交换报文的一个解决方案,主要是通过模拟一个虚拟网口,让DPDK的应用程序和Linux内核能进行通信,KNI接口也允许了报文从用户态接收后,再转发给Linux内核协议栈去使用,从图中我们可以看出KNI的mbuf使用流程以及数据报文的流向,因为DPDK内的数据报文并不是通过拷贝的方式实现的,而是通过指针的改变来实现控制权的转移,从而减少了系统资源的消耗,图中rx_q右边是用户态,左边则是内核态,如果我们用户态的数据包想要给内核使用,那就需要调用netif_rx()将报文传入Linux协议栈,其中还涉及到了DPDK 的mbuf转换内核的skb_buf的操作,反过来也是同样的道理,只不过需要调用的函数是kni_net_tx(),并且也需要将存储类型由skb_buf转换为mbuf类型。
(3)大页内存(Hugepage)技术:
在说大页内存之前,不得不提的是内存分页技术和TLB miss两个关键问题了,首先内存分页技术应该都有所了解,它就是为了实现虚拟内存管理所设的一个机制,从而将虚拟内存和物理内存地址分离,给进程带来便利和安全性,但是这个转换却会耗费计算机的很多资源,所以为了搞笑的转换两种地址,最好的方式就是想索引一样添加一个关系记录表,而Linux就是采用的分页的方案去记录这些对应关系,它的默认大小是4KB。
至于TLB miss,首先对于TLB(Translation Lookaside Buffers)转换检测缓冲区:是一个内存管理单元,用于改进虚拟地址到物理地址转换速度的缓存。如果在查询的时候,缓存未命中,即为TLB miss情况,则要多付出 20-30 个 CPU 周期的带价。假设应用程序需要 2MB 的内存,如果操作系统采用 2MB 作为分页的基本单位时,只需要一次 TLB Miss 和一次缺页中断,就可以为 2MB 的应用程序空间建立虚实映射,并在运行过程中无需再经历 TLB Miss 和缺页中断,如果是默认的4KB则会远远的增大系统资源消耗。
由于这样一种情况,大页内存就产生了,它是一种有效解决TLB miss的方案,它在DPDK中可用于数据转发或者进程间共享数据结构使用,或者物理网卡配置部分的贡献内存、寄存器地址等。大页有了,DPDK就会使用HUGETLBFS来使用大页,一开始先将大页mount到某个路径,然后DPDK运行时候,再调用mmap去讲大页映射到用户态的虚拟地址空间,从而正常使用。
这里的mmap是一种内存映射文件的方法,可以将一个文件或者其他对象映射到进程的地址空间去使用,实现文件磁盘地址和进程虚拟地址之间一段虚拟地址的一一映射关系,这样进程就可以用指针的方式去读写这段内存,而系统会自动回写页面到对应的文件磁盘上去,这样就减少了使用read或者write去操作文件,当然内核空间对这段映射区域的修改也会直接反应到用户空间去,这样也能思想不同进程间的文件共享,它的原理如下图5所示:
图5 mmap的实现原理
总之DPDK之所以使用大页的原因,是想让程序尽量的独占内存,防止内存换出导致的上下文切换等问题,并且扩大页表还能提高hash命中率,以及更适合高频的内存使用程序的状态,它的好处有一下几点:
①无需交换,不需要考虑页面由于内存空间的不足而存在的换入和换出的问题,因为默认大小为4KB,如果页面大小不足,是可能会被交换到swap分区的, 大页则永远不会。它通过共享内存的方式,使得所有大页以及页表都存在内存,避免了被换出内存会造成很大的性能抖动;
②由于所有进程都共享一个大页表,减少了页表的开销,无形中减少了内存空间的占用, 使得系统支持更多的进程同时运行。
③减少了TLB负载,因为TLB是直接缓存物 理地址和虚拟地址的映射关系的,从而可以很大的提高查找效率,但是页表越小,就会让TLB miss越多,就会导致性能降低,所以更少的页表就能减轻TLB的负载;
④减轻查内存的压力:我们每一次对内存的操作其实都涉及到了两次抽象的内存操作,如果使用更大的页面,则会让页面的总数变少,这样就能减少页表查询的负载,并且也能让hash命中率更高。
(4)NUMA技术
NUMA技术为什么会产生呢?一开始的时候我们的计算机都是采用单核的方式运行的,但是后来因为单核性能不够,由此产生了多核对称处理系统SMP,但是传统的SMP系统中,所有的处理器共享系统总线,当处理器数目越来越多时,系统总线的竞争就会加大,就让总线成为了系统性能的一个瓶颈,由于这个原因NUMA:非统一内存访问技术诞生了,它解决了SMP系统可扩展性问题,从而成为了当前高性能服务器的主流架构之一,NUMA技术可以让我们很多的服务器像单一系统那样运转,同时还能保留小系统便于编程和管理的优点。它是通过将CPU直接和某几根内存使用总线连接在一起的,从而CPU在读取自己拥有的内存情况下就会很快,跨节点读取就会比较慢。
所以NUMA系统节点一般是由一组CPU和本地内存组成,NUMA调度器负责将进程在同一节点的CPU间调度,除非负载太高,才会迁移到其他节点去处理,但是这种操作会导致数据访问延时增大。它的系统示意图如图6所示:
图6 NUMA系统示意图
这种构架下,不同的内存器件和CPU核心从属不同的 Node,每个 Node 都有自己的集成内存控制器(IMC,Integrated Memory Controller),在 Node 内部,架构类似SMP,使用 IMC Bus 进行不同核心间的通信;不同的 Node 间通过QPI(Quick Path Interconnect)进行通信。一般来说,一个内存插槽对应一个 Node。需要注意的一个特点是,QPI的延迟要高于IMC Bus,也就是说CPU访问内存有了远近(remote/local)之别,而且实验分析来看,这个差别非常明显。
因此,Linux中的NUMA默认情况下,内核是不会将内存页面从一个NUMA 节点迁移到另一个节点的,但是现在有工具NUMA Balancing可以实现将冷页面迁移到远程的节点,而不同节点上的页面迁移规则也有很多不同的想法。
至于NUMA的内存分配策略如下:①缺省(default):总是在本地节点分配(分配在当前进程运行的节点上);
②绑定(bind):强制分配到指定节点上;
③交叉(interleave):在所有节点或者指定的节点上交织分配;
④优先(preferred):在指定节点上分配,失败则在其他节点上分配。
因为NUMA默认的内存分配策略是优先在进程所在CPU的本地内存中分配,会导致CPU节点之间内存分配不均衡,当某个CPU节点的内存不足时,会导致swap产生,而不是从远程节点分配内存。
NUMA的一些操作则如下所示:
方法一:通过bios关闭 BIOS:interleave = Disable / Enable
方法二:通过OS关闭 ①编辑 /etc/default/grub 文件,加上:numa=off GRUB_CMDLINE_LINUX="crashkernel=auto numa=off rd.lvm.lv=centos/root rd.lvm.lv=centos/swap rhgb quiet";②重新生成 /etc/grub2.cfg 配置文件:# grub2-mkconfig -o /etc/grub2.cfg;③重启操作系统# reboot
确认: # dmesg | grep -i numa
[ 0.000000] Command … numa=off rd.lvm.lv= …
# cat /proc/cmdline
BOOT_IMAGE= … numa=off rd.lvm.lv= …
(5)无锁环形队列
Linux内核中有一个先进先出的数据结构,采用环形队列的数据结构来实现,提供一个无边界的字节流服务,即当它用于只有一个入队线程和一个出队线程的场景时,两个线程可以并发操作,而不需要任何加锁行为,就可以保证kfifo的线程安全。
DPDK 则基于 Linux 内核的无锁环形缓冲 kfifo 实现了自己的一套无锁机制。支持单生产者入列/单消费者出列和多生产者入列/多消费者出列操作,在数据传输的时候,降低性能的同时还能保证数据的同步。
DPDK的无锁环队列相比于链表,这个数据结构的优点如下:
①更快;只需要一个sizeof(void *)的Compare-And-Swap指令,而不是多个双重比较和交换指令;
②与完全无锁队列相似;
③适应批量入队/出队操作: 因为指针是存储在表中的,多个对象的出队将不会产生于链表队列中一样多的cache miss。 此外,批量出队成本并不比单个对象出队高。
同样它的缺点也很明显: 首先,它的大小固定;其次,大量ring相比于链表,消耗更多的内存,空ring至少包含n个指针。
(6)多核和多队列工作机制
关于DPDK,它是一个适应于多核的工作机制,一般有主从核的区分,主核负责DPDK各个模块的初始化和重合的业务调度等的处理,从核则负责具体的业务的处理。它的多核调度框架如图7所示:
图7 DPDK的多核调度框架
从DPDK的多核调度框架中,我们可以看出它的一些规则:①在启动服务的时候会选择一个逻辑核作为主核,一般情况下会选择配置列表的第一个核作为主逻辑核,然后将配置列表的其他核作为从逻辑核处理;
②所有的线程、队列等都会遵循一对一的规则,根据映射表进行配置和核绑定;
③主逻辑核它的主要工作就是负责完成一些初始化工作,比如PCI、内存和日志等系统的初始化,并且可以通过编程的方式或者设置参数让主逻辑核从业务处理中解放出来,从而只需要专心的负责调度等主逻辑核特有的功能,配置方式是在方法rte_eal_mp_remote_launch(user_loop, param, type);中将type由CALL_MASTER更改为SKIP_MASTER即可,这就可以让DPDK线程初始化跳过默认的master核。
④从核启动后需要等待主逻辑核初始化完成后,再挂载业务处理入口,并且从核只负责运行业务处理的代码。
DPDK的多队列和多线程的架构则如图8所示:
图8 多队列和多线程架构图
在这个多队列模式下,DPDK会将网卡接收队列分配个一个特定的核,并且进行绑定,之后该队列收到的报文都交给该核上的DPDK线程来进行处理,在将数据包发送给接收队列的时候可以有两种方法:
①RSS(Receive Side Scaling):接收方扩展机制,它是根据数据包的某些特征来进行hash的操作,比如TCP数据包的五元组信息、UDP数据包的四元组信息等都可以。
②FLow Director机制:这种模式下,可以设定DPDK,让它根据数据包的某些信息进行精确匹配,从而将满足条件的数据包分配到指定的队列与CPU核上去使用。
其中,当数据包被网卡接收到后,DPDK网卡会将其存储在一个高效缓冲区中,并在MBUF缓存中创建MBUF对象与实际网络数据包相连,之后所以对数据包的分析和处理操作就都会基于该MBUF,必要时候才会去 访问缓冲区的实际网络数据包。
(7)RSS哈希
在之前提到了RSS哈希机制,这是一种能够在多处理器系统下使接收报文在多个CPU之间高效分发的网卡驱动技术,它所做的是在每个进入的包上发出一个带有预定义哈希键的哈希函数。哈希函数以包IP地址、协议(UDP或TCP)和端口(5元组)作为键并计算哈希值。(如果配置为RSS散列函数,则只能使用2、3或4个元组来创建密钥)。散列值的一些最低有效位(LSBs)用于索引一个间接表。间接表中的值用于将接收到的数据分配给CPU。如图9则是数据包的处理过程:
图9 RSS哈希下 数据包的处理过程
从图中可以看出,网卡对数据包进行解析,获取IP、端口和协议等五元组信息,然后通过配置的hash函数根据五元组计算出hash值,取出hash值的低几位作为RETA的索引,然后网卡就根据RETA表中存储的值分发到对应的CPU去处理。DPDK支持设置静态的hash值和配置RETA,不过DPDK的RSS是基于端口的,并根据端口的接收队列进行数据包的分发,运行在不同CPU的应用程序就从不同的队列取数据包,DPDK中可以通过设置rte_eth_conf中的mq_mode字段来开启RSS功能, 例如 rx_mode.mq_mode = ETH_MQ_RX_RSS。当RSS功能开启后,报文对应的rte_pktmbuf中就会存有RSS计算的hash值,可以通过pktmbuf.hash.rss来访问。 这个值可以直接用在后续报文处理过程中而不需要重新计算hash值,如快速转发,标识报文流等。
但是DPDK内置的RSS哈希却有个问题,那就是默认的hash键值是非对称的,所以在我们DPDK接收的数据包中,从客服端到服务端的数据包与从服务端到客户端的数据包是分发到不同的工作队列上的,比如我们需要解析HTTP报文,那么请求和访问就会在不同的工作队列上去,或者FTP流量,也是一样的,这时候我们在做流还原等工作的时候就会出现问题,因为同一条流被分发到了不同工作队列取,这对于还原工作会造成很大的性能影响等。
最后,经过查阅各种资料和文献,发现解决方案有两种方式:
①第一种是通过替换DPDK内置的RSS哈希算法,但是这种方案可行性不是很高,因为DPDK的哈希算法是嵌入到核心代码中去的,如果想要修改,涉及的面会很广,也很难操作;
②第二种方案,是通过替换DPDK的RSS哈希键值,通过这种方式去将DPDK的RSS哈希机制改为对称hash算法,让同一条流的数据包都进入到同一个工作队列取,而不会产生去向和回向流量不在一个工作队列的情况,从而很大的提高了工作的性能,替换的RSS哈希键值如下:
static uint8_t rss_sym_key[40] = {
0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A,
0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A,
0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A,
0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A,
0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A, 0x6D, 0x5A,
};
DPDK默认的哈希键值如下:
static uint8_t rss_intel_key[40] = {
0x6D, 0x5A, 0x56, 0xDA, 0x25, 0x5B, 0x0E, 0xC2,
0x41, 0x67, 0x25, 0x3D, 0x43, 0xA3, 0x8F, 0xB0,
0xD0, 0xCA, 0x2B, 0xCB, 0xAE, 0x7B, 0x30, 0xB4,
0x77, 0xCB, 0x2D, 0xA3, 0x80, 0x30, 0xF2, 0x0C,
0x6A, 0x42, 0xB7, 0x3B, 0xBE, 0xAC, 0x01, 0xFA,
};
修改RSS哈希键值的时候,只需要导入对称哈希键值,然后再DPDK应用中,将rte_eth_conf中的rss_key改为 rss_sym_key即可,具体的是rte_eth_conf.rx_adv_conf.rss_conf.rss_key修改为rss_sym_key,即可实现对称哈希算法机制,让流全分发在一个工作队列上去使用。