目录:
- TCP是什么
- TCP报文结构
- TCP连接过程
- TCP状态转移
- TCP流量控制 —— 滑动窗口
- TCP拥塞控制
- TCP可靠传输是怎么做到的
- TCP一些有趣的问题 —— 粘包/拆包等
为什么我们需要了解TCP
以前我也认为TCP是相当底层的东西,我永远不需要去了解它。虽然差不多是这样,但是实际生活中,你依然可能遇见和TCP算法相关的bug,这时候懂一些TCP的知识就至关重要了。(本文也可以引申为,系统调用,操作系统这些都很重要,这个道理适用于很多东西)
这里推荐一篇小短文,人人都应该懂点TCP
TCP是什么
TCP —— Transmission Control Protocol 传输控制协议
TCP通信基本流程
使用TCP协议通信的双方必须先建立TCP连接,并在内核中为该连接维持一些必要的数据结构,比如连接的状态、读写缓冲区、定时器等。当通信结束时,双方必须关闭连接以释放这些内核数据。TCP服务基于流,源源不断从一端流向另一端,发送端可以逐字节写入,接收端可以逐字节读出,无需分段。
特点
- 位于传输层,基本传输结构是TCP报文段(TCP message segment)
- 面向连接 一对一,端对端,进程与进程之间通信,不适用于广播、多播程序
- 可靠传输 —— 发送应答/超时重传/报文排序/流量控制/拥塞处理
- 端对端 进程和进程之间 端口和端口之间
- 全双工通信
- 基于字节流服务 数据发送和接收没有边界限制和分段
这里解释下字节流的概念:
TCP是基于字节流服务,而UDP则是基于数据报服务。对应到实际的编程中表
现为通信双方是否必须执行相同次数的读写操作。当发送端连续执行多次写操作时,TCP模块会先把这些
数据放入`发送缓冲区`。当真正开始发送数据的时候,发送缓冲区中的数据可能被封装成一个或者多个TCP
报文段发出。TCP报文段的个数和写操作次数没有关系。
当接收端收到一个或者多个TCP报文段后,TCP模块讲它们携带的数据放入TCP`接收缓冲区`中,并通知
应用程序读取数据。可以一次性全部读出,也可以分多次读出。接收到的报文个数和读次数也没有关系。
综上,这就是字节流的概念:应用程序对数据的发送和接收没有边界限制。
相对的,UDP则是应用程序没执行一次写操作,UDP模块就将其封装成一个UDP数据报并发送之,接收端
每收到一个UDP数据报就必须进行一次读操作,否则会丢包。
TCP报文结构
TCP首部结构
需要注意的几点:
- tcp的包没有ip地址 那是ip层的事,但是有源端口和目标端口
- 一个tcp连接用一个四元组来表示(src_ip, src_port,dst_ip,dst_port)ps:准确说是五元组,加上一个协议
- 几个名词
- 固定长度为20字节,包含变长部分,最大为60字节。
- Sequence Number 即Seq,包的序号,用于解决包传输过程中的乱序问题
- Acknowledgement Number 即Ack,确认号,用于确认包收到,用于解决丢包问题
- Synchronize Sequence Numbers 即SYN 同步序号 用于包同步
- Window 窗口,即著名的滑动窗口(Silding Window),用于流量拥塞控制
- TCP Flag RST/SYN/FIN等,即包的类型,用于操控tcp的各种状态
具体分析:
- 端口号:16位。 一般服务端会使用知名端口号,而客户端一般使用系统自动选择的临时端口号。所有知名服务所使用的端口号都定义在
/etc/services
文件中[1]。 - Seq序号:32位。一次TCP通信过程中王某一个传输方向上的字节流中每个字节的编号。第一个报文段Seq会被初始化为ISN(Initial Sequence Number初始序号),这是一个随机值,后续的报文段中序号值将为ISN+报文第一个字节的偏移值。
eg.
某个tcp报文传输的是字节流中的1025~2048字节,Seq将为ISN+1025
- Ack序号:32位。用来对另一方发来的TCP报文进行响应。值为seq+1
- Offset:4位,标识TCP头部的长度,有多少字(32-bit words)因为是4位,TCP头部最大为60字节。(4位最大15,15*4=60)
- 六个标志位:TCP Flags.
- URG 表示紧急指针(urgent pointer)是否有效
- ACK 表示确认号是否有效。带ACK标志的TCP报文段叫
确认报文段
- PSH 提示接收端应该立即从TCP接收缓冲区中读走数据,为后续数据腾出缓冲区空间。
- RST 复位标识,表示要求对方重建连接。带RST标志的TCP报文段叫
复位报文段
,一般用于异常终止连接,一旦发送了RST报文段,发送端所有排队等待的数据都会被丢弃。- SYN 表示请求建立连接。带SYN标志的TCP报文段叫
同步报文段
- FIN 表示通知对方本端要关闭连接了。带FIN标志的TCP报文段叫
结束报文段
- 窗口window:TCP流量控制的一个手段。指接收窗口的大小。它告诉对方本端的TCP接收缓冲区还能容纳多少字节数据,这样可以让对端控制发送数据的速度。
- checksum:16位校验和。由发送端填充,接收端对这个字段用CRC校验,校验TCP报文在传输过程中是否损坏(头部和数据部分都会被校验)。这是TCP可靠传输的重要保障。
- urgent pointer:紧急指针,用于发送端向接收端发送紧急数据。
Options: TCP头部最后一个选项字段options是一个变长的可选字段,最多包含40byte,这也是TCP头部最长为60字节的原因。
options包含的字段非常多,这里仅选取比较重要的几个字段讲一下。
options的第一个字段kind表示选项的类型。其中,kind=2是最大报文长度选项,简称MSS。传输层每次传输数据有个最大限制MTU(Maximum Transmission Unit)。而TCP模块通常会将MSS设置为(MTU-40)字节,减掉的这40byte = 20 byte的TCP Header + 20 byte的IP Header
(一般情况下TCP和IP 头部都不包含选项字段),从而保证携带着TCP报文的IP数据报不会超过MTU,避免发生IP分片。
TCP连接过程
一般而言,TCP连接由客户端发起,并通过三次握手建立连接(特殊情况是所谓同时打开)。
TCP关闭连接的时候,则可能是客户端发起,也可能是服务器主动发起(也可能是同时关闭,和同时打开一样,比较少见)。
tcp连接流程图
经典的“三次握手”和“四次挥手”问题
- 为什么要三次握手?
对于建链接的3次握手,主要是要初始化Sequence Number 的初始值。通信的双方要互相通知对方自己的 初始化的Sequence Number(缩写为ISN:Inital Sequence Number)——所以叫SYN,全称Synchronize Sequence Numbers。也就上图中的 x 和 y。这个号要作为以后的数据通信的序号,以保证应用层接收到的数据不会因为网络上的传输的问题而乱序(TCP会用这个序号来拼接数据)。
- 为什么要四次挥手?
对于4次挥手,其实你仔细看是2次,因为TCP是全双工的,所以,发送方和接收方都需要Fin和Ack。只不过,有一方是被动的,所以看上去就成了所谓的4次挥手。如果两边同时断连接,那就会就进入到CLOSING状态,然后到达TIME_WAIT状态。
- 为什么是三次握手?为什么不是两次?
第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。
客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最终还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,因此就不会再次打开连接。
连接中的特殊状态
- 半关闭状态
TCP作为全双工连接,允许双向的数据传输各自独立的被关闭互不影响。通俗的说就是,一端可以发送结束报文段FIN给对方,告诉它本端已经发送完,但是还可以继续接收来自对方的数据。此时这种单方向关闭的状态称之为半关闭状态
TCP状态转移
上半部分是TCP三路握手过程的状态变迁,下半部分是TCP四次挥手过程的状态变迁。
TCP状态(11种):
eg.
CLOSED 初始状态,表示TCP连接是“关闭着的”或“未打开的”。
LISTEN 表示服务器端的某个SOCKET处于监听状态,可以接受客户端的连接。
SYN_RECVD 表示服务器接收到了来自客户端请求连接的SYN报文。在正常情况下,这个状态是服务 器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂,基本上用netstat很难看到这种状态,除非故意写一个监测程序,将三次TCP握手过程中最后一个ACK报文不予发送。当TCP连接处于此状态时,再收到客户端的ACK报文,它就会进入到ESTABLISHED 状态。
SYN_SENT 这个状态与SYN_RCVD 状态相呼应,当客户端SOCKET执行connect()进行连接时,它首先发送SYN报文,然后随即进入到SYN_SENT 状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT 状态表示客户端已发送SYN报文。
ESTABLISHED 表示TCP连接已成功建立。
以上为TCP三次握手的状态变迁
以下为TCP四次挥手的状态变迁
FIN_WAIT_1 其实FIN_WAIT_1 和FIN_WAIT_2 两种状态的真正含义都是表示等待对方的FIN报文。而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET进入到FIN_WAIT_1 状态。而当对方回应ACK报文后,则进入到FIN_WAIT_2 状态。当然在实际的正常情况下,无论对方处于任何种情况下,都应该马上回应ACK报文,所以FIN_WAIT_1 状态一般是比较难见到的,而FIN_WAIT_2 状态有时仍可以用netstat看到。
FIN_WAIT_2 上面已经解释了这种状态的由来,实际上FIN_WAIT_2状态下的SOCKET表示半连接,即有一方调用close()主动要求关闭连接。注意:FIN_WAIT_2 是没有超时的(不像TIME_WAIT 状态),这种状态下如果对方不关闭(不配合完成4次挥手过程),那这个 FIN_WAIT_2 状态将一直保持到系统重启,越来越多的FIN_WAIT_2 状态会导致内核crash。
TIME_WAIT 表示收到了对方的FIN报文,并发送出了ACK报文。 TIME_WAIT状态下的TCP连接会等待2*MSL(Max Segment Lifetime,最大分段生存期,指一个TCP报文在Internet上的最长生存时间。每个具体的TCP协议实现都必须选择一个确定的MSL值,RFC 1122建议是2分钟,但BSD传统实现采用了30秒,Linux可以cat /proc/sys/net/ipv4/tcp_fin_timeout
看到本机的这个值),然后即可回到CLOSED 可用状态了。如果FIN_WAIT_1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。(这种情况应该就是四次挥手变成三次挥手的那种情况)
CLOSING 这种状态在实际情况中应该很少见,属于一种比较罕见的例外状态。正常情况下,当一方发送FIN报文后,按理来说是应该先收到(或同时收到)对方的ACK报文,再收到对方的FIN报文。但是CLOSING 状态表示一方发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?那就是当双方几乎在同时close()一个SOCKET的话,就出现了双方同时发送FIN报文的情况,这是就会出现CLOSING 状态,表示双方都正在关闭SOCKET连接。
CLOSE_WAIT 表示正在等待关闭。怎么理解呢?当对方close()一个SOCKET后发送FIN报文给自己,你的系统毫无疑问地将会回应一个ACK报文给对方,此时TCP连接则进入到CLOSE_WAIT状态。接下来呢,你需要检查自己是否还有数据要发送给对方,如果没有的话,那你也就可以close()这个SOCKET并发送FIN报文给对方,即关闭自己到对方这个方向的连接。有数据的话则看程序的策略,继续发送或丢弃。简单地说,当你处于CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。
LAST_ACK 当被动关闭的一方在发送FIN报文后,等待对方的ACK报文的时候,就处于LAST_ACK 状态。当收到对方的ACK报文后,也就可以进入到CLOSED 可用状态了。
1. 服务端状态转移过程
服务器通过listen
系统调用进入LISTEN状态,被动等待客户端连接,也就是所谓的被动打开。一旦监听到SYN(同步报文段)请求,就将该连接放入内核的等待队列,并向客户端发送带SYN的ACK(确认报文段),此时该连接处于SYN_RECVD状态。如果服务器收到客户端返回的ACK,则转到ESTABLISHED状态。这个状态就是连接双方能进行全双工数据传输的状态。
而当客户端主动关闭连接时,服务器收到FIN报文,通过返回ACK使连接进入CLOSE_WAIT状态。此状态表示——等待服务器应用程序关闭连接。通常,服务器检测到客户端关闭连接之后,也会立即给客户端发送一个FIN来关闭连接,使连接转移到LAST_ACK状态,等待客户端对最后一个FIN结束报文段的最后一次确认,一旦确认完成,连接就彻底关闭了。
2. 客户端状态转移过程
客户端通过connect
系统调用主动与服务器建立连接。此系统调用会首先给服务器发一个SYN,使连接进入SYN_SENT状态。
connect
调用可能因为两种原因失败:1. 目标端口不存在(未被任何进程监听)护着该端口被TIME_WAIT状态的连接占用(详见后文)。2. 连接超时,在超时时间内未收到服务器的ACK。
如果connect
调用失败,则连接返回初始的CLOSED状态,如果调用成功,则转到ESTABLISHED状态。
客户端执行主动关闭时,它会向服务器发送一个FIN,连接进入TIME_WAIT_1状态,如果收到服务器的ACK,进入TIME_WAIT_2状态。此时服务器处于CLOSE_WAIT状态,这一对状态是可能发生办关闭的状态(详见后文)。此时如果服务器发送FIN关闭连接,则客户端会发送ACK进行确认并进入TIME_WAIT状态。
TIME_WAIT状态存在的意义,为什么不是直接_CLOSED?
客户端收到服务器的FIN报文之后,并不直接进入CLOSED状态,而是TIME_WAIT状态。客户端会在此状态等等2MSL的时长之后,才会彻底关闭。(MSL是Maximum Segment Life,报文段最大生存时间,一般为2分钟。
)
TIME_WAIT状态存在的原因有两点:
- 可靠的终止TCP连接
如果 B 没收到 A 发送来的确认报文,那么就会重新发送连接释放请求报文,A 等待一段时间就是为了处理这种情况的发生- 保证让迟到的TCP报文段有足够的时间被识别并丢弃
在Linux系统中,一个TCP端口不能被同时打开多次。当一个TCP连接处于TIME_WAIT状态时,我们无法使用此接口来建立新连接。如果不存在此状态,则可以建立一个和刚关闭的连接具有相同IP和端口的连接,也就是原来连接的化身。此化身可以收到属于原来连接的在网络中滞留迟到的报文段,这显然不应该存在,故需要TIME_WAIT状态。
另外,因为MSL是一个TCP报文的最大生存时间,所以2MSL的时间可以保证双向的数据都发送完毕,迟到的报文都已消失(被中转路由器丢弃)。所以2MSL时间之后新的连接可以绝对安全的建立,这就是TIME_WAIT状态需要持续2MSL的原因。
但是,有时候我们希望避免TIME_WAIT状态,因为当程序退出后,我们希望能立即重启它,因为此状态的存在,我们是无法立即重启的。
对于客户端而言,我们一般不需要担心此问题。因为TCP连接中,客户端通常使用的是系统自动分配的临时端口号来建立连接,这个端口号是随机的,所以一般不会和上次的重复。
但是对于服务端而言,如果是服务器主动关闭连接然后异常终止,因为服务器提供服务的总是同一个知名端口号,则会出现不能立即重启的情况。我们可以通过socket选项的SO_REUSEADDR
来强制进程立即使用处于TIME_WAIT状态的连接占用的端口,这涉及到Linux网络编程,这里暂不讨论。
TCP流量控制 —— 滑动窗口(Sliding Window)
流量控制是为了控制发送方发送速率,保证接收方来得及接收。
接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。
发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。
接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。
TCP拥塞控制
如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。
TCP 主要通过四种算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。
在Linux下有多种实现,比如reno算法,vegas算法和cubic算法等。
发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。
为了便于讨论,做如下假设:
- 接收方有足够大的接收缓存,因此不会发生流量控制;
-
虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。
1. 慢开始与拥塞避免
发送的最初执行慢开始,令 cwnd=1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 ...
注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。
如果出现了超时,则令 ssthresh = cwnd/2,然后重新执行慢开始。
2. 快重传与快恢复
在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。
在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3。
在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd/2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。
慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。
TCP的可靠传输是怎么做到的
1. 发送应答
发送端的每个TCP报文都必须得到接收方的应答,才算传输成功。
2. 超时重传
TCP为每个TCP报文段都维护一个重传定时器。
发送端在发出一个TCP报文段之后就启动定时器,如果在定时时间类未收到应答,它就将重发该报文段并重置定时器。
3. 报文重排
因为TCP报文段最终在网络层是以IP数据报的形式发送,而IP数据报到达接收端可能是乱序或者重复的。TCP协议会对收到的TCP报文进行重排、整理,确保顺序正确。
TCP的数据流
TCP报文段所携带的应用程序数据按照长度分为两种:交互数据 和 成块数据
- 交互数据 用于进行信息交互,仅包含很少的字节,使用交互数据的程序或协议对实时性要求很高,比如telnet、ssh等
- 成块数据 用于大量数据传输,长度通常为TCP报文段所允许的最大数据长度,使用成块数据的应用程序对传输效率要求高,比如ftp等。
TCP一些有趣的问题 —— 粘包/拆包等
什么是粘包拆包?
对于什么是粘包、拆包问题,我想先举两个简单的应用场景:
- 客户端和服务器建立一个连接,客户端发送一条消息,客户端关闭与服务端的连接。
- 客户端和服务器简历一个连接,客户端连续发送两条消息,客户端关闭与服务端的连接。
对于第一种情况,服务端的处理流程可以是这样的:当客户端与服务端的连接建立成功之后,服务端不断读取客户端发送过来的数据,当客户端与服务端连接断开之后,服务端知道已经读完了一条消息,然后进行解码和后续处理...。对于第二种情况,如果按照上面相同的处理逻辑来处理,那就有问题了,我们来看看第二种情况下客户端发送的两条消息递交到服务端有可能出现的情况:
第一种情况:
服务端一共读到两个数据包,第一个包包含客户端发出的第一条消息的完整信息,第二个包包含客户端发出的第二条消息,那这种情况比较好处理,服务器只需要简单的从网络缓冲区去读就好了,第一次读到第一条消息的完整信息,消费完再从网络缓冲区将第二条完整消息读出来消费。
第二种情况:
服务端一共就读到一个数据包,这个数据包包含客户端发出的两条消息的完整信息,这个时候基于之前逻辑实现的服务端就蒙了,因为服务端不知道第一条消息从哪儿结束和第二条消息从哪儿开始,这种情况其实是发生了TCP粘包。
第三种情况:
服务端一共收到了两个数据包,第一个数据包只包含了第一条消息的一部分,第一条消息的后半部分和第二条消息都在第二个数据包中,或者是第一个数据包包含了第一条消息的完整信息和第二条消息的一部分信息,第二个数据包包含了第二条消息的剩下部分,这种情况其实是发送了TCP拆,因为发生了一条消息被拆分在两个包里面发送了,同样上面的服务器逻辑对于这种情况是不好处理的。
产生tcp粘包和拆包的原因
我们知道tcp是以流动的方式传输数据,传输的最小单位为一个报文段(segment)。tcp Header中有个Options标识位,常见的标识为mss(Maximum Segment Size)指的是,连接层每次传输的数据有个最大限制MTU(Maximum Transmission Unit),一般是1500比特,超过这个量要分成多个报文段,mss则是这个最大限制减去TCP的header,光是要传输的数据的大小,一般为1460比特。换算成字节,也就是180多字节。
tcp为提高性能,发送端会将需要发送的数据发送到缓冲区,等待缓冲区满了之后,再将缓冲中的数据发送到接收方。同理,接收方也有缓冲区这样的机制,来接收数据。
发生TCP粘包、拆包主要是由于下面一些原因:
- 应用程序写入的数据大于套接字缓冲区大小,这将会发生拆包。
- 应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包。
- 进行mss(最大报文长度)大小的TCP分段,当TCP报文长度-TCP头部长度>mss的时候将发生拆包。
- 接收方法不及时读取套接字缓冲区数据,这将发生粘包。
……
如何解决拆包粘包
既然知道了tcp是无界的数据流,且协议本身无法避免粘包,拆包的发生,那我们只能在应用层数据协议上,加以控制。通常在制定传输数据时,可以使用如下方法:
- 使用带消息头的协议、消息头存储消息开始标识及消息长度信息,服务端获取消息头的时候解析出消息长度,然后向后读取该长度的内容。
- 设置定长消息,服务端每次读取既定长度的内容作为一条完整消息。
- 设置消息边界,服务端从网络流中按消息编辑分离出消息内容。
总结
写了一个简单的golang
版的tcp服务器实例,仅供参考:
例子
参考和推荐阅读书目:
- 《TCP/IP协议详解:卷一》
- 《Linux高性能服务器编程》
- 《图解TCP/IP》
注释:
eg.
- ↩