TCP协议

1.TCP 是一个具有全双工的,具有接收,发送缓冲区的,发送控制的传输控制协议

TCP 之所以叫"传输控制"协议,核心在于它替你决定了数据怎么发,而不是你想怎么发就怎么发

用户层视角:调用 write/send ,以为数据瞬间发到了对方电脑。但是不是这样子的,write和rsend数据只是从用户缓冲区拷贝到了内核的TCP发送缓冲区。

数据什么时候发送,发送多少,出错了怎么办完全由TCP协议栈(操作系统内核)自主管理,上层应用无权干涉。这就是"控制"的体现。

全双工的本质是双向并发传输,其依靠的是双方内核中各自的一对缓冲区。

客户端和服务器的 TCP 模块地位完全对等,双方都有独立的发送缓冲区和接收缓冲区。

读端:一个线程调用 read/recv ,从接收缓冲区把数据拷贝到用户空间。缓冲区空时会阻塞。

写端:另一个线程调用 write/send ,把数据从用户空间拷贝到发送缓冲区。

读写操作互不干扰。因为有两个独立的缓冲区通道,所以可以同时收发,实现了真正的全双工。

TCP 通信的本质,实际是也就是两次的深拷贝

发送时,数据有用户空间拷贝到内核发送缓冲区( write 操作)。

接收是,数据由内核接收缓冲区拷贝到用户空间( read 操作)。

2.TCP协议段格式

**4位首部长度:**表示的是标准报头加上选项的长度,最大值为1111也就是表示15,单位为4字节,所以最大表示60字节,标准报头的长度为20字节,所以选项的长度在[0,40]字节,大多数的情况下面是不带选项的,所以不做了解。

源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;

**32位序号/32位确认号:**确认应答讲解

6位标志位:

  1. URG: 紧急指针是否有效
  2. ACK: 确认号是否有效
  3. PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
  4. RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
  5. SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
  6. FIN: 通知对方, 本端要关闭了,, 我们称携带FIN标识的为结束报文段

后会还再次提到。

**16位窗口大小:**流量控制讲解

**16位校验和:**发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.

**16位紧急指针:**标识哪部分数据是紧急数据;

40字节头部选项: 暂时忽略;

三个小问题:

TCP是怎么区分报头和有效载荷的?

在学习 TCP 协议之前,我们已经了解了 UDP 是面向数据报、简单不可靠的传输层协议。而 TCP 作为面向连接、可靠传输的协议,结构更复杂,设计目标也完全不同。

TCP 报文分为两部分:TCP 报头 + TCP 有效载荷(应用层数据)

TCP 分离报头和有效载荷的规则非常简单,从固定报头中取出 4 位首部长度,若值为 5的话 5 × 4 = 20 字节,表示没有选项字段,若值大于 5 的话,首部长度 × 4 = 整个 TCP 报头总长度,报文剩下的所有字节,全部是有效载荷。

TCP中为什么没有"总长度字段"?

UDP 报头里有16 位总长度,可以直接算出有效载荷长度:有效载荷长度 = UDP 总长度 − 8 字节 UDP 报头

但 TCP 没有总长度字段,因为TCP 是面向字节流的,不是面向数据报的,一条连接上的数据是无限流,没有天然边界。TCP会将把收到的有效载荷不断写入接收缓冲区,像水流一样持续流入,所以说TCP是面向字节流的。

那么应用层怎么读取一个完整数据?

TCP 只负责传字节流,消息边界由应用层协议自己定义。

比如HTTP 协议,头部以 \r\n 分行,遇到空行表示头部结束。头部中包含 Content-Length ,按长度读取 body。

3.流量控制(基于滑动窗口来实现)

如下图所示,左侧是客户端,右侧是服务器。

假设客户端应用层是 HTTP 协议,客户端构建好 HTTP 报文后,通过 write 接口将数据交给传输层。

数据会被拷贝到 TCP 对应的 socket 发送缓冲区中。TCP 负责构建 TCP 报头,将 HTTP 报文作为有效载荷,封装成完整的 TCP 报文。

报文向下穿过网络层、数据链路层,最终由网卡发送到网络。服务器收到报文后,逐层解包、分用,最终到达传输层 TCP。TCP 分离报头与有效载荷,把 HTTP 报文放入接收缓冲区。、

客户端并不知道服务器忙,仍然持续不断发送数据。服务器不读,接收缓冲区的数据就会不断堆积。而 TCP 接收缓冲区大小是有限的。

如果缓冲区快满了,客户端还在疯狂发送,TCP 要如何保证可靠性且不浪费资源?

TCP 接收缓冲区是固定大小,TCP 自己清楚:总空间多大,已用空间多大,剩余空间多大

在每一次通信后,无论是服务端给客户端发送信息还是,客户端给服务端发送信息,之后接受数据的一方都会发送一个确认应答,给对方来告知自己已经收到数据,这个确认应答也是一个TCP报文,所以里面的16 位窗口大小字段就会告诉对方自己当前接收缓冲区的大小是多少,假如当服务端接收缓冲区快满时。

客户端收到对方的确认应答后,看16位窗口大小就明确知道:服务器还能接收多少数据。之后就会改变之后的发送数据和发送速度,来保证发送的数据量,不会超过对方的窗口大小。

如果服务器一直不读,缓冲区真满了怎么办?如果服务器实在太忙,接收缓冲区完全满了,窗口大小会被设置为 0,客户端会停止发送。

当服务端接收的能力不行的时候,要让客户端发送的速度变慢甚至不发,避免大面积丢包的情况发送,这个叫流量控制

同时,当一方发现对方的缓冲区要满了,会发送一个提醒报文设置PSH 标志位(一般没有数据)提醒对方操作系统:请尽快把接收缓冲区的数据递给应用层,腾出空间。

只要应用层一读,缓冲区腾出空间,窗口大小恢复,客户端就可以继续发送。

一个小问题:客户端和服务端都要知道对方的接受缓冲区的大小,开始刚开始是肯定不知道的啊?怎么知道第一次要发送的数据量是多少,发送数据要多块?

