【Linux网络】TCP协议

目录

什么是TCP协议

TCP(Transmission Control Protocol,传输控制协议)是互联网协议套件(TCP/IP)中一个​​面向连接的、可靠的、基于字节流​​的传输层通信协议。它确保了数据在网络中有序、无差错地传输,是许多互联网应用(如网页浏览、电子邮件、文件传输)的基石。

TCP的缓冲区

TCP既有发送缓冲区也有接收缓冲区,所以TCP是全双工的,应用层将数据交给传输层TCP,本质是交给操作系统,因为传输层和网络层是被写进了操作系统中的。应用层属于用户层,应用层缓冲区的数据和传输层TCP的缓冲区的数据进行交互,也就是发送写入接受读取,TCP缓冲区的数据不需要用户管理,这是由操作系统管理的,什么时候发送到网络,什么时候从网络接收,都由系统决定,这和文件系统很像,包括套接字那一套,缓冲区满写阻塞,缓冲区空读阻塞,也符合Linux一切皆文件的思想。

TCP协议格式

相较于UDP,TCP协议的报头明显长了很多,也复杂很多。

首先是源端口号和目的端口号,这个不必多说。

然后是4位首部长度,它表示TCP的报头长度,TCP的报头和UDP不同,它是不定长的。前二十字节我们称为标准包头,是20字节,加上选项后组成完整报头,因为选项可以不带,要带也可以带多个,所以TCP的报头是不定长的。因为首部长度只给到了4位,所以能表示的范围是 [0, 15] 看起来很小,连标准报头的大小都不到,但是其实4位首部长度是由单位的,单位是4字节,也就是1首部长度对应4字节,所以由此推导TCP报头长度范围是 [0, 60] 字节。选项的长度范围是 [0, 40] 字节。当我们读取TCP报文时,首先读取固定的20字节标准报头长度,得到4位首部长度后再进一步读取选项。

16位窗口大小用于传递自己接收缓冲区所剩空间的大小。在TCP中,有着确认应答机制,因为TCP需要保证数据传输可靠性,所以一条报文发出对方收到后就要进行应答,应答时16位窗口大小可以传递自己接收缓冲区所剩空间的大小,收到应答的一方可以根据对方接收缓冲区所剩的空间调整自己发送的速度。当一方的接收缓冲区被填满接收方来不及处理这些数据时后来的数据就会被直接丢弃,虽然TCP在发出报文收不到应答时有超时重传机制进行重传确保数据不会丢失,但是对于已经准确传递到位的数据直接进行丢弃再重传无疑是对网络资源的浪费,所以传递自己所剩接收缓冲区的大小给发送方进行动态调整无疑是更好的。在一开始进行TCP通信时,得知对方的缓冲区大小呢?因为有可能会出现对方缓冲区过小导致第一条报文导致缓冲区溢出的问题,所以实际上在TCP3次握手时会提前交换这些数据。

在使用TCP进行数据传送时,可能遇到数据较大的问题,这时就需要分段发送,虽然TCP本身并没有对有效载荷的长度有限制,但是介于网络传输能力,该分段时还是要分段的。那么问题来了,和UDP一样的,对数据进行分段发送,介于网络情况的变化,报文到达的顺序和报文发送的顺序可能不一样,这时该怎么办呢?UDP因为其不保证可靠性,所以没有做处理,TCP又该怎么办呢?为了应对这种情况,TCP让发送的数据的每一个字节都天然有自己的编号,这和数组下标类似,当我们发送数据时,就会在32位序号(seq)这里填入发送的数据块的最后一个的编号。

当接收方成功接收这样的报文时,就会根据32位序号,将其+1,填入应答报文的32位确认序号(ack)当中,返回给发送方,意思表示32位确认序号之前的报文我都收到了,下次请从32位确认序号开始发。比如发送方发送的报文中的32位序号是1000,那么接收方返回1001,表示1001之前的所有数据我都收到了,下次从1001开始发。那么到这里就有几个问题了。首先,为什么需要单独给这两个序号设置字段呢?发送方设置好32位序号发送,接收方接收到后直接将32位序号原地+1返回不就行了,这样一个字段两用节省了数据,岂不美哉?但是这是建立在发送方发一个数据接收方就必须回一个应答这样一板一眼的模式下的,打个比方就是:A问B要不要出去吃饭,B先说好啊,然后又说去哪里吃?B其实可以直接问去哪里吃,因为直接这样问就是默认答应了要出去吃饭了。在实际的TCP通信中,这样的情况也是存在的,A向B发送数据,B成功接收之后一方面要回一个确认应答,另一方面B也想要向A发个数据,这时如果分两次,那未免有些挫了,因为TCP的通信起码得有一个报头,也就是起码20字节,分两次太浪费网络资源,所以TCP允许一个报文既携带自己的数据,又携带32位确认序号对收到的报文进行应答,这种报文我们称之为捎带应答。要携带自己的数据,那就要有自己的32位序号,所以我们有必要将32位序号和32位确认序号分开。其实这本质是TCP全双工的体现,全双工可以同时收发数据,我在给你发数据时你也会在给我发,而我们俩又因为是TCP,所以有必要对对方的数据做应答,所以将应答和发送数据合并成捎带应答,这是TCP协议的一种优化机制。此外,在实际TCP通信时,要发送的数据的起始序号都是固定的吗?如果是固定的,如果出现了一个连接断开又重连,这时之前旧连接的报文因为一些原因到达了,因为起始相同,接收方无法判断这个报文是否有效怎么办?其实TCP采用的是随机初始序列号(ISN),这样每次建立新连接时的起始序号都不一样,有效防止防止旧连接报文干扰新连接,此外,随机起始序号也可以防止序列号预测攻击,如果ISN从一个固定值(比如0)开始,并且每次建立新连接都简单地递增,那么攻击者就​​很容易预测出下一个连接的初始序列号​​。随机初始序列号在TCP三次握手时确定。最后,为什么确认序号要表示确认序号之前的数据都收到了呢?为什么不是单次报文收到了呢?因为确认应答也是报文,也可能丢失,所以即使中途有几次报文丢失,只要最后的几次报文收到了就认为之前发的报文都收到了,比如我发了1000、2000、3000的报文出去,最后只收到了3001,我也会认为之前的1000、2000、3000都被接受了,TCP的数据包虽然是串行发送的(一个个发送数据包),但是通过滑动窗口机制,让在不接收确认应答的情况下也能发送下一个报文,实现了串行发送的效果。如果发送的多个报文中有一个没有收到怎么办?比如A发送了1000、2000、3000、4000、5000序号的报文,B收到了1000、2000、4000、5000,就是没收到3000,那么B向A发送的应答报文的32位确认序号会是1001、2001、2001、2001(假设A发一个B对应一个),也就是即使我后面收到了4000、5000数据,我还是只收到了2001前面的全部数据,这样发送方A的3000报文迟迟等不来确认应答,就会触发超时重传,没错,每一个发出去的数据在没有收到应答时都还会存在TCP的发送缓冲区中,并且设置一个时间,这样在时间到了的时候还没有收到应答就会将数据重发。这样当B收到3000数据时,就会直接发5001的确认应答了,因为4000和5000之前都收到过了。注意不携带数据的纯ACK报文段​​仅作确认之用,若不携带应用数据,则不占用序号空间。下一个数据报文段的序号保持不变。

