参考http://www.ruanyifeng.com/blog/2017/06/tcp-protocol.html
《码出高效》

TCP 数据包在 IP 数据包的负载里面。它的头信息最少也需要20字节,因此 TCP 数据包的最大负载是 1480 - 20 = 1460 字节。由于 IP 和 TCP 协议往往有额外的头信息,所以 TCP 负载实际为1400字节左右。

因此,一条1500字节的信息需要两个 TCP 数据包。HTTP/2 协议的一大改进, 就是压缩 HTTP 协议的头信息,使得一个 HTTP 请求可以放在一个 TCP 数据包里面,而不是分成多个,这样就提高了速度。


(图片说明:以太网数据包的负载是1500字节,TCP 数据包的负载在1400字节左右。)

传输控制协议(Transmission Control Protoco,TCP,是一种面向连接、确保数据在端到端间可靠传输的协议。面向连接是指在发送数据前,需要先建立一条虚拟的链路,然后让数据在这条链路上“流动”完成传输。为了确保数据的可靠传输,不仅需要对发出的每一个字节进行编号确认,校验每一个数据包的有效性,在出现超时情况下进行重传,还需要通过实线滑动窗口和拥塞控制等机制,避免网络状况恶化而最终影响数据传输的极端情形。

TCP报文格式

TCP是面向连接的,因此有服务器端和客户端之分。需要服务器端先在相应的端口上进行监听,准备好接受客户端发起的建立连接的请求。客户端发起的,目标机器端口就是服务器端所监听的端口号。比如一些由国际组织定义的广为人知的端口号——代表HTTP的80端口,代表SSH的22端口,代表HTTPS的443端口。

SYN用作建立连接时的同步信号;ACK用于对收到的数据进行确认,所确认的数据有确认序列号表示;FIN表示后面没有数据需要发送,通常意味着所建立的连接需要关闭了。

TCP数据包的编号(SEQ)

一个包1400字节,那么一次性发送大量数据,就必须分成多个包。比如,一个 10MB 的文件,需要发送7100多个包。

发送的时候,TCP 协议为每个包编号(sequence number,简称 SEQ),以便接收的一方按照顺序还原。万一发生丢包,也可以知道丢失的是哪一个包。

第一个包的编号是一个随机数。为了便于理解,这里就把它称为1号包。假定这个包的负载长度是100字节,那么可以推算出下一个包的编号应该是101。这就是说,每个数据包都可以得到两个编号:自身的编号,以及下一个包的编号。接收方由此知道,应该按照什么顺序将它们还原成原始文件。


(图片说明:当前包的编号是45943,下一个数据包的编号是46183,由此可知,这个包的负载是240字节。)

TCP数据包的组装

收到 TCP 数据包以后,组装还原是操作系统完成的。应用程序不会直接处理 TCP 数据包。

对于应用程序来说,不用关心数据通信的细节。除非线路异常,收到的总是完整的数据。应用程序需要的数据放在 TCP 数据包里面,有自己的格式(比如 HTTP 协议)。

TCP 并没有提供任何机制,表示原始文件的大小,这由应用层的协议来规定。比如,HTTP 协议就有一个头信息Content-Length,表示信息体的大小。对于操作系统来说,就是持续地接收 TCP 数据包,将它们按照顺序组装好,一个包都不少。

操作系统不会去处理 TCP 数据包里面的数据。一旦组装好 TCP 数据包,就把它们转交给应用程序。TCP 数据包里面有一个端口(port)参数,就是用来指定转交给监听该端口的应用程序。

应用程序收到组装好的原始数据,以浏览器为例,就会根据 HTTP 协议的Content-Length字段正确读出一段段的数据。这也意味着,一次 TCP 通信可以包括多个 HTTP 通信。

TCP建立连接

三次握手:

  1. A机器发出的一个数据包并将SYN置1,表示希望建立连接。这个包中的序列号假设是x。
  2. B机器收到A机器发过来的数据包后,通过SYN得知这是一个建立连接的请求,于是发送一个响应包并将SYN和ACK标记都置1。假设这个包中的序列号为y,而确认序列号必须是x+1,表示收到了A发过来的SYN。在TCP中,SYN被当作数据部分的一个字节。
  3. A收到B的响应包后需进行确认,确认包中将ACK置1,并将确认序列号设置为y+1,表示收到了来自B的SYN。

之所以需要三次握手是为了信息对等和防止超时。在第二次握手完成时,对B来说并不知道A收到了B的第一次回信,就是不确定A有收报的能力以及自己有发报的能力。所以需要A再一次回复来确认。而且还要防止出现请求超时导致的脏连接。

从编程角度来看TCP连接

从编程的角度,TCP连接的建立是通过文件描述符(FIle Descripter,fd)完成的。通过创建套接字获得一个fd,然后服务器端和客户端需要机遇所获得的fd调用不同的函数分别进入监听状态和发起连接请求。由于fd的数量将决定服务端进程所能建立连接的数量,对于大规模分布式服务来说,当fd不足时就会出现“open too many files”错误而使得无法建立更多的连接。为此,需要注意调整服务端进程和操作系统所支持的最大文件句柄数。通过使用ulimit -n命令来查看单个进程可以打开文件句柄的数目。如果想查看当前系统各进程产生了多少句柄,可以使用如下命令:

1
lsof -n | awk '{print $2}'| sort|uniq -c |sort -nr|more

执行结果中左侧是句柄数,右侧是进程号。lsof命令用于查看当前系统所打开fd的数量。在Linux系统中,很多资源都是以fd的形式进行读写的,除了提到的文件和TCP连接,UDP数据报、输入输出设备等都抽象成了fd。

1
在linux系统中文件句柄(file handles)和文件描述符(file descriptor)是一个一一对应的关系

想知道具体的PID对应的具体应用程序是谁,使用

1
ps -ax|grep 32764

TCP在协议层面支持Keep Alive功能,即隔段时间通过向对方发送数据表示连接处于健康状态。不少服务将确保连接健康的行为放到了应用层,通过定期发送心跳包检查连接的健康度。一旦心跳包出现异常不仅会主动关闭连接,还会回收与连接相关的其他用于提供服务的资源,确保系统资源最大限度地被有效利用。

TCP断开连接

四次挥手。A机器想要关闭连接,则待本方数据发送完毕后,传递FIN信号给B机器。B机器应答ACK,告诉A机器可以断开,但是需要等B机器处理完数据,再主动给A机器发送FIN信号。这时,A机器处于半关闭状态(FIN_WAIT_2),无法再发送新的数据。B机器做好连接关闭前的准备工作后,发送FIN给A机器,此时B机器也进入半关闭状态(CLOSE_WAIT)。A机器发送针对B机器FIN的ACK后,进入TIME-WAIT状态,经过2MSL后,没有收到B机器传来的报文,则确定B机器已经收到A机器最后发送的ACK指令,此时TCP连接正式释放。


TIME_WAITCLOSE_WAIT分别表示主动关闭和被动关闭产生的阶段性状态,如果在线上服务器大量出现这两种状态,就会加重机器负载,也会影响有效连接的创建,因此需要进行有针对性的调优处理。

TIME_WAIT:主动要求关闭的机器表示收到了对方的FIN报文,并发送出ACK报文,进入TIME_WAIT状态,等2MSL后即可进入到CLOSED状态。如果FIN_WAIT_1状态下,同时收到带FIN标志和ACK标志的报文时,可以直接进入TIME_WAIT状态,而无须经过FIN_WAIT_2状态。

CLOSE_WAIT:被动要求关闭的机器收到对方请求关闭连接的FIN报文,在第一次ACK应答后,马上进入CLOSE_WAIT状态。这种状态其实表示在等待关闭,并且通知应用程序发送剩余数据,处理现场信息,关闭相关资源。

2MSL是报文在网络上生存的最长时间,超过阈值便将报文丢弃。一般来说,MSL大于TTL衰减至0的时间。在RFC793中规定MSL为2分钟。但是在当前的高速网络中,2分钟的等待时间会造成资源的极大浪费,在高并发服务器上通常会使用更小的值。不直接关闭,进入CLOSED状态的原因有如下几点:

  1. 确认被动关闭方能够顺利进入CLOSED状态。假如最后一个ACK由于网络原因导致无法到达B机器,处于LAST_ACK的B机器通常“自信”地以为对方没有收到自己的FIN+ACK报文,所以会重发。A机器收到第二次的FIN+ACK报文,会重发一次ACK,并且重新计时。如果A机器收到B机器的FIN+ACK报文后,发送一个ACK给B机器,就“自私”地立马进入CLOSED状态,可能会导致B机器无法确保收到最后的ACK指令,也无法进入CLOSED状态。这是A机器不负责任的表现。
  2. 防止失效请求。这样做是为了防止已失效连接的请求数据包与正常连接的请求数据包混淆而发生异常。

编程角度看TCP断开

因为TIME_WAIT状态下无法真正释放句柄资源,在此期间,Socket中使用的本地端口在默认情况下不能再被使用。该限制对于客户端机器无所谓,但对于高并发服务器来说,会极大地限制有效连接的创建数量,称为性能瓶颈。所以将高并发服务器TIME_WAIT超时时间调小。

在服务器上通过变更/ etc /sysctl.conf文件来修改该省略值(秒):net.ipv4.tcp_fin_timeout=30(建议小于30秒为宜)。

修改完之后执行/ sbin / sysctl -p让参数生效即可。可通过如下命令:

1
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a,S[a]}'

查看各连接状态的计数情况。

在sysctl.conf中还有其他连接参数也用来不断地调优服务器TCP连接能力,以提升服务器的有效利用率。毕竟现代网络和路由器处理能力越来越强,跨国时延通常也在1s以内,丢包率极低。如何快速地使用连接资源被释放和复用,参数的优化往往可以取得事半功倍的效果。记得某大公司在大型购物节时,系统宕机,老总下令要加一倍的服务器来解决问题。事实上,如果是参数配置错误导致的系统宕机,即使增加硬件资源,也无法达到好的效果。硬件的增加与性能的提升绝对不是线性相关的,更多的时候是对数曲线关系。

TIME_WAIT是挥手四次断开连接的尾声,如果此状态连接过多,则可以通过优化服务器参数得到解决。如果不是对方连接的异常,一般不会出现连接无法关闭的情况。

CLOSE_WAIT过多很可能是程序自身的问题,如果在对方关闭连接后,程序没有检测到,或者忘记自己关闭连接。在某次故障中,外部请求出现超时的情况,当时的Apache服务器使用的是默认的配置方式,通过命令: netstat -ant|grep -i “443”|grep CLOSE_WAIT|wc -1发现在HTTPS的443端口堆积了2.1万个左右的CLOSE_WAIT状态。经排查发现,原来是某程序处理完业务逻辑后没有释放流操作,但程序一直运行正常,直到运营活动时才大量触发该业务逻辑,最终导致故障的产生。

