TCP 协议基础

描述一些 TCP 基本特性

TCP 简介

TCP(Transmission Control Protocol 传输控制协议)工作在 IP 和以太网的上层。

最底层的以太网协议(Ethernet)规定了电子信号如何组成数据包(packet),解决了子网内部的点对点通信问题

IP 解决多个局域网如何互通

TCP 解决了可靠传输数据的问题

TCP 报文

TCP 是在以太网之上的网络协议,它的数据包大小由以太网决定

IP 数据包在以太网数据包里面,TCP 数据包在 IP 数据包里面

以太网数据包(packet)的大小是固定的,最初是1518字节,后来增加到1522字节。其中 1500 字节是负载(payload),22字节是头信息(head)。

TCP 数据包在 IP 数据包的负载里面。它的头信息最少也需要20字节,因此 TCP 数据包的最大负载是 1480 - 20 = 1460 字节。

由于 IP 和 TCP 协议往往有额外的头信息,所以 TCP 负载实际为 1400 字节左右。因此,一条 1500 字节的信息需要两个 TCP 数据包。

TCP 头格式

TCP 头

TCP 头至少有 20 字节

  • 源端口 2 字节,目标端口 2 字节
  • 4 字节Seq Number,用来解决网络包乱序问题
  • 4 字节Ack Number,用来解决丢包的问题
  • 4 bit Offset,表示 TCP 包头的长度
  • 4 bit Reserved
  • 1 字节 TCP 标志,用于控制
  • 2 字节 Window,滑动窗口,用于流量控制
  • 2 字节 Checksum
  • 2 字节 Urgent Pointer,用来标识哪部分数据是紧急数据

TCP 控制标志
1字节的 TCP Flags 的 8 个位都是有意义的

标志位 作用
C
E
U URG,标识紧急指针 Urgent Pointer 是否有效
A ACK,标识确认序号是否有效
P PSH,用来提示接收端应用程序立刻将数据从 TCP 缓冲区读走
R RST,要求重新建立连接。含有 RST 标识的报文称为复位报文段
S SYN,请求建立连接。含有 SYN 标识的报文称为同步报文段
F FIN,通知对端, 本端即将关闭。含有 FIN 标识的报文称为结束报文段

连接管理

TCP 状态转换

网络上的传输是没有连接的,包括 TCP 也是一样的。而 TCP 所谓的“连接”,其实只不过是在通讯的双方维护一个“连接状态”,让它看上去好像有连接一样。所以,TCP 的状态变换是非常重要的,下图是 TCP 协议的有限状态机

TCP 状态机

说明:

  • CLOSED:初始状态,表示 TCP 连接是“关闭着的”或“未打开的”。

  • LISTEN :表示服务器端的某个 SOCKET 处于监听状态,可以接受客户端的连接。

  • SYN_RCVD :表示服务器接收到了来自客户端请求连接的SYN报文。
    在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂,基本上用 netstat 很难看到这种状态,除非故意写一个监测程序,将三次TCP握手过程中最后一个 ACK 报文不予发送。当 TCP 连接处于此状态时,再收到客户端的 ACK 报文,它就会进入到 ESTABLISHED 状态。

  • SYN_SENT :这个状态与SYN_RCVD 状态相呼应,当客户端 SOCKET 执行 connect() 进行连接时,它首先发送SYN报文,然后随即进入到 SYN_SENT 状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT 状态表示客户端已发送 SYN 报文

  • ESTABLISHED :表示 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 :表示 SOCKET 处于半连接状态,即有调用 close() 主动要求关闭连接。
    注意:FIN_WAIT_2 是没有超时的(不像TIME_WAIT 状态),这种状态下如果对方不关闭(不配合完成4次挥手过程),那这个 FIN_WAIT_2 状态将一直保持到系统重启,越来越多的 FIN_WAIT_2 状态会导致内核 crash。

  • 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 可用状态了。

建立连接的 3 次握手

指建立一个 TCP 连接时,需要客户端和服务端总共发送 3 个包以确认连接的建立,在 socket 编程中,这一过程由客户端执行 connect 来触发,整个流程如下图:

TCP 3次握手

步骤1:服务端打开侦听端口
步骤2:客户端向服务端发送一个TCP数据包,SYN标志位为1,并确认了Seq为J
步骤3:服务端发送一个包,ACK和SYN标志位为1,Seq为K,Ack为J+1,表示收到了客户端Seq为J的包
步骤4:客户端发送一个包,ACK标志为1,Ack为K+1,表示收到了服务端的Seq为K的包