在进行TCP通信时,需要发送各种报文,TCP进行握手连接时需要发送报文,传输数据时需要发送报文,挥手断开连接时也需要发送报文,这些虽然都是报文,但是它们的种类作用都不相同,接收方需要得知这些报文的种类,进行相应的操作,所以我们有必要区分这些报文,TCP报头中的6个标记位就是用来区分这些报文的。

ACK :确认号是否有效。之前我们也说过,为了可靠性,TCP有确认应答机制,当一条报文是应答报文时,ACK标记位会被标记(=1),表示32位确认序号是有效的,这是一条应答报文。由于有捎带应答机制的存在,所以除了第一条请求连接的报文ACK绝对没有被标记之外,其余的报文绝大多数ACK都被标记了。
SYN :请求建立连接,我们把携带SYN标识的称为同步报文段。SYN被标记时就表示这是一个​​连接请求​​或​​连接接受报文。它是TCP三次握手的发起信号。
FIN :通知对方,本端要关闭了。当FIN=1时,表示数据发送完毕,本方​​请求终止连接​​。它是TCP四次挥手过程的标志。
PSH :提示接收端应用程序立刻从TCP缓冲区把数据读走。当发送方 TCP 协议栈将一批数据发送出去后,如果​​发送缓冲区变空​​(没有更多待发送数据),它通常会在最后一个报文段中自动设置PSH标志。目的是告知接收方"我这批数据发完了,你可以立刻将已接收的数据交付给应用层,不用再等了"。这有助于减少接收端的缓冲延迟。在四次挥手时,也能看见PSH标记。一些需要保证数据及时性的场景中,也能多设置PSH标记位。
RST : 用于强制终止TCP连接。当该标志位被置为1时,表示发送方要求立即重置连接。RST是TCP协议中唯一的"强制终止"机制,不同于FIN的优雅关闭,它不经过四次挥手流程。比如在TCP建立连接的三次握手中,第三次握手报文客户端在发送时就已经认为连接成功了,因为此时它也不会收到确认应答了,但是对于服务端来说还没有连接成功,收到了第三次握手的报文时才认为是连接成功,但是这时报文丢失了,这时客户端认为连接已经成功,开始发送数据,服务端收到了没有连接成功的客户端发来的数据,一脸疑惑,所以它就会发送RST标记的报文要求终止TCP连接立刻重置。TCP虽然可靠,但是其允许连接建立失败。RST报文通常在以下场景被触发:

异常连接请求:向未监听的端口发起连接。

半开连接:一方已崩溃,另一方继续发送数据。

协议违规:收到不符合预期的序列号。

安全策略:防火墙主动阻断连接。
URG紧急指针是否有效。这是相对少见的一个标志位。当URG=1时,表示报文段中包含了​​紧急数据​​。此时,报文首部中的​​紧急指针​​字段会生效,用于指示紧急数据在报文数据部分中的位置,是一个偏移量。紧急数据通常被视为"带外数据",允许接收方应用程序优先处理。比如当一个服务器非常忙,一个客户端向其发了好几次数据了都没有收到确认应答,这时就可以发送一个URG标记的报文,服务器即使很忙,但是对于紧急指针还是会优先处理,这样返回一个报文说明现在的情况客户端再做下一步处理。紧急指针指向的数据不会很大,通常就是1字节而已。

确认应答机制


和我之前说的一样,TCP为了保证其可靠性,拥有确认应答机制。假设起始序号是1,发送1000字节的数据,那么32位序号就是1000,接收方返回的确认应答的32位确认序号是1001。

当然实际的报文收发也不会像上面这样一板一眼,由于滑动窗口的存在,发送方可以让多个报文等待应答,继续进行下一个报文的发送,而确认应答也不必是非要一个报文对一个确认应答,哪怕其中一个报文丢失了,只要后面的应答收到了,就表示前面的报文都收到了。

超时重传机制

在瞬息万变的网络中,数据丢包随时可能发生。对于UDP来说,其不保证可靠性,所以对于丢包问题,它是直接不管的。但是对于TCP来说,就要解决丢包问题,解决的方法也是基于确认应答机制,发送出去的数据必须要有回应,如果出现一个报文长时间不回应,就会触发超时重传机制,发送方就会再次发送没有得到回应的报文,可见TCP的发送数据在得到回应前是不会丢弃的。

超时重传也是分情况的,

可能是自己发送的数据本身丢包了,也可能是自己发送的数据没有丢包,但是对方发送的应答丢包了。对于第一种情况,数据本身对方并没有收到,所以重发看起来没有问题,但是对于第二种情况,数据本身对方收到了,但是又发了一次,这会不会出错呢?不会,因为报文本身有序号,两个最近发来的数据拥有相同的编号接收方会将其丢弃,这是编号的有一个用途。

超时时间怎么制定呢?看起来只要随便指定一个时间就行了,但是事情可没有看上去那么简单。如果时间过长,那势必影响传输效率,因为光等就要花掉不少时间;如果时间过短,那么有可能数据本身并没有丢,它只是在来的路上,快要到了结果你说我等不及了又发了一份,那么接收方就会频繁地接收数据,虽然是有识别序号丢弃的机制,但是它还是浪费了时间,浪费了资源,这是不好的。所以我们应该找到一个平衡,保证"确认应答一定能在这个时间内返回",这样的时间也并不是固定的,因为这回随着网络环境变化,网络环境好,数据传达地快,那么这个时间就可以相对短一点,如果网络环境差则反之。所以TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间:

Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。

如果重发一次之后,仍然得不到应答,等待 2500ms 后再进行重传。
如果仍然得不到应答,等待4
500ms进行重传,依次类推,以指数形式递增。

累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。

连接管理机制

TCP连接管理的核心在于3次握手 + 4次挥手

3次握手

首先在正常的网络服务中,一般是客户端想要连接服务端,所以先发送连接申请的是客户端。

首先,客户端会将一个TCP报文的​​SYN标志位设置为1​​,同时​​随机生成一个初始序列号​​(例如 seq = J),然后将这个报文发送给服务器。完成后,客户端进入 SYN-SENT状态。

服务器收到SYN报文后,需要做出回应。它会将报文的​​SYN和ACK标志位都设置为1​​。其中,​​ACK号是对客户端SYN的确认,值为客户端序列号加1(即 ack = J+1)​​。同时,服务器也会​​随机生成自己的初始序列号​​(例如 seq = K)。发送这个SYN+ACK报文后,服务器进入 SYN-RCVD状态。

