点击蓝字
![169565eaa65aa02b696a865992f03e47.gif](https://i-blog.csdnimg.cn/blog_migrate/a0711a50c8472f9ac43c5166bd01c691.gif)
关注我们
![74277a2988da4bcaf211b6fc3e7be910.png](https://i-blog.csdnimg.cn/blog_migrate/db8818babc35a2aba5849d33c048699c.png)
作者简介
冯志明,2019年至今负责搜索算法的相关工作,擅长处理复杂的业务系统,对底层技术有浓厚兴趣。
简述
这已经是 Linux I/O 系列的第二篇文章。之前我们讨论了“磁盘 I/O 及磁盘 I/O 中的部分零拷贝技术” 本篇开始讨论“Linux 网络 I/O 的结构”以及大家关心的零拷贝技术。
socket 发送和接收的过程
socket 是 Linux 内核对 TCP/UDP 的抽象,在这里我们只讨论大家最关心的 TCP。
TCP 如何发送数据
图-1
图-2
程序调用了 write/send,进入内核空间。
内核根据发送数据创建 sk_buff 链表,sk_buff 中最多会包含 MSS 字节。相当于用 sk_buff 把数据切割了。这个 sk_buff 形成的链表,就是常说的 socket 发送缓冲区。
*另外 ,有关MSS 的具体内容我们需要另外写一篇文章讨论,这里我们只要理解为网卡的限制即可
检查堵塞窗口和接收窗口,判断接收方是否可以接收新数据。
创建数据包(packet,或者叫 TCP 分段 TCP segment);添加 TCP 头,进行 TCP 校验。
执行 IP 路由选择,添加 IP 头,进行 IP 校验。
通过 QDisc(排队规则)队列将数据包缓存起来,用来控制网络收发的速度。
经过排队,数据包被发送到驱动,被放入 Ring Buffer(Tx.ring)输出队列。
网卡驱动调用 DMA engine 将数据从系统内存中拷贝到它自己的内存中。
NIC 会向数据包中增加帧间隙(Inter-Frame Gap,IFG),同步码(preamble)和 CRC 校验。当 NIC 发送了数据包,NIC 会在主机的 CPU 上产生中断,使内核确认已发送。
TCP 如何接收数据
图-3
图-4
(从下往上看)
当收到报文时,NIC 把数据包写入它自身的内存。
NIC 通过 CRC 校验检查数据包是否有效,之后调用 DMA 把数据包发送到主机的内存缓冲区,这是驱动程序提前向内核申请好的一块内存区域。(sk_buff 线性的数据缓冲区,后面会讲)
数据包的实际大小、checksum 和其他信息会保存在独立的 Ring Buffer(Rx.ring) 中,Ring Buffer 接收之后,NIC 会向主机发出中断,告知内核有新的数据到达。收到中断,驱动会把数据包包装成指定的数据结构(sk_buff)并发送到上一层。
链路层会检查数据包是否有效并且解析出上层的协议(网络协议)。
IP 层同样会检查数据包是否有效。检查 IP checksum。
TCP 层检查数据包是否有效。检查 TCP checksum。
根据 TCP 控制块中的端口号信息,找到对应的 socket,数据会被增加到 socket 的接收缓冲区,socket 接收缓冲区的大小就是 TCP 接收窗口。
当应用程序调用 read 系统调用时,程序会切换到内核区,并且会把 socket 接收缓冲区中的数据拷贝到用户区,拷贝后的数据会从 socket 缓冲区中移除。
1 各层的关键结构
1.1 Socket 层的 Socket Buffer
套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。
那么数据写入到哪里了?又是从哪里读出来的呢?这就要进入一个抽象的概念“Socket Buffer”。
1.1.1 逻辑上的概念
Socket Buffer 是发送缓冲区和接收缓冲区的统称。
发送缓冲区
进程调用 send() 后,内核会将数据拷贝进入 socket 的发送缓冲区之中。不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP 协议负责的。
接收缓冲区
接收缓冲区被 TCP 和 UDP 用来缓存网络上来的数据,一直保存到应用进程读走为止。recv(),就是把接收缓冲区中的数据拷贝到应用层用户的内存里面,并返回。
1.1.2 SKB数据结构(线性buffer)
Socket Buffer 的设计应该符合两个要求
保持实际在网络中传输的数据。
数据在各协议层传输的过程中,尽量减少拷贝。
怎么才能做到呢?
图-5
图-6
图-7
每个 socket 被创建后,内核都会为其分配一个 Socket Buffer(其实是抽象的)。Socket Buffer 指的是 sk_buff 链表,初始时只是一个空的指针。所以初始时 sk_buff_head 的 next 和 prev 都是空。
write 和 receive 的过程就是 sk_buff 链表 append 的过程。
sk_buff 是内核对TCP数据包的一个抽象表示,所以最大不能超过最大传输量MSS,或者说长度是固定的。
sk_buff 的结构设计是为了方便数据的跨层传递。
skb 通过 alloc_skb 和 skb_reserve 申请和释放,因此 skb 是有个池的概念的及“线性的数据缓冲区”。
1.1.3 总结(重要,关系到零拷贝的理解)
只在两种情况下创建 sk_buff:
应用程序给 socket 写入数据时。
当数据包到达 NIC 时。
数据只会拷贝两次:
用户空间与内核空间之间的拷贝(socket 的 read、write)。
sk_buff 与 NIC 之间的拷贝。
1.1.4 误区
根据《Unix网络编程V1, 2.11.2》中的描述:
TCP 的 socket 中包含发送缓冲区和接收缓冲区。
UDP 的 socket 中只有一个接收缓冲区,没有发送缓冲区。
UDP 如果没有发送缓冲区,怎么实现多层协议之间的交换数据呢?
参考 man 手册:udpwmemmin 和 udprmemmin 不就是送缓冲区和接收缓冲区吗?
https://man7.org/linux/man-pages/man7/udp.7.html
1.2 QDisc
QDisc(排队规则)是 queueing discipline 的简写。位于 IP 层和网卡的 Ring Buffer 之间,是 IP 层流量控制(traffic control)的基础。QDisc 的队列长度由 txqueuelen 设置,和网卡关联。
内核如果需要通过某个网络接口发送数据包,它都需要按照为这个接口配置的Qdisc(排队规则)把数据包加入队列。然后,内核会尽可能多地从 Qdisc 里面取出数据包,把它们交给网络适配器驱动模块。
说白了,物理设备发送数据是有上限的,IP 层需要约束传输层的行为,避免数据大量堆积,平滑数据的发送。
1.3 Ring Buffer
1.3.1 简介
环形缓冲区 Ring Buffer,用于表示一个固定尺寸、头尾相连的缓冲区的数据结构,其本质是个 FIFO 的队列,是为解决某些特殊情况下的竞争问题提供了一种免锁的方法,可以避免频繁的申请/释放内存,避免内存碎片的产生。
本文中讲的 Ring Buffer,特指 NIC 的驱动程序队列(driver queue),位于 NIC 和协议栈之间。
它的存在有两个重要作用:
可以平滑生产者和消费者的速度。
通过 NAPI 的机制,合并以减少 IRQ 次数。
图-8
NIC (network interface card) 在系统启动过程中会向系统注册自己的各种信息,系统会分配 Ring Buffer 队列及一块专门的内核内存区用于存放传输上来的数据包。每个 NIC 对应一个R x.ring 和一个 Tx.ring。一个 Ring Buffer 上同一个时刻只有一个 CPU 处理数据。
Ring Buffer 队列内存放的是一个个描述符(Descriptor) ,其有两种状态:ready 和 used。初始时 Descriptor 是空的,指向一个空的 sk_buff,处在 ready 状态。当有数据时,DMA 负责从 NIC 取数据,并在 Ring Buffer 上按顺序找到下一个 ready 的 Descriptor,将数据存入该 Descriptor 指向的 sk_buff 中,并标记槽为 used。
Ring Buffer 可能被占满,占满之后再来的新数据包会被自动丢弃。为了提高并发度,支持多队列的网卡 driver 里,可以有多个 Rx.ring 和 Tx.ring。
1.3.2 Ring Buffer 误区
虽然名字中带 Buffer,但它其实是个队列,不会存储数据,因此不会发生数据拷贝。
2 关于网络 I/O 结构的总结
网络 I/O 中,在内核空间只有一个地方存放数据,那就是 Socket Buffer。
Socket Buffer 就是 sk_buff 链表,只有在 Socket 写入或者数据到达 NIC 时创建。
sk_buff 是一个线性的数据缓冲区,是通过 alloc_skb 和 skb_reserve 申请和释放的。
每个sk_buff 是固定大小的,这与 MTU 有关。
数据只有两次拷贝:用户空间与 sk_buff 和 sk_buff 与 NIC。
3 网络 I/O 中的零拷贝
3.1 DPDK
网络 I/O 中没有没有类似 Direct I/O 的技术呢?答案是 DPDK。
我们上面讲了,处理数据包的传统方式是 CPU 中断方式。网卡驱动接收到数据包后通过中断通知 CPU 处理,数据通过协议栈,保存在 Socket Buffer,最终用户态程序再通过中断取走数据,这种方式会产生大量 CPU 中断性能低下。
DPDK 则采用轮询方式实现数据包处理过程。DPDK 在用户态重载了网卡驱动,该驱动在收到数据包后不中断通知 CPU,而是通过 DMA 直接将数据拷贝至用户空间,这种处理方式节省了 CPU 中断时间、内存拷贝时间。
为了让驱动运行在用户态,Linux 提供 UIO(Userspace I/O)机制,使用 UIO 可以通过 read 感知中断,通过 mmap 实现和网卡的通讯。
图-9
3.1.1 DPDK 缺点
需要程序员做的事情太多,开发量太大,相当于程序员要把整个 IP 协议底层实现一遍。
4 跨越磁盘 I/O 和网络 I/O 的零拷贝
通过三篇文章,我们了解了 Linux I/O 的系统结构和基本原理。对于零拷贝,网上的文章很多,我们只要简单解读一下就可以了。
*另外强调一下,下面的解读,掺杂大量个人观点,并不权威,需要读者自行判断真伪。当然,如有理解错误之处也欢迎指正。
4.1 read + write
图-10
网上的总结
4次上下文切换,2次 CPU 拷贝和2次 DMA 拷贝。
解读
read 和 write,两次系统调用,每次从用户态切换到内核态,再从内核态切换回用户态,所以4次上下文切换。
数据由 Page Cache 拷贝到用户空间,再由用户空间拷贝到 socket buffer,2次 CPU 拷贝。
现在的磁盘和网卡都是支持 DMA 的,所以从磁盘到内存,从网卡到内存的数据都是 DMA 拷贝。
4.2 mmap + write
图-11
网上的总结
4次上下文切换,1次 CPU 拷贝。针对大文件性能高,针对小文件需要内存对齐,所以浪费内存。
解读
mmap 和 write,两次系统调用,4次上下文切换,没问题。
MMU 支持下,数据从虚拟地址到 socket buffer 的拷贝,实际是PageCache 到 socket buffer 的拷贝,所以1次 CPU 拷贝,也没问题。
mmap 读取过程中,会触发多次缺页异常,造成上下文切换,所以越大的文件性能越差。
不存在浪费内存,mmap 本质是 Buffer I/O,本来也是 page 对齐的。
补充
RocketMQ 选择了 mmap+write 这种零拷贝方式,适用于消息这种小块文件的数据持久化和传输。
4.3 sendfile
图-12
网上的总结
2次上下文切换,1次CPU拷贝。
针对大文件性能高,针对小文件需要内存对齐,所以浪费内存。
解读
sendfile,一次系统调用,2次上下文切换。
数据从 Page Cache 拷贝到 socket buffer,所以1次 CPU 拷贝。
sendfile才是更适合处理大文件,所有工作都是内核来完成的,效率一定高。
补充
Kafka采用的是sendfile这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。
4.3.1 sendfile,splice,tee 区别
sendfile
在内核态从 in_fd 中读取数据到一个内部 pipe,然后从 pipe 写入 out_fd 中;
in_fd 不能是 socket 类型,因为根据函数原型,必须提供随机访问的语义。
splice
类似 sendfile 但更通;
需要 fd_in 或者 fd_out 中,至少有一个是 pipe。
vmsplice
fd_in 必须为 pipe;
如果是写端则把 iov 部分数据挂载到这个 pipe 中(不拷贝数据),并通知 reader 有数据需要读取; 如果是读端,则从 pipe 中 copy 数据到 userspace。
tee
需要 fd_in 和 fd_out 都必须为 pipe,从 fd_in pipe 中读取数据并挂载到 fd_out 中。
4.3.2 sendfile 是否可以用于 https 传输
我认为,基本上很难实现。http,https 在七层协议中,属于应用层,是在用户空间的。http 可以在用户空间写入 http 头信息,文件内容的拷贝由内核空间完成。https 的加密,解密工作是必须在用户空间完成的,除非内核支持,否则必须进行数据拷贝。
4.4 sendfile + DMA gather copy
传说中的,跨越磁盘 I/O 和网络 I/O 的零次 CPU 拷贝的技术。
图-13
网上的总结
在硬件的支持下,sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝,这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。
解读
本人才疏学浅,认为这不太可能。DMA gather 是指 DMA 允许在一次单一的DMA处理中传输数据到多个内存区域,说白了就是支持批量操作,不会有太大差异。
Socket Buffer 结构是很复杂的,它担负着数据跨层传递的作用,如果传递过程中 Page Cache 中的数据被回收了怎么办?我觉得能说得过去的至少是图-14这种方式,而且内核需要有明确的 API 支持 socket_readfile。据我所知,Linux 并没有提供这种 API。
图-14
End