简述 Linux I/O 原理及零拷贝(下) — 网络 I/O
冯志明
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
版权声明: 本文为 InfoQ 作者【Qunar技术沙龙】的原创文章。
原文链接:【http://xie.infoq.cn/article/06449d84c5f6b005775215024】。未经作者许可,禁止转载。
评论