客户端收到服务器的SYN+ACK报文后,会进行验证。确认无误后,客户端发送一个​​ACK标志位为1​​的报文,其中​​确认号 ack = K+1​​。发送完毕后,客户端进入 ESTABLISHED状态,表示连接已就绪。

当服务端收到客户端发来的最后一次握手的报文后,它就会确认双方的双向连接已经成功建立。随后,服务端的状态​​由 SYN-RCVD 转变为 ESTABLISHED​​。进入ESTABLISHED(已建立连接)状态后,服务端就可以开始与客户端进行正常的数据传输了。

所以我们也能明白,就像我之前所说的,客户端在发送第三个报文之后就认为自己已经建立连接了,因为他也不会收到应答了,所以这时要是这个报文丢失,服务端没收到这个报文也就不会进入ESTABLISHED状态,这时客户端正常传送数据,服务端收到就会一脸疑惑,也就会发送之前所收的RST标记的报文了。

了解完三次握手之后,我们有必要来好好分析一下,为什么一定得是三次握手呢?

首先,我们得明白,其实这里说是三次握手,但是实际上可以认为是四次,因为第二次握手的报文是SYN + ACK,这就是我们所说的捎带应答,本来我应该是先发ACK应答,再发SYN请求的,但是为了高效,用了捎带应答的方式。所以三次握手,本质上对通信双方的手法能力都做了测试,客户端发送报文,又收到了服务端的应答,这就表示:我能发数据,因为我发的数据对方收到了,给了应答,同时我也能收数据,因为对方发的报文我也收到了。而对于服务端来说,首先我收到了客户端的请求,我在返回应答的同时也发送了请求,随后客户端返回了应答,同样就表示我有能发送数据的能力,同时也有能接收数据的能力。所以,三次握手是确认通信双方都有通信能力的最低握手次数了。

另一方面,客户端和服务器是 1 : n 的关系,而对于操作系统来说,TCP/IP四层模型中的网络层和传输层属于操作系统内核,TCP协议所在的传输层要想管理与其连接的大量主机,势必要维护一些数据结构,如果与一台主机连接成功,势必要往维护的数据结构中增加一些数据,如果客户端和服务端只握手一次会发生什么呢?客户端发送数据,服务端接受,此时就认为连接成功,服务器分配一部分内存等资源存储建立的连接信息,如果遭遇恶意攻击,服务器收到大量虚假SYN请求,就会快速建立大量连接,把服务器资源占满,这时再有正常用户想要访问,就访问不了了,这就是最简单的SYN洪水。那如果是两次呢?一样的,因为如果是两次握手,那么就是客户端发送请求,服务端返回响应,服务端同样是在返回响应之后就认为连接已经建立成功了,和第一次没差,遭遇SYN洪水同样还是会招架不住。如果是三次握手呢?此时我们就会发现,如果是三次握手,那么最后一个报文的发送就到了客户端手上,也就是说,这此就是客户端先建立连接,服务端次之了,这样会有什么影响呢?如果这时出现大量的SYN请求,那么服务器及时收到了也不会第一时间建立连接,而是返回SYN + ACK报文,等待客户端的回应,而虚假SYN报文中的IP地址一般又都是伪造的,所以回应不了,这样就能一定程度上防止SYN洪水。我们会看整个过程,服务器会遭受攻击的原因究其根本还是因为在偶数次情况下客户端先发消息导致最后一次报文是服务器先发送导致先建立连接的人是自己,可能有人要说那1次怎么也不行呢?因为1次和0次没差别,一次报文的发送什么也证明不了,客户端不知道自己发的对方能不能收到,也不知道对方发的自己能不能收到,服务端也是如此。奇数次数的发送不仅能防止一些SYN洪水攻击,同时也最大程度地将连接地成本尽可能地先让客户端承受,也许本来就是一个正常的客户在和服务端握手,但是第三次的报文就是丢了,那么也没有关系,因为这条报文是客户端发的,此时它建立好了连接,系统为其分配了资源管理连接了,而服务器还没有,这样就一定程度上减缓了服务器的压力,因为你客户端再怎么承受成本你也就只是一条连接,而我服务器可是有非常多地连接,都是承担成本,相比之下,客户端承受更为合理。最后,还是要说明一下,虽然三次握手服务器不会先建立连接,但是服务器还是会维护一个半连接队伍,在其处于SYN-RCVD状态(俗称"半开连接"状态)时系统就会分配资源管理它了,所以现在的SYN洪水攻击,其实实际上是针对半连接队伍的,恶意组织或用户发送虚假SYN报文,服务器的半连接队伍就要存储它,虽然半连接队伍一般都是限定长度且过了一定时间就会解放的,占用的资源也不会很多,但是虚假SYN报文一般是虚假IP地址,发送的报文不会有回应,这些半开连接存在半连接队伍中,最终将其占满,正常用户就访问不了了,而且服务器等了一段时间还要超时重传,这些都消耗了服务器的资源。而针对这样的SYN洪水攻击,防范措施就不是TCP协议本身所能防范的了,因为客户端和服务端二者之间的关系归根结底还是客户端主动,服务端被动,要想防范,势必要付诸成本,这些防范措施就不是本文的重点了。

4次挥手

四次挥手用于正常断开连接,断开连接的实际上可以是任意一方,但是一般来说还是客户端主动,所以这里就拿客户端举例,流程都是一样的。

首先当客户端数据发送完毕,决定关闭连接时,会向服务器发送一个TCP报文,其中​​FIN标志位设置为1​​,并包含一个序列号(例如 seq = u)。这表示客户端不再发送数据。客户端随后进入 ​​FIN-WAIT-1​​ 状态,等待服务器的确认应答。

服务器收到FIN报文后,会发送一个确认报文,其中​​ACK标志位设置为1​​,确认号 ack = u + 1。这表示"我收到了你的关闭请求"。服务器进入 ​​CLOSE-WAIT​​ 状态。此时,​​从客户端到服务器的连接方向关闭​​,客户端不能再向服务器发送数据,但服务器可能还有数据要发送给客户端。客户端收到此ACK后,进入 ​​FIN-WAIT-2​​ 状态,等待服务器发送它自己的FIN报文。

当服务器也完成所有数据的发送后,会发送一个FIN报文(FIN标志位设为1),并包含一个序列号(例如 seq = w),通知客户端它也要关闭连接了。服务器进入 ​​LAST-ACK​​ (最后确认)状态,等待客户端的最终确认。

客户端收到服务器的FIN报文后,会发送一个ACK报文进行确认,其中确认号 ack = w + 1。客户端随即进入 ​​TIME-WAIT​​ 状态。​​服务器一旦收到这个ACK,会立即进入 CLOSED状态,连接关闭​​。而客户端会在 TIME-WAIT状态等待一段时间(2MSL,即两倍的最大报文段生存时间),以确保服务器能收到这个ACK。若在此期间没有收到服务器的重传FIN,客户端最终也会进入CLOSED状态。

为什么握手只需要三次,而挥手则需要四次呢?