在建立连接的三次握手双方就已经告知了对方自己的接受缓冲区大小了。

三次握手发送的都是纯报头、无有效载荷的报文。通信双方在 SYN/ACK 报文中,直接把自己的初始窗口大小发给对方。

所以在正式传输数据之前,双方已经知道:对方缓冲区多大,一开始能发多少,后续如何根据窗口调整发送速度。

其实16位窗口大小为窗口大小最大值并不一定就是2的16次方,里面会存在一个窗口扩大因子M,正确的窗口大小字段为16位窗口大小左移M位。

4.确认应答机制

确认应答其实也就是在收到数据后,接收数据的一方需要向对方发送一个确认应答的报文,里面带有ACK标志位,告诉对方自己已经收到了这条数据。

那么就有问题了,确认应答也是一个报文,接收到确认应答的一方是不是要在发送一条确认应答报文告诉对方已经收到你的确认应答报文了,这样子不就是陷入循环了吗?

在世界上面,是没有100%可靠的协议的,最后一条信息是没有被应答的,所以TCP中保证的是局部的靠谱。也就是说,对于客户端来说,保证的是我发出去的报文被对方收到就可以了,当我们收到对方的确认应答,我就知道我的报文被对方成功收到,对于服务端也是一样,只要我发送数据后得到了对方的确认应答报文,就代表我的数据被对方收到了。

TCP不追求绝对可靠,只保证通信双方各自发送方向的局部可靠。

4.1 捎带应答

左侧是正常的确认应答机制,那么客户端给服务器发送消息报文,即tcp数据,那么服务端此时按道理来讲要给客户端发送确认应答,但是此时恰好服务端也有要发给客户端的消息报文,即tcp数据,所以服务端就将确认应答和tcp数据整合在一起发送给客户端,所以此时客户端就收到确认应答,以及tcp数据,既确认了上一次发送的消息报文的可靠性,又接收到了来自服务端的tcp数据,所以这就是捎带应答机制,所以这样就减少了一次发送报文的消耗,但是捎带应答机制也不是时时刻刻都要采用的,如下

所以此时客户端收到了来自服务端的tcp数据,那么客户端此时自然要给服务端发送确认应答,但是在客户端发送确认应答前,此时客户端先检查自身的发送缓冲区中发现发送缓冲区为空,即没有要发送给客户端的tcp数据,所以此时客户端就不采用捎带应答机制,而是仅仅给服务端发送确认应答。

4.2 确认应答解决数据乱序问题

在TCP协议中,为了提高发送效率,数据的发送并不是串行的,不是接收到一条数据,然后接收到确认应答再发送下一条数据,这样子的效率太忙了,数据的发送肯定是并行的。对于数据的发送与到达的顺序是不一定的,可能收到网络等因素,并不是谁先发送谁就先到,TCP协议是保证数据的可靠传输和有序的。

现实中,客户端往往有大量数据要发送,因此 TCP 实际使用的是 并行发送(批量发送) 方案:

不等前一个 ACK,直接连续发一批报文。

效率明显提高,但也带来了新问题:报文乱序。

解决方案就在 TCP 报头的两个核心字段:32 位序号,32 位确认序号。

前置知识:序号

当应用层数据调用 write 拷贝到 TCP 发送缓冲区,发送缓冲区可以理解成一个 char 数组,每一个字节,都有一个唯一编号(数组下标),序号 = 该报文数据块中,最后一个字节的下标。这个序号由发送方填写。

32位序号

32位序号位发送方填写

假设客户端批量发送 4 个报文,序号依次是:1000、2000、3000、4000

假设到达服务器的顺序为:3000 先来,1000 后来, 4000 再来,2000最后到,这里的32位序号就是原来表示发送数据的序号。服务器接收到客户端发送的报文,只需要按照接收的报文的32位序号进行排序,就能恢复成客户端发送时的顺序,乱序问题也就可以解决。

32位确认序号

确认序号由接收方填写

确认序号 等于收到报文的序号 + 1,当接收方收到数据后,在确认应答中的32位确认序号中填写收到报文的序号 + 1,表示"确认序号之前的所有数据我都收完了,你下一次从这个序号开始发。"

这样子的设计允许少量的应答丢包,效率极高

假设服务器收到:1000、2000、3000、4000

它可以分别回 4 个 ACK:1001、2001、3001、4001

哪怕前面 1001、2001、3001 全部丢包,只要客户端收到 4001,就知道:前面所有数据服务器都收好了,我接下来从 4001 开始发就行。

一个小问题:

为什么要弄一个序号一个确认序号,应答的时候把序号加一返回不就好了吗?

因为对方发送数据给你,你做出回应,同时你也有可能要发送你自己的数据过去,保证效率,也相当于捎带应答。

5.六个标志位

ACK标志位

是用来标识确认序号是否有效,如果ACK标志位被设置为1,那么确认序号有效,如果ACK标志位被设置为0,那么确认序号无效,只要应答属性,ACK就会被设置为1。

SYN标志位

请求建立连接,我们把携带SYN标识的称为同步报文字段,用于TCP三次握手建立连接使用

RST标志位

要求对方重新建立连接。

客户端发第三次握手(ACK),丢包了,服务端没有收到,客户端以为:连接成了,建连接对象,准备发数据,但是服务器以为还在第二次握手,根本没建连接,当客户端一发送数据,服务器收到没有建立连接的客户端的数据,服务器直接返回 RST,客户端收到 RST就明白连接没有建立成功,明白自己连接无效,删掉旧连接,重新三次握手

下面的场景也会导致对方发送RST

场景1:服务器挂了(断电/崩溃)

服务器重启,连接对象全没了,客户端还在用旧连接发数据,服务器发现这是一条陌生连接,返回 RST

场景2:客户端断网