至此连接建立完成

说明:

  1. ISN
    通信的双方要互相通知对方自己的初始化的 Sequence Number(缩写为ISN:Inital Sequence Number),这个号是个随机数,要作为以后的数据通信的序号,以保证应用层接收到的数据不会因为网络上的传输的问题而乱序(上图中的 K 和 J)

    ISN 不能 hard code 的,不然会出问题的——比如:如果连接建好后始终用 1 来做 ISN,如果 client 发了30个 segment 过去,但是网络断了,于是 client 重连,又用了 1 做 ISN,但是之前连接的那些包到了,于是就被当成了新连接的包。此时,client 的 Sequence Number 可能是 3,而 Server 端认为 client 端的这个号是 30 了,全乱了。

    RFC793 中说,ISN 会和一个假的时钟绑在一起,这个时钟会在每 4 微秒对 ISN 做加一操作,直到超过 2^32,又从 0 开始。这样,一个 ISN 的周期大约是 4.55 个小时。因为,我们假设我们的 TCP Segment 在网络上的存活时间不会超过 Maximum Segment Lifetime(缩写为MSL – Wikipedia语条),所以,只要 MSL 的值小于 4.55 小时,那么就不会重用到 ISN

  2. 为什么不用两次?
    要是为了防止已经失效的连接请求报文突然又传送到了服务器,从而产生错误。

    假设是两次握手建立连接,会有这样一种场景:客户端发送的第一个请求连接并且没有丢失,只是因为在网络中滞留的时间太长了,由于客户端迟迟没有收到确认报文,以为服务器没有收到,此时重新向服务器发送这条报文,此后客户端和服务器经过两次握手完成连接,传输数据,然后关闭连接。此时之前滞留的那一次请求连接,因为网络通畅了, 到达了服务器,这个报文本该是失效的,但是,两次握手的机制将会让客户端和服务器再次建立连接,这将导致不必要的错误和资源的浪费。

    问题的本质是信道不可靠, 但是通信双方需要就某个问题达成一致。而要解决这个问题, 无论你在消息中包含什么信息, 三次通信是理论上的最小值。所以三次握手不是TCP本身的要求, 而是为了满足“在不可靠信道上可靠地传输信息”这一需求所导致的。本质需求是,信道不可靠, 数据传输要可靠。因此,如果信道是可靠的,即无论什么时候发出消息,对方一定能收到, 或者不关心是否要保证对方收到你的消息,那就能像UDP那样直接发送消息就可以了。

  3. 为什么不用四次
    因为三次已经可以满足需要了, 四次就多余了

  4. 建连接时 SYN 超时
    如果服务端接到了客户端发的 SYN 后回了 SYN-ACK 后,客户端掉线了,服务端没有收到客户端回来的 ACK,那么,这个连接处于一个中间状态,既没成功,也没失败。于是,服务端如果在一定时间内没有收到的 TCP 会重发 SYN-ACK。

    在 Linux 下,默认重试次数为 5 次,重试的间隔时间从1s开始每次都翻倍,5 次的重试时间间隔为1s, 2s, 4s, 8s, 16s,总共 31s,第 5 次发出后还要等 32s 才知道第 5 次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 2^6 -1 = 63s,TCP 才会把断开这个连接。

关闭连接的 4 次挥手

指断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开,整个流程如下图所示:

TCP 4次挥手

注:图中的 Client 和 Server 的说法其实不正确,正确的应该是主动关闭方和被动关闭方
步骤1:主动关闭方发送 1 个 TCP 包,FIN 标志位为 1,Seq M
步骤2:被动关闭方发送 ACK,Ack M+1
此时被动关闭方应通知高层的应用进程,另外被动关闭方还是可以发送数据到主动关闭方
步骤3:被动关闭方准备关闭后,发送 FIN 包,Seq N
步骤4:主动关闭方发送ACK,Ack N + 1,并进入 TIME_WAIT 状态

被动关闭方收到 Ack N + 1后,进入 CLOSED 状态,连接关闭;主动关闭方等待 2*MSL(Maximum Segment Lifetime)后,进入CLOSED状态,连接关闭。