我们之前也说过,握手虽然是三次,但是其实是四次,只不过中间的两次捎带应答合并了。那四次挥手为什么不行呢?首先我们要明白,服务器之所以叫服务器,就是因为它是提供服务的,客户端发送SYN请求,一般来说那就必须得跟它连,所以应答完也要发SYN,所以干脆合并算了。但是对于断开连接来说,由于TCP是全双工,你给我发数据,我也给你发,现在好了,你想断开,就得拉着我一起吗?我也许还有数据没发完呀,你愿意我不愿意,所以你要断可以,但我不一定立刻断。你给我发FIN报文,我先ACK回应你,表示我知道了,你以后别跟我发数据了,我处理完剩下要发给你的数据我也就和你断了,之后处理完了我才给客户端发FIN,客户端收到后返回ACK(只是回应,没有数据,也不占序号,不违背客户端发送FIN报文收到回应后不再发数据的规定),这样才算彻底两清。

accept函数和已连接队列

我们先写一个非常简单的tcp连接的服务端和客户端,

tcpserver

cpp 复制代码
#include<iostream>

#include<stdio.h>

#include<string>

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <arpa/inet.h>

#include<string.h>

#include<unistd.h>

#include<vector>

#include<unordered_map>

#define SIZE 1024

std::string defaultip = "0.0.0.0";
uint16_t defaultport = 8080;

const int backlog = 5;

enum
{
    SOCKETERR = 1,
    BINDERR,
    lISTENERR
};

class tcpserver;

struct task
{
    int skfd;
    sockaddr_in in;
    tcpserver* THIS;
};

class tcpserver
{
public:
    tcpserver(const std::string& ip = defaultip, uint16_t port = defaultport):
        _ip(defaultip),
        _port(port),
        _sockfd(0),
        isrunning(false)
    {
    }

    void init()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            perror("socket");
            exit(SOCKETERR);
        }

        sockaddr_in in;
        bzero(&in, sizeof(in));
        in.sin_addr.s_addr = inet_addr(_ip.c_str());
        in.sin_family = AF_INET;
        in.sin_port = htons(_port);

        if(bind(_sockfd, (const sockaddr*)&in, sizeof(in)) < 0)
        {
            perror("bind");
            exit(BINDERR);
        }
        std::cout << "bind success" << std::endl;

        if(listen(_sockfd, backlog) < 0)
        {
            perror("listen");
            exit(lISTENERR);
        }

        std::cout << "listen success" << std::endl;
    }

    void Broadcast(const std::string& message)
    {
        for(auto& e : _um)
        {
            write(e.second->skfd, message.c_str(), message.size());
        }
    }

    static void* service(void* arg)
    {
        pthread_detach(pthread_self());
        // std::cout << 1 << std::endl;
        task* tkptr = static_cast<task*>(arg);
        char buffer[SIZE] = {0};
        while(1)
        {
            ssize_t n = read(tkptr->skfd, buffer, SIZE);
            if(n > 0)
            {
                buffer[n] = 0;
                std::string ip = inet_ntoa(tkptr->in.sin_addr);
                std::string message = "[" + ip + ":" + std::to_string((int)ntohs(tkptr->in.sin_port)) + "]" + buffer;
                // std::cout << "[" << ip << ":" << tkptr->in.sin_port << "]" << buffer << std::endl;

                std::cout << message << std::endl;
                tkptr->THIS->Broadcast(message);
            }
        }

        delete tkptr;

        return nullptr;
    }

    void check_user(task*& tkptr)
    {
        std::string strid = inet_ntoa(tkptr->in.sin_addr);
        int intport = tkptr->in.sin_port;
        strid += ":" + std::to_string(intport);
        if(_um.count(strid) == 0)
        {
            _um[strid] = tkptr;
            std::cout << "add a new user:" << strid << std::endl;
        }
    }

    void run()
    {
        isrunning = true;
        char buffer[SIZE] = {0};
        std::string message;
        std::vector<pthread_t>add;
        while(isrunning)
        {
            // task* tkptr = new task;
            // tkptr->THIS = this;
            // socklen_t inlen = sizeof(tkptr->in);
            // tkptr->skfd = accept(_sockfd, (sockaddr*)&(tkptr->in), &inlen);

            // check_user(tkptr);

            // pthread_t PT;
            // // service(tkptr);
            // pthread_create(&PT, nullptr, service, (void*)tkptr);
        }
    }

    ~tcpserver()
    {
        close(_sockfd);
    }
private:
    std::string _ip;
    uint16_t _port;
    int _sockfd;
    bool isrunning;
    std::unordered_map<std::string, task*>_um;
};

int main()
{
    tcpserver ts;
    ts.init();

    ts.run();
    
    return 0;
}

tcpclient

linux版

cpp 复制代码
#include<iostream>

#include<stdio.h>

#include<string>

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <arpa/inet.h>

#include<string.h>

#include<unistd.h>

#include<vector>

#define SIZE 1024

struct task
{
    int skfd;
    sockaddr_in in;
};

void* read_thread(void* arg)
{
    pthread_detach(pthread_self());
    task* tkptr = static_cast<task*>(arg);

    char buffer[SIZE] = {0};
    while(1)
    {
        int n = read(tkptr->skfd, buffer, SIZE);
        if(n > 0)
        {
            buffer[n] = 0;
            std::cerr << buffer << std::endl;
        }
    }
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        std::cout << "3 words! error." << std::endl;
    }

    task tk;
    tk.skfd = socket(AF_INET, SOCK_STREAM, 0);
    int16_t port = std::stoi(argv[2]);
    tk.in.sin_family = AF_INET;
    tk.in.sin_port = htons(port);
    tk.in.sin_addr.s_addr = inet_addr(argv[1]);

    connect(tk.skfd, (const sockaddr*)&(tk.in), sizeof(tk.in));

    pthread_t PT;
    pthread_create(&PT, nullptr, read_thread, (void*)&tk);

    std::string message;
    while(1)
    {
        std::cout << "please enter# ";
        std::getline(std::cin, message);
        write(tk.skfd, message.c_str(), message.size());
    }
}

win版

cpp 复制代码
#define _WINSOCK_DEPRECATED_NO_WARNINGS

#include<iostream>

#include<stdio.h>

#include<string>

#include <sys/types.h>

#include<string.h>

#include<vector>

#include <winsock2.h>

#include <windows.h>

#include <thread>

#pragma comment(lib, "ws2_32.lib") // 链接Winsock库

#define SIZE 1024

struct task
{
    int skfd;
    sockaddr_in in;
};

void* read_thread(void* arg)
{
    task* tkptr = static_cast<task*>(arg);

    char buffer[SIZE] = { 0 };
    while (1)
    {
        int n = recv(tkptr->skfd, buffer, SIZE, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cerr << buffer << std::endl;
        }
    }
}