客户端断网,收不到服务器消息,服务器发数据、超时、重传、一直没 ACK确认应答,服务器判定连接无效了,向客户端发 RST,标记连接已重置,当客户端网络恢复后再发数据,服务器看到已经无效的连接,再发 RST

FIN标志位

FIN标志位用于通知对方,本端要关闭了,用于TCP四次挥手断开连接使用

所以此时客户端没有消息要发送给服务端了,所以客户端想要和服务端断开连接了,于是客户端就在报文中设置FIN标志位为1,然后发送给服务器,所以服务器就知道了,噢噢,原来你想要与我断开连接,好的,我允许断开连接,于是服务器就构建应答报文设置ACK标志位,然后发送给客户端

所以此时客户端收到了服务器的ACK应答报文,所以此时客户端就知道了,我客户端向服务器断开连接成功,所以客户端就将自己这边的连接对象释放掉

过了一会,服务端也没有数据发送给客户端了,所以服务端也想要断开连接,于是服务端就构建报文并且设置FIN标志位,然后发送给客户端,所以客户端就知道了,噢噢,服务端想要和我断开连接,于是我客户端同意断开连接,所以客户端就构建应答报文设置ACK标志位,然后发送给服务端。

所以此时服务端收到了客户端的ACK应答报文,所以此时服务端就知道了,我服务端向客户端断开连接成功,所以服务端就将自己这边的连接对象释放掉。

PSH标志位

提醒对方的应用层赶紧把数据从接收缓冲区提取走,我要发数据了。

在上面的流量控制中也提到过,收到对方的确认应答中的32位窗口不大,代表对方的接收缓冲区快满了,就会发送一个PSH报文(一般没有数据,就是一个提醒报文),让对方的应用层赶紧把数据读走。

URG标志位

一般比较少设置,表示16位紧急指针是否有效,这16位紧急指针表示的是紧急数据的偏移量。为了保证TCP数据的有序性,紧急数据最多为一个字节。

紧急数据的写入和读取:在 send里面把 flags 参数设置为 MSG_OOB,在接收 recv 也设置为 MSG_OOB 接收紧急数据。

什么情况下需要发生紧急数据呢?

当服务端连接好好的,但是一直对数据没有进行及时的处理,服务端是需要有接受紧急数据,并且判断紧急数据的编号的能力的,当客户端发现服务端的异常时,就需要去询问一下服务端的状态,是在读取,还是写入还是什么,就会设置紧急数据。

服务端接收到紧急数据后,就会优先处理这个紧急数据,给客户端发送报文进行响应。

6.超时重传

TCP 不知道包有没有丢,只能靠超时时间判断。超时没收到 ACK,就认为丢包,对数据重传。

超时时间不是固定的,是动态、指数退避的。

三种丢包/超时场景(完全对应你讲的)

场景1:数据报文丢了

主机A 发数据,数据丢失,主机B 没收到,就不会返回 ACK,主机A在一段时间中没有收到ACK,超时,再次发送数据进行重传。
场景2:ACK 报文丢了

主机A 发数据,主机B 收到数据,发送确认应答ACK ,主机B发送的ACK 半路丢失,主机A在一段时间没有收到ACK,超时,再次发送数据进行重传。

主机B就会收到重复数据,主机B知道是重复包 ,不进行处理,直接补发 ACK
场景3:ACK 只是还在网络中传输,没丢

因为网络等原因,ACK 传输的比较慢,主机A 一段时间没有收到ACK,超时时间到了,还没等到。主机A 以为数据丢失,再次发送数据进行重传。这叫"假性超时",主机A 发送了两次数据,最后 A 会收到重复 ACK,去重即可

注意:在没有收到ACK之前,发送方会对数据进行备份,唯一发送的数据丢了,就彻底没了

超时时间的设置

不能固定,因为网络时好时坏,需要网络情况进行设置,

如果设置太长,如果数据丢了,需要等待很久才会重传,效率下降。

如果设置太短,有可能正在传输就到了重传的时间了,就会出现乱重传、浪费流量。

所以 TCP 用动态超时 + 指数退避:

  1. Linux 基础单位:500ms
  2. 第一次超时:等 500ms
  3. 还没回应:等 2×500ms
  4. 还没回应:等 4×500ms
  5. 继续 8×、16×... 指数上涨
  6. 重传次数到上限就认为连接挂了,关闭连接

7.连接管理机制

7.1 三次握手

首先客户端在使用connect连接服务端时,设置连接状态为SYN_SENT,处于发送连接状态,之后发送一个SYN请求连接报文,服务端设置了listen监听后,会处于SYN_RCVD接收连接状态,这时候接收到了客户端连接请求,对于一个服务端来说,遇到客户端的请求,是肯定要接收的,所以发送捎带应答,发送SYN连接请求加ACK确认应答,表明接收连接,并且服务端也要与客户端进行连接。客户端接收到服务端发送的请求,connect执行完毕,返回,设置连接状态为ESTABLISHED建立连接的状态,发送确认ACK应答,表示同意连接,服务端收到了,accept返回,将连接状态设置为ESTABLISHED建立连接的状态,到此三次握手建立成功。

注意:accept和三次连接是没有关系的,就算没有accept函数,也可以建立三次连接,accept是获取一个已经完成三次握手的连接。

7.2 四次挥手

当客户端没有数据向服务端发送了之后,调用close来关闭服务端的连接,处于FIN_WAIT_1状态,之后发送FIN断开连接报头,服务端read读取数据返回0之后,会处于CLOSE_WAIT状态,之后发送ACK确认应答,客户端接收ACK确认应答之后,会处于FIN_WAIT_2状态。

这个时候服务端可能不会马上发送FIN请求关闭连接,因为有可能服务端是还有数据要发送给客户端的,所以会将输出缓冲区的数据进行处理,处理完毕了之后。

调用close函数,发送FIN断开连接请求给客户端,之后处于LAST_ACK状态,客户端收到FIN请求后,处于TIME_WAIT状态,最后发送ACK确认应答,至此四次挥手结束。

相关问题

1.为什么是三次握手而不是四次握手

