tcp可以保证数据可靠的被对方接收,当数据丢失时进行重传,通过确认应答机制判断数据丢失。
1.保证可靠性机制
- 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
- 32位序号/32位确认号: 后面详细讲;
- 4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60
- 6位标志位:
– URG: 紧急指针是否有效
– ACK: 确认号是否有效
– PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
– RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
– SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
– FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段 - 16位窗口大小: 后面再说
- 16位校验和: 发送端填充, CRC校验。 接收端校验不通过, 则认为数据有问题。 此处的检验和不光包含TCP首部, 也包含TCP数据部分。
- 16位紧急指针: 标识哪部分数据是紧急数据;
- 40字节头部选项: 暂时忽略;
1.1 确认应答机制
1.1.1确认应答机制概念
发送数据,要使数据确认收到,必须要收到数据的应答,双方一来一回如此操作,
如下图如此下去将会造成应答死循环。
需要注意的是:无论是数据,还是应答,本质都是一条tcp报文
因此总会有一条最新发送的确认应答不能确认是否被收到了。所以tcp并不是完全可靠的。但是我们能够确认,只要一条数据有确认应答那么这条数据肯定被对方收到。没有应答时,可能是因为数据丢失了或者应答丢失了。
1.1.2常规确认应答的工作方式
我方发送数据的目的就是为了让对方能够可靠的收到数据,因此如果数据丢失了,我方应该重新发送数据。当我方收到对方的确认应答说明我方已经可靠将数据发送给对方了。我方目的达成因此对于对方的确认应答,我方没必要给对方发送确认应答了。即只能保证数据单方向的可靠。
1.1.3报文按序到达
我们把发送出去的数据,看作是报文。一个一个的报文按顺序发送出去,但是接收时,可能因为网络状况报文接收出现延迟,导致接收所有的报文后是乱序的。因此我们给每个报文带上序号,接收报文时按照序号进行排序,那么应用层收到的数据就是有序的。
【 可靠性不仅要保证被对方收到,还要按序到达。】
1.1.4 如何确认历史数据被收到
tcp报头中涵盖一个叫做确认序号,对历史确认报文的序号+1就是确认序号。
确认序号的含义是确认序号之前的所有的报文我已经全部收到了,下次发送请从确认序号报文开始发送。
1.1.5 16位序号和16确认序号(字段讲解)
一个报文既有序号,又有确认序号。
为何这两个字段独立存在?
如果一个字段,我们可以使用标志符ACK进行区分(往下阅读会讲解),但是在tcp是支持全双工的,在我们发送数据或应答的同时,对方也有可能给我们发送数据或应答,因此我们在发送tcp报文时可能发送数据和确认应答要共有一个tcp报文,因此设计了两个字段。
如何理解序号和确认序号呢?
- 我们可以想象TCP将缓冲区的每个字节的数据都进行了编号。 即为序列号.
- 每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发。
- 每次发送数据时,都将要发送数据的最大的序号作为发送序号。
- 接收方根据报头长度,进行报文分离,将有效载荷根据序号有序的放到缓冲区里。
tcp的缓冲区(背景知识)
tcp有接收和发送缓冲区,用户调用发送系统接口时,其实是向tcp缓冲区读写数据。
tcp缓冲区的作用?
- 提高应用层效率。
- 数据的发送与接收由tcp协议控制。
- 只有OS TCP协议可以知道网络,乃至对方的转态明显,所以,只有TCP协议,能处理如何发,什么时候发,发多少,出错了怎么办?等细节问题。
- 我们把TCP协议称作为传输控制协议,怎么办就是由tcp控制。
- 又因为缓冲区的存在,所以可以做到应用层与TCP进行解耦。
1.2流量控制(16位窗口大小)
- 对方接收缓冲区满,如果我方还继续发送,那么对方会丢弃该报文。对方缓冲区满了可能是因为对方应用层来不及接收。
- 此时丢弃的报文没有得到应答那么我方会使用超时重传的机制重新发送报文。
- 如果对方接收缓冲区一直存满状态,我方不断的发送,如此下去,虽然不是问题,但是一个报文千里送过去被丢弃,有点浪费网络资源。
- 因此tcp引入了流量控制机制。
- tcp提供了16位窗口大小的字段,该字段表示我方接收缓冲区剩余空间的大小。
- 我方收到对方的窗口大小,得知对方的接收情况,根据该字段动态的调整发送数据的大小。
- 这也体现了TCP的控制能力。
6位标志位(字段讲解)
- TCP是面向连接的,TCP socket 通信前首先要connect()函数建立连接,本质就是三次握手,即交互三次报文。
- 首先server端可能会面临一个问题,server可能在任何一个时刻,都有可能有成百上千个报文再向server发送数据。
- server首先面临的是,面对大量的TCP报文,如何区分各个报文的类别
- 通过TCP的标志位来进行区分
- 6位标志位:(往下阅读将会详细介绍完6位标志位的特性)
– URG: 紧急指针是否有效
– ACK: 确认号是否有效
– PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
– RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
– SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
– FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
注意:
- 三次握手会用到SYN,进行建立连接。
- 4次挥手会用到FIN,断开链接。
- 在双方通信时,保证双方是连接状态的,说明双方通信没问题。
- 这也是为了保证可靠性,如果双方不是连接状态,既双方没建立连接。
- 和UDP一样、无法确认对方的状态、因此数据也无法保证可靠传送。
1.3 建立连接(三次握手)
- 虽然TCP有重传机制和确认应答机制,但是不能知道对方的状态。
- 对方有可能处于离线状态,如果我们一味的进行重传,那么只能等到对方在线为止。
- 这必然是不行的,因此双方在通信前应该先建立连接,获知对方能正常通信。
- 当有一方因某种原因导致需要断开连接,应该告诉对方断开连接,即四次挥手。
- 我们发现建立连接也是发送报文,因此也会有对方收不到的可能性,在最坏的情况下(往下阅读会有具体描述),如果对方还是没有响应那么可以认为对方是离线的。
- 下面我们详细讲解三次握手,是如何建立起连接的。
理解一下建立连接的本质是什么?
- server存在大量的连接,server应该对其进行管理,先描述,后组织,建立连接的结构体,再使用数据结构将其联系起来。
- 双方都需要维护数据结构,因此是有时间和空间成本的。
三次握手的过程
- client发送SYN标志的报文请求对方连接(第一次握手)。
- server收到SYN标志的报文,检测一下本机是否允许建立连接。
- 如果不允许,那么server只发送ACK的报文。
- 如果允许,那么server发送ACK+SYN的报文,即server确认应答并请求对方连接(第二次握手)。
- 如果client收到ACK的报文,那么会进行重新建立连接(三次握手)。
- 如果client收到ACK+SYN的报文,client发送ACK报文。
- 此时当client发送第三次握手后。(client认为三次握手成功了,即建立连接成功)。
- 如果第三次握手server收到,那么server认为三次握手成功了。
- 需要注意,第三次握手,client发送第三次握手和server接收第三次握手是有时间间隔的。
- 因此,第三次握手有可能丢失或者延迟较大。
- 当server还没收到第三次握手时,server认为还没建立好连接,server收到client的报文时,server发送RST报文,即serve而要求client重新建立连接。
- RST用来重置异常连接的,是连接异常的一种情况,只要双方出现连接异常,都可以使用RST,来进行连接重置。
正常三次握手(图片)
第三次握手报文丢失(图片)
TCP三次握手和四次挥手的异常连接情况
此时还不是解答以下问题的时候,因为还差一些背景知识没讲。
- 第一次握手,如果客户端发送的SYN一直都传不到被服务器,那么客户端是一直重发SYN到永久吗?客户端停止重发SYN的时机是什么?
- 第三次握手,如果服务器永远不会收到ACK,服务器就永远都留在 Syn-Recv 状态了吗?退出此状态的时机是什么?
- 第三次挥手,如果客户端永远收不到 FIN,ACK,客户端永远停留在 Fin-Wait-2状态了吗?退出此状态时机是什么时候呢?
- 第四次挥手,如果服务器永远收不到 ACK,服务器永远停留在 Last-Ack 状态了吗?退出此状态的时机是什么呢?
- 如果客户端 在 2SML内依旧没收到 FIN,ACK,会关闭链接吗?服务器那边怎么办呢,是怎么关闭链接的呢?
PUSH标志位(6位标志位知识)
- PUSH:告知对方尽快将缓冲区的数据进行向上交付。
- 接收缓冲区是有水位线的,当到达水位线时,TCP才会通知应用层接口。
- 原因在于:TCP希望应用层可以一次性拷贝一批数据。内核态到用户态是需要耗费很长时间的。因此频繁的切换状态,会使性能降低。
URG标志位(6位标志位知识)
- 目前TCP有按序到达,每一个报文,什么时候被上层取到基本是确定的。
- 如果想让一个数据尽快的被上层读到,可以设置URG。
- URG:表明该报文携带了紧急数据,需要被优先处理。
- 紧急数据在哪里?紧急数据如何获取?
- 紧急数据在有效载荷里,TCP报头有一个16位紧急指针、其用来标识紧急数据在有效载荷的具体位置。
- 应用层
ssize_t rec**加粗样式**v(int sockfd, void *buf, size_t len, int flags);
函数。 - 如果我们想获取紧急数据,我们可以调用recv函数,需要设置flags参数为
MSG_OOB
。 - 需要注意、我们只能获取1个字节的紧急数据。
紧急数据的用途
应用层工程师一般是接触不到的,常用来当server出现问题时,server不能给予对方响应,server会有一个专门的线程来获取紧急数据,所以也叫带外数据,该线程用于通知给对方server的状况。
1.4 断开连接(四次挥手)
- 一般而言,建立连接一般是client,而断开连接是双方的事,即双方都有随时断开连接。
- 下面来讲解一下四次挥手的过程(详细分析还需要到讲解TCP状态)
- 如下图、
- client发送FIN报文(第一次挥手)、告知对方我要断开连接。
- server端收到FIN报文后,发送ACK(第二次挥手)。告知对方我同意。
- server端发送FIN报文(第三次挥手),告知对方我要断开连接。
- client发送ACK报文(第四次挥手),告知对方我同意。
- 详细细节还要往下分析。
1.5 超时重传机制(重要知识)
- TCP保证可靠性机制有些体现在报头里,但有一些不在报头里。
- 超时重传机制就是其中一种。
- 主要原因就是,没必要体现,只要获取现成的报头,及OS本身的机制或自己本身代码来完成。
- 超时重传就是当发送数据出去以后,给自己设定一个定时器,在特定间隔时间里,如果没有收到应答报文,那么执行重传报文机制。
重传的时间间隔应该是多少?
- 当我们发送完对应的报文,没有收到对方的ACK,有可能是对方的ACK丢失,并不是数据丢失了。因此我们会执行超时重传。如果对方继续收取相同报文,那么就报文重复了,因此接收方应该通过序号去重,这也是保证可靠性,该接收的接收、不该接收的不接收。
- 最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”。
- 但是这个时间的长短, 随着网络环境的不同, 是有差异的。
- 如果超时时间设的太长, 会影响整体的重传效率;
- 如果超时时间设的太短, 有可能会频繁发送重复的包;
- TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间。
- Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传。
- 如果仍然得不到应答, 等待 4*500ms 进行重传。 依次类推, 以指数形式递增。
- 累计到一定的重传次数(一般是5次大概是60s), TCP认为网络或者对端主机出现异常, 强制关闭连接。
为什么是三次握手?(重要知识)
- 建立连接的目的就是为了保证可靠性,验证双方是否有通信能力。
- TCP是要进行全双工的,为了验证全双工,即全方是否具备收发能力。
- 而三次握手是验证全双工的最少次数,下面来看看他们是如何验证的。
- client发送SYN,如果收到对方的SYN+ACK报文,此时client具备收发能力了,server也局部收的能力了。
- client发送ACK,此时client认为自己建立好连接了。
- server如果收到ACK,那么就能验证自己是有发送能力了。
- 我们模拟过程就知道,1、2次握手肯定不行的。
- 4、5、6、7次已经是多余了,我们不是为了建立连接而建立连接,我们是为了验证网络状态,验证双方主机的就绪状态、验证全双工状态,来发起三次握手,过多的次数会增加建立连接的成本。
- 例如:如果为保证双方是为了确认自己和对方是否建立好连接,即三次握手只能确认自己,不能确认对方。那么可能为了确保建立好连接,而造成重传次数不确定性,因此是不会被采用的。TCP采用三次握手就是为了以最低次数较大概率连接成功。
- 其余解析:
– b.如果是一次两次握手(就建立好连接),那么将会遭到“SYN洪水攻击”,使服务器要维护大量的“链接资源”。
– c.如果是三次握手那么也会受到“SYN洪水攻击”,只是双方都要付出等价的代价,因此这种手段只能限制小数量服务器的攻击,如果有大量的肉机被安装木马病毒,大量的攻击,那么服务端也会垮掉。 - d.其实“洪水攻击”是有防御手段的,通过IP地址……
为什么是四次握手?
- 与三次握手不一样,四次握手是让双方达成共识,就是一个通知对方的机制。
- 让对方知道自己已经断开连接了,并且知道对方也断开连接的一个过程。
- 根离婚一样,双方都要签字那么才算真的离婚了。
1.6 连接管理机制
如下、什么状态收到什么报文,状态就有可能发生变化。
服务端状态转化:
- [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接;
- [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文。
- [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了。
- [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT;
- [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)
- [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接。
客户端状态转化:
- [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段;
- [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据;
- [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入
FIN_WAIT_1; - [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段;
- [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入TIME_WAIT, 并发出LAST_ACK;
- [TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态。
状态分析:
- 在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接。
- 双方建立好的连接是由五元组进行维护的,也可以认为五元组标识一个唯一的通信信道。
理解TIME_WAIT状态
现在做一个测试,首先启动server,然后启动client,然后Ctr-c使server终止,这使马上再运行server,这些网络服务都是bind同一个端口号结果是:
这是因为,虽然server的应用程序终止了,但是TCP协议层的连接并没有完全断开,因此该连接相当于一个进程,一个端口号不能绑定多个进程,因此不能再次监听同样的server端口。(往下阅读会有办法解决bind失败的问题)我们用netstat命令查看一下:
- TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。
- 注意上图说明的是不管连接是什么状态,再次bind都会失败。
- 我们使用Ctrl-C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口;
- MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;
- 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值;
- 规定TIME_WAIT的时间请读者参考UNP 2.7节
为什么是TIME_WAIT的状态时间2MSL?
- MSL是TCP报文的最大生存时间, 因此
TIME_WAIT
持续存在2MSL的话 - 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到
- 来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
- 网络中可能存在发送方的数据包,当这些数据包被接收方处理后又会向对方发送响应,一来一回最多需要等待2MSL的时间。
- 这个时间是从客户端接收到服务器的FIN报文后内核发送ACK报文开始计时的,如果在这个时间内,客户端又收到了服务器重发
的FIN报文,则重新计时。 - 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN。 这
- 时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发
LAST_ACK
) - 期间也是一来一回的,因此2MSL也是为了这。
- 最后一次ACK一直没有被收到,那么服务端会一直进行超时重传,直到重传次数超过
tcp_orphan_retries
参数控制,次数达到进入CLOSE
状态。
解决TIME_WAIT状态引起的bind失败的方法
因此有些场景下是不合理的,例如:
- 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求)。
- 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产生大量TIME_WAIT连接。
- 由于我们的请求量很大, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip,源端口, 目的ip, 目的端口, 协议)。
- 其中服务器的ip和端口和协议是固定的。 如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了, 就会出现bind失败的问题。
解决方法:
使用setsockopt()
设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符setsockopt()
设置以后解决了不能绑定相同端口的新套接字,旧连接发送的数据肯定早于新连接被创建的时间,区分的目的是因为旧连接与新连接的五元组相同,当报文传输到传输层后,根据端口号和序号的判断下交付给特定的连接。因此,新连接只会接收到新的数据,老旧连接的数据不会发送到新的连接上。
理解CLOSE_WAIT状态
- CLOSE_WAIT状态表示等待应用层调用close()函数,所以CLOSE_WAIT的等待时间由。
- server不关闭客户端连接(
close(linkfd)
),这会导致一个无用的连接长时间停留在系统里,如果存在大量的CLOSET_WAIT
状态的连接那么系统资源会越来越少,导致fd泄漏。 - 我们编译运行服务器。 启动客户端链接, 查看 TCP 状态, 客户端服务器都
ESTABLELISHED
状态, 没有问题。然后我们关闭客户端程序, 观察 TCP 状态
tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 5038/./dict_server
tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 -
tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT 5038/./dict_server
此时服务器进入了 CLOSE_WAIT 状态, 结合我们四次挥手的流程图, 可以认为四次挥手没有正确完成.
小结: 对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成。 这是一个 BUG。 只需要加上对应的 close 即可解决问题。
三次握手的异常连接处理
第一次握手丢失了,会发生什么?
当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT
状态。
在这之后,如果客户端迟迟收不到服务端的 SYN-ACK
报文(第二次握手),就会触发超时重传机制。
不同版本的操作系统可能超时时间不同,有的 1 秒的,也有 3 秒的,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。
当客户端在 1 秒后没收到服务端的 SYN-ACK
报文后,客户端就会重发 SYN
报文,那到底重发几次呢?
在 Linux 里,客户端的 SYN
报文最大重传次数由 tcp_syn_retries
内核参数控制,这个参数是可以自定义的,默认值一般是 5。
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK
,客户端就不再发送 SYN
包,然后断开 TCP 连接。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
第二次握手丢失了,会发生什么?
当服务端收到客户端的第一次握手后,就会回 SYN-ACK
报文给客户端,这个就是第二次握手,此时服务端会进入 SYN_RCVD
状态。
第二次握手的 SYN-ACK
报文其实有两个目的 :
- 第二次握手里的
ACK
, 是对第一次握手的确认报文; - 第二次握手里的
SYN
,是服务端发起建立 TCP 连接的报文;
所以,如果第二次握手丢了,就会发送比较有意思的事情,具体会怎么样呢?
因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。
然后,因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。
那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传 SYN-ACK 报文。
在 Linux 下,SYN-ACK
报文的最大重传次数由 tcp_synack_retries
内核参数决定,默认值是 5。
因此,当第二次握手丢失了,客户端和服务端都会重传:
- 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries 内核参数决定。;
- 服务端会重传 SYN-AKC 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定。
- 如果第一次握手被服务端接收到,那么客户端重传的报文会被去重。
第三次握手丢失了,会发生什么?
客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH 状态。
因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。
因为client已经认为自己建立好连接了,因此有可能会发送数据给服务端,但是服务端没建立连接时收到,那么服务端会发送RST报文,要求客户端重新建立连接。
注意:ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文。
TCP 四次挥手期间的异常
我们再来看看 TCP 四次挥手的过程。
第一次挥手丢失了,会发生什么?
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN
报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1
状态。
正常情况下,如果能及时收到服务端(被动关闭方)的 ACK
,则会很快变为FIN_WAIT2
状态。
如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK
的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries
参数控制。
当客户端重传 FIN 报文的次数超过 tcp_orphan_retries
后,就不再发送 FIN 报文,直接进入到 close 状态。
第二次挥手丢失了,会发生什么?
当服务端收到客户端的第一次挥手后,就会先回一个ACK
确认报文,此时服务端的连接进入到 CLOSE_WAIT
状态。
在前面我们也提了,ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。
这里提一下,当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT2 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。
对于 close 函数关闭的连接,由于无法再发送和接收数据,所以FIN_WAIT2
状态不可以持续太久,而 tcp_fin_timeout
控制了这个状态下连接的持续时长,默认值是 60 秒。
这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭。
第三次挥手丢失了,会发生什么?
当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于CLOSE_WAIT
状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。
此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。
服务端处于 CLOSE_WAIT
状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK
状态,等待客户端返回 ACK 来确认连接关闭。
如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries
参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。
第四次挥手丢失了,会发生什么?
当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT
状态。
在 Linux 系统,TIME_WAIT
状态会持续 60 秒后才会进入关闭状态。
然后,服务端(被动关闭方)没有收到 ACK 报文前,还是处于LAST_ACK
状态。
如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 tcp_orphan_retries
参数控制。
小结:
在极端情况下,除了FIN_WAIT2
状态等待60s后关闭连接,其余都是超时重传次数够了进行关闭连接。
状态 | 具体细节 |
---|---|
CLOSE | 连接断开 |
SYN_SENT | 请求建立连接,等待ACK+SYN 等待最长时长超时重传次数 |
SYN_RECV | 请求建立连接,等待ACK ,等待最长时长超时重传次数 |
FIN_WAIT1 | 等待ACK ,等待最长时长超时重传次数(没收到ACK进入CLOSE状态) |
CLOSE_WAIT | 等待应用层调用close(), ,等待最长时长随进程 |
FIN_WAIT2 | 等待对方FIN,等待最长时间随OS配置,一般为60s(没收到FIN进入CLOSE状态) |
LAST_ACK | 等待ACK,等待最长时长超时重传次数(没收到ACK进入CLOSE状态) |
TIME_WAIT | 等待2MSL时间 |
2. 提升效率的机制
2.1 滑动窗口
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答。 收到ACK后再发送下一个数据段。这样做有一个比较大的缺点, 就是性能较差。 尤其是数据往返的时间较长的时候。
既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。 上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候, 不需要等待任何ACK, 直接发送;
- 收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
- 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
- 窗口越大, 则网络的吞吐率就越高;
理解滑动窗口
- 如上图发送端调用write()实际就是把数据拷贝到传输层的发送缓冲区里,数据什么时候发送由传输层决定。
- 发送缓冲区里1号区为已经发送,已经确认的数据;2号区为可以/已经发送,但是还没收到确认;3号区为没有发送的数据。
2号区就是滑动窗口,我们来分析滑动窗口的一些细节问题。
如上图,收到2001序号的确认应答,我们的滑动窗口往右移动一个分段。
窗口的大小与接收方的接收能力强相关
窗口的大小不会一直不变,根据接收方的接收能力去调整。
丢包重传
情况一:数据包已经抵达, ACK被丢了
- 这种情况下, 部分ACK丢了并不要紧, 因为可以通过后续的ACK进行确认;
- 接收方是根据序号顺序发送确认应答的,比如1,3,4序号的数据都被接收,但是2号序号的数据还没被接收,接收方只会发送1号序号的确认应答,因为双方都达成了协议,确认序号代表确认序号前的数据都被接收了,这时候如果发送确认序号4,那么接收方就认为2号序号被接收了,后续2号序号数据丢失将不会被重传。
情况二: 数据包就直接丢了
- 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001"一样;
- 如果发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
- 这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中
2.2 快重传VS超时重传
超时重传前提条件是等待2MSl时间后没有收到应答才会执行重传。
快重传是收到3次相同的应答后才会重传。
区别:
- 快重传效率比超时重传效率高,原因是超时需要等待时间较长。
为什么不全使用快重传?
原因是快重传要有前提条件的,如果满足不了收到3次相同的应答,那么快重传不能使用。所以说在网络层快重传与超时重传都会使用到。
2.3 流量控制
-
接收端处理数据的速度是有限的。 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应。
-
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度。 这个机制就叫做流量控制(Flow Control);
-
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过ACK端通知发送端;
-
窗口大小字段越大, 说明网络的吞吐量越高;
-
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
-
发送端接受到这个窗口之后, 就会减慢自己的发送速度;
-
如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端
- 接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息;
- 那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
- 实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位;
2.4 拥塞控制
- 虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据。 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题。
- 之前我们考虑的是端对端的情况,现在我们来考虑网络。
- 这里引入了拥塞窗口来反应网络的情况。
- 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵。 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的。
- TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据
- 此处引入一个概念程为拥塞窗口发送开始的时候, 定义拥塞窗口大小为1;
- 每次收到一个ACK应答, 拥塞窗口加1;
- 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;
像上面这样的拥塞窗口增长速度, 是指数级别的。 “慢启动” 只是指初使时慢, 但是增长速度非常快.
- 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍。
- 此处引入一个叫做慢启动的阈值。
- 当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。
- 当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
- 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;
- 少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
- 当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
- 拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
2.5 延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小。
-
假设接收端缓冲区为1M。 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
-
但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
-
在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
-
如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
-
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高。 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
-
那么所有的包都可以延迟应答么? 肯定也不是;
– 数量限制: 每隔N个包就应答一次;
– 时间限制: 超过最大延迟时间就应答一次;
– 具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;
2.6 捎带应答
- 在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 “一发一收” 的。 意味着客户端给服务器说了 “How are you”, 服务器也会给客户端回一个 “Fine, thank you”;
- 那么这个时候ACK就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起回给客户端
- 最典型的例子就是第二次握手报文。
2.7 面向字节流
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;调用write时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用read从接收缓冲区拿数据;
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据。 这个概念叫做 全双工
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
- 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
- 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
2.8 粘包问题
- 首先要明确, 粘包问题中的 “包” , 是指的应用层的数据包。
- 在TCP的协议头中, 没有如同UDP一样的 “报文长度” 这样的字段, 但是有一个序号这样的字段。
- 站在传输层的角度, TCP是一个一个报文过来的。 按照序号排好序放在缓冲区中。
- 站在应用层的角度, 看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包。
- 因此这不是TCP要解决的问题。
- 那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界。
- 由应用层协议通过特殊字符,字描述字段,定长的方式解决。
- 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);
思考: 对于UDP协议来说, 是否也存在 “粘包问题” 呢?
- 对于UDP, 如果还没有上层交付数据, UDP的报文长度仍然在。 同时, UDP是一个一个把数据交付给应用层。
- 就有很明确的数据边界。
- 站在应用层的站在应用层的角度, 使用UDP的时候, 要么收到完整的UDP报文, 要么不收。 不会出现"半个"的情况。
3. TCP异常情况
- 进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.
- 机器重启: 和进程终止的情况相同.
- 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
- 另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接.
4. TCP小结
基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
- 当然, 也包括你自己写TCP程序时自定义的应用层协议;
TCP/UDP对比
- 我们说了TCP是可靠连接, 那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单, 绝对的进行比较
- TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
- UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播;
- 归根结底, TCP和UDP都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定
5. 理解 listen 的第二个参数
- 基于刚才封装的 TcpSocket 实现以下测试代码
- 对于服务器, listen 的第二个参数设置为 2, 并且不调用 accept
test_server.cc
#include "tcp_socket.hpp"
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage ./test_server [ip] [port]\n");
return 1;
}
TcpSocket sock;
bool ret = sock.Bind(argv[1], atoi(argv[2]));
if (!ret) {
return 1;
}
ret = sock.Listen(2);
if (!ret) {
return 1;
}
// 客户端不进行 accept
while (1) {
sleep(1);
}
return 0;
}
test_client.cc
#include "tcp_socket.hpp"
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage ./test_client [ip] [port]\n");
return 1;
}
TcpSocket sock;
bool ret = sock.Connect(argv[1], atoi(argv[2]));
if (ret) {
printf("connect ok\n");
} else {
printf("connect failed\n");
}
while (1) {
sleep(1);
}
return 0;
}
此时启动 3 个客户端同时连接服务器, 用 netstat 查看服务器状态, 一切正常.
但是启动第四个客户端时, 发现服务器对于第四个连接的状态存在问题了
tcp 3 0 0.0.0.0:9090 0.0.0.0:* LISTEN
9084/./test_server
tcp 0 0 127.0.0.1:9090 127.0.0.1:48178 SYN_RECV -
tcp 0 0 127.0.0.1:9090 127.0.0.1:48176 ESTABLISHED -
tcp 0 0 127.0.0.1:48178 127.0.0.1:9090 ESTABLISHED
9140/./test_client
tcp 0 0 127.0.0.1:48174 127.0.0.1:9090 ESTABLISHED
9087/./test_client
tcp 0 0 127.0.0.1:48176 127.0.0.1:9090 ESTABLISHED
9088/./test_client
tcp 0 0 127.0.0.1:48172 127.0.0.1:9090 ESTABLISHED
9086/./test_client
tcp 0 0 127.0.0.1:9090 127.0.0.1:48174 ESTABLISHED -
tcp 0 0 127.0.0.1:9090 127.0.0.1:48172 ESTABLISHED -
客户端状态正常, 但是服务器端出现了 SYN_RECV 状态, 而不是 ESTABLISHED 状态
这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)
- 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)
而全连接队列的长度会受到 listen 第二个参数的影响.
全连接队列满了的时候, 就无法继续让当前连接的状态进入 established 状态了.
这个队列的长度通过上述实验可知, 是 listen 的第二个参数 + 1.
为什么需要该队列?
- 充分利用服务器资源。服务的资源能百分百被利用。
- 例如:服务器占时只能服务5个连接。
- 当前队列有2个连接准备被服务。
- 当服务器有服务空位时,可以立刻在队列里拿取连接进行服务。
- 并不是队列越大越好,队列也是要系统资源维护的,队列小了,那么服务器的资源也就多了。
- 服务器的资源能百分百被利用的情况下,队列的长度根据情况来进行控制