int main(int argc, char* argv[])
{
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (result != 0) {
        std::cerr << "WSAStartup failed: " << result << std::endl;
        return 1;
    }

    task tk;
    tk.skfd = socket(AF_INET, SOCK_STREAM, 0);
    int16_t port = 8080;
    tk.in.sin_family = AF_INET;
    tk.in.sin_port = htons(port);
    tk.in.sin_addr.s_addr = inet_addr("8.148.76.120");

    connect(tk.skfd, (const sockaddr*)&(tk.in), sizeof(tk.in));

    std::thread t(read_thread, (void*)&tk);
    t.detach();

    std::string message;
    while (1)
    {
        std::cout << "please enter# ";
        std::getline(std::cin, message);
        send(tk.skfd, message.c_str(), message.size(), 0);
    }

    closesocket(tk.skfd);
    WSACleanup();
}

我们将服务端调用accept函数的部分注释掉,先启动服务端,

我们发现此时就有一个处于LISTEN状态的套接字了,这也很正常,因为确实设置了一个,然后我们再启动客户端,

此时我们发现,出现了一个已经连接状态端口8080的TCP连接了,但是我们此时还没有调用accept函数,所以我们可以明确,accpet函数本身并不参与建立连接时的三次握手,那么accept函数到底干了什么呢?其实,TCP的三次握手,是直接由内核的TCP/IP协议栈自动完成的,之前我们也说过,TCP的一条条连接内核是必要将他们用数据结构组织起来,组织的方式就是已连接队伍,而我们accept函数干的事就是从这条队伍中取出一个已经建立成功的连接​​ 。如果队列为空,默认情况下 accept()会进入睡眠(阻塞),直到有新的连接到来才被唤醒并返回 。有点类似于生产消费者模型。accept函数取出连接后,就会将其和文件系统相结合,创建一个全新的套接字描述符​​并返回,这个新套接字通常被称为 ​​"已连接套接字"​。事实上,我们是可以控制这条已连接队伍的长度的,使用指令

bash 复制代码
ss -lnt

可以查看所有监听端口的状态,

其中 ​Send-Q​表示全连接队列的最大长度(即 min(backlog, somaxconn)的结果,somaxconn​​是操作系统内核参数,全局生效,通常为128(因Linux发行版而异),它规定了​​操作系统内核​​允许的全连接队列最大长度)。​​Recv-Q​​表示当前全连接队列中已建立但未被应用程序accept()取走的连接数量。而这个连接队伍的长度其实就是函数

c 复制代码
int listen(int sockfd, int backlog);

中的backlog参数,我们可以设置它为2,看看如果连接数量超过2会发生什么。

当只有两个连接时,我们发现两个都是已连接状态,当第三个客户端尝试连接时,

我们发现竟然还能连上,但是第四次连接时,

我们发现新的连接的状态已经不是ESTABLISHED(已连接了),而是SYN_RECV,这表示它处于发出第二次握手但是没有收到客户端的第三次握手报文的状态。第三次连接即使队列溢出了却还是能连接成功我们可以认为是内核的一种弹性保护措施,但是对于过多的连接,弹性措施保护不了就会出现这种情况,这才是连接队列溢出后再尝试连接的正常现象。我们如果在等待一段时间后,

我们就会发现连接已经没了。首先我们要明确,SYN_RECV是真的没有收到报文吗?其实不是的,因为如果怕我们查看客户端的连接状态,我们会发现客户端显示的是已经连接上了,因为它发送了最后一次握手的报文,它认为自己已经建立连接成功了。当我们的连接队伍处于满载状态再次收到连接申请时,首先时会对其及进行回应的,而后就会进入半连接队伍,但是当其收到最后一次报文想入已连接队列时发现队伍已满,这时主机就会丢弃掉这条报文,所以我们才能看到连接处于SYN_RECV状态,实际上在正常握手时,SYN_RECV状态是不太好见的,因为这个状态的时间很短。当服务端长时间处于SYN_RECV状态时,也就是处于半连接状态,在半连接队伍中,过了一段时间,系统就会自动释放掉这个连接。当服务端释放掉这个连接但是客户端认为没有,这时如果继续通信就可能触发RST报文重新进行连接,只不过可能还是连接不成功。

已连接队伍的长度设置也是需要考究的,因为太短相应的处理连接能力就会小,老是会出现连接失败,太长则会增加服务器不必要的负担,所以要根据服务器的具体情况决定。

TIME_WAIT状态

我们用一段代码来对断开连接四次挥手的过程进行分析,

tcpserver

cpp 复制代码
#include<iostream>

#include<stdio.h>

#include<string>

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <arpa/inet.h>

#include<string.h>

#include<unistd.h>

#include<vector>

#include<unordered_map>

#define SIZE 1024

std::string defaultip = "0.0.0.0";
uint16_t defaultport = 8080;

const int backlog = 1;

enum
{
    SOCKETERR = 1,
    BINDERR,
    lISTENERR
};

class tcpserver;

struct task
{
    int skfd;
    sockaddr_in in;
    tcpserver* THIS;
};

class tcpserver
{
public:
    tcpserver(const std::string& ip = defaultip, uint16_t port = defaultport):
        _ip(defaultip),
        _port(port),
        _sockfd(0),
        isrunning(false)
    {
    }

    void init()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            perror("socket");
            exit(SOCKETERR);
        }

        sockaddr_in in;
        bzero(&in, sizeof(in));
        in.sin_addr.s_addr = inet_addr(_ip.c_str());
        in.sin_family = AF_INET;
        in.sin_port = htons(_port);

        if(bind(_sockfd, (const sockaddr*)&in, sizeof(in)) < 0)
        {
            perror("bind");
            exit(BINDERR);
        }
        std::cout << "bind success" << std::endl;

        if(listen(_sockfd, 2) < 0)
        {
            perror("listen");
            exit(lISTENERR);
        }

        std::cout << "listen success" << std::endl;
    }

    void Broadcast(const std::string& message)
    {
        for(auto& e : _um)
        {
            write(e.second->skfd, message.c_str(), message.size());
        }
    }

    static void* service(void* arg)
    {
        pthread_detach(pthread_self());
        // std::cout << 1 << std::endl;
        task* tkptr = static_cast<task*>(arg);
        char buffer[SIZE] = {0};
        while(1)
        {
            ssize_t n = read(tkptr->skfd, buffer, SIZE);
            if(n > 0)
            {
                buffer[n] = 0;
                std::string ip = inet_ntoa(tkptr->in.sin_addr);
                std::string message = "[" + ip + ":" + std::to_string((int)ntohs(tkptr->in.sin_port)) + "]" + buffer;
                // std::cout << "[" << ip << ":" << tkptr->in.sin_port << "]" << buffer << std::endl;

                std::cout << message << std::endl;
                tkptr->THIS->Broadcast(message);
            }
            else if(n == 0)
            {
                sleep(3);
                close(tkptr->skfd);
                break;
            }
        }

        delete tkptr;

        return nullptr;
    }

    void check_user(task*& tkptr)
    {
        std::string strid = inet_ntoa(tkptr->in.sin_addr);
        int intport = tkptr->in.sin_port;
        strid += ":" + std::to_string(intport);
        if(_um.count(strid) == 0)
        {
            _um[strid] = tkptr;
            std::cout << "add a new user:" << strid << std::endl;
        }
    }

    void run()
    {
        isrunning = true;
        char buffer[SIZE] = {0};
        std::string message;
        std::vector<pthread_t>add;
        while(isrunning)
        {
            task* tkptr = new task;
            tkptr->THIS = this;
            socklen_t inlen = sizeof(tkptr->in);
            tkptr->skfd = accept(_sockfd, (sockaddr*)&(tkptr->in), &inlen);

            check_user(tkptr);

            pthread_t PT;
            // service(tkptr);
            pthread_create(&PT, nullptr, service, (void*)tkptr);
        }
    }

    ~tcpserver()
    {
        close(_sockfd);
    }