三次握手其实就对于四次握手,会把服务端的ACK确认应答和FIN请求连接作为一个捎带应答,客户端发起请求,服务端是必然要接受然后也向客服端去申请的,这个申请是必然的,所以会被压缩为一个捎带应答。

2.三次握手的好处

1.保证全双工的可靠性

所以前两次握手验证了客户端可以发送消息,服务端可以接收消息,并且客户端到服务器是可靠的,后两次握手验证了服务端可以发送消息,服务端可以接收消息,并且服务端到客户端是可靠的,即三次握手验证了客户端和服务端的是否可以发送和接收数据报文,即三次握手验证了客户端和服务器的全双工,验证全双工通路顺畅。

2.奇数次握手可以保证握手失败的情况下连接成本是在客户端的。

奇数次握手时,最后一次发送的一定是在客户端,那么如果握手失败的话,客户端会等待服务端的ACK应答,超时进行重发,资源的占用也会在客户端,服务端需要建立与多个客户端的连接,这样子设计也是减少服务端的压力。

3.一次,二次握手可以吗

答案是肯定不可以的。

一次握手

一次握手首先客户端是无法确定服务端是否收到自己的信息的,第二,如果由恶意的客户端,一直发送SYN请求连接(SYN洪水),服务器就会在自己的内核层面建立了无数个连接对象,这些对象也不通信,一直占着服务器的资源,消耗服务器内存,服务器的内存是有限的,当内存空间被申请为了,就会崩溃。

二次握手

二次握手的话,那么如果客服端因为原因崩溃了,但是连接已经在服务端形成了,这样子会对服务端的资源进行消耗。

8.Listen的第二个参数

之前提到通过listen的第二个参数baklog表示的是最大的连接个数,说的不够清晰,现在来好好理解一下。

代码示例:

我们先看结果,然后结合代码进行分析

首先我们对于新连接的到来,上面都不处理,也不调用accept来获取连接,将listen的第二个参数设为backlog设为1。

cpp 复制代码
#pragma once
#include<iostream>
#include<sys/types.h>   
#include<sys/socket.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<stdio.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<pthread.h>
#include<signal.h>
#include"log.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"
#include"Daemon.hpp"
using namespace std;
const string defaultip="0.0.0.0";
const int&listensock=-1;
const int backlog=1;
enum{
    UsageError=1,
    SockError,
    BindError,
    ListenError,
};
class TcpServer;
struct ThreadData
{
public:
    ThreadData(int fd,const string&ip,const uint16_t&p,TcpServer*t)
    :sockfd(fd)
    ,clientip(ip)
    ,clientport(p)
    ,tsvr(t)
    {}
public:
    int sockfd;
    string clientip;
    uint16_t clientport;
    TcpServer*tsvr;
};
class TcpServer
{
public:
    TcpServer(const uint16_t &port,const string&ip=defaultip)
    :listensock_(listensock)
    ,port_(port)
    ,ip_(ip)
    {}
    void InitServer()
    {
        listensock_=socket(AF_INET,SOCK_STREAM,0);
        if(listensock_<0)
        {
            lg(Fatal,"create listensock_ error, error:%d,errstring:%s",errno,strerror(errno));
            exit(SockError);
        }
        lg(Info,"create listensock_ success,listensock_",listensock_);
        int opt=1;
        setsockopt(listensock_,SOL_SOCKET,SO_REUSEADDR|SO_REUSEPORT,&opt,sizeof(opt));
        struct sockaddr_in local;
        memset(&local,0,sizeof(local));
        local.sin_family=AF_INET;
        local.sin_port=htons(port_);
        socklen_t len=sizeof(local);
        inet_aton(ip_.c_str(),&(local.sin_addr));
        int n=bind(listensock_,(struct sockaddr*)&local,len);
        if(n<0)
        {
            lg(Fatal,"bind error, error:%d,errstring:%s",errno,strerror(errno));
            exit(BindError);
        }
        lg(Info,"bind socket success,listensock_:%d",listensock_);
        //Tcp是面向连接的,服务器一般是"被动的",一直处于等待连接的状态。
        int m=listen(listensock_,backlog);//第二个参数不要设太大,用处后面会说。
        if(m<0)
        {
            lg(Fatal,"listen error, error:%d,errstring:%s",errno,strerror(errno));
        }
        lg(Info,"listen socket success,listensock_:%d",listensock_);
    }
    // static void*Routine(void*args)  
    // {
    //     ThreadData*td=static_cast<ThreadData*>(args);
    //     td->tsvr->Service(td->sockfd,td->clientip,td->clientport);
    //     delete td;
    //     return nullptr;
    // }
    void Run()
    {
        // Daemon();
        lg(Info,"Tcpserver is running...");
        ThreadPool<Task>::GetInstance()->Start();
        signal(SIGCHLD,SIG_IGN);//从一个已经关闭的文件描述符内写入会收到一个SIGCHLD信号,忽略这个信号,来保证服务端的健壮性,不要影响其他的客户端
        for(;;)
        {
            // struct sockaddr_in client;
            // socklen_t len=sizeof(len);
            // int sockfd=accept(listensock_,(struct sockaddr*)&client,&len);//一个客户端都会对应一个sockfd
            // if(sockfd<0)//这里的sockfd是用来对外通信使用的,与listensock_不一样。
            // {
            //     lg(Warning,"accept error,error:%d,errstring:%s",errno,strerror(errno));
            //     continue;
            // }
            // uint16_t clientport=ntohs(client.sin_port);
            // char clientip[32];
            // inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));
            // lg(Info,"Get a new link...,sockfd:%d,clientip:%s,clientport:%d",sockfd,clientip,clientport);
            // //单进程版
            // // Service(sockfd,clientip,clientport);
            // // close(sockfd);
            // //多进程版:通过子进程创建孙进程来实现,关闭子进程,来实现父进程回收子进程后,实现父孙进程的并行。
            // // pid_t id=fork();
            // // if(id==0)
            // // {
            // //     //child
            // //     close(listensock_);
            // //     if(fork()>0) exit(0);
            // //     Service(sockfd,clientip,clientport);
            // //     close(sockfd);
            // //     exit(0);
            // // }
            // // //father
            // // close(sockfd);
            // // pid_t rid=waitpid(id,nullptr,0);
            // //多线程版本
            // // ThreadData*td=new ThreadData(sockfd,clientip,clientport,this);
            // // pthread_t tid;
            // // pthread_create(&tid,nullptr,Routine,td);
            // //线程池版本
            // Task t(sockfd,clientip,clientport);//1.任务(普通转发) 2.任务(翻译)
            // ThreadPool<Task>::GetInstance()->Push(t);
        }
    }
    // void Service(int sockfd,const string&clientip,const uint16_t&clientport)
    // {
    //     char buffer[4096];
    //     while(true)
    //     {
    //         ssize_t n=read(sockfd,buffer,sizeof(buffer));//这里accept返回的sockfd已经是打开了,不需要open打开
    //         if(n>0)
    //         {
    //             buffer[n]=0;
    //             cout<<"client say# "<<buffer<<endl;
    //             string echo_string="TcpServer say# ";
    //             echo_string+=buffer;
    //             write(sockfd,echo_string.c_str(),echo_string.size());
    //         }
    //         else if(n==0)
    //         {
    //             lg(Info,"%s:%d quit,server close sockfd:%d",clientip.c_str(),clientport,sockfd);
    //             break;
    //         }
    //         else
    //         {
    //             lg(Warning,"read error,sockfd:%d,clientip:%s,clientport:%d",sockfd,clientip.c_str(),clientport);
    //             break;
    //         }
    //     }
    // }
    ~TcpServer()
    {}
private:
    int listensock_;
    uint16_t port_;
    string ip_;
};