说明:

  1. 主动,被动关闭
    由于 TCP 连接是全双工的,因此每个方向都必须要单独进行关闭。当一方完成数据发送任务后,发送一个 FIN 来表示这个方向不再有数据发送了,但仍然能够接收数据(这种状态叫做半连接状态),直到另一方也发送 FIN。

    关闭可以由客户端或服务端发起,首先发起的一方是主动关闭,另一方是被动关闭

  2. 完全关闭以及半关闭
    应用可以关闭 TCP 输入和输出信道中的任一个,或者将两个都关闭。调用 close() 可以将输入和输出信道都关闭,这被称作“完全关闭”,用 shutdown() 单独关闭输入或输出信道,这被称作“半关闭”。

  3. TCP 关闭及重置错误
    如果一端向一个已关闭的输入信道发送数据,操作系统就会回送一条 TCP “连接被对端重置”

  4. 为什么要TIME_WAIT状态,而不是直接关闭
    原因一:TIME_WAIT确保有足够的时间让对端收到了ACK,如果被动关闭的那方没有收到Ack,就会触发被动端重发FIN,一来一去正好2个MSL。而主动方能在这个时间段内收到重传的报文,并且可以重启2MSL计时器。

    原因二:防止类似与“三次握手”中提到了的“已经失效的连接请求报文段”出现在本连接中。送完最后一个确认报文后,在这个2MSL时间中,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样新的连接中不会出现旧连接的请求报文。

数据发送

TCP 数据包的发送和接收由操作系统完成,操作系统不会去处理 TCP 数据包里面的数据。它将原始数据打包成 TCP 数据包发送出去,收接 TCP 数据包并组装好发给应用程序。

应用程序不需要关心如何发送和接收 TCP 数据包,它应该将这些数据看作字节流,根据应用层协议进行解析

确认应答机制

连接建立越来后,就可以使用该连接发送数据。发送的时候,TCP 协议为每个包编号(sequence number,简称 SEQ),以便接收的一方按照顺序还原。万一发生丢包,也可以知道丢失的是哪一个包。

第一个包(其实就是 SYN 包)的编号是一个随机数。为了便于理解,这里就把它称为 1 号包。假定这个包的负载长度是 100 字节,那么下一个包的编号应该是 101,接收方就知道应该按什么顺序还原数据。

接收方收到TCP 数据包,要发送一个确认消息(ACK包)
ACK 包携带两个信息: 1. 期待要收到下一个数据包的编号;2. 接收方的接收窗口的剩余容量

例如,发送方发送了 1 号,接收方就要发送一个 ACK,ACK 编号是 2。

滑动窗口

数据发送如果是发一个包确认一个包,再发下一个包,性能会比较低。所以一次发送多个数据包,从而提升性能。

滑动窗口就是确定一次发多少个数据包的技术。在 TCP 头有一个 Window 字段,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。发送端就根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。

TCP 缓冲区如下图

TCP-缓冲区

  1. 左边是发送端程序,包含3个指针
    LastByteAcked:指向了被接收端Ack过的位置(表示成功发送确认)
    LastByteSent:表示发出去了,但还没有收到成功确认的Ack
    LastByteWritten:指向的是上层应用程序正在写TCP缓冲区的位置

  2. 右边是接收端程序,同样包含3个指针
    LastByteRead:指向了上层应用程序从TCP缓冲区中读到的位置
    NextByteExpected:指向收到的连续包的最后一个位置
    LastByteRcved:指向的是收到的包的最后一个位置

接收端在给发送端回 ACK 中 会汇报自己的 Window = MaxRcvBuffer – LastByteRcvd – 1,而发送方会根据这个窗口来控制发送数据的大小,以保证接收方可以处理

发送端在发送数据时,先发前 N 个包(窗口),不需要等待 ACK,直接发送
收到第一个ACK后,窗口发后移动,继续发送后面的数据包。
因为窗口不断向后滑动, 所以叫做滑动窗口

滑动窗口的动画示例

示例

  1. 打开 Wireshark 并捕捉 HTTP 数据包
  2. 用 curl http://www.cnblogs.com/qingergege/p/6603488.html 打开网站,得到下列数据包

示例

192.168.1.108 是客户端
101.37.225.65 是服务端

数据包 3,4,5 就是 TCP 3次握手的过程
数据包 19,20,21,22 就是 TCP 4 次挥手的过程
中间部分就是数据传输过程

TCP 机制

连接队列(backlog)

内核有两个队列保存 TCP 连接

  • 一个是半连接状态的,这个队列中的是已经发送了 SYN 包,等待 ACK 的连接
  • 一个是已连接状态的,这个队列中的是已经握手完成的连接,accept() 从这个队列返回连接

超时重传机制

主机 A 发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机 B 。如果主机 A 在一个特定时间间隔内没有收到 B 发来的 ACK,就会进行重发。TCP 有多种算法来进行超时重传。

延迟确认

如果对每个 TCP 包都单独发送一个 ACK 包,代价比较高。所以 TCP 会延迟一段时间,如果这段时间内有数据发送到对端,则在数据包中捎带发送 ACK 信息。如果在延迟 ACK 定时器触发时候,发现 ACK 尚未发送,则立即单独发送。

慢启动

服务器发送数据包,当然越快越好,最好一次性全发出去。但是,发得太快,就有可能丢包。线路不好的话,发得越快,丢得越多。解决方法是确定一个理想速率,需要慢慢试。

开始的时候,发送得较慢,然后根据丢包的情况,调整速率:如果不丢包,就加快发送速度;如果丢包,就降低发送速度。

所以已经使用了一段时间的 TCP 连接的速度要比刚开始的连接快

Nagle算法

TCP/IP 协议中,发送端一次发多少数据,是由窗口大小决定的。如果窗口很小,很可能一个数据包发送的数据很少,而协议头却占了大部分,从而造成浪费(Silly Windows Syndrome)。Nagle 算法就是为了解决这个问题。Nagle 算法只允许一个未被 ACK 的包存在于网络,在该包的 ACK 未到达之前或 Window Size>=MSS 前不发送数据,而是在攒数据,等攒到的数据足够多才发送。

例如,client 端调用 socket 的 write 操作将一个 int 型数据(称为 A 块)写入到网络中,由于此时连接是空闲的(也就是说还没有未被确认的小段),因此这个 int 型数据会被马上发送到 server 端,接着 client 端又调用 write操作写入‘\r\n’(简称 B 块),这个时候,A 块的 ACK 没有返回,所以可以认为已经存在了一个未被确认的小段,所以 B 块没有立即被发送,而是等待。而这时可能会写入其他小块数据(C块,D块等)。一直等待 A 块的ACK收到(大概40ms之后),B,C,D 块才会被放到一个 TCP 包中发送。

例外情况,直接发送,不等待前一个ACK

  • 如果数据长度达到 MSS (Max Segment Size)
  • 如果该包含有 FIN
  • 设置了 TCP_NODELAY 选项
  • 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于MSS)均被确认
  • 上述条件都未满足,但发生了超时

默认情况下,发送数据采用 Nagle 算法。这样虽然提高了网络吞吐量,但是实时性却降低了,在一些交互性很强的应用程序来说是不允许的,使用TCP_NODELAY选项可以禁止 Nagle 算法。此时,应用程序向内核递交的每个数据包都会立即发送出去。需要注意的是,虽然禁止了 Nagle 算法,但网络的传输仍然受到 TCP 确认延迟机制的影响

数据包的遗失处理

每一个数据包都带有下一个数据包的编号。如果下一个数据包没有收到,那么 ACK 的编号就不会发生变化。

举例来说,现在收到了 4 号包,但是没有收到 5 号包。ACK 就会记录,期待收到 5 号包。
过了一段时间,5 号包收到了,那么下一轮 ACK 会更新编号。
如果 5 号包还是没收到,但是收到了 6 号包或 7 号包,那么 ACK 里面的编号不会变化,总是显示 5 号包。
这会导致大量重复内容的 ACK。
如果发送方发现收到三个连续的重复 ACK,或者超时了还没有收到任何 ACK,就会确认丢包,即5号包遗失了,从而再次发送这个包。

通过这种机制,TCP 保证了不会有数据包丢失。

常见问题

粘包问题

首先要明确,粘包问题中的 “包”,是指应用层的数据包。

在TCP的协议头中,没有如同 UDP 一样的 “报文长度” 字段,但是有一个序号字段。
站在传输层的角度,TCP 是一个一个报文传输,按照序号排好序放在缓冲区中。站在应用层的角度, 看到的只是一串连续的字节数据。那么应用程序看到了这一连串的字节数据,就不知道从哪个部分开始到哪个部分是一个完整的应用层数据包。此时数据之间就没有了边界,就产生了粘包问题。

明确两个包之间的边界就解决粘包问题的关键

异常情况

进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。

机器重启:和进程终止的情况相同。

机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行 reset。即使没有写入操作,TCP 自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。

TIME_WAIT 累积与端口耗尽问题

主动关闭连接一方,在收到被动关闭方的 FIN 后状态会变为 TIME_WAIT,然后等待 2MSL 这么长的时间。在这段时间内,这个连接的端口是不能被再次使用的,并且会占用资源。如果主动关闭了大量的连接,会造成在一段时间内端口耗尽的问题。

参考

TCP 的那些事儿(上)
TCP 的那些事儿(下)
TCP 协议简介
TCP 详解