private:
    std::string _ip;
    uint16_t _port;
    int _sockfd;
    bool isrunning;
    std::unordered_map<std::string, task*>_um;
};

int main()
{
    tcpserver ts;
    ts.init();

    ts.run();
    
    return 0;
}

tcpclient

cpp 复制代码
#include<iostream>

#include<stdio.h>

#include<string>

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <arpa/inet.h>

#include<string.h>

#include<unistd.h>

#include<vector>

#define SIZE 1024

struct task
{
    int skfd;
    sockaddr_in in;
};

void* read_thread(void* arg)
{
    pthread_detach(pthread_self());
    task* tkptr = static_cast<task*>(arg);

    char buffer[SIZE] = {0};
    while(1)
    {
        int n = read(tkptr->skfd, buffer, SIZE);
        if(n > 0)
        {
            buffer[n] = 0;
            std::cerr << buffer << std::endl;
        }
    }
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        std::cout << "3 words! error." << std::endl;
    }

    task tk;
    tk.skfd = socket(AF_INET, SOCK_STREAM, 0);
    int16_t port = std::stoi(argv[2]);
    tk.in.sin_family = AF_INET;
    tk.in.sin_port = htons(port);
    tk.in.sin_addr.s_addr = inet_addr(argv[1]);

    connect(tk.skfd, (const sockaddr*)&(tk.in), sizeof(tk.in));

    pthread_t PT;
    pthread_create(&PT, nullptr, read_thread, (void*)&tk);

    std::string message;
    while(1)
    {
        std::cout << "please enter# ";
        std::getline(std::cin, message);
        write(tk.skfd, message.c_str(), message.size());
    }
}

首先,让进程都跑起来,

那么查看TCP连接状态,自然也是连接成功。然后我们直接终止客户端进程,模拟客户端主动断开连接的情况,

这时我们就会看到,客户端进入了FIN_WAIT2状态,服务端进入了CLOSE_WAIT状态,这完全符合四次挥手的示意图,然后,因为我在服务端的线程逻辑中加了read函数返回0也就是对端关闭连接的时候sleep3秒主动关闭连接也就是close掉套接字描述符。所以等一会后我们就会看到

最终服务端的套接字被释放,而客户端的套接字还处在TIME_WAIT状态。在四次挥手中,主动关闭连接的一方会进入TIME_WAIT状态,进入TIME_WAIT状态后,要等待若干时长之后自动释放(CLOSED)。那么,问题来了,为什么要这么做呢,为什么不直接释放呢?主要原因有二:

(1)防止最后一个ACK应答丢失。假如TIME_WAIT是直接关闭连接,那么如果最后一个ACK报文在网络上丢失,对端迟迟等不来ACK应答,就会超时重传,但是这时我已经关闭了连接,再也收不到了,也就更不会回应了。虽然就算我收不到,在等待重传到了一定程度也会自动释放,但是能保持TIME_WAIT状态一段时间确保能回应重传,让对方能及时关闭连接还是有必要的。

(2)让旧连接的报文消逝​。网络情况复杂,可能存在"迷途"的报文段(例如由于路由器异常),它们会延迟一段时间后才到达目的地。如果没有TIME_WAIT状态的等待期,一个旧连接的报文有可能在连接关闭后到达,并被立刻重建的、具有相同四元组的新连接收到,这会造成数据混乱。维持 2MSL 的TIME_WAIT状态,足以确保两个方向上的旧报文都在网络中自然消亡,不会干扰新的连接。像之前所说的随机初始序列号也是防止旧报文干扰的一种手段。对于TIME_WAIT状态收到的旧报文,大多数还是直接丢弃的。

什么是MSL呢?MSL是报文最大生存时间,协议规定的​​理论最大值​​,一个安全上限。​​2MSL的等待 = 1MSL(确保最后的ACK到达对端)+ 1MSL(等待对端可能重传的FIN到达本方),这个设计充分考虑了报文往返路径上可能的最大延迟以及重传的需要,是TCP协议实现可靠关闭的基石。需要注意其与最大传送时间的区别,它是网络通信中的​​实际测量值​​或​​估算值​,一般较短。在Linux中,我们通过查看配置文件

了解MSL的时长,可以看到这里是60秒。所以2MSL是60秒,MSL是30秒。

其实了解完TIME_WAIT状态之后,就会有一个问题了,那就是如果此时主动断开连接的不是客户端,而是服务端,那么就会是客户端正常退出,服务端端口进入TIME_WAIT状态,那么就会发生下面这种情况

可以看到,端口号8080处于TIME_WAIT,这时如果重启服务端进程就会出现bind: Address already in use 的报错。为什么会出现这样的报错呢。这就是因为即使进程已经退出,但是端口还是处在TIME_WAIT状态,这时由内核TCP协议板块进行管理,等待2MSL后自动释放,期间该端口资源实际上仍被内核视为"已占用",如果进程退出重启,那么就会出现bind失败,资源已被占用的报错。这其实是一个不小的问题,如果服务过程中出现了服务端主动断开连接并重启进程,就会触发这个问题,这样我们就没法立刻重启进程,而必须得等待2MSL时间才能重启,所以我们可以通过函数

c 复制代码
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

进行设置,

c 复制代码
int opt = 1;
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

第一个参数传套接字描述。第二个参数传选项,最常用的就是SOL_SOCKET,表示通用套接字层选项,与协议无关。第三个参数传选项,SO_REUSEADDR​​​​允许重用本地地址(端口)​​,解决 TIME_WAIT状态导致的 Address already in use问题, SO_REUSEPORT允许多个套接字绑定到相同的地址和端口,用于负载均衡,只用一个SO_REUSEADDR其实就可以了,两个也行。第四个参数指向一个缓冲区的指针,该缓冲区包含了你要为选项 optname设置的​​新值​​,1就表示启用,最后一个参数是钱一个参数的大小。

当我们使用这个函数设置成功之后,我们就会发现即使端口处于TIME_WAIT状态也不会bind失败了,

