理解TCP/IP网络栈

看到一篇讲解TCP/IP协议栈原理及其处理数据包流程很好的文章,特记录下来。

原文地址:理解TCP/IP网络栈&编写网络应用

数据传输

网络栈有很多层。图1表示了这些层的类型:


图1 发送数据时网络栈中各层对数据的操作

这些层可以被大致归类到三个区域中:(1)用户区;(2)内核区;(3)设备区。用户区和内核区的任务由CPU执行。用户区和内核区被叫做“主机(host)”以区别于设备区。在这里,设备是发送和接收数据包的网络接口卡(Network Interface Card,NIC)。它有一个更常用的术语:网卡。
我们来了解一下用户区。首先,应用程序创建要发送的数据(图1中的“User Data”)并且调用write()系统调用来发送数据(在这里假设socket已经被创建了,对应图1中的“fd”)。当系统调用被调用之后上下文切换到内核区。
像Linux或者Unix这类POSIX系列的操作系统通过文件描述符(file descriptor)把socket暴露给应用程序。在这类系统中,socket是文件的一种。文件系统执行简单的检查并调用socket结构中指向的socket函数。
内核中的socket包含两个缓冲区:一个用于缓冲要发送的数据;一个用于缓冲要接收的数据。
当write()系统调用被调用时,用户区的数据被拷贝到内核内存中,并插入到socket的发送缓冲区,这样来保证发送的数据有序。之后,TCP被调用了。
socket会关联一个叫做TCP控制块(TCP Control Block,TCB)的结构,TCB包含了处理TCP连接所需的数据,包括连接状态,接收窗口,阻塞窗口,序列号等。
如果当前的TCP状态允许数据传输,一个新的TCP分段(TCP segment)将被创建,当然,如果由于流量控制或其他原因不能传输数据,系统调用将会结束,之后返回用户态,即将控制权交回到应用程序代码。
TCP分段有两部分:TCP头和携带的数据。


图2 TCP帧的结构

TCP数据包的payload部分会包含在socket发送缓冲区里的没有应答的数据。携带数据的最大长度是接收窗口、阻塞窗口和最大分段长度(MSS)中的最大值。之后会计算TCP校验值。在计算时,头信息(ip地址、分段长度和端口号)会包含在内。根据TCP状态可发送一个或多个数据包。事实上,当前的网络栈使用了校验卸载(checksum offload),TCP校验和会由NIC计算,而不是内核。但是,为了解释方便我们还是假设内核计算校验和。
被创建的TCP分段继续走到下面的IP层。IP层向TCP分段中增加了IP头并且执行了IP路由。IP路由是寻找到达目的IP的下一条IP地址的过程。
在IP层计算并增加了IP头校验和之后,它把数据发送到链路层。链路层通过地址解析协议(Address Resolution Protocol,ARP)搜索下一跳IP地址对应的MAC地址。之后它会向数据包中增加链路头,在增加链路头之后主机要发送的数据包就是完整的了。
在执行IP路由时,会选择一个传输接口(NIC)。接口被用于把数据包传送至下一跳IP。于是,用于发送的NIC驱动程序被调用了。
在这个时候,如果正在执行数据包捕获程序(例如tcpdump或wireshark)的话,内核将把数据包拷贝到这些程序的内存缓冲区中。相同的方式,接收的数据包直接在驱动被捕获。
在接收到数据包传输请求之后,NIC把数据包从系统内存中拷贝到它自己的内存中,之后把数据包发送到网络上。在此时,由于要遵守以太网标准(Ethernet standard),NIC会向数据包中增加帧间隙(Inter-Frame Gap,IFG),同步码(preamble)和crc校验和。帧间隙和同步码用于区分数据包的开始(网络术语叫做帧,framing),crc用于保护数据(与TCP或者IP校验和的目的相同)。NIC会基于以太网的物理速度和流量控制决定数据包开始传输的时间。
当NIC发送了数据包,NIC会在主机的CPU上产生中断。所有的中断会有自己的中断号,操作系统会用这个中断号查找合适的程序去处理中断。驱动程序在启动时会注册一个处理中断的函数。操作系统调用中断处理程序,之后中断处理程序会把已发送的数据包返回给操作系统。

数据接收

现在我们看一下数据是如何被接收的。数据接收是网络栈如何处理流入数据包的过程。


图3 接收数据时网络栈中各层对数据的操作

