前言
周未看了这篇文章(原文) 了,觉得很是不错,基本把网络行几个关键点以及它们之间的上下文 (context) 阐述出来了,决定用自己的话翻译出来,中间可能会插入一些自己的想法,不会一词一句对应翻译,也因为理解英语更多是一种意会。
没有 TCP/IP 的网络服务是不可想像的,所有的网络服务多是基于坚实的 TCP/IP, 对于开发人员来说,理解数据是怎么样在网络传输非常关键,这样在面对诸如:如何调优 (tuning) 提高服务的性能,问题怎么定位以及从最基本的底层对新技术如何来认知时就能迎韧而解了。
下面基于 Linux 系统中的 数据流 (data flow) 和控制流 (control flow) 以及就硬件层面来描述整个网络栈 (network stack) 的运作
TCP/IP 中的关键点 #
想自己设计一种网络协议来快速传送数据以及保证数据的顺序性和一致性?
TCP/IP 当初就是为了这个而设计的,下面是一些理解这种协议栈的关键点
TCP/IP 从技术上来讲分属不同的结构层,应该把它们分开来讲解,即便如此,这里把它们联系在一起去表述。
- 面向连接
一条传输数据的 TCP 连接在由两个服务端点构成,它由两端的地址组成,结构为:
本地 IP:port, 对端 IP:port(<local IP address, local port number, remote IP address, remote port number>)
- 双向字节流
以字节流 (byte stream) 的表现形式来进行数据传送,在语言层面看来比如,C 里面的一个 char 类型,Golang 里面的一个 byte 类型数据,可以说 byte 是数据结构模型设计要考虑到的最小单位了(比如说 age 用 int32 就行了,用 int64 就会浪费多一倍的资源:内存,带宽,存储等),当然有时候也会考虑到 bit 的程度 (1 byte = 8 bits) - 按顺序递交传送
接收数据的顺序与发送的数据的顺序相同,为了保证顺序性,使用了一个 32 位的数据来进行标记(SEQ)。 - 依赖确认标记 (ACK) 来保证可用性
如果发送端没有收到接收端 对之前发送过去的数据的确认标记, 发送端就会给接收端再次发送之前的那组数据,也因此发送端会为这一接收端缓存一组 未确认的数据 (unacknowledged data buffer) - 流控制
接收端每次会给对端(发送端)带上未使用的缓存大小和接收窗口大小 (unused buffer size, receive window), 对端会根据接收端的可接收大小发送尽可能多的数据。
在 three handshake 建立连接的时候,双方就会交换这些数据来进行初始化 拥塞控制
拥塞窗口 congestion window 与接收窗口是分开,作用不一样。拥塞窗口通过限制大量的数据在网络上传输来防止网络拥堵。发送端会通过不同的算法算出给对端发送对端拥塞窗口允许的最大数据量,这些算法如 TCP Vegas, Westwood, BIC, and CUBIC 等。与流控制不同,拥塞控制只由发送端自己决定,即 接收端并不会像流控制里面的接收窗口一样每次自行向发送端 传送拥塞数据,发送端在每次收到对端的 ACK 标记会自行计算这个拥塞窗口数据传输 #
一个 TCP/IP 网络传输栈由多层组成,如图表 1
Figure 1: Operation Process by Each Layer of TCP/IP Network Stack for Data Transmission.
可以说这些网络层分属三个不同的区域
- 用户区
- 内核区
- 设备区
用户区 和 内核区由 CPU 驱动,这两个区一起也叫做 host, 以此来与设备区进行划分。这里的设备指用来发送和接收包 (packet) 的 NIC(Network Interface Card, 网卡啦),也叫做 LAN 卡
用户区就是一般编程能够操作调用函数的区域,比如当我们写一个应用来发送数据时 会调用用 write()
这个系统调用 (system call) 来进行发送,如图 1 中的 User data 所示。 假如图 1 的 fd 即 socket 已经被创建,当这个write()
被调用后,接下来的运算就会转换到内核区。
POSIX 系列的操作系统包括 Linux 和 Unix 会以一个文件柄也叫文件描述符 (file descriptor) 的形式表现 socket,网络编程进行收发数据就像对一般的文件读写一样。这种 file descriptor 是一种在一般的文件结构上绑定 socket 结构的文件类型,通过特定的函数(如 c 里面的 int socket(int domain, int type, int protocol)) 由系统检查后可创建。
socket 文件有两个缓存区
- 发送缓存
- 接收缓存
当 write 被调用时 用户区的数据会被复制到内核区的内存中,然后这些数据会被添加到 发送缓存的尾部,这也是发送的顺序表现。在图 1 中,灰色区指向的是 socket 缓存的数据。至此 TCP 层的工作就来了。
socket 文件结构中包含有一个 TCP Control Block (TCB), 这个 TCB 包含有一些控制 TCP 连接的必要数据,这些数据与 TCP 的连接状态有关包括:LISTEN
, ESTABLISHED
, TIME_WAIT
; receive window, congestion window, sequence number, resending timer, 等等。
如果 TCP 的状态允许数据传输,那么一个 TCP 分组(在 TCP 层叫 segment,IP 层叫 packet) 就会被创建;反之因为流控制或者其它的原因,那么这个 write() 就会返回错误到用户区转而让应用处理。
TCP 的分组构成:
- TCP header, 组头
- payload, 应用数据
如图 2 所示
Figure 2: TCP Frame Structure
这里的 payload 指的是 socket 发送缓存中未得到确认的应用数据, payload 的最大值可以是 receive window, congestion window, and maximum segment size (MSS) 其中之一
接着,TCP 校验值将会被计算出来,检验包含了头部信息如 IP 地址,包长度大小和 TCP 协议号。根据 TCP 的状态一个分组可以分成一个或者多个 packets 进行传输。
实际上 TCP 的校验是由 NIC 完成的而不是在内核区的 TCP 层进行,但为了方便讲解,我们就假设是在内核中进行 TCP 校验。
TCP 层产生的 segment 将会进入到 IP 层,在 IP 层中加上 IP 头后进行 IP 路由定向 (IP routing)。IP 路由定向会经过下一个跳跃 IP 找到最终要到传输的 IP。
IP 层中的数据在加上 IP 层的头部信息后进行校验计算,然后被送到数据链路层 (Ethernet), 数据链路层由 ARP(Address Resolution Protocol) 协议得到 IP 对应该的 MAC 地址后加上该层的头部信息,到此用户区和内核区的数据的包装就算完成了。
IP 路由定向的结果是找到 NIC 接口,这个接口用于在 IP 和下一个跳跃 IP 之间传输 packet, NIC 需要特写的特定的传输驱动 (the transmit NIC driver) 来工作。
同时,如果有 tcpdump 或者 Wireshark 等抓包程序运行时,内核会把数据包复制到这些程序的可使用的内存中。另一个方面,接收的时候 数据将会被 NIC 驱动捕获。通常抓包是在这一层(设备层)完成的。
NIC 传输驱动的运作与 NIC 制造商定义好的协议有关
在收到传输请求后,NIC 会从内核内存中复制要传输的数据 packet 到自己的内存中(从系统内存中复制到网卡内存中),也就是 direct memory access(DMA),然后再把数据向数据线上发送。至此,根据以太网标准 (Ethernet standard), 数据会被加上 IFG (Inter-Frame Gap), preamble, and CRC. 其中 IFG 和 preamble 用来判断 packet 的起始位置(也就是帧构成,framing), CRC 与 TCP 和 IP 中的一样都起到校验的作用。packet 传输与数据链路的物理速度以及它的流控制有关,一大堆东西,一下子说不清。
当 NIC 发送一个 packet 时(即在接收端接收数据时),它会向 CPU 生成一个中断信号,每一个中断信号都有一个数字,系统会根据这个信号去搜索合适的驱动来处理这个中断,这个驱动在启动的时候会注册这个中断的处理函数。系统通过执行这个函数来处理中断。
上面简述了当应用进行写操作时数据在内核和设备之间运输过程,然而即使没有应用的写操作请求,内核也可以通过 TCP 层来传输数据。比如,当收到确认通知而且接收窗口扩展后,内核把 socket 发送缓存中剩下的数据进行 TCP 分组打包后发送,这个与否是启用 Nagle 算法有关,Linux 默认是不启用的。
数据接收过程 #
图 3 展示了网络栈处理数据接收的过程
Figure 3: Operation Process by Each Layer of TCP/IP Network Stack for Handling Data Received.
首先 NIC 会把接收到的 packet 写入到自己的内存中,然后检查 packet 中的校验值是否正确,如果正确就会把这 packet 发送到系统的内存中去,这块内存由 NIC 驱动提前申请好。NIC 驱动在申请好这一块内核内存后会把内存的地址和大小与 NIC 同步,如果无法申请内核中的内存,NIC 可能会把接收到的包丢弃。(这是大多数网络栈经过内核处理接收 packet 的过程,不过也有一种叫做 DPDK(drive NIC nerwok interface controller directly),packet 不经过内核直接由用户区的应用所用,像 scylla 这种尽可能榨干硬件设备性能的数据库就支持使用这种技术。)
NIC 向内核内存发送 packet 的过程中,会给系统发送一个中断信号,然后 NIC 驱动就可以检查是否能够处理这个新的 pakcet, 这里会使用到产商定义的 NIC 驱动和 NIC 的通信协议。
当 NIC 驱动要向上层传递 packet 时,传递的 packet 必须与系统规定的 packet 结构相对应,因此 packet 必须被转化成系统能处理的 packet struct 比如 Linux 中的 sk_buff, BSD 系统中的 mbuf 以及微软中的 NET_BUFFER_LIST。
在数据链路层会检查 packet 的合理性和解析上一层的协议。此时就会用该层 Ethernet header 中的 ethertype 值,像 IPv4 的 ethertype 为 0x0800,然后它把当前的 header 去掉后的 packet 向上一层即 IP 层传递。
在 IP 层也会根据头部的 checksum 检查收到的 packet 的合理性,IP 层会决定是否要进行 IP 路由定向,是让系统处理这个 packet,还是把这个 packet 发向其它的系统主机。如果让本地系统处理这个 packet,它就会根据头部信息中的协议值解析上一层的协议,像 TCP 协议的值为 6。处理后的 packet 就可以到达 TCP 层了。
在 TCP 层时,同理,检查 packet 后,它会找到 packet 关联的 TCP 控制块 (TCP control block)。在这里,标记 packet 唯一性的值 就派上用场了。根据 TCP 的协议,如果接收的是新的数据,它就会把这个数据放到接收缓存中同时发送一个新的 TCP segment 来告诉对端这一数据(packet)已经确认被接收到。至此,TCP/IP 接收数据就完成了。
之前(不知道多久)socket 的缓存大小是由应用或者系统配置定义,现在可以由接收窗口来自动调整了。
当应用使用 read() 这个系统调用时,数据就会从内核的内存中被复制到应用的内存中,然后 socket 接收缓存中这份数据就会被删除掉(弄个偏移而已),这里也会涉及到 TCP 层,TCP 会因为可用接收缓存大小的增加而增大接收窗口大小,同时会高对端发送一个表示状态的 packet,如果没有 packet 要发送,那么这个 read() 算是完成了被调用。
简单总结,发送数据是层层封装和加上校验;而 接收数据时是层层检查和解析的,下上层的数据处理都是那么井然有序。
网络栈技术开发指南 #
上面已经阐述了网络栈中的基本要点,早在 1990 年并没这么多的技术逻辑要处理,然而现在的网络栈技术变得严谨又复杂,应用广泛,整个网络结构的实现越来越深入。
如今的网络技术栈应用可划分为以下几个:
Packet 流程控制 #
比如像 firewall, NAT 这样的网络过滤器和控制器,用户可以在上面基础的网络层中加入自己的控制代码,通过配置实现不同的效果。
根据协议进行性能调优 #
在现在有网络环境下提搞吞吐量,降低延迟,提高稳定性,这些与 TCP 协议相关的内容都是等着开发人员去学习和掌握的。 不同的拥塞算法以及 TCP 中另外一个实现机制 SACK 就是典型的样例。这已经超出了本文的阐述范围了,故不作深入探讨。
packet 处理效率 #
减少 CPU 计算量,使用更少的内存,降低内存 IO 的次数,从这几个大的方面考虑提高 packet 的处理效率。比如在降低延迟方面的措施有:并行处理栈 (stack parallel processing),头部信息预测 (header prediction),写时复制 (zero-copy),单点复制 (single-copy),离线校验 (checksum offload),TSO, LRO, RSS 等等
网络栈中的流控制 #
进一步了解 Linux 网络栈中的内部。如今什么算是一个网络栈,子系统不是,简单而言,对事件响应的事件驱动器就是一个网络栈,因此不让一个线程去处理一个栈(不知道在说什么)。图 1 和图 3 展示了流控制的过程,图 4 对这一控制流进行了阐明。
在图 4 的流程 1 中,应用调用了 read 和 write 来执行 TCP 任务,但是却没有数据进行传递。
Figure 4: Control Flow in the Stack.
流程 1 和流程 2 中,在执行 TCP 任务后,如果有数据要传送,数据就会被一层一层传递到 NIC 驱动中。在 NIC 驱动之前会有一个队列,数据先进入到这个队列,然后由这个队列的实现逻辑决定什么时候由 NIC 驱动处理。在 Linux 中,这个队列叫做 qdisc(queue discipline), Linux 就是通过操控 qdisc 来进行传输控制。 默认下,qdisc 是一个先入先出队列。通过使用其它类型的 qdisc 可以达到不同的 packet 控制效果,比如 手动丢包,包延迟, 包传送速率等等。在流程 1 和流程 2 中,应用的工作线程也可以运行 NIC 驱动。
流程 3 展示了 TCP 所使用的已过期的计时器,例如,当TIME_WAIT 已经超时 TCP 就会被通知断开对应的那条连接。
流程 3 和流程 4 展示了软中断计时器 (softirq) 处理计时中断的过程。
当 NIC 驱动收到中断请求时就会释放已经传输的 packet。多数情况下,驱动的执行将会处于终止状态。 流程 5 中 传输队列累积了 packet,驱动将请求软中断,而软中断的处理单元 handler 将把传输队列中的 packet 转移到驱动中。
当 NIC 收到中断请求且发现有新的已接收的 packet 时,它会就会向 softirq 软中断发起请求。 softirq 软中断将调用驱动把 NIC 己接收的 packet 向上一层传递。在 Linux 中,上面所示的负责处理接收 packet 的部分叫做 New API(NAPI)。 因为驱动不会直接向上层传递 packet,但是上层会直接来获取 packet,所以接收的过程有点像轮洵,实际代码也命名为 NAPI 或者 poll(轮洵)。
流程 6 展示了 到 TCP 执行完成, 流程 7 表现了要请求传输更多的 packet,流程 5 6 7 全部由响应 NIC 中断信号的 softirq 来执行。
怎么实现中断处理和 packet 接收 #
图 5 展示了处理中断的过程,中断处理并不复杂,更重要的是要了解什么会影响 packet 接收性能
Figure 5: Processing Interrupt, softirq, and Received Packet.
(响应处理单元即为 xxx 的 handler, 总觉得还是使用 handler 理解起来比较顺畅简单,翻译成中文太长觉得累赘)
假设一个 CPU0 正在处理应用的逻辑运算,同时 NIC 收到一个 packet,那 NIC 就会向 CPU0 生成一个中断信号,然后 CPU 就会执行内核中断响应处理单元 kernal interrupt(irq) handler。这个 响应处理单元 (handler) 会找到相应的中断信号数字再执行特定驱动的 handler 响应处理单元。 这里,驱动会先释放之前已经(向上)传递的 packet,再调用 napi_schedule() 函数来处理接收到的 packet,再由这个函数请求 softirq 软中断(就是上面提到的 softirq)。
当驱动的 interrupt handler 中断响应处理单元 执行结束后,接下来的操作就由内核来处理了。内核会执行 softirq 的中响应单元。
当驱动的中断 context 被执行后,softirq 的 context 将会被执行,这两个 context 是由同一个线程执行的,但是它们使用不同的 stack。还有,中断 context 的执行会阻塞硬件的中断,但 softirq 却允许硬件中断。
softirq 接收 packet 的函数是 net_rx_action(), 这个函数中会调用驱动的函数 poll() 。 poll 中调用 netif_receive_skb() 后就会把 packet 一个个向上一层传递。
当 softirq 执行结束后,应用就会从上一次阻塞的地方继续运行。
不管是 Linux,BSD 还是 Microsoft Windows,CPU 收到中断去处理接收 packet 的过程是一样的。
如果检查服务器时发现多个 CPU 中只有一个 CPU 频繁处理 softirq 的情况,这就是上面所说的情况了。为了达到更优的资源利用,可以使用现有的像多队列的网卡(multi-queue NIC), RSS 和 RPS 技术
数据结构 #
下面结合一些代码来阐释网络栈的数据处理过程,其中一些关键的数据结构务必要先了解,实际开发中进行代码 review 时不了解数据结构直接看代码简直是找虐。Linus Torvalds 曾说过 “Bad programmers worry about the code. Good programmers worry about data structures and their relationships.”, 与算法代码相比,弄懂数据结构的设计和结构之间的关系更为重要。
sk_buff 结构
一个 sk_buff 或者 skb 包含了一个 packet 所有属性。 如图 6 所示。随着更多功能加入, sk_buff 的结构变得愈加复杂,但是基本的功能多数人都可以想得到。
Figure 6: Packet Structure sk_buff.
packet 和元数据(meta data) #
sk_buff 结构中直接保存 packet 数据或者一个指向 packe 的数据的指针。在图 6 中,不同层段的(从数据链路到 socket 缓存)packet 使用指针来表示,而指针针向的是物理内存页。
像头部和主体数据长度这些必要的信息会被保存在元数据区(meta data)。例如,图 6 中 the mac_header, the network_header, and the transport_header 分别指向 Ethernet header, IP header and TCP header 的起始位置。这样让处理 TCP 协议起来变得简单容易。
如何增加或者删除一个头部信息 #
头部信息在层与层之间进行传递之前会被加入或者删除。使用指针移动来进行操作是最高效的了。例如,删除 Ethernet header 只需要把头指针向前移动它的长度就可以了。
怎么组合或者拆分 packet #
双向链表,在 socket 缓存中增加或者删除 packet 时直接使用前后指针操作数据结点。来一个面试题, 如何快速找到一个单链表的中间结点; 如何确定两条链表相交与否。
内存快速创建与释放 #
无论什么时候新增一个 packet 就必须要申请一个结构内存来存放。一个快速内存创建器是必须的,如当在一个 10-Gigabit 速度的网络上传输数据时,起码有会有 100 百万每称的 packet 会被创建或者删除。
TCP 协议控制逻辑 #
有一个专门表示 TCP 连接的结构,之前叫做 TCP control block,在 Linux 使用 tcp_sock 来命名这个结构,如图 7 所示,可以看到 file,socket 和 tcp_sock 这几个文件类型的关系。
Figure 7: TCP Connection Structure.
当一个系统函数 (system call) 被调用时,会搜索应用到这个函数的 file descriptor。在 Unix 类系统中,socket,file 和 device 都可以当成一个文件 file,一个 file 结构包含了最基本的信息。socket 结构包含了 socket 相关的信息,它使用一个指向 file 的指针来表示。这里的 socket 最终会包含 tcp_sock。
TCP 协议中用到的所用状态信息都保存在 tcp_sock中,比如序列号,接收窗口,拥塞控制信息,重传计时器。
tcp_sock中包含了类型为 sk_buff 的接收和发送缓存链表。dst_entry 为 IP 路由定向的结果用来防止过多次的路由查找。dst_entry 允许查询 ARP 结果,比如目标 MAC 地址。dst_entry 是路由表(routing table)的一部分,路由表过杂复杂,在此不讨论。 NIC 就是使用了 dst_entry 中的信息来确定发送 packet 的目的地。 NIC 由 net_device 结构表示。
整个 TCP 结构实例的大小体现了一条 TCP 连接占用的内存大小,除去 packet 不算,几 KBs 左右。
最后看 TCP 连接搜索表(TCP connection lookup)。它是一张哈希表,用来保存接收 packet 的连接地址。哈希值(key,value 中的 key) 由 使用 Jenkins 哈希算法计算而出,据说因为该算法对哈希表起到保护作用才被选用的。
给你代码:如何传输数据 #
阅读内核中与执行栈执行关键任务的代码,在这里观察两个频率较高的路经。
当执行 write 系统调用时:
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, ...)
{
struct file *file;
[...]
file = fget_light(fd, &fput_needed);
[...] ===>
ret = filp->f_op->aio_write(&kiocb, &iov, 1, kiocb.ki_pos);
struct file_operations {
[...]
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, ...)
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, ...)
[...]
};
static const struct file_operations socket_file_ops = {
[...]
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
[...]
};
write()->aio_write()
aio_write() 是一个函数指针,fd 中的 file_structure 里面有一个 file_operations, 它是一个函数表,用来保存函数指针比如 aio_read 和 aio_write。实际上与 socket 对应的是 socket_file_ops。在 socket 中,aio_write 与 sock_aio_write 对应。函数表类似于 Java 或者 Golang 里面的接口(interface), 它在内核中通常用作代码抽象或者反构(code abstraction or refactoring)
static ssize_t sock_aio_write(struct kiocb *iocb, const struct iovec *iov, ..)
{
[...]
struct socket *sock = file->private_data;
[...] ===>
return sock->ops->sendmsg(iocb, sock, msg, size);
struct socket {
[...]
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};
const struct proto_ops inet_stream_ops = {
.family = PF_INET,
[...]
.connect = inet_stream_connect,
.accept = inet_accept,
.listen = inet_listen, .sendmsg = tcp_sendmsg,
.recvmsg = inet_recvmsg,
[...]
};
struct proto_ops {
[...]
int (*connect) (struct socket *sock, ...)
int (*accept) (struct socket *sock, ...)
int (*listen) (struct socket *sock, int len);
int (*sendmsg) (struct kiocb *iocb, struct socket *sock, ...)
int (*recvmsg) (struct kiocb *iocb, struct socket *sock, ...)
[...]
};
sock_aio_write()->sendmsg()
在 sock_aio_write 中,从 file 指针中获取 socket 结构指针,并调用其中的 sendmsg 函数。 socker 结构中包含了 proto_ops 这个函数表(接口)。对于 IPv4 TCP, inet_stream_ops实现了 proto_ops 这个接口,sendmsg 则由 tcp_sendmsg实现。
int tcp_sendmsg(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size)
{
struct sock *sk = sock->sk;
struct iovec *iov;
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;
[...]
mss_now = tcp_send_mss(sk, &size_goal, flags);
/* Ok commence sending. */
iovlen = msg->msg_iovlen;
iov = msg->msg_iov;
copied = 0;
[...]
while (--iovlen >= 0) {
int seglen = iov->iov_len;
unsigned char __user *from = iov->iov_base;
iov++;
while (seglen > 0) {
int copy = 0;
int max = size_goal;
[...]
skb = sk_stream_alloc_skb(sk,
select_size(sk, sg),
sk->sk_allocation);
if (!skb)
goto wait_for_memory;
/*
* Check whether we can use HW checksum.
*/
if (sk->sk_route_caps & NETIF_F_ALL_CSUM)
skb->ip_summed = CHECKSUM_PARTIAL;
[...]
skb_entail(sk, skb);
[...]
/* Where to copy to? */
if (skb_tailroom(skb) > 0) {
/* We have some space in skb head. Superb! */
if (copy > skb_tailroom(skb))
copy = skb_tailroom(skb);
if ((err = skb_add_data(skb, from, copy)) != 0)
goto do_fault;
[...]
if (copied)
tcp_push(sk, flags, mss_now, tp->nonagle);
[...]
}
tcp_sendmsg 中 从 socket 得到 tcp_sock 等指针并且把应用要发送的数据拷贝到 socket 的发送缓存中。问题来了,一个 sk_buff 要 copy 多少应用数据呢?一个 sk_buff 可以包含 MSS(max segment size) 大小节字,它来用创建多个 packet。使用 TSO 和 GSO 算法可以让一个 sk_buff 保存大于 MSS 大小的 payload。
sk_stream_alloc_skb 函数会创建一个新的 sk_buff,
而 ** skb_entail**会把新的 sk_buff 追加到 send_socket_buffer的尾部。
** skb_add_data** 拷贝应用的数据到 sk_buff的数据缓存中;所有要发送的的数据在这几步骤重复执行后会被拷贝到 发送缓存中。这些数据以单个结点为 MSS 大小的 sk_buffs链表的形式存在于 socket 的发送缓存中。
最后, 就到 tcp_push处理了。
static inline void tcp_push(struct sock *sk, int flags, int mss_now, ...)
[...] ===>
static int tcp_write_xmit(struct sock *sk, unsigned int mss_now, ...)
int nonagle,
{
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;
[...]
while ((skb = tcp_send_head(sk))) {
[...]
cwnd_quota = tcp_cwnd_test(tp, skb);
if (!cwnd_quota)
break;
if (unlikely(!tcp_snd_wnd_test(tp, skb, mss_now)))
break;
[...]
if (unlikely(tcp_transmit_skb(sk, skb, 1, gfp)))
break;
/* Advance the send_head. This one is sent out.
* This call will increment packets_out.
*/
tcp_event_new_data_sent(sk, skb);
[...]
在 TCP 序列允许的情况下 tcp_push函数会尽可能多的把发送缓存中的 sk_buffs 传递下去。
起先, tcp_send_head会取得缓存中的第一个 sk_buff并且调用 tcp_cwnd_test 和 tcp_snd_wnd_test 来检查 拥塞窗口和对端允许接收新数据的窗口大小 没问题后,由 tcp_transmit_skb来创建一个 packet。
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb,
int clone_it, gfp_t gfp_mask)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
struct inet_sock *inet;
struct tcp_sock *tp;
[...]
if (likely(clone_it)) {
if (unlikely(skb_cloned(skb)))
skb = pskb_copy(skb, gfp_mask);
else
skb = skb_clone(skb, gfp_mask);
if (unlikely(!skb))
return -ENOBUFS;
}
[...]
skb_push(skb, tcp_header_size);
skb_reset_transport_header(skb);
skb_set_owner_w(skb, sk);
/* Build TCP header and checksum it. */
th = tcp_hdr(skb);
th->source = inet->inet_sport;
th->dest = inet->inet_dport;
th->seq = htonl(tcb->seq);
th->ack_seq = htonl(tp->rcv_nxt);
[...]
icsk->icsk_af_ops->send_check(sk, skb);
[...]
err = icsk->icsk_af_ops->queue_xmit(skb);
if (likely(err <= 0))
return err;
tcp_enter_cwr(sk, 1);
return net_xmit_eval(err);
}
** tcp_transmit_skb** 通过 sk_buff的来创建一份拷贝,它并不拷贝全部数据而只是使用了元数据(metadata)。再通过调用 skb_push来封装 头部信息并记录这些信息。Send_check 会计算这个 TCP 校验值。在使用离线校验 (checksum offload) 情况下,payload 数据不会被校验。最后由 queue_xmit把数据向 IP 层传递。与 IPv4 对应的 Queue_xmit 由 ip_queue_xmit实现。
int ip_queue_xmit(struct sk_buff *skb)
[...]
rt = (struct rtable *)__sk_dst_check(sk, 0);
[...]
/* OK, we know where to send it, allocate and build IP header. */
skb_push(skb, sizeof(struct iphdr) + (opt ? opt->optlen : 0));
skb_reset_network_header(skb);
iph = ip_hdr(skb);
*((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff));
if (ip_dont_fragment(sk, &rt->dst) && !skb->local_df)
iph->frag_off = htons(IP_DF);
else
iph->frag_off = 0;
iph->ttl = ip_select_ttl(inet, &rt->dst);
iph->protocol = sk->sk_protocol;
iph->saddr = rt->rt_src;
iph->daddr = rt->rt_dst;
[...]
res = ip_local_out(skb);
[...] ===>
int __ip_local_out(struct sk_buff *skb)
[...]
ip_send_check(iph);
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL,
skb_dst(skb)->dev, dst_output);
[...] ===>
int ip_output(struct sk_buff *skb)
{
struct net_device *dev = skb_dst(skb)->dev;
[...]
skb->dev = dev;
skb->protocol = htons(ETH_P_IP);
return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,
ip_finish_output,
[...] ===>
static int ip_finish_output(struct sk_buff *skb)
[...]
if (skb->len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb))
return ip_fragment(skb, ip_finish_output2);
else
return ip_finish_output2(skb);
ip_queue_xmit 会执行 IP 层中所需要的工作。
__sk_dst_check 检查缓存路由是否可用,如果不可用,它就会执行 IP 路由查找。然后再调用 skb_push 来封装 IP 头部信息并记录。之后, ip_send_check计算 IP 头部校验值再调用相关的网络过滤函数。
当使用的是 TCP 协议时,ip_finish_output中进行 IP gragmentaion 用到的 IP fgagment 会被生成,否则不会。之后 ip_finish_output2 会被调用,加上数据链路层的头部信息,最终一个 packet 就生成了。
int dev_queue_xmit(struct sk_buff *skb)
[...] ===>
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q, ...)
[...]
if (...) {
....
} else
if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
qdisc_run_begin(q)) {
[...]
if (sch_direct_xmit(skb, q, dev, txq, root_lock)) {
[...] ===>
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q, ...)
[...]
HARD_TX_LOCK(dev, txq, smp_processor_id());
if (!netif_tx_queue_frozen_or_stopped(txq))
ret = dev_hard_start_xmit(skb, dev, txq);
HARD_TX_UNLOCK(dev, txq);
[...]
}
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev, ...)
[...]
if (!list_empty(&ptype_all))
dev_queue_xmit_nit(skb, dev);
[...]
rc = ops->ndo_start_xmit(skb, dev);
[...]
}
生成后的 packet 会被 dev_queue_xmit函数进行传输。
首先这个 packet 经过 qdisc。如果默认的 qdisc 正运行中并且为空,sch_direct_xmit被调用 来跳过队列直接向驱动发送这个 packet。 由Dev_hard_start_xmit调用驱动,在调用驱动之前会先给设备的发送环 (TX) 上锁来防止多个线程同时对设备进行操作。当内核锁住 TX 后,驱动中与传输相关的代码就不需要再上锁了,这有点像并行处理话题,这里不讨论。
Ndo_start_xmit调用驱动中的代码,前面看到过 ptype_all 和 dev_queue_xmit_nit,其中 ptype_all是一个包含了 packet 抓取功能的模块列表。如果有抓包程序运行,那就 packet 就会被 ptye_all拷贝到抓包程序的内存中去。比如,tcpdump 中显示的 packet 就是传输到驱动中的 packet。当启用 checksum offload 或者 TSO 时,NIC 会操控 packet,所以 tcpdump 中的 packet 与传输到网线上的 packet 并不一样。当完成 packet 的网络传输时,驱动的中断响应处理单元 (the driver interrupt handler) 会返回对应的 sk_buff
给你代码:如何接收数据 #
接收的 packet 会被存放到 socket 的接收缓存中。在执行完 NIC 驱动的 handler 中断响应处理单元之后,着手从 napi poll 开始观察。
static void net_rx_action(struct softirq_action *h)
{
struct softnet_data *sd = &__get_cpu_var(softnet_data);
unsigned long time_limit = jiffies + 2;
int budget = netdev_budget;
void *have;
local_irq_disable();
while (!list_empty(&sd->poll_list)) {
struct napi_struct *n;
[...]
n = list_first_entry(&sd->poll_list, struct napi_struct,
poll_list);
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight);
trace_napi_poll(n);
}
[...]
}
int netif_receive_skb(struct sk_buff *skb)
[...] ===>
static int __netif_receive_skb(struct sk_buff *skb)
{
struct packet_type *ptype, *pt_prev;
[...]
__be16 type;
[...]
list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
[...]
type = skb->protocol;
list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
if (pt_prev) {
ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
[...]
};
** net_rx_action** 是 softirq handler 用来接收 packet 的函数。 请求 napi poll 后的驱动会从 poll_list获取,然后这个驱动的 poll handler 将会被执行。 驱动会把接收到的 packet 使用 sk_buff 来封装并且调用 netif_receive_skb。
如果有一个模块要获取所有的 packet,那么 netif_receive_skb就会向这个模块传递。与 packet 向外传输差不多,注册到 ptype_all 表的模块 (module) 会被传递 packet,也是在这时去抓取 packet。
之后先通过 ethernet 层向上传送 packet。Ethernet 层中的 packet 在头部要包含 2 个字节的 ethertype 用来表示 packet 的类型。 驱动会把这个值保存在 sk_buff(sbk->protocol) 中。每一个种协议都会有对应的 packet 类型并且会在 ptype_base 哈希表中注册这个结构指针。与 IPv4 相关的是ip_packet_type, 其中 ETH_P_IP就是 IPv4 在 ethertype 的值。
接下来 IPv4 类型的 packet 会调用 ip_rcv函数。
int ip_rcv(struct sk_buff *skb, struct net_device *dev, ...)
{
struct iphdr *iph;
u32 len;
[...]
iph = ip_hdr(skb);
[...]
if (iph->ihl < 5 || iph->version != 4)
goto inhdr_error;
if (!pskb_may_pull(skb, iph->ihl*4))
goto inhdr_error;
iph = ip_hdr(skb);
if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
goto inhdr_error;
len = ntohs(iph->tot_len);
if (skb->len < len) {
IP_INC_STATS_BH(dev_net(dev), IPSTATS_MIB_INTRUNCATEDPKTS);
goto drop;
} else if (len < (iph->ihl*4))
goto inhdr_error;
[...]
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);
[...] ===>
int ip_local_deliver(struct sk_buff *skb)
[...]
if (ip_hdr(skb)->frag_off & htons(IP_MF | IP_OFFSET)) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
[...] ===>
static int ip_local_deliver_finish(struct sk_buff *skb)
[...]
__skb_pull(skb, ip_hdrlen(skb));
[...]
int protocol = ip_hdr(skb)->protocol;
int hash, raw;
const struct net_protocol *ipprot;
[...]
hash = protocol & (MAX_INET_PROTOS - 1);
ipprot = rcu_dereference(inet_protos[hash]);
if (ipprot != NULL) {
[...]
ret = ipprot->handler(skb);
[...] ===>
static const struct net_protocol tcp_protocol = {
.handler = tcp_v4_rcv,
[...]
};
ip_rcv 函数会执行 IP 层所需要的工作。这个函数会检查 payload 长度和头部校验。经过一系列的网络检查过滤之后,将会执行 ip_local_deliver函数。如果有需要会进行 IP fragments 重组。
之后来到ip_local_deliver_finish, 这个函数会使用 __skb_pull 去掉 IP 头部信息,然后再进行上一层协议的头部信息处理工作。 与 Ptype_base 类似,每个传输协议会在 inet_protos中注册自己的 net_protocal结构。对 IPv4 对应的是 tcp_protocal,其中已经注册为 handler 的 tcp_v4_rev将会被调用。
当 packet 来到 TCP 层时,对 packet 的处理会根据 TCP 的状态与 packet 类型的不同而不一样。下面观察一下在处理处于已经建立状态( ESTABLISHED)的 TCP 接收的 packet 的过程。下面的是最常见的在没有包丢失和乱序情况下服务器接收数据的经过。
int tcp_v4_rcv(struct sk_buff *skb)
{
const struct iphdr *iph;
struct tcphdr *th;
struct sock *sk;
[...]
th = tcp_hdr(skb);
if (th->doff < sizeof(struct tcphdr) / 4)
goto bad_packet;
if (!pskb_may_pull(skb, th->doff * 4))
goto discard_it;
[...]
th = tcp_hdr(skb);
iph = ip_hdr(skb);
TCP_SKB_CB(skb)->seq = ntohl(th->seq);
TCP_SKB_CB(skb)->end_seq = (TCP_SKB_CB(skb)->seq + th->syn + th->fin +
skb->len - th->doff * 4);
TCP_SKB_CB(skb)->ack_seq = ntohl(th->ack_seq);
TCP_SKB_CB(skb)->when = 0;
TCP_SKB_CB(skb)->flags = iph->tos;
TCP_SKB_CB(skb)->sacked = 0;
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
[...]
ret = tcp_v4_do_rcv(sk, skb);
首先 tcp_v4_rec函数会检查接收到的 packet 的合理性。当头部大小大于数据的偏移 (th->doff < sizeof(struct tcphdr)/4) 时,将视为数据不合理无法使用。若正常则会执行到 __inet_lookup_skb来从 TCP 连接的哈希表中找到这个 packet 的连接,之后将会得到所需要的 tcp_sock和 socket。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
[...]
if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */
sock_rps_save_rxhash(sk, skb->rxhash);
if (tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len)) {
[...] ===>
int tcp_rcv_established(struct sock *sk, struct sk_buff *skb,
[...]
/*
* Header prediction.
*/
if ((tcp_flag_word(th) & TCP_HP_BITS) == tp->pred_flags &&
TCP_SKB_CB(skb)->seq == tp->rcv_nxt &&
!after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt))) {
[...]
if ((int)skb->truesize > sk->sk_forward_alloc)
goto step5;
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPHPHITS);
/* Bulk data transfer: receiver */
__skb_pull(skb, tcp_header_len);
__skb_queue_tail(&sk->sk_receive_queue, skb);
skb_set_owner_r(skb, sk);
tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;
[...]
if (!copied_early || tp->rcv_nxt != tp->rcv_wup)
__tcp_ack_snd_check(sk, 0);
[...]
step5:
if (th->ack && tcp_ack(sk, skb, FLAG_SLOWPATH) < 0)
goto discard;
tcp_rcv_rtt_measure_ts(sk, skb);
/* Process urgent data. */
tcp_urg(sk, skb, th);
/* step 7: process the segment text */
tcp_data_queue(sk, skb);
tcp_data_snd_check(sk);
tcp_ack_snd_check(sk);
return 0;
[...]
}
实际上执行协议的代码是从 tcp_v4_do_rcv函数开始。
如果是已经建立了连接的 TCP 那么 tcp_rcv_established将会被调用, 已经建立的 TCP 会被分开来执行和优化,因为ESTABLISHED是最常见的 TCP 状态。tcp_tcv_established首先会执行头部预测代码,通过头部预测可以快速知道,比如最常发生的,有没有数据要传输并且接收到的 packet 是不是下次必须要接收到的数据。又如,头部信息中的序列号正是 TCP 所预想要接收到的话,那么就会把 packet 追加到 socket 的接收缓存中并且对个 packet 发送 ACK进行确认告知。
if ((int)skb->truesize > sk->sk_forward_alloc)
这里的代码是为了知道 socket 的接收缓存中是否还可以容下新的 packet。
如果可以那么 头部预测就算成功预测,之后 通过 __skb_pull来去掉 TCP 的头部信息。再之后 __skb_queue_tail会把处理后的 packet 追加到 socket 的接收缓存中。最后如里有必要(一般都要) __tcp_ack_snd_check函数会被调用来进行 ACK的回发。到这一步,数据的接收就完成了。
如果没有可以多余的空间去保存新的 packet,那就慢一些了。 tcp_data_queue函数会先申请创建缓存空间再追加 packet。可以说 socket 的接收缓存是自动扩容的。与上面快的操作不同,如果可以的话这里会调用 tcp_data_snd_check来传输新 packet,最后 tcp_ack_snd_check函数会被调用来创建和回发 ACK。
上面一快一慢的执行路径代码不算多,通常在实现时会进行优化。但对于非常规的执行比如说 乱序的 packet 处理起来则会慢得多了。
驱动和 NIC 是怎么进行通信的? #
驱动和 NIC 之间的通信非常低层,多数应用开发人员不需要关心,但是 NIC 中为性能优化做了很多工作,了解它的工作原理可以让你受益非浅。
驱动和 NIC 进行的是异步通信。
进行数据发送时,首先由驱动发起 packet 传输请求,CPU 不等待结果直接执行这个请求。然后由 NIC 来发送 packet 并且通知 CPU,这样驱动就会返回已经发送的 packet(大小) 即刚刚请求的结果。
进行数据接收也是异步的。首先驱动发起接收 packet 的请求然后 CPU 就会执行相应的任务。NIC 在收到 packet 时会通知 CPU,驱动随即处理接收到的 packet 也就是刚刚请求的结果。
在这里需要使用一个空间来保存请求和返回的数据。通常使用一个环,其实是一个固定大小可以循环使用的结构队列,环的意义也由此而来,队列中的一个结构实例保存一个请求或者返回的数据。
(在 Golang 里面,使用 channel 队列来进行数据缓存,使用 goroutine 要对 channel 进行异步读写生产或者消费,这里的驱动和 NIC 就相当生产都与消费都的关系,两个 gorutine; 而上面所说的环就相当于 channel,但是 channel 可读可写的信号类似于这里的中断信号)
从图 8 中可以知道这个环是怎么被使用的。
Figure 8: Driver-NIC Communication: How to Transmit Packet.
驱动从上层得到 packet 之后会创建 NIC 可以解析的数据描述,这个数据包含了 packet 的大小和 packet 在内存的地址。因为 NIC 需要内存实际的物理地址进行内存寻址,那么驱动就要把 packet 的虚拟内存地址转化成物理地址。之后驱动会把这个数据放到 TX(transmit) 发送环中。
接下来,如步骤 2,NIC 会从 CPU 收到新的请求通知, 驱动通过 CPU 就可以实现往 NIC 的内存中写入数据。在这里,PIO(programmed input and output) 就是指 CPU 直接向设备发送数据的传输方法。
被通知到的 NIC 从内核内存的 TX 中得到驱动的数据描述,如步骤 3,这里使用了 DMA(Directly Memory Access),即不通过 CPU 的帮助直接对特定内存中的数据进行读写。
这样 NIC 从内核内存的数据就知道了要处理的 packet 的大小以及它在内核内存中的地址,如步骤 4。当启用到 checksum offload 功能时,NIC 会在从内存中获取到 packet 后计算这个 packet 的校验。
之后在 NIC 发送数据(如步骤 5)之后,会把已发送数据的大小写入到内核的内存中,如步骤 6。接下来再向 CPU 发送一个中断请求,如步骤 7。接着驱动可以读取目前已发送的 packt 大小和返回已发送的 packet。
图 9 展示了接收 packe 的过程
Figure 9: Driver-NIC Communication: How to Receive Packets.
首先驱动要先创建一段内存当作接收缓存并在这段缓存中写一个与 NIC 通信用的数据接收描述 (the receive descriptor): 缓存的大小和内存的地址。与发送数据的描述相似,这个接收描述会保存 DMA 可操作的内存的物理地址。之后驱动会把这个接收描述放进 RX(receive) 环中,如步骤 1。
通过 PIO,驱动会让 CPU 通知 NIC 在 RX 环中有新的数据描述,如步骤 2,这样 NIC 就可以从内核内存中获取到这个接收描述放到自己的内存中,如步骤 3 所示。
当有 packet 来到并接收后,如步骤 4,NIC 会向内核内存发送接收到的 packet,如步骤 5 所示。如要 checksum offload 就进行校验计算。之后如步骤 6,包括接收 packet 的大小,checksum 结果以及其它信息会保存到一个独立的环 (the receive return ring) 中,这个环中保存的就是驱动请求的结果了。
之后 NIC 会向 CPU 发起一个中断,如步骤 7。驱动就知道从独立的接收环中获取接收到的 packet 并进行之后的数据传递处理。如果有必要,它会创建更多的缓存然后重复步骤 1、2。
如果说要对这个 NIC 相关的技术栈进行调优,多数人认为应该可以从缓存用的环和中断处理着手。当 TX 环比较大时,可以一次性发送更多的数据。当 RX 环较大时,可以一次性接收更多的数据。在多数情况下,NIC 会使用一个定时器来减少对 CPU 发送中断的次数,防止 CPU 为处理大量中断而造成大的负载。不管是发送还是接收时,为了避免对系统产生大量的中断请求,会有规律的把中断累积后再进行请求。
栈里面的缓存和流控制 #
在这个网络技术栈中进行了多次流控制。如图 10 展示了发送数据时使用的缓存。
从应用创建数据后开始,数据先会缓存到 socket 的缓存,如果没有多余的缓存空间,应用所执行的系统调用将会失败或者一直阻塞应用工作线程的任务执行。因此,应用的数据向内核传递的速率就受到 socket 缓存大小的制约了。
Figure 10: Buffers Related to Packet Transmission.
然后在 TCP/IP 层创建 packet(segment) 并通过传输队列 qdisc 向驱动传递 packet,这是一个典型的先入先出队列,它的最大发送大小可以通过 ifconfig
来查看,通常会达到上千个 packet。
而 TX 环则存在于驱动和 NIC 之间。像上面有提到,TX 其实就是一个传输请求队列。如果没有空余的空间可用那么将不会产生传输请求,要传输的 packet 也将会堆积在传输队列中,当超过一定量时就会出丢包现像了。
NIC 会把要向外传输的 packet 放到自己的缓存中。packet 在 NIC 中的传输速率与物理参数相关,比如说 1Gb/s 的 NIC 是不能提供 10Gb/s 的数据运输的。受到数据链路中流控制的限制,如果 NIC 的没有空余的缓存去容纳 packet,那么 packet 的运输将会停止。
如果内核向 NIC 传递 packet 的速率大于 NIC 向外运输 packet 的速率,packet 就会在 NIC 中积累。若 NIC 中没空余的缓存可用,那么对 TX 环中的请求将停止响应。越来越多的请求在 TX 环中堆积的话最终会出队列爆满,驱动不能再创建新的运输请求,packet 也就会向上一层堆积。
图 11 展示了接收数据时所到的缓存。
网络上到来的 packt 会先在 NIC 的接收缓存中暂时存留。从流控制的角度来看,RX 环就像是驱动和 NIC 之间的 packet 缓存。驱动获取来自于 RX 环中的 packet 再向上一层传递。因为受系统控制的 NIC 驱动使用了 NAPI 这种机制,所以驱动到上一层之间没有使用缓存,也可以认为上一层是直接从 RX 环中获取 packet。来到 TCP 层时,主体数据 payload 就会被存放于 socket 的接收缓存中。应用就可以从 socket 的接收缓存中拿到这些数据了
Figure 11: Buffers Related to Packet Receiving.
如果驱动没有支持 NAPI 的话,packet 就会被保存到 backlog 队列中。backlog 也就可以看作是驱动与上一层之间的缓存了。
如果内核处理 packet 的速度慢于 NIC 从网络上收到 packet 的速度,RX 环就会爆满,NIC 中的缓存也不再能容下后面的流入的 packet 了。当启用数据链路的流控制时,NIC 会向发送数据的对端 NIC 发送一个停止运输数据的请求或者把后面新的 packet 给丢掉。
丢包现像的出现与 socket 的接收缓存大小无关,因为 TCP 支持端对端的流控制。反而,当使用的是不支持流控制的 UDP 协议而且应用处理数据速度过慢时就可能出现因为 socket 缓存不足导致丢包现像出现。
图 10、11 中 TX 和 RX 环的大小可以通过 ethtool
来查看(Linux 下是可以的)。如果要提高数据传输的吞吐量,提高这两个环和 socket 缓存的大小会有帮助。当这些值大小提高后,大量的数据可以快速进行接收和运输,这就可以降低由于缓存不足导致数据传输失败的可能性。
起初作者是打算阐述一些对网络应用开发,性能测试,性能问题探测有帮助技术基础,相对于本文所提到的与作者所计划的并不算少了(我自己翻译都用了好几天了)。作者希望这些能帮助开发者更好的开发网络应用和检测应用的性能。不过 TCP/IP 协议太复杂了。没有必要为了分析性能问题而去弄懂每一行内核中与 TCP/IP 相关的代码,了解层与层之间的上下文(context) 协作 就很有帮助了。
随着系统性能表现的不断得以提高以及网络栈中技术的不断改进和优化,如今(2017.06.07)要一个服务器支持 10-20Gb/s 的 TCP 吞吐量已经不是什么问题。但与性能相关的技术如 TSO, LRO, RSS, GSO, GRO, UFO, XPS, IOAT, DDIO 以及 TOE 让人头疼。
原文作者后继还会写与性能问题相关的技术文章,有兴趣的可以从下面的链接找寻。
原文作者:
Hyeongyeop Kim, Senior Engineer at Performance Engineering Lab, NHN 性能工程实验室的高级工程师。
原文链接:
Understanding TCP/IP Network Stack & Writing Network Apps