他就表示允许bind正在被使用但是是TIME_WAIT状态的端口。因为TIME_WAIT状态也不进行通信,不影响后续通信。

为什么对于子进程没有这个问题呢?因为子进程不主动绑定固定端口,所以子进程主动关闭就不会有这个问题,主要还是服务器,因为不能立刻重启可能会有大问题。

流量控制

接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。

接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段,通过ACK端通知发送端。

窗口大小字段越大,说明网络的吞吐量越高。

接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。

发送端接受到这个窗口之后,就会减慢自己的发送速度。

如果接收端缓冲区满了,就会将窗口置为0,这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。

接收端如何把窗口大小告诉发送端呢?回忆我们的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息。那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移 M 位。可以看到,流量控制既保证了可靠性,也保证了效率。

滑动窗口

之前我们都说过,TCP有着确认应答机制,那么如果TCP只是一板一眼的发一个数据,收一个数据,也就是串行发送数据,那么效率必然低下,

所以我们采用

这样的串行发送方式,我们在等待的时间里同时发送数据。那么这样就有几个问题了,首先,已经发送出去没有收到应答的报文我们有必要保存起来,因为TCP保证可靠性,那么保存在哪?再者,我们不可能无限制地一直发送报文,因为需要考虑到网络状况和接受方的接收能力,所以我们有必要限制报文的发送。这就有必要提到滑动窗口了。

TCP的发送缓冲区维护了一个滑动窗口,在滑动窗口内的数据是可以随时发送的数据,滑动窗口前面的是已经发送过并且收到了应答的数据,后面的是不能直接发送的数据。这个滑动窗口直接在发送缓冲区上维护,不需要额外开辟空间保存,维护的方式也很简单,就是左右两个指针,滑动窗口的滑动本质就是指针的移动。实际上,在进一步细分,滑动窗口内也能分成已发送未接收应答和未发送这两个部分,待发送后面也会有等待数据填充的区域。滑动窗口限制了随时可以发送数据的数量,当窗口内的数据发送完之后,还没有应答来更新窗口,那么就不能继续发送数据了。

滑动窗口的滑动策略是怎么样的呢?首先滑动窗口的大小和对方的串口大小一致,那么在最开始时的窗口大小就和流量控制中讲的一样,是通过三次握手交换得来的。之后,我们不断的发送窗口中可以随时发送的数据,然后等待应答,当应答到来时,会附上ack(32为确认序号)表示序号之前的数据对方已经全部收到了,那么,我们就能将start指针移到确认序号所对应的位置。这里就还要提一下ISN(随机初始序号)了,这在之前讲过,因为ISN是随机的,所以ack要想转化成真正的缓冲区偏移量,那么就要减去ISN。这样start指针就更新完成了,对于end指针,我们可以认为是start指针加上16位窗口大小,这个窗口也能扩容,流量控制中全讲过。

滑动窗口可以应对丢包吗?丢包也分为两种,一种是发送方的数据直接丢包,一种是接收方的ACK丢包。

对于接收方的ACK丢包,这种并不要紧,因为我们之前也说过,ACK应答中的ack是指确认序号之前的数据全部都收到了,即使中间丢了一个,只要后面的收到了就不会有问题,就算正好是最后一个丢了,那也有超时重传。

对于发送方发送的报文丢失,会发生什么呢?如果一个报文丢失了,那么即使收到了后面的报文,返回的ACK应答的ack也只会写丢失报文之前的报文结束位置+1。例如在这里,1001~2000丢失了,那么之后的报文就都是ack = 1001,这就表示1001前面的我都收到了,给我1001开始的数据吧。如果发送方一直受到这样的数据连续三次,就会将对应的数据1001 - 2000重新发送,这个时候接收端收到了1001之后,,再次返回的ACK就7001了,因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中,这种机制被称为"高速重发控制"(也叫 "快重传")。发送方的报文丢包都靠快重传吗?不一定,假设正好是最后几次报文丢失,那么返回的ACK还没到3次,这样就不会触发,这时就还要靠超时重传了,超时重传可以认为是一种兜底策略。

滑动窗口的移动本质是双指针的移动,那么双指针有可能向左移动吗?不可能,因为ack在增大,16位窗口大小也不会是负数,所以双指针最多原地不动。滑动窗口的大小有可能扩大吗?可能,如果接收方处理数据的速度突然变快或网络状况变好,一次更新出了更大的窗口大小,那么滑动窗口就会扩大。滑动窗口可能不变吗?可能,如果接收方和网络状况很稳定,发多少处理多少,那么窗口大小可以不变。滑动窗口可能变小吗?也可以,如果接收方处理数据的速度突然变快或网络状况变差,一次更新出了更小的窗口大小,那么滑动窗口就会变小。那么在滑动窗口的不断向前移动中,可能会越界吗?不会,因为其实发送缓冲区可以认为是环形的,首尾相连,当缓冲区用完的时候,写指针就会取模回到开头,复用已经发送完数据并收到应答的空闲缓冲区。发送缓冲区可以认为是一个不断向前延伸的传送带,不会出现越界的情况。

延迟应答

在TCP通信中,如果对于发送方的数据接收方都是收到之后立刻返回,那么返回的窗口可能就会比较小,发送方拿到这样一个窗口之后,立刻就将对应大小的数据发送出去了,而且远没有达到自己的极限,即使窗口更大一点,也能及时地处理完。那么,因为每次返回的窗口都比较小,即使本来就很小地一段数据搞了好多次收发才结束,每条报文又不止只有有效载荷,还有报头,想想都知道这很浪费资源。所以我们应该怎么做呢?延迟应答,接收方每次接收到数据之后,都不急着立刻回答,而是等一段时间,给自己的上层更多的时间把数据读上去,那么就能一次更新出更大的窗口了,这样就能在尽可能少的报文收发中传输尽可能多的数据。当然这也同样提醒我们,应用层代码逻辑中也应尽量快速、大量地将接收缓冲区的数据取走。窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

那么所有的包都可以延迟应答么?肯定也不是。

数量限制:每隔N个包就应答一次;

时间限制:超过最大延迟时间就应答一次;

具体的数量和超时时间,依操作系统不同也有差异。一般N取2,超时时间取200ms。

拥塞控制

之前我们所说的各种通信策略,都是针对通信地双方的,而TCP牛逼就牛逼在它不仅考虑到通信双方想出了很多策略,而且她还考虑到了网络。考虑到网络,它就超出了操作系统的范畴了,网络状态的好坏,也不是内核所能控制的,网络一旦出现拥塞(硬件设施出问题或数据量太大),操作系统所能做的就是在察觉到网络拥塞时就赶紧降低发送速度,期望网络状态变好。