之后打开3个终端来连接服务端

左侧就是我们使用3个终端来连接服务端,之后我们使用

cpp 复制代码
netstat -tnp|head -2&&netstat -ntp|grep 8080

指令来查看客户端的连接状态,发送有服务端两个连接是处于ESTABLISHED,一个服务端连接处于SYN_SENT。

listen的第二个参数backlog代表的是全连接的数量,全连接也就是代表完成三次握手的连接,backlog+1就是全连接的数量,因为我们将backlog的数量设置为1,所以全连接的数量也就是2。所以只能有两个客户端可以完成三次握手与服务端连接。

当第三个客户端发送SYN连接的时候,作为服务端肯定是接收连接,发送ACK确认应答+FIN建立连接,客户端接收到后,发送确认应答,服务端收到客户端的确认应答后,因为全连接队列满了,并且我们没有通过accept来获取连接,也就是没有从全连接的队列中取走连接,所以服务端发现全连接队列满了,就将客户端的请求ACK丢弃了。

所以服务端就会处于SYN_RECV状态,但是客户端认为自己已经发出了ACK确认应答,客户端认为三次握手是完成了的,所以就会处于ESTABLISHED状态。

所以listen的第二个参数+1就表示全连接队列的大小,accept的作用其实就是将获取全连接队列中的连接,listen的第二个不能太少也不能太多,太多的话,如果操作系统忙碌,就无法及时的对全连接的请求进行处理,太少,操作系统的资源就无法被有效的利用。

那么肯定会有多个客户端因为全连接队列满了,但是服务端对应的连接还处于SYN_RECV状态,所以服务端就会有一个半连接队列将这些连接管理起来,处于SYN_RECV状态的连接就叫做处于半连接状态,当全连接中有连接被accept函数取走时,半连接队列中的连接就会被移到全连接中。

当我们过一会再去查看状态时。

就会发现这个半连接消失了,也正常,操作系统是不会做如何浪费时间和资源的事情的,半连接存在本来就是在占资源,长时间没有被移到全连接队列,就会被操作系统给释放掉。

理清一下,连接的变化关系:

有客户端连接,服务端收到 SYN ,创建对应客户端的服务端半连接对象,加入到入半连接队列。之后服务端发送ACK确认应答加SYN连接请求,客户端发送ACK确认应答,服务端收到之后,检查全连接队列空间,如果若有空间,半连接对象从半连接队列出队,转为全连接对象,加入到全连接队列。

全连接队列等待应用层调用 accept 函数,将全连接队列头部取出连接。

9.理解TIME_WAIT状态

cpp 复制代码
void Run()
    {
        // Daemon();
        lg(Info,"Tcpserver is running...");
        ThreadPool<Task>::GetInstance()->Start();
        signal(SIGCHLD,SIG_IGN);//从一个已经关闭的文件描述符内写入会收到一个SIGCHLD信号,忽略这个信号,来保证服务端的健壮性,不要影响其他的客户端
        for(;;)
        {
            struct sockaddr_in client;
            socklen_t len=sizeof(len);
            int sockfd=accept(listensock_,(struct sockaddr*)&client,&len);//一个客户端都会对应一个sockfd
            if(sockfd<0)//这里的sockfd是用来对外通信使用的,与listensock_不一样。
            {
                lg(Warning,"accept error,error:%d,errstring:%s",errno,strerror(errno));
                continue;
            }
            uint16_t clientport=ntohs(client.sin_port);
            char clientip[32];
            inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));
            lg(Info,"Get a new link...,sockfd:%d,clientip:%s,clientport:%d",sockfd,clientip,clientport);
            sleep(1000);
            // //单进程版
            // // Service(sockfd,clientip,clientport);
            // // close(sockfd);
            // //多进程版:通过子进程创建孙进程来实现,关闭子进程,来实现父进程回收子进程后,实现父孙进程的并行。
            // // pid_t id=fork();
            // // if(id==0)
            // // {
            // //     //child
            // //     close(listensock_);
            // //     if(fork()>0) exit(0);
            // //     Service(sockfd,clientip,clientport);
            // //     close(sockfd);
            // //     exit(0);
            // // }
            // // //father
            // // close(sockfd);
            // // pid_t rid=waitpid(id,nullptr,0);
            // //多线程版本
            // // ThreadData*td=new ThreadData(sockfd,clientip,clientport,this);
            // // pthread_t tid;
            // // pthread_create(&tid,nullptr,Routine,td);
            // //线程池版本
            // Task t(sockfd,clientip,clientport);//1.任务(普通转发) 2.任务(翻译)
            // ThreadPool<Task>::GetInstance()->Push(t);
        }
    }

