理解网络栈

我们不敢想象离开了 TCP/IP 互联网服务会变成什么样子。所有我们在 NHN 上开发和使用的互联网服务都基于一个一致的基础,TCP/IP。理解数据是如何在网络上传输的,能够帮助你通过调优来提升性能、排除故障,或者是将其引入到新的技术中去。

本文将基于 Linux OS 和硬件层中的数据流及控制流来介绍网络栈的整体操作方案。

TCP/IP 的关键特性

怎样才能设计一个用来快速传输数据的网络协议,同时能够保证数据顺序又不丢失数据?

TCP/IP 就是以这样的初衷来设计的。以下是理解网络栈所必须的 TCP/IP 的关键特性。

TCP 与 IP 从技术上讲,由于 TCP 和 IP 拥有不同的层次结构,正确的方式是将它们分开来介绍。然而,这里我们会将其看做一个整体。

1、面向连接

首先,一个连接在两个端点之间(local and remote)被创建,然后数据才会被传输。这里的 “TCP 连接标识符” 是两个端点的地址的结合体,其结构如下:

<local IP address, local port number, remote IP address, remote port number>

2、双向字节流

双向的数据通信通过字节流(byte stream)来完成。

3、有序抵达

一个接收者以发送者发送的顺序来接收数据。为此,必须确保数据的顺序。为了标记顺序,使用了一个 32 位的整形数据类型。

4、通过 ACK 确保可靠性

当一个发送者在将数据发送给一个接收者之后没能收到一个 ACK(确认应答) 时,发送者 TCP 将会重新发送该数据给接收者。因此,发送方将会缓存那些没有得到接收者返回 ACK 的数据。

5、流控(Flow Control)

发送者竟会根据接收者的承受能力发送尽可能多的数据。接收者会将其能够接收的数据的最大字节数(unused buffer size, receive window)发送给发送者。发送者将会根据接收者的接收窗口所能支持的大小,尽可能发送更多的数据。

6、拥堵控制(Congestion Control)

拥堵窗口 以独立于接收窗口的方式来使用,通过限制网络上的数据流量来避免网络拥堵。与接收窗口一样,发送者通过一些算法来根据接收者拥堵窗口所能支持的最大字节数来发送尽可能多的数据,这些算法有 TCP Vegas、Westwood、BIC、CUBIC。与流控不同,拥堵控制仅由发送方实现。

数据传输

就像其名字中的提示一样,一个网络“栈”拥有很多层。下面的图示中展示了各层的类型:

NAME

可见有多个不同的层,并且归类为三种不同的空间:

  • 用户空间
  • 内核空间
  • 设备空间

用户与内核空间的任务会由 CPU 来完成执行。用户与内核空间又被称为 “Host” 以与设备空间加以区分。这里的设备是网络适配器(Network Interface Card, NIC),它会发送或接收数据包。这比通常称呼的 “网卡(LAN Card)” 更为准确。

让我们看一下用户空间。首先,应用会创建用来发送的数据(User Data)并通过 write() 系统调用来发送数据。这里假定 socket(fd) 已经被创建过了。当执行了系统调用,则会切换到内核空间。

POSIX 系列的操作系统,包括 Linux 和 Unix,通过一个文件描述符将 socket 保留给应用。在 POSIX 系列的操作系统中,socket 只是文件的一种。文件层会执行一个简单的检查,并通过连接到文件构造体的 socket 构造体来调用 socket 函数。

内核 socket 拥有两个缓冲区:

  • 一个是用于发送的 socket 发送缓冲区
  • 一个是用于接收的 socket 接收缓冲区

当“系统写”被调用,处于用户空间的数据会被复制到内核内存并被添加到“socket 发送缓冲区”的末端,以按照数据添加的顺序进行发送。就像上图中浅灰色的方块引用着 socket 缓冲区中的数据。然后 TCP 被调用。

这里有一个关联到 socket 的 TCP 控制块(TCP Control Block, TCB) 结构,TCB 包含着用于处理 TCP 连接的必要信息。TCP 中的数据包括:连接状态(LISTEN, ESTABLISHED, TIME_WAIT),接收窗口,拥堵窗口,序列号,重发计时器,等等。

如果 TCP 的当前状态运行数据传输,一个新的 TCP 段(segment, 即数据包) 会被创建。如果因为流控或类似的原因不能进行数据传输,系统调用就此终止并将模式(mode)返回给用户模式,即将控制权交给应用。

下面是两个 TCP 段,其结构如下图所示:

  • TCP 头
  • 负荷(payload)
NAME

负荷中包含了保存在未进行应答确认的 socket 发送缓冲区中的数据。负荷的最大长度即为接收窗口、拥堵窗口、最大分段值(MSS),这三者中的最大值。

然后,TCP 的校验和(checksum)被计算。在这个校验和计算中,包含了虚拟(pseudo)头信息,如 IP 地址、分段长度、协议号。根据 TCP 的状态,可以传输一个或更多的数据包。

事实上,由于现在的网络栈采用无负载(offload)的校验和,因此 TCP 校验和是由 NIC 计算的,而非内核。然而我们为了方便,这里假设是由内核完成的 TCP 校验和计算。

被创建的 TCP 分段会进入到 IP 层。IP 层将 IP 头添加到 TCP 分段,并执行 IP 路由。IP 路由 是为了搜索下一个跳板 IP 以最终能够抵达目的 IP。

在 IP 层计算并添加了 IP 头校验和之后,它会将数据发送到以太网(Ethernet)层。以太网层将会根据**地址解析协议(Address Resolution Protocol,ARP)**搜索下一个跳板 IP 的 MAC 地址。让后将以太网头添加到数据包。以太网数据包的添加则完成了主机(host)数据包的创建。

在 IP 路由执行之后,传输接口会得到 IP 路由的结果。该接口用于将数据包发送给下一个跳板 IP 或直接 IP。因此,网络适配器(NIC) 设备被调用。

这时,如果运行了一个数据包捕获程序,比如 tcpdump 或 Wireshark,内核将会把数据包数据复制到这些程序使用的内存中。这样,直接就能在设备上捕获接收到的数据包。通常来说,流量整形器(shaper)功能会被时限为运行在这一层上。

设备通过由 NIC 厂商定义的设备 NIC 通信协议来请求数据包的传输。

在接收到数据包传输请求之后,NIC 会将数据包从主内存复制到其自身的内存,然后发送给网线。这时,为了遵循以太网标准,会在数据包中添加 IFG(InterFrame Gap)、报头、CRC。IFG 和报头用于识别数据包的开始,CRC 则与 TCP 和 IP 的校验和一样用于保护数据。数据包的传输会基于以太网的速度和以太网的流控被启动。

当 NIC 发送一个数据包时,NIC 会使主机 CPU 中断。每次中断都拥有自己的中断号,然后 OS 通过该中断号查找合适的设备来处理该中断。在设备启动时会注册一个函数(中断处理器)来处理该中断。OS 调用该中断处理器,然后中断处理器将被传输的数据包返回给 OS。

到目前为止,我们已经讨论了当应用执行一次写入时贯穿内核与设备的整个数据传输过程。然而,再没有一个来自应用的直接写请求的情况下,内核可以直接通过调用 TCP 来传输一个数据包。比如,当接收了一个 ACK 且接收窗口被扩充,内核创建一个包含 socket 缓冲区中剩余数据的 TCP 分段,并将其发送给接收者。

数据接收

现在让我们看一下数据是如何被接收的。数据接收是网络栈用来处理传入的数据包的步骤。下图展示了网络栈如何处理一个接收到的数据包:

NAME

首先,NIC 将数据包写人到它的内存。它通过执行 CRC 检查来验证其有效性,然后将其发送给主机(host)的内存缓冲区。该缓冲区是一块已经由驱动程序向内核请求过的专门用于接收数据包的内存。一旦该内存被分配,驱动程序则会将其地址和大小发送给 NIC。如果是 NIC 已经接收到一个数据包但又没有分配可用的主机内存缓冲区,则 NIC 会将该数据包丢弃。

将数据包发送给主机内存缓冲区之后,NIC 会向主机 OS 发送一个中断(interrupt)。

然后,驱动器会检查它是否能够继续处理新的数据包。截止目前,驱动器与 NIC 使用的的通信协议由厂商定义。

当驱动器需要向上层发送数据包时,该数据包的结构必须被包装为一个 OS 使用的数据包结构,以便 OS 能够理解该数据包。比如,Linux 中的 sk_buff,BSD 系列内核中的 mbuf,微软 Windows 中的 NET_BUFFER_LIST,这些均是对应到各个 OS 的数据包结构。然后,驱动程序将被包装过的数据包发送给上层。

以太网层会检查该数据包的有效性,然后根据以太网头部中的以太网类型(ethertype)的值多路分解(de-multiplexes)上层协议(网络协议)。 比如 IPv4 的以太网类型协议是 0x0800。然后移除掉数据包的以太网头部并将其发送被 IP 层。