怎么判断网络出现拥塞了呢,这里会有很多算法,判断标准多种多样,但是,基本逻辑就是:丢包少,那就是常规情况,丢包多,多半是网络拥塞了!拥塞了怎么办呢?对丢包数据重发吗?当然不行,网络都拥塞了,还继续发送数据,那不是更加的雪上加霜。所以我们的策略是,迅速降低发送数据的速度,因为大家都是TCP/IP协议栈,所以我有拥塞控制,你也有,这就是共识,网络出现拥塞了,虽然大家不一定都能第一时间察觉到,但是只要察觉到了就都会进行拥塞控制,那么只要不是网络设施出了问题,大概能减缓拥塞。

拥塞控制的具体策略是怎样的呢?首先是慢启动,连接建立后,拥塞窗口cwnd初始值很小(如1个MSS)。每收到一个确认(ACK),cwnd就增加1个MSS。这样,每个往返时延(RTT)内,cwnd会翻倍,实现指数级增长。我们滑动窗口的大小那里的说法也要更新了,其实滑动窗口的大小准确的说应该是 min(窗口大小,缓冲区有效数据大小,拥塞窗口大小)。慢启动的特点就是初始慢,但是增长非常快,而我们其实不能一直让其增长,所以我们引入了慢启动的阈值ssthresh,当拥塞窗口超过这个阈值的时候,就会进入拥塞避免阶段,这时不再按照指数方式增长,而是按照线性方式增长。

当网络真的出现拥塞导致丢包时,TCP会根据丢包信号的不同,采取两种策略:

情况一:超时重传(严重拥塞)​​

这通常意味着网络拥塞比较严重,连确认包都很难返回。

​​措施​​:TCP会认为网络状况很差,采取最严厉的调整:ssthresh迅速降为当前 cwnd的一半(但不能小于2),然后将 cwnd重置为1,​​重新开始慢启动​​过程。这种从高峰跌到谷底的做法对吞吐量影响较大。

情况二:快速重传与快速恢复(轻度拥塞)​​

当接收方收到一个失序的数据包时,会立即重复发送上一个已确认的包的ACK。如果发送方​​连续收到3个重复的ACK​​,就推断某个包丢了(但后续的包能收到,说明网络仍有一定通行能力)。

​​快速重传​​:发送方不必等待超时,​​立即重传​​那个疑似丢失的数据包。

​​快速恢复​​:接着,将 ssthresh设置为当前 cwnd的一半,并将 cwnd设置为新的 ssthresh值(有的实现会加3),然后直接进入​​拥塞避免​​阶段,而不是慢启动。这样可以避免吞吐量断崖式下跌,是TCP性能的一个重要优化。

这种动态变化的方式也符合网络,网络出现拥塞,先发送少量数据,然后迅速增长。

捎带应答

捎带应答(Piggybacking Acknowledgement)是TCP协议中一种用于提升网络传输效率的巧妙机制。简单来说,它​​将确认应答(ACK)信息"捎带"在发往对端的数据包中一起发送​​,而不是单独发送一个只包含ACK的空包。

面向字节流

TCP与UDP不同,它是面向字节流的。当我们在上层封装好要发送的报文后,将其交给传输层的TCP协议,TCP完全不关心你是什么报文,在TCP眼里,报文就是多少字节的数据,它会将其写入缓冲区中,什么时候发也由它决定,也许向下传递了多个报文后才会进行发送,发送时也完全是以字节数据的视角发送的。报文很小的话可能发送了几个完整的外加半个的报文过去,报文很大可能一次只能发半个。而接收方的TCP层收到后也是同理,解析出报头后上层只能按字节大小取,不会有一次发送对应一次接受的情况。这就是面向字节流,

粘包问题

伴随面向字节流而来的就是粘包问题,因为面向字节流​将应用层数据视为​​无结构的连续字节序列​​,不保留应用层消息的边界,所以应用层拿上来的数据是连续的字节序列,我们需要制定协议自行将其转化成一个个应用层报文。

将字节序列转化成报文的核心就是明确两个包之间的边界,又称定界。定界的方法如下:

1.定长报文。所有报文都一个长度自然就能定界了。

2.使用特殊字符划分边界。报文结尾加上特殊字符,特殊字符得是报文中绝对不能出现的,这样读到了它就代表读到了字符串结尾。

3.使用自描述字段 + 定长报头。定长报头让我们可以稳定读取报头,报头中又有描述正文长度的字段,这样就能读取正文了。

4.使用自描述字段 + 特殊字符。特殊字符划定报头和正文的边界,这样就能读到报头,然后根据包头中的描述正文长度的字段就能读取正文了。

TCP异常情况

下面是处在TCP连接时发生各种异常情况会出现的结果。

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

机器重启:和进程终止的情况相同,重启时会结束所有进程的。

机器断电:断电方直接哑火。如果对方向断电方发送了数据,那么就会等不到应答,从而触发超时重传,重传到一定次数没有回应就会断开连接。即使中途来电重启,进程也不再认识旧链接,这时会发送复位报文重置连接。

网线被拔:如果被拔网线的发送了数据等待应答,那么就会迟迟等不来应答,这时就会主动断开连接,如果没发送数据,这时会有保活机制。

tcp的KeepAlive(保活)机制与心跳包的原理相同。默认两小时之内双方之间没有任何数据包发送,则在两小时到达时开启KeepAlive的一方会发送探测包(心跳包)。75秒内若有相应的ack确认包返回,则会修改连接开始时间继续保持连接;若没有相应的ack确认包返回,发送方会总共发送10个这样的探测,每个探测75秒。如果没有收到一个响应,发送方就认为对方主机已经关闭并终止连接。保活机制的作用:1.防止长时间没有和服务器进行数据交互从而被防火墙程序关闭连接。2.检测连接是否出现异常。

通过保活机制判断对方不在了也会断开连接。对于对端也是同理。网线被拔了中途插回有可能可以恢复链接。

应用层也能实现自己的心跳包检测,这样也能更加灵活。

总结

可靠性:

校验和

序列号(按序到达)

确认应答

超时重发

连接管理

流量控制

拥塞控制

提高性能:

滑动窗口

快速重传

延迟应答

捎带应答

其他:

定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)

TCP/UDP对比

我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢?TCP和UDP之间的优点和缺点,不能简单、 绝对的进行比较。TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景。UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ,视频传输等。另外UDP可以用于广播。归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。

相关推荐
Dovis(誓平步青云)2 小时前
《Linux 构建工具核心:make 命令、进度条、Gitee》
linux·运维·学习
墨白曦煜4 小时前
HTTP首部字段(速查-全47种)
网络·网络协议·http
cqsztech6 小时前
oracle linux 10 +pg18 源码安装要点
linux·数据库·oracle
奥尔特星云大使6 小时前
ALTER 与 UPDATE、DROP 与 DELETE区别
linux·运维·数据库·mysql
duangww7 小时前
部署sapui5应用到linux
linux·sap fiori
siriuuus7 小时前
Linux ssh/scp/sftp命令使用及免密登录配置
linux·ssh·sftp·scp
MOON404☾9 小时前
基于TCP的简易端口扫描器
网络·tcp/ip·php
teacher伟大光荣且正确9 小时前
Linux 下编译openssl
linux·运维·服务器