先建立一个客户端连接到服务端,查看状态,是否完成了三次握手。

可以看到确实三次握手成功,都处于了ESTABLISHED状态,接下来我们先让客户端打开连接。

下面的图,先断开的就是左边的状态,后退出的就是右边状态。

查看客户端和服务端的状态。

客户端ctrl+c退出后,发送了FIN退出报文给服务端,服务端发送ACK确认应答,服务端处于了CLOSE_WAIT状态,并且发送确认应答,客户端处于了FIN_WAIT_2状态。

之后我们也退出服务端,查看状态。

这个时候,客户端处于了TIME_WAIT状态,TIME_WAIT状态就表示处于着状态的一端需要等待一段时间才可以退出,这个时间是两个MSL的时间,MSL时间是一个报文可以在网络中存在的最长时间,也就是一个报文一来一回的最长时间,这样子设计,也计算了,因为网络问题,报文被阻塞在路由器中的时间。

先退出的一端需要处于TIME_WAIT状态。

处于TIME_WAIT状态有两个主要的原因:

1.让发送双方的历史数据消散。如果一个客户端因为异常退出了,重新建立连接的时候,操作系统为其分配了一个与上一次一摸一样的临时端口,那么服务端上一次发送的报文可能还在网络中,当客户端连接上来后,有可能会收到服务端上一次发送的报文,客户端接收到"垃圾数据。

2.让四次挥手变得更加可靠。如果客户端主动关闭连接了,没有进行TIME_WAIT状态,如果最后发送给服务端的ACK确认应答丢失了,服务端没有收到,就进行超时重传,由于客户端已经退出了,不可能收到进行发送ACK确认应答的,服务端就会一直进行超时重传,虽然最后会因为多次超时重传断开连接,但是进行多次超时重传,也是消耗资源的。

setsockopt函数

主动断开连接的一方是会处于TIME_WAIT状态的,因为这个时候的端口号是被占用的,那么这个时候是不能马上进行重启的,对于客户端来说,没关系,因为客户端的端口号是操作系统随机绑定的,所以重新绑定绑定到重复的可能性很小,所以客户端的重启一般不会失败。

但是对于服务端就不一样了,服务端的端口号是固定的,也就是说服务端退出了,是不能马上重启的,处于TIME_WAIT有时候是不好的。

如果服务端是淘宝,天猫这样子的服务端,他们的成交量可能是一秒钟成千上万的,如果服务端因为异常不能马上重启的话,是损失很多金钱的。

所以使用setsockopt可以让服务端马上重启。

所以第一个参数传入网络文件描述符sockfd,这里是服务器的监听文件描述符listensock

然后第二个是设置为SOL_SOCKET,即socket编程

那么第三个参数则是SO_REUSEADDR|SO_REUSEPORT,即重复使用地址,这里的地址是指IP地址和端口号port

那么第四个设置选项是选项,所以这里我们定义一个opt为1,然后传入地址即可,那么最后传入选项的地址即可

使用了setsockopt之后,就可以让当服务器主动退出的时候,即使服务器的连接处于TIME_WAIT状态,也可以立即进行重启。

10.滑动窗口

之前就提到过,TCP发送报文并不是一次性发送的,而是一次性发送多个报文。

但是TCP的发送缓冲区中对于报文的管理是怎么样的呢?万一需要超时重传的话,所以就需要对报文进行存储,不能发送出去之后,就把报文给删除了。

TCP的发送缓冲区使用滑动窗口的方式来进行管理,目前我们认为分为3个部分,一个部分已发送已确认,也就是发送出去的报文,已经收到对方的确认应答了,对于这个区域的数据,已经可以被删除了,但是这里的删除其实也就是被新数据覆盖了。一个部分为已发送未确认,也就是发送出去的报文,对方还没有进行确认应答,这个部分我们也称为滑动窗口,最后就是还没有发送的区域。

正因为有了这个滑动窗口,我们也就是可以给对方发送多条数据,同时到没有收到确认应答,也就可以从滑动窗口中找到对应数据,给对方进行超时重传。

TCP 的发送缓冲区,我们可以把它想象成一个字节数组,里面每一个字节都有自己的位置编号。为了管理这些数据,TCP 用了两个"指针":

一个叫 win_start ,表示窗口的起点;

一个叫 win_end ,表示窗口的终点。
这两个指针一摆,发送缓冲区就自然被分成三块:

win_start 左边:已经发出去、并且收到对方确认的数据

win_start 到 win_end 中间:已经发出去、但还没收到确认的数据,这一块就是滑动窗口

win_end 右边:还没发送的数据

所谓滑动窗口滑动,其实就是这两个指针往右挪,滑动窗口是不会向左滑动的,比如前面一部分数据收到确认了,TCP 就知道这些数据安全送达,不需要再保留。于是直接把 win_start 往右移,把这部分数据划到"已确认区"。这些数据不用真删除,只要标记成可被新数据覆盖就行。

因为底层用了取模(环形缓冲区),相当于数组循环使用,所以也不会出现越界的情况

滑动窗口虽然是一整块逻辑区域,但真正发送时,不能一次性全部发走。因为网卡硬件有限制,一次只能发一小段数据(MTU 限制),不能把整个窗口塞成一个巨大报文。所以滑动窗口内部,会被切分成很多小段,每一小段对应一个 TCP 报文的载荷。

滑动窗口的大小:min(对方主机的接收能力大小,拥塞窗口的大小(网络的接收能力,动态的))。

情况1:少量应答丢包