IP 层同样会检查数据包的有效性,或者说是检查 IP 头部的校验和。它将逻辑性的检测是否需要执行 IP 路由并使本机系统来处理该数据包,还是将其发送给其他的系统。如果该数据包必须由本机系统来处理,IP 层会通过查阅 IP 头部的原始值来多路分解上层协议(传输协议)。TCP 的原始值是 6。然后移除掉数据包的 IP 头部并将其发送给 TCP 层。

向下层一样,TCP 层会通过检查 TCP 校验和来验证数据包的有效性。如前面提到的,由于当前的网络栈使用的是无负载(offload)校验和,因此 TCP 的校验和由 NIC 完成计算,而非内核。

然后开始搜索数据包关联的 TCP 控制块(TCB)。这时,<source IP, source port, target IP, target port> 会作为数据包的标示符。在搜索连接之后,它会执行协议来处理数据包。如果接收到的是新的数据包,会将其数据添加到 socket 接收缓冲区。根据 TCP 的状态,它可以发送一个新的 TCP 数据包,比如一个 ACK 数据包。现在,TCP/IP 对数据包的接收已经处理完成。

Socket 接收缓冲区的大小是 TCP 的接收窗口大小。确定的一点是,当接收窗口很大的时候 TCP 的吞吐会随之增长。在过去,socket 的缓冲区的大小会由应用或 OS 的配置来调整。最新的网络栈会拥有自动调整 socket 接收缓冲区大小的功能,比如调整接收窗口。

当应用调用了系统读(system read)调用,空间会被切换到内核空间,socket 缓冲区中的数据则会被复制到用户空间的内存。然后被复制过的数据会被从 socket 缓冲区移除。然后 TCP 被调用。因为 socket 缓冲区中出现了新的空间,TCP 则会增长接收窗口的大小。然后根据协议的状态发送一个数据包。如果没有需要传输的数据包,系统调用则会被终止。

网络栈开发指南

目前已经介绍的网络栈层次的功能都是非常基础的功能。上世纪 90 年代初的网络栈功能并没有比上面所介绍的功能多。然而,最新的网络栈则拥有更多的功能,因为网络栈的实现也变得更加高级,所以也更加复杂。

最新的网络栈按用途分类如下。

数据包处理步骤控制(Manipulation)

这是一个类似网络过滤器(NetFilter, NAT, 防火墙)或流量控制的功能。通过在基本的处理流程中插入用户可控的代码,基于用户的配置,该功能能够完成不同的工作。

协议性能

其目的在于提升 TCP 协议在给定网络环境下的吞吐量、延迟和稳定性。通过一些拥堵控制算法和额外的 TCP 功能来实现,比如经典的 SACK。协议的提升在这里不会做过多讨论,因为已经超出了本文的范围。

数据包处理效率

数据包处理效率的目的在于,通过减少系统处理数据包时的 CPU 周期、内存使用、内存访问,来提高每秒能够处理的数据包的最大数量。已经有多种尝试来减少系统中的延迟。这些尝试包括栈并行处理、头部预测、zero-copy、single-copy、无负载校验和、TSO、LRO、RSS 等等。

网络栈中的控制流程

现在让我们更加详细的看一下 Linux 网络栈的内部流程。网络栈更像是一个子系统,一个网络栈根本上来说是以时间驱动的方式运行,并对发生的事件做出相应。因此,并没有单独的线程来执行网络栈。上面在对网络栈层次的讨论中展示了其简化版的流程,下图中阐述了更加准确的控制流程。

NAME

Flow(1),一个应用调用了系统调用来执行(使用) TCP。比如,调用了系统读或系统写,然后执行 TCP。然而,这里并没有数据包传输。

Flow(2) 与 Flow(1)类似,但它在执行 TCP 之后需要对数据包进行传输。它会创建一个数据包并将其向下发送给驱动设备。驱动设备之前会有一个队列。数据包首先会进入到队列,然后队列的实现结构决定了将数据包发送给驱动设备的时机。这便是 Linux 的排队机制(qdisc)。Linux 的流量控制功能便是对 qdisc 的控制。默认的 qdisc 是一个简单的先进先出(FIFO)队列。通过使用一个另外的 qdisc,操作者可以实现多种效果,比如人造丢包、包延迟、传输速度控制等等。在 Flow(1) 和 Flow(2) 中,应用的处理线程同样会用来执行驱动设备。

Flow(3) 展示了 TCP 所使用的计时器(timer)过期的场景。比如,当 TIME_WAIT 计时器过期时,TCP 会被调用以删除连接。

Flow(4) 与 Flow(3) 类似,即 TCP 使用的计时器过期,且 TCP 执行结果的数据包需要被传输。比如,当重复计时器(retransmit timer)过期时,为得到 ACK 的数据包将会被重新传输。

Flow(3) 和 Flow(4) 展示了执行计时器软中断请求(softiq)的步骤,它处理了计时器中断。

当 NIC 驱动设备接收到一个中断,它会释放已传输的数据包。大多数情况下,驱动设备的执行会在这里终止。Flow(5) 展示的是数据包在传输队列中的累积。驱动设备会请求软中断(softiq),软中断处理器会执行传输队列来将累积的数据包发送给驱动设备。

当 NIC 驱动设备收到了一个中断并发现了一个新接收到的数据包,它会请求软中断。软中断会调用驱动是被来处理数据包并将其发送给上层。在 Linux 中,向上面展示的对接收到的数据包的处理被称为 New API(NAPI)。这个过程类似于轮询,因为驱动设备并未直接将数据包发送给上层,但是上层会直接得到该数据报。这里实际的代码会被称为 NAPI poll 或 poll。

Flow(6) 展示了 TCP 的执行完成,而 Flow(7) 展示了仍需要处理额外的数据包。Flow(5,6,7) 都是由处理了 NIC 中断的软中断请求执行。

如何处理中断、接收数据包

中断的处理是相当复杂的;然而,你需要了解与数据包接收处理相关的性能问题。下图展示了中断的处理步骤:

NAME

假如 CPU 0 正在处理一个应用程序(用户程序)。这时,NIC 收到一个数据包并为 CPU 0 生成一个中断。然后 CPU 执行了内核的中断处理器(irq)。这个处理器会引用该中断的序号,然后调用驱动设备的中断处理器。驱动设备首先会释放掉已经传输过的数据包,然后调用napi_schedule()来处理接收到数据包。该函数会请求 softirq(软中断)。

在驱动设备的中断处理器的执行终止后,控制权会传递给内核处理器。内核处理器会为 softirq 执行中断处理器。

在中断上下文被处理之后,softirq(软中断)上线文会被执行。中断上下文与软中断上下文会被同一个线程处理,但是,两个上下文使用不同的栈。同时,中断上下文会阻塞硬件中断,但软中断上下文则允许硬件中断。

处理接收到的数据包的软终端处理器为 net_rx_action() 函数。该函数会对驱动设备调用 poll() 函数。然后 poll() 函数会调用 netif_receive_skb() 函数将接收到的数据包一个接一个的发送给上层。在处理完软中断之后,应用会中停止点重启执行,以便能够请求一个系统调用。

然而,接收到中断的 CPU 会从头到尾的处理接收到的数据。在 Linux、BSD、微软中的处理步骤基本如此。

如果你检查服务器的 CPU 利用率,有时你会发现服务器的众多 CPU 中仅有一个 CPU 在艰难的处理软中断。这种现象的发生则是因为目前我们已经解释过的对接收到的数据包的处理方式。为了解决这个问题,出现了多队列 NIC、RSS、RPS。

数据结构

下面是一些关键的数据结构。

sk_buff 结构

首先是表述数据包的 sk_buffskb。下图展示了 sk_buff 的一部分结构。由于函数已经发生了进化因此变得更加复杂。然而其基本功能则十分简单,任何人都能理解。

NAME

包数据与元数据

该结构直接包含了包数据或者通过一个指针来引用包数据。上图中,一些(由以太网至缓冲区)数据包通过数据指针引用,一些额外的数据(frags)则引用了实际的数据页(page)。

一些必要的信息比如头部、荷载长度则被保存在元数据区。比如上图中,mac_header、network_header、transport_header 拥有相同的指针数据,并分别指向以太网头、IP 头、TCP 头的起始位置。这种方式使得 TCP 协议的处理变得简单。

如何添加或删除头部

头部在经过各个网络栈的层时会被添加或删除。指针的使用则为了更加高效的处理。比如,想要移除以太网头部,仅需要递增对应的头部指针。

如何合并或拆分数据包

链接列表用于高效的处理类似从 socket 缓冲区、数据包链中添加或删除数据包的荷载数据这样的任务。next 指针、prev 指针则用于此目的。

快速分配与释放

因为数据包一旦创建就需要分配该结构,所以使用了快速分配器(allocator)。比如,如果数据在 10GB 带宽的以太网中传输,每秒则会有多余 1 百万的数据包被创建和删除。

TCP 控制块(TCB)

然后,是一个用于表示 TCP 连接的结构,前面我们笼统的称之为 TCB。Linux 使用 tcp_sock 来表示该结构。在下图中,你可以看到文件、socket、tcp_sock 之间的关系。

