1.摘要
之前在网上看到了这篇描述tcp网络栈原理的文章,感觉不错,决定抽空把这篇文章翻译一下。一来重新温习一下TCP相关知识,二来练练英文。由于原文太长,这里把文章分成上下两部分,分别对应了原理和代码。 很久没翻译文章了难免有误,建议有能力的同学还是看一下原文。
原文地址:
2.概述
我们难以想象没有了TCP/IP之后的网络服务。所有我们开发并在NHN(原文发布的网站)使用的网络服务都基于TCP/IP这个坚实的基础。理解数据如何通过网络传输可以帮助你通过调优、排查或引进新技术之类的手段提升性能。
本文将基于Linux OS和硬件层的数据流和控制流来描述网络栈的整体运行方式。
3.TCP/IP的关键特性
我如何设计一个能在快速传输数据的同时保证数据顺序并且不丢失数据的网络协议?TCP/IP在设计时就基于这个考虑。以下是在了解整体网络栈前需要知道的TCP/IP的主要特性:
TCP和IP
从技术上讲,TCP和IP处于不同的层,应该分别解释它们。但在这里我们把他们看做一个整体。
-
面向连接
首先,传输数据前需要在两个终端之间建立连接(本地和远程)。在这里,“TCP连接标识符(TCP connection identifier)”是两个终端地址的组合,类似
本地ip地址,本地端口号,远程ip地址,远程端口号
的形式。 -
双向字节流
通过字节流实现双向数据通信。
-
顺序投递
接收者在接收数据时与发送者发送的数据顺序相同。因此,数据需要是有序的,为了表示这个顺序,TCP/IP使用了32位的int数据类型。
-
通过ACK实现可靠性
当发送者向接收者发送数据,但没有收到来自接收方的ACK(acknowledgement,应答)时,发送者的TCP层将重发数据。因此,发送者的TCP层会把接收者还没有应答的数据暂存起来。
-
流量控制
发送者的发送速度与接收者的接收能力相关。接收者会把它能接收的最大字节数(未使用的缓冲区大小,又叫接收窗口,receive window)告知发送者。发送者发送的最大字节数与接收者的接收窗口大小一致。
-
阻塞控制
阻塞窗口是不同于接收窗口的另一个概念,它通过限制网络中的数据流的体积来防止网络阻塞。类似于接收窗口,发送者通过通过一些算法(例如TCP Vegas,Westwood,BIC,CUBIC)来计算发送对应的接收者的阻塞窗口能容纳的最多的数据。和流量控制不同,阻塞控制只在发送方实现。(译注:发送者类似于通过ack时间之类的算法判断当前网络是否阻塞,从而调节发送速度)
4.数据传输
网络栈有很多层。图一表示了这些层的类型:
图1:发送数据时网络栈中各层对数据的操作
这些层可以被大致归类到三个区域中:
- 用户区
- 内核区
- 设备区
用户区和内核区的任务由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的发送缓冲区末尾。这样来保证发送的数据有序。在图1中,浅灰色框表示在socket缓冲区中的数据。之后,TCP被调用了。
socket会关联一个叫做TCP控制块(TCP Control Block)的结构,TCB包含了处理TCP连接所需的数据。包括连接状态(LISTEN
,ESTABLISHED
,TIME_WAIT
),接收窗口,阻塞窗口,顺序号,重发计时器,等等。
如果当前的TCP状态允许数据传输,一个新的TCP分段(TCP segment,或者叫数据包,packet)将被创建。如果由于流量控制或者其它原因不能传输数据,系统调用会在这里结束,之后会返回到用户态。(换句话说,控制权会交回到应用程序代码)
TCP分段有两部分
- TCP头
- 携带的数据
图2:TCP帧的结构
TCP数据包的payload部分会包含在socket发送缓冲区里的没有应答的数据。携带数据的最大长度是接收窗口、阻塞窗口和最大分段长度(maximum segment size,MSS)中的最大值。
之后会计算TCP校验值。在计算时,头信息(ip地址、分段长度和端口号)会包含在内。根据TCP状态可发送一个或多个数据包。
事实上,当前的网络栈使用了校验卸载(checksum offload),TCP校验和会由NIC计算,而不是内核。但是,为了解释方便我们还是假设内核计算校验和。
被创建的TCP分段继续走到下面的IP层。IP层向TCP分段中增加了IP头并且执行了IP路由(IP routing)。IP路由是寻找到达目的IP的下一跳IP地址的过程。
在IP层计算并增加了IP头校验和之后,它把数据发送到链路层。链路层通过地址解析协议(Address Resolution Protocol,ARP)搜索下一跳IP地址对应的MAC地址。之后它会向数据包中增加链路头,在增加链路头之后主机要发送的数据包就是完整的了。
在执行IP路由时,会选择一个传输接口(NIC)。接口被用于把数据包传送至下一跳IP。于是,用于发送的NIC驱动程序被调用了。
在这个时候,如果正在执行数据包捕获程序(例如tcpdump或wireshark)的话,内核将把数据包拷贝到这些程序的内存缓冲区中。用相同的方式,接收的数据包直接在驱动被捕获。通常来说,traffic shaper(没懂)函数被实现以在这个层上运行。
驱动程序(与内核)通过请求NIC制造商定义的通讯协议传输数据。
在接收到数据包传输请求之后,NIC把数据包从系统内存中拷贝到它自己的内存中,之后把数据包发送到网络上。在此时,由于要遵守以太网标准(Ethernet standard),NIC会向数据包中增加帧间隙(Inter-Frame Gap,IFG),同步码(preamble)和crc校验和。帧间隙和同步码用于区分数据包的开始(网络术语叫做帧,framing),crc用于保护数据(与TCP或者IP校验和的目的相同)。NIC会基于以太网的物理速度和流量控制决定数据包开始传输的时间。It is like getting the floor and speaking in a conference room.(没看懂)
当NIC发送了数据包,NIC会在主机的CPU上产生中断。所有的中断会有自己的中断号,操作系统会用这个中断号查找合适的程序去处理中断。驱动程序在启动时会注册一个处理中断的函数。操作系统调用中断处理程序,之后中断处理程序会把已发送的数据包返回给操作系统。
到此为止我们讨论了当应用程序执行了write
之后,数据流经内核和设备的过程。但是,除了应用程序直接调用write之外,内核也可以直接调用TCP传输数据包。比如当接收到ACK并且得知接收方的接收窗口增大之后,内核会创建包含socket缓冲区剩余数据的TCP片段并且把数据发送给接收者。
5.数据接收
现在我们看一下数据是如何被接收的。数据接收是网络栈如何处理流入数据包的过程。图3展现了网络栈如何处理接收的数据包。
图3:接收数据时网络栈中各层对数据的操作
首先,NIC把数据包写入它自身的内存。通过CRC校验检查数据包是否有效,之后把数据包发送到主机的内存缓冲区。这里说的缓冲区是驱动程序提前向内核申请好的一块内存区域,用于存放接收的数据包。在缓冲区被系统分配之后,驱动会把这部分内存的地址和大小告知NIC。如果主机没有为驱动程序分配缓冲区,那么当NIC接收到数据包时有可能会直接丢弃它。
在把数据包发送到主机缓冲区之后,NIC会向主机发出中断。
之后,驱动程序会判断它是否能处理新的数据包。到目前为止使用的是由制造商定义的网卡驱动的通讯协议。
当驱动应该向上层发送数据包时,数据包必须被放进一个操作系统能够理解和使用的数据结构。例如Linux的sk_buff
,或者BSD系列内核的mbuf
,或者windows的NET_BUFFER_LIST
。驱动会把数据包包装成指定的数据结构,并发送到上一层。
链路层会检查数据包是否有效并且解析出上层的协议(网络协议)。此时它会判断链路头中的以太网类型。(IPv4的以太网类型是0x0800)。它会把链路头删掉并且把数据包发送到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会增加接收窗口的大小(因为缓冲区有了新空间)。并且会根据协议状态发送数据包。如果没有数据包传送,系统调用结束。
6.网络栈开发方向
到此为止描述的网络栈中的函数都是最基础的函数。在1990年代早期的网络栈函数只比上面描述的多一些。但是最近的随着网络栈的实现层次变高,网络栈增加了很多函数和复杂度。
最新的网络栈可以按以下需求分类:
6.1.操作报文处理过程
它包括类似Netfilter(firewall,NAT)的功能和流量控制。通过在处理流程中增加用户可以控制的代码,可以由用户控制实现不同的功能。
6.2.协议性能
用于改进特定网络环境下的吞吐量、延迟和可靠性。典型例子是增加的流量控制算法和额外的类似SACK的TCP功能。由于已经超出了范围,在这里就不深入讨论协议改进了。
6.3.高效的报文处理
高效的报文处理指的是通过降低处理报文的CPU周期、内存用量和处理报文需要访问内存的次数这些手段,来提升每秒可以处理的报文数量。有很多降低系统延迟的尝试,比如协议栈并行处理(stack parallel processing)、报头预处理(header prediction)、零拷贝(zero-copy)、单次拷贝(single-copy)、校验卸载(checksum offload)、TSO(TCP Segment Offload)、LRO(Large Receive Offload)、RSS(Receive Side Scaling)等。
7.网络栈中的流程控制
现在我们看一下Linux网络栈内部流程的细节。网络栈基于事件驱动的方式运行,在事件产生时做出相应的反应。因此,没有一个独立的线程去运行网络栈。图1和图3展示了最简单的控制流程图。图4说明了更加准确的控制流程。
图4:网络栈中的流程控制
在图4中的Flow(1),应用程序通过系统调用去执行(使用)TCP。例如,调用read
系统调用或者wirte
系统调用并执行TCP。但是,这一步里没有数据包传输。
Flow(2)跟Flow(1)类似,在执行TCP后需要传输报文。它会创建数据包并且把数据包发送给驱动程序。驱动程序上层有一个队列。数据包首先被放入队列,之后队列的数据结构决定何时把数据包发送给驱动程序。这个是Linux的队列规则(queue discipline,qdisc)。Linux的传输管理函数用来管理队列规则。默认的队列规则是简单的先入先出队列。通过使用其它的队列管理规则,可以执行多种操作,例如人造数据丢包、数据包延迟、通信比率限制,等等。在Flow(1)和Flow(2)中,驱动和应用程序处于相同的线程。
Flow(3)展示了TCP使用的定时器超时的情况。比如,当TIME_WAIT定时器超时后,TCP被调用并删除连接。
类似Flow(3),Flow(4)是TCP的定时器超时并且需要发送TCP执行结果数据包的情况。比如,当重传计时器超时后,会发送“没有收到ACK”数据包。
Flow(3)和Flow(4)展示了定时器软中断的处理过程。
当NIC驱动收到中断时,它将释放已经传输的数据包。在大多数情况下,驱动的执行过程到这里就结束了。Flow(5)是数据包积累在传输队列里的情况。驱动请求软中断,之后软中断的处理程序把传输队列里堆积的数据包发送给驱动程序。
当NIC驱动程序接收到中断并且发现有新接收的数据包时,它会请求软中断。软中断会调用驱动程序处理接收到的数据包并且把他们传送到上一层。在Linux中,上面描述的处理接收到的数据包的过程叫做New API(NAPI)。它和轮询类似,因为驱动并不直接把数据包传送到上一层,而是上层直接来取数据。实际代码中叫做NAPI poll 或 poll。
Flow(6)展示了TCP执行完成,Flow(7)展示了需要更多数据包传输的情况。Flow(5、6、7)的NIC中断都是通过软中断执行的。
8.如何处理中断和接收数据包
中断处理过程是复杂的,但是你需要了解数据包接收和处理流程中的和性能相关的问题。图5展示了一次中断的处理流程。
图5:处理中断、软中断和接收数据
假设CPU0正在执行应用程序。在此时,NIC接收到了一个数据包并且在CPU0上产生了中断。CPU0执行了内核中断处理程序(irq)。这个处理程序关联了一个中断号并且内核会调用驱动里对应的中断处理程序。驱动在释放已经传输的数据包之后调用napi_schedule()
函数去处理接收到的数据包,这个函数会请求软中断。
在执行了驱动的中断处理程序后,控制权被转移到内核中断处理程序。内核中的处理程序会执行软中断的处理程序。
在中断上下文被执行之后,软中断的上下文会被执行。中断上下文和软中断上下文会通过相同的线程执行,但是它们会使用不同的栈。并且中断上下文会屏蔽硬件中断;而软中断上下文不会屏蔽硬件中断。
处理接收到的数据包的软中断处理程序是net_rx_action()
函数。这个函数会调用驱动程序的poll()
函数。而poll()
函数会调用netif_receive_skb()
函数,并把接收到的数据包一个接一个的发送到上层。在软中断处理结束后,应用程序会从停止的位置重新开始执行。(After processing the softirq, the application restarts execution from the stopped point in order to request a system call.没太明白这一句的system call是啥意思)
因此,接收中断请求的CPU会负责处理接收数据包从始至终的整个过程。在Linux、BSD和Windows中,处理过程基本是类似的。
当你检查服务器CPU利用率时,有时你可以检查服务器的很多CPU中是否只有一个CPU在艰难执行软中断。这个现象产生的原因就是上文所解释的数据包接收的处理过程。为了解决这个问题开发出了多队列NIC(multi-queue NIC)、RSS和RPS。