前置知识
网卡(Network Interface Card,简称NIC)
https://blog.csdn.net/hellozhxy/article/details/120711251
UIO
(Linux Userspace I/O)
作用
作为高并发大流量网络开发框架的DPDK,能够避免内核中断爆炸和大量数据拷贝的方 法,在用户空间能够直接和硬件进行交互。
原理
将硬件操作映射到用户空间的kernel bypass方案
实现
1.开发运行在内核的UIO模块,因为硬中断只能在内核处理
2.read感知中断,通过/dev/uioX读取中断
3.通过mmap和外设共享内存
轮询模式驱动(PMD)
Polling mode drive
用户空间驱动使得应用程序不需要经过linux内核就可以访问网络设备卡。网卡设备可以通过DMA方式将数据包传输到事先分配好的缓冲区,这个缓冲区位于用户空间,应用程序通过不断轮询的方式可以读取数据包并在原地址上直接处理,不需要中断,而且也省去了内核到应用层的数据包拷贝过程。
中断的缺点
由于linux系统是通过中断的方式告知CPU有数据包过来的,当网络的流量越来越大,linux系统会浪费越来越多的时间去处理中断,当流量速率达到10G的时候,linux系统可能会被中断淹没,浪费很多CPU资源。
缺点
因此相对于linux系统传统中断方式,Intel DPDK避免了中断处理、上下文切换、系统调用、数据复制带来的性能上的消耗,大大提升了数据包的处理性能。同时由于Intel DPDK在用户空间就可以开发驱动,与传统的在内核中开发驱动相比,安全系数大大降低。因为内核层权限比较高,操作相对比较危险,可能因为小的代码bug就会导致系统崩溃,需要仔细的开发和广泛的测试。而在应用层则相反,比较安全,且在应用层调试代码要方便的多。
大页内存
Linux操作系统通过查找TLB来实现快速的虚拟地址到物理地址的转化。由于TLB是一块高速缓冲cache,容量比较小,容易发生没有命中。当没有命中的时候,会触发一个中断,然后会访问内存来刷新页表,这样会造成比较大的时延,降低性能。Linux操作系统的页大小只有4K,所以当应用程序占用的内存比较大的时候,会需要较多的页表,开销比较大,而且容易造成未命中。相比于linux系统的4KB页,Intel DPDK缓冲区管理库提供了Hugepage大页内存,大小有2MB和1GB页面两种,可以得到明显性能的提升,因为采用大页内存的话,可以需要更少的页,从而需要更少的TLB,这样就减少了虚拟页地址到物理页地址的转换时间。‘
DPDK中的内存管理如图,最下面是连续的物理内存,这些物理内存是由2MB的大页组成,连续的物理内存上面是内存段,内存段之上则是内存区,我们分配的基本单元对象是在内存区中分配的,内存区包含了ring队列,内存池、LPM路由表还有其他一些高性能的关键结构。
CPU亲和性
CPU的亲和性(CPU affinity),它是多核CPU发展的结果。随着核心的数量越来越多,为了提高程序工作的效率必须使用多线程。但是随着CPU的核心的数目的增长,Linux的核心间的调度和共享内存争用会严重影响性能。利用Intel DPDK的CPU affinity可以将各个线程绑定到不同的cpu,可以省去来回反复调度带来的性能上的消耗。
在一个多核处理器的机器上,每个CPU核心本身都存在自己的缓存,缓冲区里存放着线程使用的信息。如果线程没有绑定CPU核,那么线程可能被Linux系统调度到其他的CPU上,这样的话,CPU的cache命中率就降低了。利用CPU的affinity技术,一旦线程绑定到某个CPU后,线程就会一直在指定的CPU上运行,操作系统不会将其调度到其他的CPU上,节省了调度的性能消耗,从而提升了程序执行的效率。
多核轮询模式:多核轮询模式有两种,分别是IO独占式和流水线式。IO独占式是指每个核独立完成数据包的接收、处理和发送过程,核之间相互独立,其优点是其中一个核出现问题时不影响其他核的数据收发。流水线式则采用多核合作的方式处理数据包,数据包的接收、处理和发送由不同的核完成。流水线式适合面向流的数据处理,其优点是可对数据包按照接收的顺序有序进行处理,缺点是当某个环境(例如接收)所涉及的核出现阻塞,则会造成收发中断。
IO独占式多核轮询模式中每个网卡只分配给一个逻辑核进行处理。每个逻辑核给所接管的网卡分别分配一个发送队列和一个接收队列,并且独立完成数据包的接收、处理和发送的过程,核与核之间相互独立。系统数据包的处理由多个逻辑核同时进行,每个网卡的收发包队列只能由一个逻辑核提供。当数据包进入网卡的硬件缓存区,用户空间提供的网卡驱动通过轮询得知网卡收到数据包,从硬件缓冲区中取出数据包,并将数据包存入逻辑核提供的收包队列中,逻辑核取出收包队列中的数据包进行处理,处理完毕后将数据包存入逻辑核提供的发包队列,然后由网卡驱动取出发往网卡,最终发送到网络中。
IO独占式多核轮询模式架构图:
通常使用ifconfig查看网络接口的时候,会显示TX和RX数据,其实很简单:
RX==receive,接收,从开启到现在接收封包的情况,是下行流量。
TX==Transmit,发送,从开启到现在发送封包的情况,是上行流量。
内存池和无锁环形缓存管理
此外Intel DPDK将库和API优化成了无锁,比如无锁队列,可以防止多线程程序发生死锁。然后对缓冲区等数据结构进行了cache对齐。如果没有cache对齐,则可能在内存访问的时候多读写一次内存和cache。
内存池缓存区的申请和释放采用的是生产者-消费者模式无锁缓存队列进行管理,避免队列中锁的开销,在缓存区的使用过程中提高了缓冲区申请释放的效率。
kni
Kni(Kernel NIC Interface)内核网卡接口,是DPDK平台提供的用于将数据重入内核协议栈的一个组件,其目的是充分运用传统内核协议栈已实现的较稳定的协议处理功能。
DPDK平台对数据包的处理绕过了内核协议栈,直接交给用户空间处理,而用户空间没有完善的协议处理栈,如果让开发人员在用户空间实现完整独立的协议栈,开发工作是非常复杂的,因此DPDK平台提供了KNI组件,开发人员可以在用户空间实现一些特殊的协议处理功能,再通过KNI重入内核协议栈功能将普通常见的协议交由传统内核协议栈处理。KNI通信机制如下:
为什么要弄一个kni接口,虽然dpdk的高速转发性能很出色,但是也有自己的一些缺点,比如没有协议栈就是其中一项缺陷,当然也可能当时设计时就将没有将协议栈考虑进去,毕竟协议栈需要将报文转发处理,可能会使处理报文的能力大大降低。
KNI组件通过创建KNI虚拟接口设备,将数据包经过虚拟接口实现用户空间和内核协议栈间的通信。当网卡接收到数据包时,应用程序通过用户空间驱动将数据包获取到用户空间,KNI组件将需要数据包发送至KNI虚拟接口,由KNI虚拟接口交给内核协议栈处理,处理后若有响应报文,则再交给KNI虚拟接口返回给应用程序。其中发送数据包至内核协议栈以及接收内核协议栈回复的数据包,是由两个不同的逻辑核分别进行处理,不阻塞应用程序让内核协议栈发送数据包或从内核协议栈接收数据包的过程。
KNI接口实际上是一个虚拟出来的设备,该虚拟设备定义了四个队列,分别是接收队列(rx_q)、发送队列(tx_q)、已分配内存块队列(alloc_q)、待释放内存块队列(free_q)。接收队列用于存放用户空间程序发往KNI虚拟设备的报文,发送队列用于存放内核协议栈要往KNI虚拟设备的报文。已分配内存块队列存放已向内存中申请的内存块,供内核协议栈发送报文时取出使用。待释放内存块队列用于记录KNI虚拟设备从用户空间程序处接收到报文后将不再使用的内存块,然后将该队列中的内存块释放回内存。用户空间程序从网卡接收到报文时,将报文发送给KNI虚拟设备,KNI虚拟设备接收到用户空间程序发来的报文后,交给内核协议栈进行协议解析。发送报文时,原始数据先由内核协议栈进行协议封装,然后将报文发送给KNI虚拟设备,KNI虚拟设备接收到报文后,再将报文发送给用户空间程序。
如果只想抓取udp数据包的数据,dpdk就只需抓取这一种,arp和icmp等等的数据都原封不动的交给kni处理
d第一集
d第二集
|
|
对网络数据过滤
比如ip地址黑名单
1、在网卡处拦截,dpdk
2、在协议栈拦截,iptables(工具、netfilter的实现)、netfilter
3、应用程序 fd 黑名单
千万并发,C10K C1000K C10M
2-6个linux服务器上做不到(传统linux服务器:数据从网卡复制到内核协议栈再从内核协议栈复制到应用程序,需要cpu参与
dpdk 从网卡中使用DMA的方式从网卡中的数据直接拷贝到内存,不需要cpu参与,零拷贝
多队列网卡 大页
dkdp如何支持千万级并发
Linux I/O原理和Zero-copy
如今的网络应用早已从 CPU 密集型转向了 I/O 密集型,网络服务器大多是基于 C-S
模型,也即 客户端 - 服务端
模型,客户端需要和服务端进行大量的网络通信,这也决定了现代网络应用的性能瓶颈:I/O。
计算机存储器
寄存器、高速缓存、主存和磁盘
主内存是操作系统进行 I/O 操作的重中之重,绝大部分的工作都是在用户进程和内核的内存缓冲区里完成的,因此我们接下来需要提前学习一些主存的相关原理。
物理内存
我们平时一直提及的物理内存就是上文中对应的第三种计算机存储器,RAM 主存,它在计算机中以内存条的形式存在,嵌在主板的内存槽上,用来加载各式各样的程序与数据以供 CPU 直接运行和使用。
虚拟内存
在计算机领域有一句如同摩西十诫般神圣的哲言:"计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决",从内存管理、网络模型、并发调度甚至是硬件架构,都能看到这句哲言在闪烁着光芒,而虚拟内存则是这一哲言的完美实践之一。
|
|
用户态和内核态
因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的系统资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以,为了减少有限资源的访问和使用冲突,Unix/Linux 的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。
用户进程在系统中运行时,大部分时间是处在用户态空间里的,在其需要操作系统帮助完成一些用户态没有特权和能力完成的操作时就需要切换到内核态。那么用户进程如何切换到内核态去使用那些内核资源呢?答案是:1) 系统调用(trap),2) 异常(exception)和 3) 中断(interrupt)。
I/O 缓冲区
read(2)/write(2)
是 Linux 系统中最基本的 I/O 读写系统调用,我们开发操作 I/O 的程序时必定会接触到它们,而在这两个系统调用和真实的磁盘读写之间存在一层称为 Kernel buffer cache
的缓冲区缓存。在 Linux 中 I/O 缓存其实可以细分为两个:Page Cache
和 Buffer Cache
,这两个其实是一体两面,共同组成了 Linux 的内核缓冲区(Kernel Buffer Cache)
I/O 模式
在 Linux 或者其他 Unix-like 操作系统里,I/O 模式一般有三种:
- 程序控制 I/O
- 中断驱动 I/O
- DMA I/O:DMA 全称是 Direct Memory Access,也即直接存储器存取,是一种用来提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。整个过程无须 CPU 参与,数据直接通过 DMA 控制器进行快速地移动拷贝,节省 CPU 的资源去做其他工作。
一次完整的读磁盘文件然后写出到网卡的底层传输过程如下:
可以清楚看到这里一共触发了 4 次用户态和内核态的上下文切换,分别是 read()/write()
调用和返回时的切换,2 次 DMA 拷贝,2 次 CPU 拷贝,加起来一共 4 次拷贝操作。
通过引入 DMA,我们已经把 Linux 的 I/O 过程中的 CPU 拷贝次数从 4 次减少到了 2 次,但是 CPU 拷贝依然是代价很大的操作,对系统性能的影响还是很大,特别是那些频繁 I/O 的场景,更是会因为 CPU 拷贝而损失掉很多性能,我们需要进一步优化,降低、甚至是完全避免 CPU 拷贝。
零拷贝 (Zero-copy)
零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
Zero-copy 能做什么?
- 减少甚至完全避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作,从而减少用户态 – 内核态上下文切换带来的系统开销。
- 减少甚至完全避免操作系统内核缓冲区之间进行数据拷贝操作。
- 帮助用户进程绕开操作系统内核空间直接访问硬件存储接口操作数据。
- 利用 DMA 而非 CPU 来完成硬件接口和内核缓冲区之间的数据拷贝,从而解放 CPU,使之能去执行其他的任务,提升系统性能。
Zero-copy 的实现方式有哪些?
从 zero-copy 这个概念被提出以来,相关的实现技术便犹如雨后春笋,层出不穷。但是截至目前为止,并没有任何一种 zero-copy 技术能满足所有的场景需求,还是计算机领域那句无比经典的名言:“There is no silver bullet”!
而在 Linux 平台上,同样也有很多的 zero-copy 技术,新旧各不同,可能存在于不同的内核版本里,很多技术可能有了很大的改进或者被更新的实现方式所替代,这些不同的实现技术按照其核心思想可以归纳成大致的以下三类:
- 减少甚至避免用户空间和内核空间之间的数据拷贝:在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的
Page Cache
和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是通过增加新的系统调用来完成的,比如 Linux 中的 mmap(),sendfile() 以及 splice() 等。 - 绕过内核的直接 I/O:允许在用户态进程绕过内核直接和硬件进行数据传输,内核在传输过程中只负责一些管理和辅助的工作。这种方式其实和第一种有点类似,也是试图避免用户空间和内核空间之间的数据传输,只是第一种方式是把数据传输过程放在内核态完成,而这种方式则是直接绕过内核和硬件通信,效果类似但原理完全不同。
- 内核缓冲区和用户缓冲区之间的传输优化:这种方式侧重于在用户进程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种方法延续了以往那种传统的通信方式,但更灵活。
减少甚至避免用户空间和内核空间之间的数据拷贝
mmap()
|
|
一种简单的实现方案是在一次读写过程中用 Linux 的另一个系统调用 mmap()
替换原先的 read()
,mmap()
也即是内存映射(memory map):把用户进程空间的一段内存缓冲区(user buffer)映射到文件所在的内核缓冲区(kernel buffer)上。
利用 mmap()
替换 read()
,配合 write()
调用的整个流程如下:
- 用户进程调用
mmap()
,从用户态陷入内核态,将内核缓冲区映射到用户缓存区; - DMA 控制器将数据从硬盘拷贝到内核缓冲区;
mmap()
返回,上下文从内核态切换回用户态;- 用户进程调用
write()
,尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态; - CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
- DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
write()
返回,上下文从内核态切换回用户态。
通过这种方式,有两个优点:一是节省内存空间,因为用户进程上的这一段内存是虚拟的,并不真正占据物理内存,只是映射到文件所在的内核缓冲区上,因此可以节省一半的内存占用;二是省去了一次 CPU 拷贝,对比传统的 Linux I/O 读写,数据不需要再经过用户进程进行转发了,而是直接在内核里就完成了拷贝。所以使用 mmap()
之后的拷贝次数是 2 次 DMA 拷贝,1 次 CPU 拷贝,加起来一共 3 次拷贝操作,比传统的 I/O 方式节省了一次 CPU 拷贝以及一半的内存,不过因为 mmap()
也是一个系统调用,因此用户态和内核态的切换还是 4 次。
mmap()
因为既节省 CPU 拷贝次数又节省内存,所以比较适合大文件传输的场景。虽然 mmap()
完全是符合 POSIX 标准的,但是它也不是完美的,因为它并不总是能达到理想的数据传输性能。首先是因为数据数据传输过程中依然需要一次 CPU 拷贝,其次是内存映射技术是一个开销很大的虚拟存储操作:这种操作需要修改页表以及用内核缓冲区里的文件数据汰换掉当前 TLB 里的缓存以维持虚拟内存映射的一致性。但是,因为内存映射通常针对的是相对较大的数据区域,所以对于相同大小的数据来说,内存映射所带来的开销远远低于 CPU 拷贝所带来的开销。此外,使用 mmap()
还可能会遇到一些需要值得关注的特殊情况,例如,在 mmap()
–> write()
这两个系统调用的整个传输过程中,如果有其他的进程突然截断了这个文件,那么这时用户进程就会因为访问非法地址而被一个从总线传来的 SIGBUS 中断信号杀死并且产生一个 core dump。有两种解决办法:
- 设置一个信号处理器,专门用来处理 SIGBUS 信号,这个处理器直接返回,
write()
就可以正常返回已写入的字节数而不会被 SIGBUS 中断,errno 错误码也会被设置成 success。然而这实际上是一个掩耳盗铃的解决方案,因为 BIGBUS 信号的带来的信息是系统发生了一些很严重的错误,而我们却选择忽略掉它,一般不建议采用这种方式。 - 通过内核的文件租借锁(这是 Linux 的叫法,Windows 上称之为机会锁)来解决这个问题,这种方法相对来说更好一些。我们可以通过内核对文件描述符上读/写的租借锁,当另外一个进程尝试对当前用户进程正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会告诉用户内核正在破坏你加在那个文件上的读/写租借锁,这时
write()
系统调用会被中断,并且当前用户进程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 同样会被设置为 success。文件租借锁需要在对文件进行内存映射之前设置,最后在用户进程结束之前释放掉。
sendfile()
使用 sendfile()
完成一次数据读写的流程如下:
- 用户进程调用
sendfile()
从用户态陷入内核态; - DMA 控制器将数据从硬盘拷贝到内核缓冲区;
- CPU 将内核缓冲区中的数据拷贝到套接字缓冲区;
- DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
sendfile()
返回,上下文从内核态切换回用户态。
基于 sendfile()
, 整个数据传输过程中共发生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和 mmap()
+ write()
相同,但是因为 sendfile()
只是一次系统调用,因此比前者少了一次用户态和内核态的上下文切换开销。读到这里,聪明的读者应该会开始提问了:"sendfile()
会不会遇到和 mmap()
+ write()
相似的文件截断问题呢?",很不幸,答案是肯定的。sendfile()
一样会有文件截断的问题,但欣慰的是,sendfile()
不仅比 mmap()
+ write()
在接口使用上更加简洁,而且处理文件截断时也更加优雅:如果 sendfile()
过程中遭遇文件截断,则 sendfile()
系统调用会被中断杀死之前返回给用户进程其中断前所传输的字节数,errno 会被设置为 success,无需用户提前设置信号处理器,当然你要设置一个进行个性化处理也可以,也不需要像之前那样提前给文件描述符设置一个租借锁,因为最终结果还是一样的。
sendfile() with DMA Scatter/Gather Copy
上一小节介绍的 sendfile()
技术已经把一次数据读写过程中的 CPU 拷贝的降低至只有 1 次了,但是人永远是贪心和不知足的,现在如果想要把这仅有的一次 CPU 拷贝也去除掉,有没有办法呢?
当然有!通过引入一个新硬件上的支持,我们可以把这个仅剩的一次 CPU 拷贝也给抹掉:Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather – 分散/收集功能,并修改了 sendfile()
的代码使之和 DMA 适配。scatter 使得 DMA 拷贝可以不再需要把数据存储在一片连续的内存空间上,而是允许离散存储,gather 则能够让 DMA 控制器根据少量的元信息:一个包含了内存地址和数据大小的缓冲区描述符,收集存储在各处的数据,最终还原成一个完整的网络包,直接拷贝到网卡而非套接字缓冲区,避免了最后一次的 CPU 拷贝:
sendfile() + DMA gather
的数据传输过程如下:
- 用户进程调用
sendfile()
,从用户态陷入内核态; - DMA 控制器使用 scatter 功能把数据从硬盘拷贝到内核缓冲区进行离散存储;
- CPU 把包含内存地址和数据长度的缓冲区描述符拷贝到套接字缓冲区,DMA 控制器能够根据这些信息生成网络包数据分组的报头和报尾
- DMA 控制器根据缓冲区描述符里的内存地址和数据大小,使用 scatter-gather 功能开始从内核缓冲区收集离散的数据并组包,最后直接把网络包数据拷贝到网卡完成数据传输;
sendfile()
返回,上下文从内核态切换回用户态。
基于这种方案,我们就可以把这仅剩的唯一一次 CPU 拷贝也给去除了(严格来说还是会有一次,但是因为这次 CPU 拷贝的只是那些微乎其微的元信息,开销几乎可以忽略不计),理论上,数据传输过程就再也没有 CPU 的参与了,也因此 CPU 的高速缓存再不会被污染了,也不再需要 CPU 来计算数据校验和了,CPU 可以去执行其他的业务计算任务,同时和 DMA 的 I/O 任务并行,此举能极大地提升系统性能。
splice()
数据传输过程图:
使用 splice()
完成一次磁盘文件到网卡的读写过程如下:
- 用户进程调用
pipe()
,从用户态陷入内核态,创建匿名单向管道,pipe()
返回,上下文从内核态切换回用户态; - 用户进程调用
splice()
,从用户态陷入内核态; - DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端"拷贝"进管道,
splice()
返回,上下文从内核态回到用户态; - 用户进程再次调用
splice()
,从用户态陷入内核态; - 内核把数据从管道的读取端"拷贝"到套接字缓冲区,DMA 控制器将数据从套接字缓冲区拷贝到网卡;
splice()
返回,上下文从内核态切换回用户态。
相信看完上面的读写流程之后,读者肯定会非常困惑:说好的 splice()
是 sendfile()
的改进版呢?sendfile()
好歹只需要一次系统调用,splice()
居然需要三次,这也就罢了,居然中间还搞出来一个管道,而且还要在内核空间拷贝两次,这算个毛的改进啊?
我最开始了解 splice()
的时候,也是这个反应,但是深入学习它之后,才渐渐知晓个中奥妙,且听我细细道来:
先来了解一下 pipe buffer 管道,管道是 Linux 上用来供进程之间通信的信道,管道有两个端:写入端和读出端,从进程的视角来看,管道表现为一个 FIFO 字节流环形队列: