https://cizixs.com/2017/07/27/understand-tcp-ip-network-stack/
TL;DR
[TOC]
译者注:很久没有翻译文章了,最近在网络看到这篇介绍网络栈的文章非常详细,正好最近在看这方面的内容,索性翻译过来。因为很多文章比较长,而且很多内容比较专业,翻译过程中难免会有错误,如果读者发现错误,还望不吝指出。文章中 Linux 内核源码摘自哪个版本原文并没有表明,我也没有找到对应的版本,代码的缩进可能会有问题。
原文地址: cubrid.org/blog/understanding-tcp-ip-network-stack,有删减。
没有 TCP/IP 的网络服务是无法想象的,理解数据是怎么在网络中传递的能够让你通过调优、排错来提高网络性能。
这篇文章会介绍 Linux OS 和硬件层的数据流和控制流的网络操作。
怎么设计一种网络协议,才能保证数据传输速度很快、能够保证数据的顺序而且没有数据丢失呢? TCP/IP 的设计目标就是如此,下面这些 TCP/IP 的主要特性是理解网络栈的关键。
TCP and IP
技术上说,TCP 和 IP 在不同的网络层,应该分开来表述。方便起见,我们这里把它们作为一个概念。
通信双方先建立连接,才能发送数据。TCP 连接是由四元组唯一确定的:
<本地 IP,本地端口,远端 IP,远端端口>
使用字节流来进行双向数据传输
接收方接收到的数据顺序和发送方的发送顺序一致,要做到这点,数据要有顺序的概念,每个数据段用一个 32 位的整数来标志它的顺序。
如果发送方发送报文之后,没有收到接收方返回的 ACK 确认,发送方会重新发送数据。因此,发送方的 TCP 缓存中保存着接收方还没有确认的数据。
发送方最多能发送的数据由接收方能接受的数据量决定。接收方会发送它能接收的最大数据量(可用的 buffer 大小,接收窗口大小)给发送方,发送方也只能发送接收方接收窗口能够允许的字节大小。
拥塞窗口独立于接收窗口,通过限制网络中数据流来阻止网络拥塞。和接收窗口类似,发送方根据一定的算法(比如 TCP Vegas、Westwood、BIC、和 CUBIC)发送接收方拥塞窗口允许的最大数据。和流控不同,拥塞控制只在发送方实现。
网络栈有多个层,下图展示了网络不同的层:
这些层大致可以分为三类:
用户域和内核域的任务是 CPU 执行的,它们两个也和成为主机,用以和设备域进行区别。设备指的是发送和接受报文的网卡(Network Interface Card/NIC)。
我们来看用户域。首先应用构造出需要发送的数据(上图中的 User Data部分),然后调用 write()
系统调用发送数据。假设 socket(图中的 fd) 已经创建,当系统调用执行的时候,就进入到内核域。
Linux 和 Unix 这种 POSIX 系列的操作系统暴露文件操作符给应用程序,供它们操作 socket。对于 POSIX 系列操作系统来说,socket 就是一种文件。文件层进行简单的检查,然后通过和文件结构体关联的 socket 结构体调用 socket 对应的函数。
内核 socket 有两个缓存:
当调用 write
系统调用时,用户域的数据被拷贝到内核内存中,然后添加到 socket 发送缓存的尾部,这是为了按照顺序发送数据。在上图中,浅灰色矩形框代表着 socket 缓存中的数据。接着,TCP 层被调用了。
socket 和 TCB(TCP Control Block) 相关联,TCB 保存着处理 TCP 连接需要的信息,比如连接状态(LISTEN、ESTABLISHED、TIME_WAIT)、接收窗口、拥塞窗口、序列号、重发计时器等。
如果当前的 TCP 状态允许数据传输,就会生成一个新的 TCP segment(或者说报文)。如若因为流控等原因无法进行数据传输,系统调用到此结束,重新回到用户模式(或者说,控制权又重新交给应用)。
如下图所示。TCP 段有两部分:
payload 包括了 socket 发送缓存的数据,payload 的最大值是接收窗口、拥塞窗口和最大段(Maximum Segment Size/MSS) 的三者的最大值。
接着,计算出 TCP checksum。计算 checksum 的时候,会考虑到 IP 地址、segment 长度、和协议号等信息。根据 TCP 状态不同,可以传输的报文从 1 个到多个不等。
NOTE:事实上,TCP checksum 是网卡计算的,不是内核。但是为了简单起见,我们假定是内核做了这件事。
创建的 TCP 段往下走到 IP 层。IP 层给 TCP 段加上 IP 头部,并执行路由的逻辑。路由是查找下一跳的 IP 地址的过程,目的是更接近目的 IP。
IP 层计算并添加上 checksum 之后,报文被发送到以太网层。以太网层通过 ARP(Address Resolution Protocol) 协议查找下一跳的 MAC 地址,然后把以太网层的头部添加到报文上,这样主机上的报文就是最终的完整状态。
IP 层经过路由,就知道了要传输报文的网络接口(NIC),报文就从这个网口发送到下一跳 IP 所在机器。因此,下一步就是调用网口的驱动。
NOTE:如果有网络抓包工具(比如 wireshark 或者 tcpdump)在运行,内核会把报文数据拷贝到应用使用的内存区。
网卡驱动根据网卡制造商编写的通信协议向网卡发送传输数据的请求,收到请求之后,网卡(NIC)把数据从主内存拷贝到自己的内存区,然后发送到网络线路上。这个过程中,为了遵循以太网协议,网卡还会为报文添加IFG(Inter-Frame Gap)、preamble、CRC。其中 IFG 和 preamble 是为了区分报文/帧的开始,CRC 是为了保护报文的内容(和 TCP IP 中的 checksum 功能相同)。数据传输的速度决定于以太网的物理速度以及流量控制的现状。
网卡发送数据的时候,会在主机 CPU 产生中断,每个中断都有编号,操作系统根据编号找到对应的驱动处理这个中断。驱动程序会在启动的时候注册它的处理函数到系统,操作系统调用驱动注册的处理函数,然后处理函数把传输的报文返回给操作系统。
至此,我们讨论了应用执行写操作时数据在内核和设备中的发送流程。需要注意的是,即使没有应用层显式的写操作,内核也会调用 TCP 层来发送数据。比如,当收到 ACK 报文,接收窗口扩大,内核会把 socket 发送缓存中的数据组装成 TCP 数据段,发送给接收方。
这部分,我们来看看数据的接收流程,数据接收流程是网络栈是怎么处理接收到的数据的。下图主要展示了这个过程:
首先,NIC 把报文拷贝到自己的内存中,检查 CRC 判断报文是否有效。如果有效,则发送到报文到主机的内存缓存区中。这部分内存缓存区是驱动提前申请的,专门用来保存接收到的数据。当缓存被分配的时候,驱动会告诉网卡这块内存的地址和大小。如果网卡接收到某个报文的时候,这部分缓存空间不足了,那么网卡可能直接丢掉报文。
把报文发送到主机的内存后,网卡会向主机发送一个中断。驱动这时候会检查它是否能处理这个报文,如果可以,它会把报文发送给上层的网络协议。往上层发送数据时,报文必须是操作系统能够理解的形式。比如 linux 的 sk_buff
,BSD 系统的 mbuf
,windows 系统的 NET_BUFFER_LIST
,都是操作系统能理解的报文结构体。驱动需要把结构化的报文发送给上层。
以太网层检查报文是否合法,然后把上层协议的类型从报文中抽取出来。以太网层在 ethertype
字段中保存着上层使用的协议类型。IPv4 协议对应的值是 0x8000
,报文中以太网层的头部会被去掉,剩下的内容发送到上层的 IP 去处理。
IP 层也会检查报文是否合法,不过它检查的是 IP 协议头部的 checksum。根据 IP 协议头部的地址,这一层会判断报文应该交给上层处理,还是执行路由抉择,或者直接发送给其他系统。如果报文应该有当前主机处理,那么上层协议(传输层)类型会从 IP 头部的 proto
字段读取,比如 TCP 协议对应的值是 6。然后报文的 IP 层头部被去掉,剩下的内容继续发送到上层的 TCP 层处理。
和其他层一样,TCP 层也会先检查报文是否合法,它的判断依据是 TCP checksum。然后它找到和报文关联的 TCB(TCP Control Block),报文是由 <source IP, source Port, target IP, target Port>
四元组作为连接标识的。系统找到对应的连接就能继续协议层的处理。如果这是收到的新数据,它会把数据拷贝到 socket 接收缓存中。根据 TCP 连接的状态,可能还会发送一个新的 TCP 报文(比如 ACK 报文通知对方报文已经收到)。至此,TCP/IP 报文接收流程就完成了。
socket 接收缓存的大小就是 TCP 接受窗口,在一定条件下,TCP 的吞吐量会随着接收窗口增加而增加。过去接收窗口是应用或者操作系统进行配置的,现在的网络栈能够自动调整接收窗口。
当应用调用 read
系统调用时,控制权就到了内核,内核会把数据从 socket 缓存拷贝到用户域的内存中,拷贝之后缓存中的数据就被删除。接着 TCP 相关的函数被触发,因为有了新的缓存空间可用,TCP 会增加接收窗口的大小。如果需要,TCP 还会发送一个报文给对方,如果没有数据要发送,系统调用就到此结束。
上面只描述了网络栈各层最基本的功能。1990 年代网络栈的功能就已经比上面描述的要多,而最近的网络栈功能更多,复杂度也更高。
最新的网络栈按照功能可以分成下面几类:
Netfilter(firewall,NAT)和流量控制允许用户在基本流程中插入控制代码,改变报文的处理逻辑。
性能是为了提高 TCP 协议在网络环境中的吞吐量,延迟和稳定性,例子包括各样的拥塞控制算法和 SACK 这样的 TCP 功能。这类的协议改进不会在本文讨论,因为它超出了文章的范围。
报文处理效率是为了提高每秒能处理的报文数量,一般是通过减少 CPU 周期,内存使用,和内存读取时间。减少系统延迟要很多方法,比如并行处理、头部预测、zero-copy、single-copy、checksum offload、TSO、LRO、RSS 等。
现在,让我们详细分析网络栈内部的数据流。网络栈基本工作模式是事件驱动的,也就是说事件发生会触发一系列的处理逻辑,因此不需要额外的线程。下图展示了更精细的数据控制流程:
图中 (1) 表示应用程序调用了某个系统调用来执行(或者说使用)TCP,比如调用 read
或者 write
系统调用函数。不过,这一步并没有任何报文传输。
(2)和 (1) 类似,只是执行 TCP 逻辑之后,还会把报文发送出去。TCP 会生成一个报文,然后往下一直发送到网卡驱动。报文会先到达队列,然后队列的实现决定什么时候把报文发给网卡驱动。这个过程是 linux 中的 queue discipline(qdisc),linux 流量控制就是控制 qdisc 实现的。默认的 qdisc 算法是先进先出(FIFO),通过使用其他的 qdisc 算法,用户可以实现各种效果,比如人为的报文丢失、报文延迟、传输速率控制等。
流程 (3) 表示 TCP 使用的计时器过期的过程。比如 TIME_WAIT计时器过期,TCP 会被调用删除这个连接。
流程(4)和 流程(3)类似,TCP 计时器过期,但是需要重新发送报文。比如,重传计时器过期,没有接收到 ACK 的报文会重新发送。这两个流程展示了计时器软中断的处理过程。
当网卡驱动接收到一个中断,它会释放传输的报文。大多数情况下,驱动的任务到此就结束了。流程(5)是报文在传输队列(transmit queue)集聚,网卡驱动请求一个软中断(softirq),中断处理函数把传输队列中的报文发送到网卡驱动中。
当网卡驱动接受到一个中断,并且收到了一个新的报文,它也会请求一个软中断。这个软中断会处理接收到的报文,调用驱动程序,并把报文传输到上层。在 Linux 系统中,处理接收到报文的过程被称为 New API(NAPI),它和 polling 类似,因为驱动不会直接把报文发送给上层,而是上层直接获取报文。对应的代码成为 NAPI poll 或者简称 poll。
流程(6)展示了 TCP 执行完成的过程,流程(7)是 TCP 流程需要传输额外的报文。(5)、(6)和(7)都是软中断执行的,而软中断之前也处理了网卡中断。
中断处理的过程很复杂,但是我们需要了解和报文接收有关的性能问题。下图展示了处理中断的过程:
假设 CPU 0 在执行应用程序,这时网卡收到一个报文,并为 CPU 0 产生一个中断。CPU 会执行内核中断(irq)处理(do_IRQ()
),它会找到中断号,调用对应的驱动中断处理函数。驱动释放传送的报文,然后调用 napi_schedule()
函数处理接收的报文。这个函数发起软件中断(softirq)。
驱动中断处理完成结束后,控制权就回到了内核处理函数,内核处理函数执行软件中断的中断处理(do_softirq()
)。
软件处理函数处理接收报文的是 net_tx_action()
函数。这个函数调用驱动的 poll()
函数,poll()
函数继续调用 netif_receive_skb()
函数,然后把接收到的报文逐个发送给上层。软件中断处理结束后,应用就从系统调用之后的地方继续执行。
CPU 收到中断之后,会从头执行到结束,Linux、 BSD 和 Windows 系统的执行流程大致如此。当检查服务器 CPU 使用率时,有时候会看到多个 CPU 中只有一个 CPU 在执行软中断,就是因为这样。为了解决这个问题,提出了很多方案,比如多队列网卡、RSS、和 RPS。
下面介绍网络栈主要的数据结构。
sk_buff 或者说 skb 代表一个报文,下图就展示了 sk_buff 的结构。随着网络功能的增加,这个结构体也会越来越复杂,但是基本的功能却保持不变。
这个结构体包含了报文数据,或者保存了指向报文数据的指针。上图中,data 指针指向了报文,frags
指向真正的页。
诸如头部和 payload 长度这些信息保存在 meta data 区域,比如上图中 mac_header
、network_header
和 transport_header
分别指向了以太网头部、IP 头部和 TCP 头部,这种方式让 TCP 报文处理更容易。
在网络栈各层向上或者向下移动时,各层会添加或者删除头部。指针是为了操作更有效,比如要删除以太网头部,只需要增加 head
指针偏移量就行。
链表用来高效地执行添加或者删除报文 payload 的任务,next
和 prev
指针就是这个功能。
因为每次创建报文都要分配一个结构体,因此这里使用了快速分配。比如,如果数据在 10G 的以太网传输,那么每分钟至少要创建和删除一百万报文。
其次,还有一个代表 TCP 连接的结构体,被称为 TCP control block,Linux 中对应的是 tcp_sock
。在下图中,你可以看到 file、socket、和 tcp_sock
的关系:
系统调用发生时,系统会检查应用使用的文件描述符。对于 Unix 系列的操作系统来说,socket、file、和文件系统的设备都被抽象为文件,因此 file
的结构体中保存的信息最少。对于 socket,有一个额外的 socket
结构体保存着和这个 socket 有关的信息。file
有一个指针指向 socket
,而 socket
又指向 tcp_sock
。tcp_sock
可以分成 sock、inet_sock 不同的类型来支持出 TCP 之外的各种协议。可以把这理解为多态!
TCP 协议的所有状态信息都保存在 tcp_sock
中,比如序列号、接受窗口、拥塞控制、和重传计时器都保存在 tcp_sock
。
socket 发送缓存和接收缓存就是 sk_buff
列表,它们也保存了 tcp_sock
信息。dst_entry
和 IP 路由结果是为了避免频繁地进行路由。dst_entry
允许快速搜索 ARP 结果,也就是目的 MAC 地址。dst_entry
是路由表的一部分,路由表的结构非常复杂,这篇文章不会讨论。报文传输要使用的网络设备也能通过 dst_entry
搜索到,网络设备对应的结构体是 net_device
。
因此,通过 file
结构体和各级指针就能找到处理 TCP 报文需要的结构体(从文件一直到网络驱动),各种结构体的大小之和也就是 TCP 连接要占用的内存大小,这个值在几 KB(当然不包括报文的数据)。对着更多的功能加进来,这个内存使用也会逐渐增加。
最后,我们来看看 TCP 连接查找表(lookup table),这是一个哈希表,用来搜索接收到的报文属于哪个 TCP 连接。哈希值是通过报文的 <source IP, target IP, source port, target port>
四元组和 Jenkins 哈希算法计算的,据说使用这个算法是为了应对对哈希表的攻击。
我们通过阅读 Linux 内核源码来看看网络栈具体执行的关键任务,我们将会观察经常用到的两条线路。
首先,第一条是应用程序调用 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
系统调用,内核执行 write()
函数。首先要根据 fd 找到真正的而繁忙操作符,然后调用 aio_write
,这是一个函数指针。在 file
结构体中,你可以看到 file_operations
结构体指针,这个结构体被称作函数表,里面包含了 aio_read
和 aio_write
等函数指针。socket 真正的函数表是 socket_file_ops
,socket 使用的 aio_write
函数是 sock_aio_write
。这个函数表的功能类似于 Jave 的 interface,可以方便内核进行代码抽象和重构。
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
函数从 file
结构体中获取 socket
结构,然后调用 sendmsg
,这也是一个函数指针。socket
结构体包括了 proto_ops
的函数表,IPv4 对应的实现是 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;
..