NAME

当触发了一个系统调用时,它会在文件描述符中搜索该应用使用的触发了该调用的文件。对于 Unix 系列的 OS 来说,文件以及用于通用文件系统存储的驱动设备均被抽象为文件。因此,该结构仅包含了最少的信息。对于一个 socket,一个单独的 socket 结构保存了 socket 相关的信息,然后文件以指针的形式引用该 socket。然后 socket 又以同样的方式引用了 tcp_socktcp_sock 又被划分为 into_sock、inet_sock 等等,以支持不同类型的特定 TCP 协议。可以被看做是一种多态。

所有被 TCP 协议使用的状态信息均被保存在 tcp_sock 中。比如,序列号、接收窗口、阻塞控制、重发计时器。

Socket 发送缓存区、socket接收缓冲区实际上是 sk_buff 列表,其中包含的是 tcp_sock。同时引用了 dst_entry、IP 路由结构,以避免过度频繁的路由。dst_entry 支持对 ARP 结构的简单搜索,比如目的地的 MAC 地址。dst_entry 是路由表的一部分。而路由表的结构则太过复杂了,本文不再讨论。用于数据包传输的 NIC 则通过 dst_entry 进行搜索。而 NIC 则被表示为 net_device 结构。

因此通过搜索文件,可以使用指针非常容易的找到用于处理 TCP 连接所必须的所有结构(从文件到驱动设备)。结构的大小则是 TCP 连接所使用的内存大小,仅占用很少的几个 KB(包含包数据)。随着功能的增加,内存占用也会逐渐增加。

最后,让我们看一下 TCP 连接的查找表。这是一个用于搜索数据包所归属的 TCP 连接的哈希表。哈希值使用数据包的 <source IP, target IP, source port, target port> 作为输入,基于 Jenkins 哈希算法来计算。据说哈希算法的选择是基于对防御哈希表攻击的考虑。

跟随代码:如何传输数据

我们将通过跟随实际的 Linux 内核源码来检查网络栈中所执行的关键任务。这里我们将遵循两个常用的路径。

首先,这个路径用于在应用调用一个系统写调用时传输数据。

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() 函数。首先,文件描述符 fd 的实际文件结构会被取到。然后 aio_wirte 被调用。这是一个函数指针。在文件结构中,你会看到 file_operattions 结构体。该结构通常被称为函数表,包含了 aio_read 和 aio_write 这样的函数指针。socket 的实际函数表是 socket_file_ops。socket 使用的 aio_write 函数实际是 sock_aio_write。函数表的目的与 Java 中的接口类似。它被普遍的用于内核对代码的抽象或重构。

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, ...)
 
[...]
 
};

socket_aio_write() 函数重文件得到 socket 结构体并调用 sendmsg。它同样是一个函数指针。socket 结构体包含了 proto_ops 函数表。IPV4 TCP 对 proto_ops 的实现是 inet_stream_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(i.e. TCB),并将应用请求用来传输的数据复制到 socket 发送缓冲区。当把数据复制到 sk_buff 时,一个 sk_buff 又会包含多少字节呢?一个 sk_buff 复制并包含 MSS(tcp_send_mss) 个字节以帮助实际创建数据包的代码。Maximum Segment Size(MSS) 实际表示了一个 TCP 包能够包含的最大荷载大小。通过使用 TSO 和 GSO,sk_buff 能够保存比 MSS 更多的数据。这些将会在后续详细讨论,而非在本文。

sk_stream_alloc_skb 函数创建了一个新的 sk_buffskb_entail 将新创建的 sk_buff 添加到 send_socket_buffer 的头部。skb_add_data 函数会将实际的应用数据复制到 sk_buff 的数据缓冲区。通过几次这样的重复(创建 skb_buff 并添加到 socket 发送缓冲区),所有数据都会被复制。因此,以 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_push 函数会基于 TCP 允许的范围尽可能多的传输 socket 发送缓冲区中的 sk_buffs。首先,tcp_send_head 被调用以得到 socket 发送缓冲区中的第一个 sk_buff,然后执行 tcp_cwnd_testtcp_snd_wnd_test 来检查正在接收的 TCP 的阻塞窗口和接收窗口是否允许新数据包的传输。然后,tcp_transmit_skb 函数被调用以创建一个数据包。

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 的副本(pskb_copy)。这次,并未复制应用的完整数据,而仅仅是元数据。然后调用 skb_push 来保护头部数据空间并记录头部数据值。send_check 计算了 TCP 的校验和。基于无负载(offload)校验和,荷载数据并未被计算。最终,queue_xmit 被调用以将数据包发送给 IP 层。IPV4 的 queue_xmitip_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 头部字段值。之后,随着函数的调用,ip_send_check 会计算 IP 头部的校验和并调用网络过滤器(netfilter)函数。如果 ip_finish_output 函数需要 IP 分片则会创建 IP 片段。当使用 TCP 时并不需要 IP 分片。因此,ip_finish_output2 会被调用,它添加了以太网(Ethernet)头部。最终,一个数据包完成。

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);
 