慢启动和ACK

服务器发送数据包,当然越快越好,最好一次性全发出去。但是,发得太快,就有可能丢包。带宽小、路由器过热、缓存溢出等许多因素都会导致丢包。线路不好的话,发得越快,丢得越多。

最理想的状态是,在线路允许的情况下,达到最高速率。但是我们怎么知道,对方线路的理想速率是多少呢?答案就是慢慢试。

TCP 协议为了做到效率与可靠性的统一,设计了一个慢启动(slow start)机制。开始的时候,发送得较慢,然后根据丢包的情况,调整速率:如果不丢包,就加快发送速度;如果丢包,就降低发送速度。

Linux 内核里面设定了(常量TCP_INIT_CWND),刚开始通信的时候,发送方一次性发送10个数据包,即”发送窗口”的大小为10。然后停下来,等待接收方的确认,再继续发送。

默认情况下,接收方每收到两个 TCP 数据包,就要发送一个确认消息。”确认”的英语是 acknowledgement,所以这个确认消息就简称 ACK。

ACK 携带两个信息。


发送方有了这两个信息,再加上自己已经发出的数据包的最新编号,就会推测出接收方大概的接收速度,从而降低或增加发送速率。这被称为”发送窗口”,这个窗口的大小是可变的。

(图片说明:每个 ACK 都带有下一个数据包的编号,以及接收窗口的剩余容量。双方都会发送 ACK。)

注意,由于 TCP 通信是双向的,所以双方都需要发送 ACK。两方的窗口大小,很可能是不一样的。而且 ACK 只是很简单的几个字段,通常与数据合并在一个数据包里面发送。


(图片说明:上图一共4次通信。第一次通信,A 主机发给B 主机的数据包编号是1,长度是100字节,因此第二次通信 B 主机的 ACK 编号是 1 + 100 = 101,第三次通信 A 主机的数据包编号也是 101。同理,第二次通信 B 主机发给 A 主机的数据包编号是1,长度是200字节,因此第三次通信 A 主机的 ACK 是201,第四次通信 B 主机的数据包编号也是201。)

即使对于带宽很大、线路很好的连接,TCP 也总是从10个数据包开始慢慢试,过了一段时间以后,才达到最高的传输速率。这就是 TCP 的慢启动。

数据包的遗失处理

TCP 协议可以保证数据通信的完整性,这是怎么做到的?

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

举例来说,现在收到了4号包,但是没有收到5号包。ACK 就会记录,期待收到5号包。过了一段时间,5号包收到了,那么下一轮 ACK 会更新编号。如果5号包还是没收到,但是收到了6号包或7号包,那么 ACK 里面的编号不会变化,总是显示5号包。这会导致大量重复内容的 ACK。

如果发送方发现收到三个连续的重复 ACK,或者超时了还没有收到任何 ACK,就会确认丢包,即5号包遗失了,从而再次发送这个包。通过这种机制,TCP 保证了不会有数据包丢失。

(图片说明:Host B 没有收到100号数据包,会连续发出相同的 ACK,触发 Host A 重发100号数据包。)