首先,NIC把数据包写入它自身的内存。通过CRC校验检查数据包是否有效,之后把数据包发送到主机的内存缓冲区。这里说的缓冲区是驱动程序提前向内核申请好的一块内存区域,用于存放接收的数据包。在缓冲区被系统分配之后,驱动会把这部分内存的地址和大小告知NIC。如果主机没有为驱动程序分配缓冲区,那么当NIC接收到数据包时有可能会直接丢弃它。
在把数据包发送到主机缓冲区之后,NIC会向主机发出中断。之后,驱动程序会判断它是否能处理新的数据包。到目前为止使用的是由制造商定义的网卡驱动的通讯协议。
当驱动应该向上层发送数据包时,数据包必须被放进一个操作系统能够理解和使用的数结构。例如Linux的sk_buff,或者BSD系列内核的mbuf,或者windows的NET_BUF_LIST,驱动会把数据包包装在指定的数据结构,并发送到上一层。
链路层会检查数据包是否有效并且解析出上层的协议(网络协议)。此时它会判断链路头中的以太网类型。(IPv4的以太网类型是0×0800)。它会把链路头删掉并且把数据包发送到IP层。
IP层同样会检查数据包是否有效。或者说,会检查IP头校验和。它会执行IP路由判断,判断是由本机处理数据包还是把数据包发送到其它系统。如果数据包必须由本地系统处理,IP层会通过IP header中引用的原型值(proto value)解析上层协议(传输协议)。TCP原型值为6。系统会删除IP头,并且把数据包发送到TCP层。
就像之前的几层,TCP层检查数据包是否有效,同时会检查TCP校验和。就像之前提到的,如果当前的网络栈使用了校验卸载,那么TCP校验和会由NIC计算,而不是内核。
之后它会查找数据包对应的TCP控制块(TCB),这时会使用数据包中的<源ip,源端口,目的IP,目的端口>做标识。在查询到对应的连接之后,会执行协议中定义的操作去处理数据包。如果接收到的是新数据,数据会被增加到socket的接收缓冲区。根据tcp连接的状态,此时也可以发送新的TCP包(比如发送ACK包)。此时,TCP/IP接收数据包的处理完成。
socket接收缓冲区的大小就是TCP接收窗口。TCP吞吐量会随着接收窗口变大而增加。过去socket缓冲区大小是应用或操作系统的配置的定值。最新的网络栈使用了一个函数去自动决定接收缓冲区的大小。
当应用程序调用read系统调用时,程序会切换到内核区,并且会把socket接收缓冲区中的数据拷贝到用户区。拷贝后的数据会从socket缓冲区中移除。之后TCP会被调用,TCP会增加接收窗口的大小(因为缓冲区有了新空间)。并且会根据协议状态发送数据包。如果没有数据包传送,系统调用结束。

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

这一部分内容已经在Linux网络协议栈数据处理流程一文中详细叙述过。
中断处理过程是复杂的,但是你需要了解数据包接收和处理流程中的和性能相关的问题。图4展示了一次中断的处理流程。


图4 处理中断、软中断和接收数据

假设CPU0正在执行应用程序,在此时,NIC接收到了一个数据包并且在CPU0上产生了中断。CPU0执行了内核中断处理程序(irq)。这个处理程序关联了一个中断号并且内核会调用驱动里对应的中断处理程序。驱动在释放已经传输的数据包之后调用napi_schedule()函数去处理接收到的数据包,这个函数会请求软中断。在执行了驱动的中断处理程序后,控制权被转移到内核中断处理程序。内核中的处理程序会执行软中断的处理程序。
在中断上下文被执行之后,软中断的上下文会被执行。中断上下文和软中断上下文会通过相同的线程执行,但是它们会使用不同的栈。并且中断上下文会屏蔽硬件中断;而软中断上下文不会屏蔽硬件中断。
处理接收到的数据包的软中断处理程序是net_rx_action()函数。该函数会调用驱动程序的poll()函数。而poll()函数会调用netif_receive_skb()函数,并把接收到的数据包一个接一个的发送到上层。在软中断处理后,应用程序会从停止的位置重新开始执行。
因此,接收中断请求的CPU会负责处理接收数据包从始至终的整个过程。在Linux、BSD和Windows中,处理过程基本是类似的。

相关数据结构

sk_buff

首先,sk_buff结构或skb结构代表一个数据包,图5展现了sk_buff中的一些结构。随着功能变得更强大,它们也变得更复杂了。


图5 数据包结构