[...]
 
}

已完成的数据包由 dev_queue_xmit 函数来传输。首先,数据包通过 qdisc(队列规则) 来传递。如果使用的是默认的 qdisc 且队列为空,sch_direct_xmit 函数被调用以将数据包直接下发给驱动设备,从而跳过队列。dev_hard_start_xmit 函数会调用实际的驱动设备。在调用驱动设备之前,首先对驱动设备的 TX 加锁。这么做是为了避免多个线程对驱动设备的同时访问。由于内核锁住了驱动设备的 TX,驱动设备的传输代码则无需再进行加锁。这些与并行编程紧密相关,我们下次再进行讨论。

ndo_start_xmit 函数调用了驱动设备代码。就在之前,你会看到 ptype_alldev_queue_xmit_nit。ptype_all 是一个包含了一些模块的列表,比如数据包捕获。如果捕获程序正在运行,数据包则会被 ptype_all 复制到另外的程序。因此,tcpdump 所展示的数据包正式要传输给驱动设备的数据包。如果使用了无负载校验和或 TSO,NIC 会篡改数据包。因此 tcpdump 得到的数据包会与传输到网线上的数据包有所不同。在完成数据包的传输之后,驱动设备中断处理器会返回 sk_buff

跟随代码:如何接收数据

大体的执行路线为接收一个数据包,然后将数据添加到 socket 接收缓冲区。在执行完驱动设备处理器之后,紧接着会首先执行 napi 拉取处理。

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 是接收数据包的软中断处理器。首先,请求过 napi 拉取的驱动设备从 poll_list 中被找到,然后驱动设备的拉取处理器被调用。驱动设备使用 sk_buff 包装接收到的数据包,然后调用 netif_receive_skb

如果有一个请求所有数据包的模块,netif_receive_skb 会将所有的数据包发送给该模块。像数据包传输中一样,数据包会被传输给注册到 ptype_all 列表的模块。数据包在这里被捕获。

然后,会基于数据包的类型将其传输给上层。以太网(Ethernet)数据包的头部中拥用 2-byte 的以太网类型字段,其值表示了数据包的类型。驱动设备会将该值记录到 sk_buff(sk->protocol) 中。每种协议都拥有各自的 packet_type 结构体,并将该结构体的指针注册到名为 ptype_base 的哈希表。IPV4 使用 ip_packet_type,其 Type 字段的值是 IPV4 以太网类型(ETH_P_IP)。因此,IPV4 数据包会调用 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 层所必须的一些任务。它会检验数据包,比如长度和头部校验和。在流经网络过滤器码时,它会执行 ip_local_deliver 函数。如有必要,它会装配 IP 分段。然后,通过网络过滤器码调用 ip_local_deliver_finiship_local_deliver_finish 函数会通过 __skb_pull 移除掉 IP 头部,然后搜索协议值与 IP 头部的协议值一样的上层协议。类似于 ptype_base,每个传输协议都会将其各自的 net_protocol 结构体注册到 inet_protos。IPV4 TCP 使用 tcp_protocol 并调用注册为一个处理器的 tcp_v4_rcv

当数据包来到 TCP 层,数据包处理流程会基于 TCP 状态和数据包类型变化。这里,我们将会看到这样的数据包处理步骤:预期的下一个数据包已经以 ESTABLISHED 的 TCP 连接状态被接收到。当没有丢失的或乱序抵达的数据包时,接收数据的服务端将会频繁执行这样的路线。

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_rcv 函数首先会验证接收到的数据包。如果头部大小大于数据偏移,即 th->doff < sizeof(struct tcphdr) / 4,则出现头部错误。然后,__inet_lookup_skb 会从 TCP 连接哈希表中查找数据包对应的 TCP 连接。基于找到的 sock 结构体,所有必要的结构体和 sock 都会被找到,比如 tcp_sock

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 处于 ESTABLISHED 状态,则调用 tcp_rcv_esablished 函数。ESTABLISHED 状态会被单独的处理和优化,因为这是最常见的状态。tcp_rcv_esablished 函数首先会执行头部预测代码,在通常场景中,头部预测也会被快速地执行以进行检测。这里的通常场景指的是没有需要传输的数据,而已接收到的数据包其实是需要下次被接收的数据包,比如,序列号是正在接收的 TCP 所预期的序列号。这时,通过将数据添加到 socket 缓冲区并发送 ACK 类完成该步骤。