如果是应答丢包了,如果图中的序号为 3000 的数据应答丢包了,服务端是知道数据有没有收到的,那么后续服务端接收到新的数据,给客户端发送的应答里面就是 4001,5001,确认序号定义是之前的数据全部收到了,那么这样子应答丢失也就不会受影响了,所以是支持少量的应答丢失。

情况2:数据丢包

如果是数据真的丢了的话,如果图中的 1001-2000 的序号数据丢包了,那么后续服务端收到新的数据的应答的确认序号还是会为 1001,表示1001以前的数据都收到了,客户端在收到 3 次重复的确认序号就会进行重传,这个重传叫做快重传,之后客户端会把 1001-2000 的数据重发,服务端在接收到后,就发送已经收到的数据的序号最大值,告诉客户端接下来要发送的序号。

如果是数据丢包的话,长时间没有收到1001-2000数据的ACK确认应答,也会重新发送数据的,那么为什么还需要快重传这个东西呢?

快重传VS超时重传

快重传有前提,超时重传是兜底。

快重传必须满足:连续 3 个重复 ACK。但如果是通信最后一段数据丢了,比如 9001~10000 那段丢了,后面已经没有更多报文了,接收端根本发不出 3 个重复 ACK,甚至连 2 个都发不出来。这种情况快重传触发不了,只能靠超时重传来兜底补发。

快重传主要负责处理中间丢包,速度快、效率高

超时重传用来处理末尾丢包、极端丢包、完全收不到 ACK 的情况,用来保底

滑动窗口的移动

一、右指针不动,左指针右移,窗口变小

这种情况最常见。接收端收到了数据,但上层应用太忙,没来得及读取缓冲区。于是接收缓冲区剩余空间变小,它在 ACK 确认应答中里告诉发送端:"我的接收缓冲区的空间不多了,你少发点。"

发送端收到新的确认号,于是左指针右移(确认新数据的起点)右指针不动(对方的缓冲区的空间不多了)

二、左右指针都右移,右移更快,窗口变大

接收端一边收数据,应用层读数据特别快,超过了接收端接收数据的速度。接收缓冲区越用越空,剩余空间变大。接收端就在 ACK 里告诉发送端:"我空间很大,你可以多发。"、

发送端收到新的确认号,于是左指针正常右移(确认新数据的起点),右指针移动更快(对方的缓冲区的空间不多了)。

三、左右指针都右移,但左移更快,窗口变小

接收端一直在收数据,但应用层读得很慢。缓冲区越来越满,能接收的空间越来越小。

发送端收到新的确认号,于是左指针移动快,右指针移动的很慢。

四、左右指针移动速度一样,窗口大小不变

接收端收数据的速度,和应用层读数据的速度刚好一样。缓冲区剩余空间保持一个稳定的大小。

发送端收到新的确认号,于是左指针右移,右指针跟着右移。

五.滑动窗口能变成 0 吗?

能,而且非常常见。接收端一直在收数据,但上层应用层太忙了,导致完全不读取接收缓冲区的数据。

接收缓冲区会被彻底塞满,剩余空间 = 0。接收端就会在 ACK 里告诉发送端:我的接收窗口是 0,别发了,发送端收到后,就会把:win_begin 直接移到和 win_end 重合窗口大小 = 0,发送停止。

11.重新理解序号

之前我们理解 TCP 序号时,为了方便,简单说成数据块最后一个字节的下标。但这只是方便理解,真实的序号不是这样。

网络中可能存在"历史残留包",就算有 TIME_WAIT 等待,也不能 100% 保证旧包全部消失。如果服务器重启、客户端重连,旧包的序号如果和新连接序号刚好一样,就会被当成新数据接收,导致通信错乱。

真正的 TCP 序号规则是:序号 = 一个随机初始值 + 数据在发送缓冲区里的下标。使用随机值为了防止网络里残留的旧报文,干扰新连接的数据。每次建立连接,都用一个随机数当起点。这样旧报文的序号几乎不可能和新连接重合,就算旧包传过来,序号对不上,直接丢弃,不会影响新通信。

在三次握手时生成、交换,客户端第一次握手(SYN),自己生成一个随机数,比如 1234,

放到报文的序号字段里发给服务器。服务器第二次握手(SYN+ACK)也生成自己的随机数,比如 4321,同样放到序号字段发给客户端。双方都拿到对方的随机数,然后取较小的那个作为本次连接统一的初始序号。比如 1234 和 4321,就用 1234 当起点。

确认初始序号的时候,双方在接收到序号,使用确认序号减去初始序号就可以找到下一次该发生哪一段数据了。

12.延迟应答

TCP 通信是双向的,左边能发给右边,右边也能发给左边。我们今天只看一边:左边发、右边收,来讲延迟应答。

如果想让 TCP 传输更快,就要让发送方一次能发更多数据。而发送方一次能发多少,完全看接收方通告的接收窗口有多大。窗口越大,一次发得越多,效率越高,所以可以通过延迟应答来使接收方尽量通告一个更大的窗口

延迟应答也就是让接收方收到数据后,不马上回复 ACK,而是等一会儿再回复。

因为在等待的这段时间里,上层应用可能会把接收缓冲区里的数据读走。一旦应用读走数据,缓冲区空出来了,剩余空间就变大,接收方再回复 ACK 时,就能通告一个更大的接收窗口。当窗口变大,发送方一次能发更多数据,整体速度也就更快了。

但是延迟应答不一定能让窗口变大的,概率的问题,如果上层应用特别忙,在延迟等待的时间里完全没读缓冲区,那窗口大小就不会变,延迟应答也就没起到效果。但是大多数情况下,应用都会及时读数据,所以延迟应答大概率能提升窗口、提升效率。

要注意延迟应答不能等太久,等待时间绝对不能超过发送方的超时重传时间,如果等太久,发送方会以为丢包了,开始重传,反而更慢。