这个结构直接包含或者通过指针引用了数据包。一些必要的信息比如头和内容长度被保存在元数据区。例如,在图5中,mac_header、network_header和transport_header都有相应的指针,指向链路头、IP头和TCP头的起始地址。这种方式让TCP协议处理过程变得简单。
数据包在网络栈的各层中上升或下降时会增加或删除数据头。为了更有效率的处理而使用了指针。例如,要删除链路头只需要修改head pointer的值。

TCP控制块

其次,有一个表示TCP连接的数据结构,之前它被抽象的叫做TCP控制块。Linux使用了tcp_sock这个数据结构。在图6中,你可以看到文件、socket和tcp_socket的关系。


图6 TCP Connection结构

当系统调用发生后,它会找到应用程序在进行系统调用时使用的文件描述符对应的文件。对Unix系的操作系统来说,文件本身和通用文件系统存储的设备都被抽象成了文件。因此,文件结构包含了必要的信息。对于socket来说,使用独立的socket结构保存socket相关的信息,文件系统通过指针来引用socket。socket又引用了tcp_sock。tcp_sock可以分为sock,inet_sock等等,用来支持除了TCP之外的协议,可以认为这是一种多态。
所有TCP协议用到的状态信息都被存在tcp_sock里。例如顺序号、接收窗口、阻塞控制和重发送定时器都保存在tcp_sock中。
socket的发送缓冲区和接收缓冲区由sk_buff链表组成并被包含在tcp_sock中。为防止频繁查找路由,也会在tcp_sock中引用IP路由结果dst_entry。通过dst_entry可以简单的查找到目标的MAC地址之类的ARP的结果。dst_entry是路由表的一部分,而路由表是个很复杂的结构,在这篇文档里就不再讨论了。用来传送数据的网卡(NIC)可以通过dst_entry找到。网卡通过net_device描述。
因此,仅通过查找文件和指针就可以很简单的查找到处理TCP连接的所有的数据结构(从文件到驱动)。这个数据结构的大小就是每个TCP连接占用内存的大小。这个结构占用的内存只有几kb大小(排除了数据包中的数据)。但随着一些功能被加入,内存占用也在逐渐增加。
最后,我们来看一下TCP连接查找表(TCP connection lookup table)。这是一个用来查找接收到的数据包对应tcp连接的哈希表。系统会用数据包的<来源ip,目标ip,来源端口,目标端口>和Jenkins哈希算法去计算哈希值。选择这个哈希函数的原因是为了防止对哈希表的攻击。

追踪代码:传输数据

我们将会通过追踪实际的Linux内核源码去检查协议栈中执行的关键任务。这里以发送数据的执行路径为例。
首先是应用程序调用write系统调用。当应用调用了write系统调用时,内核将在文件层执行write()函数。首先,内核会取出文件描述符对应的文件结构体,之后会调用aio_write,这是一个函数指针。在文件结构体中,你可以看到file_perations结构体指针。这个结构被通称为函数表(function table),其中包含了一些函数的指针,比如aio_read或者aio_write。对于socket来说,实际的表是socket_file_ops,aio_write对应的函数是sock_aio_write。在这里函数表的作用类似于java中的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;
    };    

    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()函数会从文件结构体中取出socket结构体并调用sendmsg,这也是一个函数指针。socket结构体中包含了proto_ops函数表。IPv4的TCP实现中,proto_ops的具体实现是inet_stream_ops,sendmsg的实现是tcp_sendmsg。
tcp_sengmsg会从socket中取得tcp_sock(也就是TCP控制块,TCB),并把应用程序请求发送的数据拷贝到socket发送缓冲中(根据发送数据创建sk_buff链表),当把数据拷贝到sk_buff中时,每个sk_buff会包含多少字节数据?在代码创建数据包时,每个sk_buff中会包含MSS字节(通过tcp_send_mss函数获取),在这里MSS表示每个TCP数据包能携带数据的最大值。通过使用TSO(TCP Segment Offload)和GSO(Generic Segmentation Offload)技术,一个sk_buff可以保存大于MSS的数据。在这篇文章里就不详细解释了。
sk_stream_alloc_skb函数会创建新的sk_buff,之后通过skb_entail把新创建的sk_buff放到send_socket_buffer的末尾。skb_add_data函数会把应用层的数据拷贝到sk_buff的buffer中。通过重复这个过程(创建sk_buff然后把它加入到socket发送缓冲区)完成所有数据的拷贝。因此,大小是MSS的多个sk_buff会在socket发送缓冲区中形成一个链表。最终调用tcp_push把待发送的数据做成数据包,并且发送出去。
tcp_push函数会在TCP允许的范围内顺序发送尽可能多的sk_buff数据。首先会调用tcp_send_head取得发送缓冲区中第一个sk_buff,然后调用tcp
_cwnd_test和tcp_send_wnd_test检查堵塞窗口和接收窗口,判断接收方是否可以接收新数据。之后调用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);
    /* ... */
    return net_xmit_eval(err);
}