接着你会看到实际大小(truesize)与 sk_forward_alloc 的比较语句,用来检查 socket 接收缓冲区中是否有自由空间来添加新的数据包数据。如果有,则“命中”头部预测(预测成功)。然后调用 __skb_pull 来移除 TCP 头部。之后,调用 __skb_queue_tail 将数据包添加到 socket 接收缓冲区。最终,__tcp_ack_snd_check 会被调用以发送 ACK,如果需要的话。通过以上方式,数据包的处理完成。

如果没有足够的自由空间,会执行一个较慢的路线。tcp_data_queue 会重新申请缓冲空间并将数据包添加到 socket 缓冲区。这时,如果可能的话,socket 接收缓冲区大大小会被自动增长。与快速路线不同的是,如果可能的话,tcp_data_snd_check 会被调用以传输一个新的数据包。最终,__tcp_ack_snd_check 会被调用以发送 ACK,如果需要的话。

这两种执行路线的代码规模并不大,这是基于对常见场景的优化实现的。换句话说,这意味着不常见的场景的处理会显著变慢。乱系抵达就是非常见场景的一种。

驱动设备与 NIC 之间如何通信

驱动设备与 NIC 之间的通信是网络栈的底层部分,多数人并不关心这一块。然而,NIC 正在执行越来越多的任务以解决性能问题。理解基本的操作模式将有助于你理解额外的技术。

驱动设备与 NIC 之间进行的是 异步 通信。首先,驱动设备会请求一个数据包传输调用,然后 CPU 并不等待响应,而是执行另一个任务。然后,NIC 发送数据包并提醒 CPU,驱动设备返回接收到的数据包结果。数据包接收与传输一样,也是异步的。首先,驱动设备数据包接收调用,CPU 则会去指定别的任务。然后,NIC 接收数据包并提醒 CPU,驱动设备会处理接收到的数据包并返回结果。

因此,需要一个地方来保存请求和响应。大多数情况下,NIC 会使用 环(ring) 结构体,ring 类似于普通的队列结构,带有固定的条目数量,一个条目保存一个请求或响应数据。这些条目会按顺序依次被使用。固定的数量以及按顺序的复用,因此称此结构为 ring。

下图展示了数据包的传输步骤,你可以看到 ring 是如何被使用的。

NAME

驱动设备从上层接收数据包并穿件 NIC 能够理解的发送描述符。发送描述不默认会包含数据包大小及其内存地址。因为 NIC 需要物理地址来访问内存,驱动设备需要将虚拟地址转换为物理地址。然后,驱动设备将发送描述符添加到 TX ring(1)。TX ring 是一个发送描述符 ring。

接着,驱动设备会通知 NIC 新的请求(2)。驱动设备直接将注册数据写入到指定的 NIC 内存地址。这样,程序化(programmed) IO(PIO) 则成为 CPU 直接将数据发送给驱动设备的传输方法。

被通知的 NIC 从主机内存中获得 TX ring 的发送描述符(3)。由于驱动设备直接访问内存而无需 CPU 的干预,这种访问被称为 Direct Memory Acess(DMA)。

在得到发送描述符之后,NIC 会检测数据包地址、大小,然后从主机内存中得到实际额的数据包(4)。基于无负载校验和(offload checksum),NIC 会在从内存得到数据包时计算校验和。因此,很少会出现开销。

NIC 发送数据(5) 并将发送的数据包的需要写入到内存(6)。然后,它会发送一个中断(7)。驱动设备读取所发送的数据包的序号,然后返回到目前为止已发送的数据包。

下图展示了接收数据包的步骤。

NAME

首先,驱动设备会分配主机内存缓冲以接收数据包,然后创建一个接收描述符。接收描述符默认会包含缓存大小及内存地址。像发送描述符一样,它会在接收描述符中保存 DMA 需要使用的物理地址。然后,将接收描述符添加到 TX ring(1)。描述符作为一个接收请求,RX ring 作为一个接收请求 ring。

通过 PIO,驱动设备会通知 NIC 有一个新的描述符(2)。NIC 从 RX ring 中得到新的描述符,并将描述符中的缓冲带下、位置保存到 NIC 内存(3)。

