【LINUX网络】TCP原理
目录
本文介绍
1. 什么是TCP?
2. TCP结构
为什么需要协议栈:两台主机通信的复杂性解决方案
3. 确认应答机制
进一步理解什么是确认和请求以及序号
进一步理解什么是序号和确认序号
并发发送带来的问题以及解决方案(序号)
****理解这个事,就要先理解序号是怎么来的****:
4. 捎带应答机制
5. 流量控制机制(进一步理解缓冲区)
6. 超时重传
7. 连接管理机制
三次握手、四次挥手:
再谈四次挥手
TIME_WAIT
***如果没有time_wait***
8. 进一步理解流量控制(Flow Control)
16位窗口大小太小怎么办?
9. 滑动窗口
关于滑动窗口的异常丢包问题:
高速重发控制(快重传)
10. 再谈标记位
关于PSH 提示读走缓冲区数据
关于RST和丢弃游离的报文
关于URG紧急指针
11.拥塞控制
12. 延迟应答
13.粘包问题
14.TCP连接异常
本文介绍
【LINUX网络】HTTP协议基本结构、搭建自己的HTTP简单服务器-CSDN博客
【LINUX网络】HTTP服务器搭建完善+报头属性&状态码-CSDN博客
在之前的demo代码中,为什么时不时的在测试完成之后会绑定失败?并且完全不用担心,因为过了一小会儿就能自动bind成功了。这背后涉及的就是TCP更底层的原理,今天就是来尝试学习这些原理的。
从协议层次出发,其本质原因是:服务端先断开,双方的TCP层次四次挥手进入time_wait状态,该状态中是不能进行连接的。
为了改变这个情况,需要设置一下套接字。
什么是四次挥手?如何设置这个套接字?这就是本文想简单·探讨的问题。
1. 什么是TCP?
TCP,transport control protocol——传输控制协议,不同于User Datagram Protocol(UDP),是一种需要先把内容发到自己这一层的缓冲区进行控制(而非直接发进内核,UDP就这么干且没有缓冲区,上一文有分享),通过各种控制手段保证自己的传输具有可靠性
2. TCP结构
TCP标准报头的长度通常是20字节,但可以通过选项字段扩展到更大的长度。我们将按照右边的顺序逐步理解内容。
a. 与UDP刚开始的两个字段一样,作为一个传输层的协议,首先需要记录来自哪里,更需要在接受该数据报的一段被解包和分用,所以必须要有16位的源端口号和目的端口号。编程中想要bind,就是为了这个字段
b. 序号:对方需要对我发送的报文进行确认。给每一个TCP报文带上序号,除了进行确认,为了保证可靠性,序号还可以保证按序到达(后面会具体说明)。
c. 4位首部长度:理解这个字段之前,先想一想应用层是如何对TCP报文进行解包的。
除开选项部分,有20位的标准TCP报头长度,这是一定可以被取出来的。4位首部长度就像HttpRequest中的Content-Length一样,是用于整个报头长度的(标准20字节+选项)
可是,2^4才是16,连20字节都不够,这是怎么标识的呢?
四位首部长度的值,其实表示的是该 TCP 头部有多少个 32 位 bit(有多少个 4 字节); 所以 TCP 头部最大长度是 15 * 4 = 60
先提20字节,再提4位首部长度,就能提取完整报头了。竟然不像UDP那样有对整个报文长度大小(包括数据部分)的描述字段(16位UDP总长度)??
综上所述,一排是32位bit,也就是4字节,所以在没有选项时,整个tcp报头就是20/4=5行
内核中的TCP:
可以观察到,在实际的操作系统中,source\dest\seq\ack_seq\window\check\urg_ptr都是写死了的字段,但是根据实际情况中大小端的不同,一些标志位依然会不同。
关于序号、确认序号的事情,我们稍后会有比较详细的学习。
为什么需要协议栈:两台主机通信的复杂性解决方案
通信复杂性的增长
随着网络通信的发展,两台主机之间的通信,主要表现在已经从进程间通信,兄弟进程之间通信等零距离通信变成远距离通信。并且通信难度越来越大,主要表现在以下几个方面:
- 硬件差异:不同厂商生产的网络设备(网卡、路由器等)可能采用不同的底层技术
- 操作系统差异:Windows、Linux、macOS等系统处理网络通信的方式各不相同
- 应用多样性:从简单的文本传输到视频流媒体,各种应用对网络的要求差异巨大
- 距离因素:本地网络与跨洲通信面临的挑战完全不同
协议栈作为解决方案
协议栈(Protocol Stack)通过分层的方式解决了这些复杂性:
分层设计:将通信过程分解为多个层级,每层专注于特定功能
- 物理层:处理电子信号传输
- 数据链路层:处理本地网络帧传输
- 网络层:处理路由和IP寻址
- 传输层:提供端到端通信(如TCP/UDP)
- 应用层:处理特定应用协议(如HTTP/FTP)
标准化接口:每层只与相邻层交互,隐藏下层实现细节
模块化设计:可以单独更新或替换某一层而不影响其他层
TCP的可靠性机制
TCP(传输控制协议)作为协议栈中传输层的代表,其可靠性主要体现在:
数据包确认机制:接收方必须确认收到的数据包
超时重传:未收到确认的数据包会在超时后重新发送
序列号机制:每个字节都有唯一序列号,确保数据顺序正
流量控制:通过滑动窗口机制防止发送方淹没接收
拥塞控制:动态调整发送速率以避免网络拥塞
在这么多强大的协议和办法之下,TCP/IP已经成为了最著名、应用最广的协议,接下来我们会依次学习其中比较重要的机制。
3. 确认应答机制
假设以上是一个client端和server端,client端发消息,server端怎么才能保证自己收到了呢?
server端需要做一个应答,让client知道server收到了。
现在就有一个很明显的问题,client怎么收到server端的应答呢?这是一个死循环逻辑
“客户端发消息,服务器接受并应答。为了保证客户端收到应答,客户端给服务器的应答再返回一个应答,为了保证服务器收到客户端应答的应答。。。。。
这是一个死循环逻辑,我们想一想:服务器真的很关心自己的应答是否被client收到吗?
服务器要同时服务多个客户端,似乎有一种“在中央 被人求着办事的 ”既视感。
尽管总有一条新消息是不能保证的,但是老消息是一定能保证的!
因此,可以认为:
客户端才需要关心是不是真的发过去了,server端不那么关心自己的应答是否被client收到。如果客户端没有应答,客户端会再发一次,再次进行请求即可(客户端求人办事态度要好)。所以,可靠性谈的不是最新消息的可靠性,而是历史消息的可靠性。
所以tcp的确认应答,本质就是一次request和一次确认,如果没有收到确认,就再发送一次。可以说,以现在的角度理解,最初的TCP就像现在的理解一样,TCP发消息是一种“赌”,赌他能不能收到的消息,收不到就再发。
也有可能是服务器给客户端发,所以也有可能是s给c发,c来进行确认。
进一步理解什么是确认和请求以及序号
什么是确认?什么是请求?
请求 一定是 tcp报头+有效载荷
确认 一般至少是一个裸的TCP报头(选项和数据为空)
——————>>>我们得出重要结论,不管是确认还是请求,都只是比较特殊的TCP报头
只不过有可能比较特殊,很多模块可能为空。
进一步理解什么是序号和确认序号
按照以上的收发逻辑,S和C进行通信都是串行的。这使得整体的通信效率极低。在tcp的衍化中,逐渐学会了并发的发消息:
没有异常的情况下,每一个消息都会有应答,这样发送效率会比较高。
并发发送带来的问题以及解决方案(序号)
作为缓冲区,需要保证收到的报文是有序的——乱序的报文也是不可靠的表现。
所以就需要序号的存在了。当一堆报文发过来,服务器可以通过报文的序号来排序,再依次返回。
客户端给服务器发送一个序号,服务器需要给客户端一个确认序号。
一般情况下,确认序号=序号+1
比如上图,并发的发来了序号分别为10、20、30、40,确认序号就是11,21,31,41
****理解这个事,就要先理解序号是怎么来的****:
从代码层、应用层的write或者sendto这些函数开始,即将发送给对面的内容(包括操作系统的内容)都会放到TCP的发送缓冲区中去,这个时候,一条消息(一次write里的内容)就不再重要,它现在已经归TCP管理了,TCP想拆就拆,想合就合
TCP将数据流(被放入缓冲区的数据,不管你是一个string还是一个JSON串,本质都是一个个的char或者char数组)分割成一个个数据段(Segment),每个段作为一次发送的内容——所以一条消息可能都在一个段里,也有可能分布在几个段里。
每个segment,就会开始拥有属于自己这个段的序列号。序列号是怎么确定的呢?有的读者可能听过ISN,不过此处我们做最简单的阐释,既然缓冲区的内容都是一个一个的段,所有的段合在一起本质也是一个char大数组,我们就可以用数组下标来给字节流标记序号。
实际上,第一个位置的序列号就是0,比如第一个segment长度是1000,那么0~999个字节就是第一段的内容,所以此时的序号就是999,而作为对于这一次segment的确认序号,就应该是,就应该是999+1=1000,表示1000之前的字节已经全部解决,下一次该从1000发起。下图就是这个原理,只不过是从1算起,而非0算起。
所以说确认序号等于序号+1,就是说 确认序号 之前的内容以及全部收到了。
TCP的设计还有一个很厉害的点:
确认应答的2001,指的是2001前面的字节已经全部处理好了(其实编号应该是从0开始)
包括1000也处理好了,不用再担心之前的!
4. 捎带应答机制
刚刚的应答,服务器发送的至少是一个TCP报头,但是如果服务器也要给客户端返回数据呢?总不能再重新发一次吧,操作系统不会做浪费资源的事情,一定会就这这次机会再返回一个内容。
所以应答的内容,在很大概率上,即是应答,又是数据。 说白了就是,发出去的不一定是空报头,确认的报文是也可以是有自己的内容的。
而作为两端互相通信的协议,每一次发消息几乎都是对上一条消息的回应,并且有自己的新内容(data)过去。就像微信聊天一样,你说一句,我说一句。。。。。。
所以TCP在正常通信的时候,是在不断的发消息和确认,这样就能高并发的一直进行通信。
综上所述,其实s和c一直在互发报文,因此需要序号和确认序号一起存在。
有时候,OS为了整体更高效,甚至会在三次握手中也采取一定的捎带应答,在捎带应答中会交换一个“窗口”的大小以及开始传输的随机序号。
窗口大小会在后面详细讲解,随机的序号则可以简单理解成:本来下标是0-1000,加入一个随机数就变成了7-1007,更不容易被游离的报文撞对序号,当然这只是一个举例的“加密”,真正的加密可能复杂很多。
5. 流量控制机制(进一步理解缓冲区)
还有一个TCP的字段刚刚没有介绍:16位窗口大小
服务器来不及收的时候,只能在OS层被丢掉。客户端等不到应答,就会执行对应的重发
网络报文路途遥远,千辛万苦来到你的操作系统,你却告诉他不能被接受?
不接受,是一种浪费。OS不会做浪费时间、浪费空间的事,直接丢弃不是最优的解决办法。
所以,需要客户端动态控制自己的发送量,这种类型叫“流量控制”。如何进行流量控制呢?怎么才能得知对面还有多少剩余空间呢——16位窗口大小决定了当前缓冲区剩余空间大小。由于通信两端都需要进行流量控制。
16位窗口区,也说明缓冲区是一个大小为64Kb(实际情况可能不一样,一般为128kb,这个要根据具体的操作系统和网络配置来决定,OS内核在编译时可以修改大小;有一个选项也能调整窗口大小)
只要把自己的接受能力通告给对方,对方就可以进行对应的控制
至此,就可以做两个流量方向的可靠性保证。
那如果s的缓冲区空间很大呢?流量控制不仅仅是减少数据量发送,还可以增加发送。
当缓冲区被发满了,就会对缓冲区进行回绕,把新的报文从0开始编号。此时在0开始的报文应该早就消散了才对。
缓冲区是一个只拿数据报的地方,不论是接受还是缓冲,两个缓冲区拿到的都是只有数据,什么封装和解包他们都不用管,因此TCP没有描述数据有多长的字段,直接根据序列号全部放入缓冲区,这期间没有任何的标志位或者是分割来记录数据的长度或者是边界,所以导致数据与数据之间是没有分割的————形成一个真正的水流一样的紧挨着的数据,这就是“面向字节流”!!
其实TCP报文的缓冲区设计就是一种生产者消费者模型(基于字节流,读写阻塞的生产者消费者模型。)对于接收缓冲区来说,为什么不能去read空,因为此时的缓冲区相当于是交易场所,上层是消费者,网络层是生产者,此时是已经被阻塞队列阻塞,在等待新的资源。
6. 超时重传
在这之前,一直都假设TCP不会丢包。但是如果存在网络波动甚至丢包的情况,此时接收方可能就无法接收到完整的数据或者根本无法接收到数据。对这个问题,一共存在两种情况:
1. 数据真的丢包,没有传过去
2.应答没有成功(报文有可能没丢,只是中途某一个路由器故障,或者某一个路由器压力太大,卡在了路由器里))
在上述两种情况下,发送方难以判断数据是否已被接收方成功接收(因为在发送方的视角都一样)。因此,发送方只能采用一种通用的处理方法,即超时重传。具体而言,超时重传是指在经过一段时间后,如果发送方未收到任何反馈,则重新发送相同的数据。
然而,这种方法面临一个关键问题:如何确定超时时间?理想情况下,超时时间应恰好足够让应答返回,但实际中,由于网络状况和接收方处理能力的差异,这个时间是不固定的。如果设置过长的超时时间,传输效率会显著降低;而如果设置过短的超时时间,可能会导致频繁重传,增加网络负担。为了在各种网络环境下都能实现高效通信,TCP协议会动态计算最大超时时间。
在现代操作系统中,超时时间以500毫秒为单位进行控制,每次判定超时重传的超时时间都是500毫秒的整数倍,并且以指数形式递增。例如,如果第一次重传(第一次等待1×500)后仍未收到应答,发送方将等待2×500毫秒(1秒)后再次重传;如果仍未收到应答,将等待4×500毫秒(2秒)后继续重传。当重传次数达到一定阈值时,TCP会认为网络或对端主机出现异常,从而强制关闭连接。
操作系统是有对应的接口的,可以设定闹钟。操作系统如何设定闹钟???
7. 连接管理机制
TCP是一种需要连接的协议,下面我们学习TCP是如何进行握手和挥手的
学习管理机制之前,了解一下六位标志位的作用(现代操作系统可能有8个)
URG: 紧急指针是否有效ACK: 确认号是否有效(置1表示当前是一个应答,但是不影响他有data返回)PSH: 提示接收端应用程序立刻从 TCP 缓冲区把数据读走RST: 对方要求重新建立连接; 我们把携带 RST 标识的称为复位报文段SYN(synchronization): 请求建立连接; 我们把携带 SYN 标识(SYN位置为1)的称为同步报文段FIN: 通知对方, 本端要关闭了, 我们称携带 FIN 标识的为结束报文段标志位的作用是,是区分不同的报头的类型,有的报头是用于建立连接的,有的是用于数据通信的,有的是用于断开连接的。
在TCP中,客户端和服务端为了可以进行通信,必须先要建立连接,此时就需要进行三次握手,一旦连接创建完成后就会形成对应的连接结构,因为一开始创建了连接,那么最后不再通信时必须要断开连接,此时就需要进行四次挥手,四次挥手结束后,操作系统就会释放对应的连接结构:
所有的请求,不过就是不同种类的TCP报文
标志位的本质,是区分报文类型的!
最重要的是下面三个:
A C K:表明自己是一个确认报文,要关心确认序号 将ACK置1,就能表示自己是企业人保温,因为有捎带应答,所以大部分TCP报文的ACK都是
在教材中,可能写的是发的是一个ACK,其实发的都是完整的报文。
S Y N:同步标志位,建立连接的请求
F Y N:连接断开标志位,通信结束时,进行握手协议
下面是具体的握手挥手过程:
三次握手、四次挥手:
每次客户端发起请求之前,做的就是socket一个fd,执行connect功能,等待服务器的返回
每一轮close之后,如果客户端想发起通话:客户端先发一个SYN(一个SYN是1的请求,但是一定要意识到这是发了一个完整的TCP报文,只是把SYN标志位置1了),
一旦发送,客户端TCP就处于SYN_SENT状态,“就像你追女神的时候发了消息等着她回消息的状态··”
服务器收到SYN,进入SYN_RCVD状态,发起ACK表示应答,同一个报文中同时再发起SYN,表示服务器也希望与客户端同步。
在服务器收到SYN之前,服务器自身就应该经历了我们之前实现demo代码时的步骤:sockfd 、 _fd.family=...._fd.xxx=..(sockaddr结构体进行赋值) 、bind、listen 然后进程卡在accept处,等待接受SYN
客户端收到了女神的答应,并知道了女神请求他成为女神的男朋友,非常高兴,客户端直接进入ESTABULISHED状态,对于客户端来说,已经建立连接成功。然后他再返回一个ACK为1的报文,表示客户端已知晓,服务器进入ESTABULISHED状态
至此,三次握手结束。
三次握手当然有失败的时候,比如最后一次ACK没传过去(只有客户端进入ESTABULISHED状态)——三次握手允许失败的,失败了不过重新再建立连接即可,失败了就不进入下面的数据通信阶段
三次握手,是由操作系统自动完成的。
对于服务器:
accept并不参加三次握手,accept和服务器进程只管阻塞,三次握手是OS的事情,accept不参加三次握手,只是等待三次握手完成。
对于客户端:
connect也没有严格参与三次握手,只是激发操作系统去发SYN
一旦发出去SYN,客户端立刻就变成SYN_SENT状态
只要收到SYN,服务器端就会变成SYN_RCVD
只要客户端发出ACK,就认为自己已经进入esdabulish,对于客户端来说,三次握手结束。
所以,三次握手,以我们现在的认知,本质是“赌”,赌最后一个链接对面收到了。
不确定报文是否被对面收到最后一次ACK。客户端往往先建立好。
四次挥手其实是同理,客户端请求断开,服务器同意断开;服务器请求断开,客户端同意断开,然后就断开了:
也就是说,断开连接需要争得双方同意,需要关闭两个朝向上的连接,因为TCP是全双工的,必须获得双方同意,否则会出现:一方已经被拒绝,不能被发出信息;一方依然可以发出请求,可以进行通信。
维度不同的是,四次挥手发送的不再是SYN请求同步,而是FIN(finish)请求结束。
为什么是三次握手:
其实三次握手和四次挥手是一样的,本质也是c问s答,然后s问c答。但是为了操作系统的高效,s的答就变成了一次捎带应答
那为什么断开连接的四次挥手不能一起呢?
四次挥手也可能变成三次挥手(双方都想直接断开),但是有的时候是服务器没有那么想进行挥手、断开的,所以会等一段时间,这段时间服务器依然可以和客户端进行通信。
以上过程,说明三次握手体现了两个机器建立连接的意愿,除此之外, 三次握手的另外一个作用是为验证全双工信道的通畅性。
所以,三次握手的两个目的如下:
三次握手结束,建立好了连接之后,该如何理解连接呢?
对于整台机器,连接那么多,OS要对连接进行管理。比如说之前的struct Socket,还有TCP连接控制块(TCP Control Block, TCB),等等。
既然吻合我们的老规矩“先描述、再组织”,OS一定就会把这些连接和文件描述符表等这些内容一起管理(因为一个连接就对应一个fd!),管理是有时间成本和空间成本的,当一个连接在通信完成后如果不断开,势必会造成文件描述符泄露和内存泄露。
因此,我们再谈四次挥手,仔细学习挥手的过程和机制。
再谈四次挥手
一个close触发一对FIN+ACK,关闭一个文件描述符
之前谈到了,有如下特殊情况:
1. 当客户端想和服务器断开,但是服务器不想和客户端断开。
现在希望的模式是,服务器给客户端发消息就行了,但是客户端都已经关闭文件描述符了,还如何通信?
close不是TCP层次的内容,上层调用close的时候,表示现在在应用层的意愿就是直接关闭,或者就是双方都希望关闭的状态。因此,如果是上述的特殊情况,是不适合直接调closed的,在此介绍另一种关文件的函数:
顾名思义,how有三个取值,分别是SHUT_WR(关闭write功能),SHUT_RD(关闭read功能),SHUT_RDWR(关闭write、read功能,和调用closed(fd)就没什么区别了)
由应用层控制使用shutdown,应用层自己选择怎么关,从而是可以实现半通信的。
当客户端发起断开,先经历:
客户端发起断开(也就是代码层调用了close),客户端从ESTABLISHED转移到FIN_WAIT_1,并且发送了一个FIN;服务端收到之后变成CLOSE_WAIT,返回一个ACK(注意,这个ACK是一定会返回的,如果服务器不想断开,只是不发FIN,而不是不返回ACK)
(注意,以上只是客户端发起断开的例子,也有可能是服务器发起断开,过程完全对称)
服务器如果不关闭应答,就会一直处于一个CLOSE_WAIT的状态,也就是一直不发LAST_ACK 对应的FIN ,也就是服务器端一直不调用close或者shutdown,那么此时只会进行两次挥手,客户端(发起断开的一方)也会一直处于FIN_WAIT_2状态。
可以使用之前我们的demo进行实验:
客户端一旦在FIN_WAIT_2状态比较久了,就会进入CLOSED自动结束。但是CLOSE_WAIT不会自动结束,这会导致文件描述符泄露和比较严重的内存泄漏
另外一种情况,如果是服务器在CLOSE_WAIT状态下发送了FIN,也就是处于LAST_ACK状态的话,但是没有收到主动发起断开方的ACK(可能是发生了丢包!),是会自行断开进入CLOSED的。所以,希望读者正确理解这个ACK,ACK是一种一定会返回的应答机制,而没有“如果我不想断开,我就不回ACK”的说法
故,FIN_WAIT_2 & LAST_AC都是一段时间之后会自动关闭的
TIME_WAIT
主动断开 连接的一方,在收到被动断开的一方发送的FIN后,会进入TIME_WAIT状态
TCP 协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个 MSL(maximum segment lifetime)的时间后,才能回到 CLOSED 状态
TIME_WAIT有两个作用:1.处理完本次通话的游离的报文 2. 尽力保证最后一次ACK发送到位
先解释什么是MSL:MSL是报文在网络中存活的最长时间,也就是说,任意报文都会在一个MSL中完成一次发送方到接收方的通信。并且MSL是通信双方都会计算并知晓的一个时长
如果没有TIME_WAIT,游离的报文会怎么样?
对于游离的报文,当双方没有建立通信连接的时候,一般的游离报文就会被直接丢弃。
***如果没有time_wait***
假设今天是服务器关闭连接:
所以现在其实是 服务器发出关闭请求FIN,服务器发出后进入FIN_WAIT_1,客户端收到FIN后进入CLOSED_WAIT,并发出ACK回应,服务器收到ACK后变成FIN_WAIT_2(此时已完成两次握手)。客户端再发出FIN,服务器收到后进入TIME_WAIT阶段。
如果今天TIME_WAIT不进行等待,而是直接关闭(也就是进入CLOSED),并且此前有游离的报文一直卡在网络中的,正卡在了路由器的某处但是正努力进行传输。如果是两个端口建立TCP连接之前,这样一些游离的报文自然都会被抛弃——但是倘若已经断开两个连接,又在这批游离报文到达之前进行三次握手、建立起了连接,那么之前游离的报文就会被接受了。相当于是第二次连接收到了第一次发送的报文。这样一些报文,可能早就因为超时问题被补发了,所以如果再被接受,容易造成错误!万一序列号再对应上了,就很可怕了。
所以,为了避免这种情况——尽管概率极低,但是每天有数以万计的人在不停的发起请求,根据大数定律,必须有TIME_WAIT的等待 ,让还在网络中游荡的报文都能被接受或者返回。
以上情况相对来说更容易在服务器上出现,假设有一个服务器挂了,他必须马上重启,很可能会二次连接,收到之前的错误。
以上是第一个必须要有time_wait的理由;第二个必须要有Time_wait的理由如下:
应用层调用closed(fd); 并不会真正关闭文件描述符,而是要等到通信中的四次握手结束,进入CLOSED状态,才会真正不能通信,所以TIME_WAIT的第二大作用就是,保证最后一次返回的ACK能尽量正常返回(总有直接丢包的时候!)。
对于发起LAST_ACK的一端来讲,如果不能收到这个ACK,他就会重复发LAST_ACK对应的FIN,直到发起断开的一方返回最后的ACK。如果一直不返回最后的ACK,就会自动断开,这样自然是不好的,我们希望能通过正常渠道断开,就需要这个TIME_WAIT来维护,返回这个ACK,让被动断开的一方能更加高效的断开。
MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同, 在 Centos7 上默认配置的值是 60s;
现在就能理解为什么会bind失败了:因为主动关闭连接的那一方(比如直接结束应用层程序),会引导TCP层也开始断开连接。如果是先断开的服务器(即服务器是主动断开的一方),那么服务器会进入TIME_WAIT状态,服务器会占用这个端口等待两分钟,而一般情况下,端口和进程是一对一严格绑定的,OS不会允许你新启动的进程去bind这个还在被占用的端口!
那为什么先断开客户端没事呢?因为客户端都是OS自动绑定(之前文章里有讲,在sendto或者write的时候自动绑定),再次启动的时候不会出现port冲突的情况。
处理办法:设置服务器的时候表示允许地址复用
让我们深入理解为什么 TIME_WAIT 状态需要持续 2倍的MSL:
MSL(Maximum Segment Lifetime)是 TCP 报文在网络中的最大生存时间。将 TIME_WAIT 设为 2MSL 的主要考虑是:
- 确保双向传输中所有延迟或未被接收的报文都能完全消失。这样当服务器快速重启时,就不会收到上一个连接残留的错误数据包。
另一个重要作用是保证连接可靠终止:
- 如果最后一个 ACK 丢失,服务器会重发 FIN 报文。
- 虽然客户端进程可能已终止,但 TCP 连接仍处于 TIME_WAIT 状态,可以响应这个 FIN 并重发最后的 ACK,确保连接正常关闭。
8. 进一步理解流量控制(Flow Control)
我们已经了解了,TCP通过报头的16位窗口大小来知道对方的接收缓冲区还有多少空间,也简单了解了确认序号机制
假设在今天的极端环境下,接收方的应用层一直没有去缓冲区取数据:
窗口大小为0了,理论上发数据的一方就不方便再发数据了。
作为主机A,怎么知道多久可以二次再发了呢?
所以等待期间,可以发送一个窗口探测的包——一个只有报头 没有数据的包(DATA为空依然是一个完整的TCP报头!),这样可以让主机B进行返回一个带有剩余空间大小的应答。
除此之外,如果 主机B有空间了,也会主动通知 主机A
为了避免两个方法其中一个丢包的问题,所以更新通知和窗口探测都会一起用
16位窗口大小太小怎么办?
二进制下,左移就是*2。
再次强调,流量控制!=减少流量,比如下图,第一次只发一个,第二次发了一批,这就是流量控制时在增加发送的流量
首次发送,怎么知道对方的接受能力?
这也是三次握手的时候的一个小目的,三次握手就一定互相知道接受能力了。
9. 滑动窗口
之前有讲过,串行发送的效率实在是太低了
在真实的TCP中,一定是会进行并行的批量化发送和应答,可是每次发送多少内容呢?
这其实是一个流量控制问题,滑动窗口可以说就是用来辅助流量控制的,毕竟刚刚的更新通知和窗口探测只是用于处理剩余空间为0的极端情况的
因为我们有流量控制,注定了主机发送的内容是有限的。
假设左侧是发送缓冲区,右侧是接收缓冲区:
滑动窗口,本质是发送缓冲区的一个区域。红叉代表的是有数据的地方,剩下的白色是可以用来放数据的地方。既然我们把整个缓冲区看作是一个大的char数组,那一定可以用一个start下标和end下标标识出对应的滑动窗口。
对算法有了解的朋友都知道,滑动窗口是左右指针都只做+不做-的一种算法,而滑动窗口的大小就是当前对方接受缓冲区剩余空间的大小,也就是对方的接收窗口的大小。
由此不难推出:滑动窗口管理的其实就是 无需等待确认应答而可以继续发送数据的区域
此图中只发送了一个segment,其实是可以一次性并发的发送四个段(说明此时接收缓冲区还有4kb的大小)
发送这四个段的时候,不需要等待任何ACK,直接发送;收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据;依次类推;
为什么收到一个ACK之后就可以移动?
返回的ACK里包含当前接收方的接收窗口大小,也有当前的确认序号。每当一个ack返回,start = 确认序号
end = start+窗口大小
因为返回一个确认序号(假设今天是1001),表示1-1000的所有字节都已经发到了,下次可以从1001开始!
每次滑动,都是根据返回的报文来的,所以可以认为,流量控制的本质就是滑动窗口。
滑动窗口的大小是在一直动态变化的,当滑动窗口大小变成0的时候,就表示当前没有可以直接并行发送的字节段,需要窗口探测或者更新通知出马。
关于没有丢包等等异常情况时,几个常见的问题:
1.如果start+窗口大小=end超过发送缓冲区,会导致越界吗?
把缓冲区想象成环形队列!滑动窗口之前的“已发送、已确认”部分,其实就表示已经可以被覆盖(数据可删除)了。依然可以理解成一个生产者消费者模型,发送方的ip层是消费者,发送方的应用层是生产者。
2.窗口越大,则网络的吞吐率就越高
3.为什么滑动窗口里的内容要分段并发?直接组合成一个大报文不行吗?
因为数据链路层不能发送那么大的内容!所以由于底层限制,TCP的报文也必须分段
关于滑动窗口的异常丢包问题:
根据我们之前对丢包问题的学习,我们知道丢包有两种情况,要么数据丢,要么应答丢,但是对于今天的主机A来说,两个都是一样的,都需要进行重传。
1. 如果窗口中的左端报文丢失
也就是1-1000如果丢失,由于确认应答机制中确认序号的定义(确认序号表示该序号之前的数据已经全部发到了),此时四次并发的报文返回的、连续的确认序号都是1,客户端就确认,至少1~1000就丢了(有可能还丢了其他报文,只是先处理字节流中靠前面的内容)。
根据滑动窗口计算公式,start不会左移,而是会下次再补发1-1000。并且,接受端会自动把后面三个正常发送的报文放到接收缓冲区对应的位置上(简单理解就是,给定一个已经开辟出来空间的数组,数组下标就是序号,这个时候来了10~20下标的数据,他就直接存放到10~20下标出,来了0~9下标数组,直接存放到0~9下标处),会留出1-1000的位置,不需要对1001-4000都进行补发。当第一个1-1000的补发数据到了,确认序号会直接跳到4001,表示这一整个的滑动窗口内容都发完了,接下来就可以根据新返回的应答去右移出新的滑动窗口的位置了。
2. 如果是中间或者最右端的报文丢了
最右边或者中间丢了,再经过一次正常的ACK之后都会进行正常的滑动窗口移动,此时就会转变成最左边丢包的情况,可以被正常补发。
更不需要担心如果多个丢包的问题,反正会按照序列号,从最小的开始处理和移动,保证数据报全部都发出去
高速重发控制(快重传)
当我们发出去我们的数据,如果一定时间内没有收到,就要进行超时重传。
不过,为了让滑动窗口能更快速的滑动,有一个新的补发机制。
当某一段报文段丢失之后,发送端会一直收到1001这样的ACK,就像是在提醒发送端"我想要的是1001"一样,如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;这个时候接收端收到了 1001 之后, 再次返回的 ACK 就是 7001 了(因为 2001 -7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
快重传是一种提高效率的方法,超时重传是一种兜底的、保障的重传方法。
10. 再谈标记位
不熟悉的还有URG PSH RST
关于PSH 提示读走缓冲区数据
PSH: 提示接收端应用程序立刻从 TCP 缓冲区把数据读走比如接受缓冲区一直满了,但是上层一直没有取走,这个时候就会有这个标志位,表示提醒接受方尽快提走数据;同时也在提醒对方当下我发送的数据很重要,请及时取走发送PSH(push),也会让接收方的一些系统调用的条件就绪,比如唤醒一个之前在read的进程
RST: 对方要求重新建立连接; 我们把携带 RST 标识的称为复位报文段比如:三次握手的ACK没有发过去,客户端直接开始发DATA,服务器还在SYN_RCVD状态,此时服务器就要返回RST(reset)变成1的的报文。
关于RST和丢弃游离的报文
收到一个游离的报文之后:
第一种情况,检查连接,连接如果不存在,那就说明当前报文所属的连接已经关闭了,应该回复一个rst,表示这里没有你要的链接;
第二种情况,如果当前的连接还在,但是这个报文不在合法的窗口内,那就说明已经过期了,直接丢弃不回应rst注意,回复rst只是说明建议你重置连接,至于要不要发起连接,是另外一端的事情,和当前的这台机器无关。
关于URG紧急指针
如果想让一些数据提前、插队到达,就目前的学习来说,因为有按序到达的确认机制,暂时是做不到的。
可是实际生产中是有这种需求的,比如:终止上传,当你的文件正在传输,你希望立刻终止上传,是不是需要这个“终止指令”快马加鞭的先传到对端上去,而现在应该已经有不少的数据在传输的缓冲区中排队了。
于是就有了URG紧急指针,URG置1表示当前有这个选项,然后我们要通过16位紧急指针去寻找这个紧急数据。
TCP设计出了这样的“插队系统”,但是不允许你有大量的插队,因此紧急数据一般只占一个字节
这就是16位紧急指针的意义。
16位紧急指针,表示的是一个特殊的偏移量,说明紧急数据在哪里。
例如,如果紧急数据是1个字节,且从数据流的第10个字节开始,那么紧急指针的值将是11。
并且,紧急指针一般就是一个字节,是一种信号,类似于enum中的一种类型,告诉对端当前应该进入一种怎样怎样的状态
上层如何读紧急数据?
对于应用层来说:
send(sock, "E", 1, MSG_OOB); // 发送一个字节的紧急数据 "E"
其实就是一个选项的事——MSG_OOB,表示此时有带外数据,需要特殊处理
接受和发送都必须带上此选项。
char buffer[1]; recv(sock, buffer, 1, MSG_OOB); // 读取紧急数据
对于应用层,其实就是一个flag的问题。看似是直接单独发了一个紧急数据,但追求高效的操作系统一般不会只发一个字节的报文,这个紧急数据一定是和其他缓冲区中的内容放在一起的,因此才有了刚刚的16位紧急指针作为偏移量的说法。
接受收时,读取不会像一般的数据那样去normal流里读,他有不一样的读取方式,单独获取,此处不再过深介绍。
11.拥塞控制
-----------------------目前还没有考虑过网络的通畅情况
前文中我们强调,通过对端接收缓冲区剩余空间大小,调整自己TCP层的滑动窗口大小
事实是,除了对端的接受能力,当前网络的状态也会很大程度上的影响当前的传输,就像买了一台最高时速200km/h的车,但是在市区的下班高峰的时候,你能开的最高速度仅仅取决于当前有多堵。
今天简单了解一下TCP是如何通过“拥塞控制”来应对网络拥堵的。
少量数据包丢失可以重传,大量的丢包,一定不是对端或者本端的问题,一般会判断为网络问题。现在如果进行重传,还会加重网络的负担,进一步加重网络的拥堵 。
因为服务器不止是和你一个客户端在进行通信,当网络拥堵,所有和服务器在进行通信的都客户端都出现了大面积的丢包(数据报堵在各个路由器里),当每个客户端都进行重传,无疑是对整个网络的雪上加霜。
拥塞控制是一种给大家的策略,只要识别到了拥塞,大家都会等
TCP中还存在一个拥塞窗口(Congestion Window,简称cwnd),但拥塞窗口并不是一个真正的窗口(像滑动窗口那样被start和end维护起来),而是一个计数器。
拥塞窗口和对端接收缓冲区剩余量一起用于调整当前滑动窗口大小,可以简单的理解为,滑动窗口 = min(对端接受缓冲区大小,拥塞窗口),拥塞窗口是用于去探测当前网络到底能有多大的承载量的参数。
首先,TCP 会执行 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按 照多大的速度传输数据;
拥塞窗口的初始值通常为1个MSS(最大报文段长度,单位为字节,比如上图可以理解成是1000字节)。
增长方式:在慢启动阶段,每收到一个ACK,拥塞窗口增加1个MSS。拥塞窗口呈指数增长(也就是收到的ACK也是指数级增长)。
慢开始,指的不是开始的时候很慢,而是开始的时候网络吞吐量很低。
现在被称之为快速恢复阶段,既指数倍增加,当达到一个拥塞窗口的阈值的时候,就会进入拥塞避免状态,从指数增加变成线性增加。因为滑动窗口取的是两个数据的最小值,所以其实不会被特别大的、指数级别的拥塞窗口数量干扰,但是为了让这个数据具有实际的对网络的描写能力,所以不能一直指数增加,适当时候需要变成线性增加。
如果发生了网络拥塞(或者连续三次相同ACK触发快速重传),cwnd都会从1开始重新“快速恢复”,并且此时的网络拥塞阈值会“乘法减小”,更快的进入拥塞避免。
拥塞窗口为什么要一直变化?
拥塞窗口除了指导滑动窗口变化,同时也是网络健康情况的评估,而网络的拥堵情况,也一定是随时变化的!每次拥堵后重新从1开始,就是重新开始探测网络的健康情况。
拥塞控制的目的,就是让网络不拥堵的情况下,尽快把数据都传过去。
12. 延迟应答
------------------------------一种不一定有用的设计
设计思想是:收到了对应的报文,先不着急做应答, 等待当前的滑动窗口大小更大之后(网络吞吐量更大之后),再做出应答
这是一个“不做一定没用,做了可能有用的一个机制”,因为网络吞吐量可能直接就被网络堵塞这一层限制了。
理解延迟应答就是理解一个事情:
窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;1m数据可以通过两次500k来发送,但是一定不如一次1m的并发来的快。
例如:
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小.假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口就是 500K;但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费掉了;在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的窗口大小就是 1M;
当然,为了和之前的机制统一,延迟应答也不会毫无限制的“延迟”:
数量限制: 每隔 N 个包就应答一次;时间限制: 超过最大延迟时间T就应答一次;
隔包太长了一定会导致超时重传,所以隔包的时间不会太长,一般的N的值也就是2,T的时间就是200ms(远小于超时重传的时间)
一定程度上也是利用了确认序号的定义——xxxx表示xxxx之前的内容都已经到达!
所以,如果四次传输,1001 2001 3001的确认序号都丢了,只有4001成功返回,也不会出错,也可以看成是一种延迟应答。
13.粘包问题
用户层基于字节流读取应用层报文的时候,会发生无从下手的情况,根本不知道哪是哪。
因此在应用层,我们要有明确报文边界的方式。
对于定长的包, 保证每次都按固定大小读取即可; 例如上面的 Request 结构, 是固定大小的, 那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可)
注意,以上都是应用层的工作,TCP只负责把字节流丢进接受缓冲区!
14.TCP连接异常
建立好的TCP连接,在面对异常情况,比如突然关闭了某一端的进程,会发生什么呢?
进程终止: 进程终止会释放文件描述符(文件的生命周期是随进程的), 仍然可以发送 FIN. 和正常关闭没有什么区别。因为四次挥手等内容都是操作系统自行完成的,不需要应用层参与!机器重启: 和进程终止的情况相同.机器掉电/网线断开:假如今天客户端突然网线被拔了客户端浏览器发现自己的网没了,客户端连接检测到网络波动,就会释放掉当前连接,但是服务器会认为网络还在。当服务器感觉客户端一直不活跃,客户端不回消息,服务器就会进行写入、尝试联系:
一旦接收端有写入操作, 接收端发现连接已 经不在了, 就会进行 reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.另外, 应用层的某些协议, 也有一些这样的检测机制. 例如 HTTP 长连接中, 也会定期检测对方的状态. 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接.
虽然在 TCP中都有自动的连接保活,但是具体的保活机制应该更倾向于应用层。
比如QQ,有自己的保活机制——当客户端一直不发消息的时候,只是保持登录状况,难道需要服务器一直去询问吗?所以客户端很可能会悄悄的、不让用户知道的,每隔一段时间就给服务器发消息,进行保活,只不过这些都是在应用层实现的罢了。
这就是TCP的简介,一个对传输可靠性和传输性能都有要求的协议。内容几乎全部手码,感谢写到这里的自己和读到这里的你(如果有人读到这里的话)^ - ^