tcp_transmit
_skb会创建指定sk_buff的拷贝(通过pskb_copy),但它不会拷贝应用层发送的数据,而是拷贝一些元数据。之后会调用skb_push来确保和记录头部字段的值。send_check计算TCP校验和(如果使用校验和卸载checksum offload技术则不会做这一步计算)。最终调用queue_xmit把数据发送到IP层。IPv4中queue_xmit的实现函数是ip_queue_xmit。
ip_queue_xmit函数执行IP层的一些必要的任务。__sk_dst_check检查缓存的路由是否有效。如果没有被缓存的路由项,或者路由无效,它将会执行IP路由选择(IP routing)。之后调用skb_push来计算和记录IP头字段的值。之后,随着函数执行,ip_send_check计算IP头校验和并且调用netfilter功能。如果使用ip_finish_output函数会创建IP数据分片,但在使用TCP协议时不会创建分片,因此内核会直接调用ip_finish_output2来增加链路头,并完成数据包的创建。
最终的数据包会通过dev_queue_xmit函数完成传输。首先,数据包通过排队规则传递。如果使用了默认的排队规则并且队列是空的,那么会跳过队列而直接调用sch_direct_xmit把数据包发送到驱动。dev_hard_start_xmit会调用实际的驱动程序。在调用驱动之前,设备的发送被锁定,防止多个线程同时使用设备。由于内核锁定了设备的发送,驱动发送数据相关的代码就不需要额外的锁了。
ndo_start_xmit函数会调用驱动的代码。在这之前,你会看到ptype_all和dev_queue_\xmit_nit。ptype_all是个包含了一些模块的列表(比如数据包捕获)。如果捕获程序正在运行,数据包会被ptype_all拷贝到其它程序中。因此,tcpdump中显示的都是发送给驱动的数据包。当使用了校验和卸载(checksum offload)或TSO(TCP Segment Offload)这些技术时,网卡(NIC)会操作数据包,所以tcpdump得到的数据包和实际发送到网络的数据包有可能不一致。在结束了数据包传输以后,驱动中断处理程序会返回发送了的sk_buff。

驱动和网卡如何通信

驱动(driver)和网卡(NIC)之间的通讯处于协议栈的底层,大多数人并不关心。但是,为了解决性能问题,网卡会处理越来越多的任务。理解基础的处理方式会帮助你理解额外这些优化技术。
网卡和驱动之间使用异步通讯。首先,驱动请求数据传输时CPU不会等待结果而是会继续处理其它任务,之后网卡发送数据包并通知CPU,驱动程序返回通过事件接收的数据包(这些数据包可以看作是异步发送的返回值)。
和数据包传输类似,数据包的接收也是异步的。首先驱动请求接受接收数据包然后CPU去执行其它任务,之后网卡接收数据包并通知CPU,然后驱动处理接收到的数据包(返回数据)。
因此需要有一个空间来保存请求和响应(request and response)。大多数情况网卡会使用环形队列数据结构(ring structure)。环形队列类似于普通的队列,其中有固定数量的元素,每个元素会保存一个请求或一个相应数据。环形队列中元素是顺序的,“环形”的意思是队列虽然是定长的,但是其中的元素会按顺序重用。
图7展示了数据包的传输过程,会看到其中如何使用环形队列。


图7 驱动和网卡之间的通讯:传输数据包

驱动接收到上层发来的数据包并创建网卡能够识别的描述符。发送描述符(send descriptor)默认会包含数据包的大小和内存地址。这里网卡需要的是内存中的物理地址,驱动需要把数据包的虚拟地址转换成物理地址。之后,驱动会把发送描述符添加到发送环形队列(TX ring)(1),发送环形队列中包含的实际是发送描述符。
之后,驱动会把请求通知给网卡(2)。驱动直接把数据(通知)写入指定的网卡的寄存器地址中。
网卡被通知后从主机的发送队列中取得发送描述符(3)。这种设备直接访问内存而不需要调用CPU的内存访问方式叫做直接内存访问(Direct Memory Access,DMA)。
在取得发送描述符后,网卡会得到数据包的地址和大小并且从主机内存中取得实际的数据包(4)。如果有校验和卸载(checksum offload)的话,网卡会在拿到数据后计算数据包的校验和,因此开销不大。
网卡发送数据包(5)之后把发送数据包的数量写入主机内存(6)。之后它会触发一次中断,驱动程序会读取发送数据包的数量并根据数量返回已发送的数据包。