在数据被接收到之后(4),NIC 会将接收到的数据包发送给主机内存缓冲区(5)。如果存在无负载校验和函数,NIC 会在此刻计算校验和。所接收数据包的实际大小、校验和结果以及其他一些信息会被保存到另一个单独的 ring(6),即“接收返回 ring”。接收返回 ring 中包含了接收请求处理的结果,比如响应。然后 NIC 发送一个中断(7)。驱动设备从接收返回 ring 中获得数据包信息并处理接收到的数据包。如有必要,它会分配新的内存并重复步骤 (1)、(2)。

为了调优网络栈,很多人指出对 ring 和中断的设置进行调整。当 TX ring 很大时,大量发送请求可以被一次处理完成。当 RX ring 很大时,大量数据包的接收可以被一次处理完成。对于带有较大爆发的数据包传输、接收来说,一个大的 ring 将会有助于负载。在大多数情况下,NIC 会使用一个计时器来减少中断的数量,因为 CPU 可能需要遭受大量处理中断的开销。为了避免主机系统中大量中断的泛滥,中断可以被收集并在发送或接收数据包时定期发送(中断合并)。

栈缓冲区与流控

流控会在网络栈的多个阶段中执行,下图中展示了用于发送数据的缓冲区。

NAME

首先,一个应用创建数据并将其添加到 socket 发送缓冲区。如果缓冲区中没有自由空间,系统调用则会失败,或者应用线程会发生阻塞。因此,必须使用 socket 缓冲区大小限制来控制数据流入内核的速度。

TCP 创建并通过传输数据队列(qdisc) 将其发送到驱动设备。这是一个典型的 FIFO 队列类型,队列的最大长度是 txqueuelen 的值,该值可以通过 ifconfig 命令来检查。通常它可以承载数千个数据包。

TX ring 介于驱动设备和 NIC 之间。如前面提到的,它被认为是一个传输请求队列。如果队列中没有可用的自由空间,没有传输请求会被创建,数据包也会被累积到传输队列中。如果基类了太多数据包,多余的数据包则会被丢弃。

NIC 将需要发送的数据包保存到内部缓冲。来自该缓冲的数据包速度受物理速度的影响,比如 1 Gb/s 的 NIC 无法提供 10 GB/s 的性能。同时基于以太网的流控,如果 NIC 接收缓冲中没有可用的自由空间,数据包传输将会被停止。

下图展示了接收数据所流经的缓冲。数据包首先被保存到 NIC 的接收缓冲。基于流控的视角,驱动设备与 NIC 之间的 RX ring 被认为是一个数据包 缓冲。驱动设备从 RX ring 获取传入的数据包并将其发送给上层。由于被服务系统使用的 NIC 驱动设备默认使用 NAPI,驱动设备与上层之间没有缓冲。因此,可以认为是上层直接从 RX ring 直接获得数据包。荷载数据被保存的 socket 接收缓冲区。然后应用从 socket 接收缓冲区获取这些数据。

NAME

不支持 NAPI 的驱动设备会将数据包保存的后备(backlog)队列。然后,NAPI 来获取这些数据包。因此,后备队列可以被看做是上层和驱动设备间的缓冲。

如果内核对数据包的处理速度慢于 NIC 中数据包流的速度, RX ring 会变满。然后 NIC 的缓冲变满。当使用了以太网流控时,NIC 会发送一个请求给传输 NIC 以停止传输,或者丢去数据包。

在 socket 接收缓冲区中不会出现因为空间不足而丢弃数据包,因为 TCP 支持端到端的流控。对于大多数工作而言吞吐量是重中之重,提升 ring 和 socket 缓冲区大小将大有帮助。增加大小能够在快速传输或接收大量数据包时减少因为空间不足引起的错误。

总结

一开始,我计划仅向大家介绍有助于开发网络程序、执行性能测试、故障排除相关的内容。尽管我有初步计划,但本文件中的描述数量并不小。我希望本文能帮助您开发网络应用程序并监视他们的性能。TCP/IP协议本身很复杂,有很多例外。不过,您不必了解 OS 中 TCP/IP 相关的每一行代码以了解性能并分析这些现象。仅了解它的上下文就会对你很有帮助。

随着系统性能和 OS 网络栈实现的不断进化,最新的服务能够为任意程序提供 10-20 Gb/s 的 TCP 吞吐。这些时间以来,已经有太多性能相关的技术类型,比如 TSO, LRO, RSS, GSO, GRO, UFO, XPS, IOAT, DDIO, TOE,就像字母汤(alphabet soup),让我们变得困惑。

在下一篇文章中,我将从性能的角度解释网络堆栈,并讨论该技术的问题和影响。

Reference