操作系统一般用两种方式控制"等多久":

  1. 按数量等(常用),每收到 2 个报文,才回复一次 ACK。不在收到每一个报文都应答,减少报文数量,效率更高。

  2. 按时间等,最多等 200ms,时间一到必须回复,保证不会触发对方超时重传。

13.阻塞控制

上面说到了滑动窗口的大小:min(对方主机的接收能力大小,拥塞窗口的大小(网络的接收能力,动态的))。

TCP 虽然已经有滑动窗口,可以高效、可靠地批量发数据,但如果一上来就发一大堆,还是会出问题。数据的传输是在网络中进行的,那么在网络拥塞的时候,那么发送数据的速度就应该慢下来,等待网络的情况好转之后,再进行发送了。

所以TCP引入了慢传输的机制,刚开始别多发,先少发一点探路,看看网络堵不堵,没问题再慢慢加大。

为了衡量"能发多少",TCP 引入了一个新概念:拥塞窗口(cwnd)表示当前网络能承受的发送上限。单位可以理解成报文个数(MSS 个数)。

刚开始,拥塞窗口 = 1(只发 1 个报文)每收到一个 ACK,拥塞窗口 +1,发送给对方多少数据,会根据网络情况和对方发送回来的ACK确认应答来决定,当ACK确认应答中,接收缓冲区还很大,就应该尽量发送多的数据,所以阻塞窗口的增长过程是:1 → 2 → 4 → 8 → 16 → 32 ...指数级往上冲。一开始发的很慢,但后面涨得极快。这就是"慢启动":起步慢,增长快。

但是随着指数增长,发送的数据越来越多,网络就会开始变得拥塞了,所以 TCP 设定了一个慢启动阈值(ssthresh)。当拥塞窗口 < 阈值,窗口为指数增长(慢启动阶段),当拥塞窗口 ≥ 阈值:改成线性增长,慢慢加,不再疯涨。

当TCP 大量丢包 ,就认为是网络堵了,一旦堵了,将慢启动阈值设为当前拥塞窗口 / 2,拥塞窗口直接重置为 1,重新开始进行慢启动:1 → 2 → 4 → 8...,到慢启动阈值后再线性增长。不断循环。

主机真正能发送的数据量,不是你想发多少就发多少,而是:实际滑动窗口大小 = min(接收窗口 rwnd, 拥塞窗口 cwnd),谁小听谁的。

14.粘包问题

TCP 协议是面向字节流的,他的报头里面是没有数据的长度的,面对一个用户要发送的信息,是有可能被拆分为多个报文发送过去的,对于上层的 http/https 协议报文的格式,在 TCP 眼里面就是一个个的字节, 这些字节就像流水一样,从用户流到内核,在发送到对方,对方对于这些字节一次取多少,分几次取,都是用户决定的。

所以就会出现粘包的问题(应用层将数据读取上来并不知道从哪里到哪里是一条完整的报文),常见的有四种解决方案。

1. 固定长度报文

每个包规定死长度,比如都是 60 字节。读到不够 60 就继续等,够了就作为一个完整的报文。

2. 特殊字符分隔

每个包末尾加一个不会出现在正文里的特殊符号,比如 \n 或 \0 。读到分隔符,就认为一个包结束。

3.使用自描述字段+定长报头

像 UDP 协议一样在自描述字段里面说明报文的长度,减掉报头就是数据长度。

4. 特殊字符 + 长度字段(HTTP 风格)

用 \r\n 按行读,读到空行表示头结束。头里有 Content-Length ,告诉后面正文多长。这就是 HTTP 解决粘包的方式。

15.TCP异常

一、进程终止(程序崩溃、被杀死)

不管是正常退出,还是异常崩溃,操作系统在回收进程时,都会做同一件事:关闭该进程打开的所有文件,包括 socket。而 TCP 连接本质就是内核维护的连接对象,一个连接本质上面也就是一个文件描述符,通信也就是向这个文件描述符读取和写入数据。所以进程一死,系统会自动给对方发送 FIN 报文,走正常四次挥手,把连接优雅关闭。这种属于正常关闭,并不属于异常断开。

二、机器关机 / 重启

关机或重启时,操作系统会做的第一步是:把所有正在运行的进程全部关闭、回收资源。既然所有进程都被终止了,那就和上面"进程终止"完全一样,系统会自动给所有 TCP 连接发 FIN,走四次挥手,优雅关闭连接。所以关机、重启也属于正常关闭,并不属于异常断开

三、掉电 / 网线断开(真正的异常断开)

这两种是一类,都是瞬间断连,双方完全收不到 FIN。比如网线突然拔了、机器突然断电。这时候,本端直接"消失",没有时间来发送FIN,根本不可能发 FIN,也不可能进行挥手。那对端怎么知道连接挂了?靠 TCP 保活机制(keep-alive):系统会每隔一段时间,发一个探测报包。如果连续多次探测都没回应,内核就认为连接已失效,自动释放连接、清理资源、不再维护。这种才是真正的异常断开。

相关推荐
上去我就QWER2 小时前
详解HTTP协议中的multipart/form-data
网络·网络协议·http
@encryption3 小时前
TCP,IP
服务器·网络·tcp/ip
F1FJJ4 小时前
我用一条命令把内网的 RDP 桌面开到了浏览器里 —— Shield CLI 与主流隧道工具的技术对比
网络·golang
bigcarp5 小时前
邮箱服务中的代发邮件-发送邮件登录账号不等于发件地址 MAIL FROM≠登录账号
网络
Predestination王瀞潞5 小时前
5.3.2 通信->HTTP3超文本传输协议标准(IETF RFC 9114):Headers 请求头 响应头
网络·网络协议·tcp/ip
源远流长jerry5 小时前
RDMA vs 传统以太网:寻址粒度为何决定性能天花板
linux·网络
sugar__salt5 小时前
网络原理(五)——HTTP
网络·网络协议·http
謓泽6 小时前
【MODBUS】串口 RTU / Modbus TCP / 透明就绪
网络·串口·modbus
budingxiaomoli6 小时前
数据链路层&&应用层知识总结
网络