图8展示了接收数据包的过程。


图8 驱动程序和网卡之间的通讯:接收数据包

调优网络栈时,大部分人会说环形队列和中断的设置需要被调整。当发送环形队列很大时,很多次的发送请求可以一次完成;当接收环形队列很大,可以一次性接收多个数据包。更大的环形队列对于大流量数据包接收/发送是很有用的。由于CPU在处理中断时有大量开销,大量大多数情况下,网卡使用一个计时器来减少中断。为了避免对宿主机过多的中断,发送和接收数据包的时候中断会被收集起来并且定期调用(interrupt coalescing,中断聚合)。

网络栈中的缓冲区和流量控制

在网络栈中流量控制在几个阶段被执行。图9展示了传输数据时的一些缓冲区。首先,应用会创建数据并把数据加入到socket发送缓冲区。如果缓冲区中没有剩余空间的话,系统调用会失败或阻塞应用进程。因此,应用程序到内核的发送速率由socket缓冲区大小来限制。


图9 数据发送相关的缓冲

TCP通过传输队列(qdisc)创建并把数据包发送给驱动程序。这是一个典型的先入先出队列,队列最大长度是txqueuelen,可以通过ifconfig命令来查看实际大小。通常来说,大约有几千个数据包。
驱动和网卡之间是传输环形队列(TX ring),它被认为是传输请求队列(transmission request queue)。如果队列中没有剩余空间的话就不会再继续创建传输请求,并且数据包会积累在传输队列中,如果数据包积累的太多,那么新的数据包会被丢弃。
网卡会把要发送的数据包保存在内部缓冲区中。这个队列中的数据包速度受网卡物理速度的影响(例如,1Gb/s的网卡不能承担10Gb/s的性能)。根据以太网流量控制,当网卡的接收缓冲区没有空间时,数据包传输会被停止。
当内核速度大于网卡时,数据包会堆积在网卡的缓冲区中。如果缓冲区中没有空间时会停止处理传输环形队列(TX ring)。越来越多的请求堆积在传输环形队列中,最终队列中空间被耗尽。驱动程序不能再继续创建传输请求数据包会堆积在传输队列(transmit queue)中。压力通过各种缓冲从底向上逐级反馈。
图10展示了接收数据包经过的缓冲区。数据包先被保存在网卡的接收缓冲区中。从流量控制的视角来看,驱动和网卡之间的接收环形缓冲区(RX ring)可以被看作是数据包的缓冲区。驱动程序从环形缓冲区取得数据包并把它们发送到上层。服务器系统的网卡驱动默认会使用NAPI,所以在驱动和上层之间没有缓冲区。因此,可以认为上层直接从接收环形缓冲区中取得数据,数据包的数据部分被保存在socket的接收缓冲区中。应用程序从socket接收缓冲区取得数据。


图10 与接收数据包相关的缓冲

不支持NAPI的驱动程序会把数据包保存在积压队列(backlog queue)中。之后,NAPI处理程序取得数据包,因此积压队列可以被认为是在驱动程序和上层之间的缓冲区。
如果内核处理数据包的速度低于网卡的速度,接收循环缓冲区队列(RX ring)会被写满,网卡的缓冲区空间(NIC internal buffer)也会被写满,当使用了以太流量控制(Ethernet flow control)时,接收方网卡会向发送方网卡发送请求来停止传输或丢弃数据包。
因为TCP支持端对端流量控制,所以不会出现由于socket接收队列空间不足而丢包的情况。但是,当使用UDP协议时,因为UDP协议不支持流量控制,如果应用程序处理速度不够的时候会出现socket接收缓冲区空间不足而丢包的情况。
在图9和图10中展示的传输环形队列(TX ring)和接收环形队列(RX ring)的大小可以用ethtool查看。在大多数看重吞吐量的负载情况下,增加环形队列的大小和socket缓冲区大小会有一些帮助。增加大小会减少高速收发数据包时由于缓冲区空间不足而造成的异常。

总结

原文写的很好,共勉!