一、TCP协议

 1.数据传输的总体框架

如何找到接收缓冲区?

当主机收到报文后,通过 端口号port套接字描述符sockfd 来定位接收缓冲区(本质就是文件),过程如下(以下大致思路,实际略有不同):

1.找到对应进程

需要使用网络的每个进程都有唯一的pid,还有一个或多个端口号port。而为了通过端口号port快速定位进程,操作系统会维护一个哈希表构造(port:pid)的键值对存放在哈希表中,port是key值,pid是value。

2.找到缓冲区

套接字(缓冲区)本质就是文件,套接字描述符sockfd本质就是文件描述符,所以,可以通过对应进程PCB中的文件描述符表找到缓冲区。

将TCP层解包后的有效载荷放入找到的接收缓冲区中,应用层就可以根据文件描述符sockfd用read读取有效载荷(报文)了,这就完成了数据的接收。

发送也是一样,不过是走了相反的方向。

2.TCP协议基础知识

TCP全称为 “传输控制协议(Transmission Control Protocol”),它可以对数据的传输进行细致的控制。

传输控制协议

为什么 TCP 叫传输控制协议?

  • 用户调用的网络发送函数,如 write、send、sendto 等,实际是将数据从应用层缓冲区拷贝到 TCP 协议层(也就是操作系统内部的发送缓冲区)
  • 用户调用的网络接收函数,如 read、recv、recvfrom 等,实际是将数据从 TCP 协议层的接收缓冲区拷贝到用户层的缓冲区中

真正负责双方主机 TCP 协议层之间数据发送的过程,完全由 TCP 自主决定。比如什么时候发送数据、一次发送多少数据、发送过程中出现错误该如何处理等,这些都由 TCP 协议自身控制,属于操作系统内部的操作,与用户层没有关联。这正是 TCP 被称为传输控制协议的原因 —— 数据传输的整个过程由它自行控制和决定。

全双工通信模式

客户端(c)向服务端(s)发送数据,与服务端(s)向客户端(c)发送数据,使用的是不同的发送缓冲区和接收缓冲区对。所以客户端给服务端发送数据时,并不会影响服务端给客户端发送数据,这说明 TCP 是全双工通信模式,一方在发送数据时,不影响另一方同时发送数据。

3.TCP协议格式

  • TCP协议报头=固定长度首部(20字节)+选项
  • TCP协议报文=固定长度首部(20字节)+选项+数据

选项的内容不讲解,只需要知道这部分不是定长的。

格式说明

图中每一行包含 4 个字节(32 位):

字段 长度 说明
源端口号(Source Port) 16 位 发送方进程的端口号
目的端口号(Destination Port) 16 位 接收方进程的端口号
TCP序列号(Sequence Number) 32 位 表示本报文段所发送数据的第一个字节的编号(后面讲)
TCP确认序号(Acknowledgment Number) 32 位 表示接收方期望收到发送方下一个报文段的第一个字节数据的编号(后面讲)
首部长度(Header Length) 4 位

也叫数据偏移(Data Offset),指数据段中 “数据” 部分起始处距离 TCP 报文段起始处的字节偏移量,用于确定 TCP 报头部分的长度(固定首部+选项)。

计算方法:TCP报头所占字节数=首部长度 * 4(后面讲)

保留(Reserved) 6 位 为 TCP 协议未来的功能扩展预留空间,目前这 6 位必须全部为 0
控制标志(Control Flags) 6 位 共包含 6 个标志位,每个标志位占 1 个 bit,分别对应不同的控制功能(后面讲)
窗口大小(Window Size) 16 位 表示滑动窗口的大小(接收方还能接受的字节数据量),用于 TCP 的流量控制(后面讲)
校验和(Checksum) 16 位 用于验证传输的数据是否损坏。发送端根据数据内容计算生成校验和数值,接收端根据接收到的数据重新计算校验和。若两个数值相同,说明数据有效;反之则无效,接收端会丢弃该数据包。校验和的计算基于伪报头 + TCP 报头 + TCP 数据三部分
紧急指针(Urgent Pointer) 16 位 仅当标志位字段的 URG 标志位为 1 时才有意义,用于指出有效载荷中紧急数据的位置(偏移量),紧急数据的大小固定为1字节。
选项(Options) 可变

长度不固定,但必须是 32bits(4 字节)的整数倍,内容可根据需求变化,因此必须通过首部长度来区分选项的具体长度

数据 可变 应用层的报文和有效载荷

补充说明

1.各标志位详解

标志位字段共 6bit,包含 6 个标志位,每个标志位占 1bit,只有 0 和 1 两种状态。

  • URG (Urgent): 为1时,表示报文段中有紧急数据,应优先处理。此时紧急指针字段有效。
  • ACK (Acknowledgment): 为1时,表示TCP确认号字段有效。在连接建立后,绝大多数报文此位都被置1。
  • PSH (Push): 为1时,催促接收方立即将 接收缓冲区中的数据 推送给上层应用程序,而不必等待缓冲区填满。常用于交互式应用(如Telnet、SSH),以降低延迟。
  • RST (Reset): 为1时,表示需要重置连接。通常用于异常终止连接,或拒绝一个非法的连接请求。
  • SYN (Synchronize): 为1时,表示这是一个连接请求或连接接受报文。用于三次握手建立连接。
  • FIN (Finish): 为1时,表示发送方数据已发送完毕,要求释放连接。用于四次挥手关闭连接。

2.重置连接

基础知识:

  • TCP需要双方保持连接才能通信
  • 两个主机间每建立一个连接,操作系统就会通过链表等数据结构管理起来
  • 当RST标志位为1时,表示当前连接非法,需要重新连接

举例说明:

  1. 客户端与服务端通信时,突然服务器断电了,主机直接停止运行。
  2. 此时,服务器中维护连接的数据结构消失,但是客户端维护的连接数据结构还在。
  3. 所以,客户端认为它与服务器的连接依旧正常,还会给服务器发数据。
  4. 服务器重新启动后,接收到客户端的数据,但发现并没有和客户端建立连接。
  5. 此时,服务器就给客户端发送一个RST标志位为1的报文,告诉客户端连接不合法,请建立新连接。

3.紧急指针

紧急数据通常称为带外数据,TCP规定只能有1字节,需要被尽快读取。

URG=1 时表示有紧急数据,此时16位紧急指针字段有效,表示紧急数据在有效载荷中的偏移量。接收方读取 TCP 报头后,会先从紧急指针指示的偏移量处读取 1 字节紧急数据,再从有效载荷起始位置读取剩余数据。

实际上,紧急指针和 URG 标志位一般用不到。若需使用带外数据,可能是运维人员用于查询服务器状态(如服务器压力大时,发送 1 字节带外数据询问状态,服务器返回 1 字节状态码,带外数据无需经过冗长数据流,可直接在应用层读取)。

若要读取带外数据,可将 recv/send 的 flags 标志位按位或上 MSG_OOB

ssize_t recv(int socket, void *buf, size_t len, int flags);
ssize_t send(int socket, const void *buf, size_t len, int flags);  
eg.
recv(sockfd,&ch,1,MSG_OOB);

TCP报文的报头和有效载荷如何分离呢?

TCP 报文头部与有效载荷的分离依赖于头部中的 ‍“数据偏移”(Data Offset)‍ 字段。具体步骤如下:

1.读取 TCP 报文的前 20 字节

这是 TCP 头部的基本部分(固定长度),其中包含 数据偏移 字段(位于第 13 字节的高 4 位)。

2.解析“数据偏移”字段

该字段以 4 字节 为单位表示整个头部的长度。例如:

  • 若数据偏移值为 5,表示头部长度为 5×4 = 20 字节(无选项)。
  • 若数据偏移值为 8,表示头部长度为 8×4 = 32 字节(含 12 字节选项)。
  • 注意:数据偏移长度为4位,表示范围是 0 (0000) ~15(1111),又以4字节为单位,结合实际,头部长度的范围是20字节~60字节

3.根据数据偏移计算头部结束位置

从报文起始位置向后偏移 (数据偏移值 × 4) 字节,即得到头部结束处。之后的所有内容即为 有效载荷(应用层数据)。

4.处理选项字段(如果有)‍

如果数据偏移大于 5,说明头部包含选项。在读取完基本头部(20 字节)后,继续读取 (数据偏移值×4 – 20) 字节的选项,然后剩余部分才是有效载荷。

示例

假设收到一个 TCP 报文,其数据偏移字段为 6,则:

  • 头部总长度 = 6 × 4 = 24 字节。
  • 前 20 字节为固定头部,接着 4 字节为选项,之后全部为有效载荷。

为什么在TCP报头中,只表示了报头的大小?

为什么在TCP报头中,没有表示 报头+有效载荷 的大小,只表示了 报头 的大小?而在UDP报头中有16位的UDP长度字段,其定义了整个UDP数据报的长度(报头+有效载荷/数据)

1.TCP 是面向字节流的协议

  • TCP 把数据视为连续的字节流,不保留单个“消息”边界。接收方依靠序列号和确认号来重组数据流,不需要在 TCP 层知道每个报文段的确切数据长度。
  • 整个 TCP 段的长度可以通过 IP 总长度减去 IP 头部长度和 TCP 头部长度(由“数据偏移”字段给出)间接得到。
  • 因此,TCP 头部只需提供头部长度(数据偏移),而不需要再额外携带整个报文段的总长度。

2.UDP 是面向数据报的协议

  • UDP 每个数据报都是独立的,接收端必须能够明确区分每个报文的起止。16 位 UDP 长度字段直接指明头部与数据的总长度,让接收方知道当前数据报在哪里结束
  • 由于 UDP 没有连接状态,也不使用序列号,只能依靠长度字段判断报文边界,否则无法从 IP 层交付的数据中正确分离出 UDP 报文。

3.协议分工的不同

  • IP 层已经提供了总长度字段,TCP 利用这一信息(结合自己的头部偏移)即可算出数据长度,避免了在传输层重复记录。
  • UDP 同样可以借助 IP 长度推算,但为了自包含(self‑contained)以及支持伪首部校验和计算,仍保留了独立长度字段。

4.TCP协议的可靠性

通信不可靠的现象

  • 数据包可能丢失在网络中,对方主机没有接收到。
  • 一份数据可能会被分成多个数据包按顺序发送,但对方接收的顺序很可能会改变。
  • 一份数据可能在一段时间内没有被对方收到,所以发送方又重发了一次,此时接收方会收到两份一样的数据。
  • 发送方太快,接收方接收缓冲区很快被占满,以后发来的数据就会被丢弃。
  • 发送方太慢,接收方大部分时间都在等待数据,处于饥饿。

总而言之,丢包、乱序、重复、校验失败、发送太快发送、太慢都是不可靠的表现。可靠的通信都需要避免这些问题。

而在其中,最核心的就是避免丢包问题。

确认应答机制概念

TCP协议通过很多机制来避免丢包,确保可靠性,其中确认应答机制尤其重要。

所谓确认应答机制可以简单理解为:在双方通信时,发送方每发送一个数据包,接收方就要返回一个ACK应答。如果在一定的时间内没有接收到应答,发送方就只能再次将同样的数据发送,直到收到应答。

正确理解可靠性

如果对端没有给我们应答,那我们就不能确定对端是否收到我发送的消息,我们只能保证历史消息的可靠性

1.对历史消息的保证:

  • 关键机制:具有应答。
  • 一旦发送出去的报文收到了接收方的确认应答,发送方就能100%确定该历史消息已经被对方成功接收。这是可靠性得以实现的基础。

2.对最新消息的挑战:

  • 核心困境:最新的报文永远没有应答。
  • 在通信过程中,刚刚发送出去的、最新的那个报文,在它被确认之前,其状态是未知的。发送方无法确定它是否已经到达、是否在途中丢失或损坏。因此,单凭应答机制,无法保证“最新”报文的可靠性。

5.TCP协议的通信过程

发送方发送一个报文后,接收方返回一个确认“应答”,以确认收到数据(确认应答机制)。对于应答,我们不再需要回应,因为ACK不携带数据。

下面我们以客户端给服务端发送报文为例(TCP/UDP通信中,服务端和客户端是等价的

实际上,客户端一般是一次发送多个消息,服务端则对每个消息做应答

二、TCP的主要机制

1.确认应答(ACK)机制

如果发送方每次只发一个数据段,等到收到接收方的应答后,再发送下一个数据段,具体实现就会简单很多。但是TCP实际工作时,为了提升效率,发送方会同时发送多个数据段,而接收方会同时对每个数据段发出对应的应答。

那如何让接收方收到的数据段按序排列,发送方收到的应答与发送的数据段对应?——使用字节序列号来标识数据段本身,其实就是填写报文中的报头来标识有效载荷或做出应答,下面探讨其如何实现:

 对于TCP来说,它传输的不是一个个独立的“消息包”,而是一个连续的、无结构的字节流。

  • 应用层视角: 你可能会发送两条消息:“Hello” 和 “World”。
  • TCP视角: 它看到的是一个连续的字节序列:‘H’, ‘e’, ‘l’, ‘l’, ‘o’, ‘W’, ‘o’, ‘r’, ‘l’, ‘d’。

在发送缓冲区中,TCP将每个字节的数据都进行了编号,即为序列号(简单理解:发送缓冲区为char类型的数组,序列号为数组下标)。TCP是以报文段(多个字节)为单位来发送数据的,而这个报文段的序列号就是 该报文段的第一个字节的序列号 。

发送方的发送报头

TCP发送报头中: TCP序列号 就是 发送的报文段的序列号——报文段中第一个字节的序列号 

假设1000个字节长度为一个报文段:

  • 第一个报文段:TCP序列号 = 1。这意味着它包含了从第1个字节开始的1000个字节(即字节1 到 1000)。
  • 第二个报文段:TCP序列号 = 1001。这意味着它包含了从第1001个字节开始的1000个字节(即字节1001 到 2000)。
  • 第三个报文段:TCP序列号 = 2001。这意味着它包含了从第2001个字节开始的1000个字节(即字节2001 到 3000)。

接收方的确认报头

接收方的确认同样是基于字节序列号的。

假设成功接收(1000个字节长度为一个报文段):

  • 当接收方成功收到第一个报文段(字节1-1000)后,它会回复:ACK=1001。
  • 这个 1001 的含义是:“截至到第1000号字节的所有数据我都已收到,我下一个期望收到的字节的序列号是1001”。
  • 当发送方收到 ACK=1001 后,它就知道前1000个字节已经安全送达,可以继续发送从1001开始的数据。

假设发生丢包(1000个字节长度为一个报文段):

  1. 发送方同时发送第一个报文段:TCP序列号 = 1(即字节1 到 1000)、第二个报文段:TCP序列号 = 1001(即字节1001 到 2000)
  2. 成功接收第一个报文段,丢失第二个报文段
  3. 接收方回复两个相同的报文:ACK=1001
  4. 当发送方收到两个 ACK=1001 后,它就知道只有第一个报文段(前1000个字节)安全送达,需要重传第二个报文段

接受方回复报文的报头:标志位ACK为1,TCP确认号为1001(简便写法:ACK=1001

标志位ACK为1代表发送的报文具有应答性质,TCP确认号为1001代表收到了1001序列号之前的所有数据,可以继续发送从1001开始的数据

为什么需要两个序号(TCP序列号、TCP确认号)?

常见疑问:既然一方发送数据,一方接收数据,为什么不能只用一个序号作为发送方的序列号/接收方的确认号

  • 发送方需要TCP序列号来标识自己发出的数据。
  • 接收方需要TCP确认号来告诉对方“我已经收到了哪些数据”

因为这里的发送方和接收方只是相对概念,通信的每一方都可以既是发送方又是接收方,同时为了提升效率,在发送数据段时,可能该数据段既有作为接收方的应答也有作为发送方的数据,所以就同时需要TCP序列号和TCP确认号

通信双方的TCP序列号和TCP确认号对应情况:

两组序号相互独立,互不干扰,体现了TCP的全双工通信模式

初始序列号是如何确定的?

在TCP建立连接(三次握手)时,通信双方会各自随机生成一个初始序列号。使用随机值是为了安全,防止被恶意预测和攻击。

  • 客户端 在 SYN 报文中携带自己的初始序列号(例如 seq = x)。
  • 服务端 在 SYN-ACK 报文中携带自己的初始序列号(例如 seq = y),并确认客户端的序列号(ACK = x+1)。
  • 客户端 在最后的 ACK 报文中确认服务端的序列号(ACK = y+1)。

至此,连接建立,双方后续的数据传输都基于这个初始序列号递增。

2.超时重传机制

丢包的两种情况

丢包后就需要重传,而丢包分为两种情况:

情况一:主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B

情况二:实际上数据并没有丢失,只是主机B发给A的ACK应答报文丢失了

所以,发送方无法分辨是数据丢失还是应答丢失,只要特定的时间间隔没有收到应答ACK,都当作丢包处理。

TCP的重传机制

TCP协议通过一套统一的机制来避免丢包,保证可靠性:

  • 发送方超时重传:发送方无需关心具体是哪种丢包,只要特定的时间间隔没有收到应答ACK,就会触发重传
  • 接收方丢弃重复数据:如果是应答ACK丢失,发送方的重传会导致接收方收到两份完全相同的数据。但TCP在报头中为每个数据字节设置了序列号,接收方可以利用序列号识别出重复的数据包并将其丢弃,确保向上层应用提交的数据是完整且不重复的

确定超时的时间

  • 最理想的情况下,找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”。
  • 如果超时时间设的太长,会影响整体的重传效率
  • 如果超时时间设的太短,有可能会频繁发送重复的包
  • 但是这个时间的长短,随着网络环境的不同,是有差异的

TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间

  • Linux中,超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍.
  • 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传
  • 如果仍然得不到应答,等待 4*500ms 进行重传,依次类推,以指数形式递增
  • 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接

在Linux中,默认的最大重传次数由/proc/sys/net/ipv4/tcp_retries2决定,通常是15次。

    3.连接管理机制

    总述

    在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接

    在建立连接和断开连接时,客户端和服务端的状态会产生变化,这些状态的本质其实就是一个整数(宏)。

    TCP通过三次握手,建立管理连接的数据结构。

    作用:

    • 便于操作系统管理服务器与多个客户端建立的连接
    • 是TCP连接各种机制的基础(确认应答机制、超时重传机制……),保证了可靠性

    具体内容:

    struct tcp_sock {
        struct inet_connection_sock inet_conn;
        // TCP专用字段:序列号、窗口、拥塞控制等
    };
    
    struct inet_connection_sock {
        /* inet_sock has to be the first member! */
        struct inet_sock          icsk_inet;           // 基础的INET套接字信息
    
        struct request_sock_queue icsk_accept_queue;   // 用于管理建立连接前的请求队列(如TCP的SYN队列和accept队列)
    
        unsigned long             icsk_timeout;        // 当前连接的超时时间
        struct timer_list         icsk_retransmit_timer; // 数据包重传定时器
        struct timer_list         icsk_delack_timer;   // 延迟ACK定时器
    
        __u32                     icsk_rto;            // 重传超时时间(根据RTT动态计算)
        __u32                     icsk_pmtu_cookie;    // 记录路径MTU(Path MTU)发现的信息
    
        const struct tcp_congestion_ops *icsk_ca_ops;  // 指向拥塞控制算法的操作函数集
        const struct inet_connection_sock_af_ops *icsk_af_ops; // 地址族相关的操作(IPv4/IPv6)
    
        __u8                      icsk_ca_state:6,     // 拥塞控制状态机(如Open, Disorder, CWR, Recovery, Loss)
        __u8                      icsk_retransmits;    // 记录超时重传的次数
        __u8                      icsk_pending;        // 记录挂起的定时器事件(如ICSK_TIME_RETRANS)
        __u8                      icsk_backoff;        // 重传退避指数,用于计算重传超时
        __u8                      icsk_syn_retries;    // SYN重试次数
        // ... 可能还有其他字段
    };
    

    所以建立连接是需要成本的——时间和空间

    注:这里的SYN、ACK、FIN……其实是指传输的报头中对应标志位为1

    服务端状态转化:

    • [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 状态

    三次握手

    客户端connect主动发起连接进行三次握手,这个过程是由操作系统自动完成的

    1. 首先客户端和服务端都处于CLOSE状态。
    2. 服务端先进行LISTEN,监听某个窗口,客户端发送SYN请求建立连接,同时发送一个初始序号client_isn,客户端进入SYS_SENT状态。
    3. 服务端接收到请求后,发送SYN请求给客户端,并且确认接收到客户端请求,服务端初始化自己的序号server_isn,并且确认序号填上client_isn+1,随后服务端进入SYN_RCVD状态。
    4. 客户端接收到服务端连接信息后,回复服务端,并且确认序号填上server_isn+1,服务端进入ESTABLISHED状态,服务端接收到客户端确认后,进入ESTABLISHED状态。

    三次握手完成后,就可以进行相互发送数据了。

    三次握手的作用

    TCP通过三次握手,建立管理连接的数据结构——TCP连接各种机制的基础,保证了TCP连接的可靠性

    如果服务端不调用accept,三次握手建立连接能够成功吗?

    能够成功。

    在TCP协议中,三次握手是由操作系统内核的TCP协议栈完成的,而不是由应用程序控制的。因此,当客户端发送SYN报文段到达服务器时,服务器内核会自动回复SYN-ACK,并在收到客户端的ACK后完成三次握手。此时,连接已经建立,并被放入服务器的监听套接字的连接队列(accept queue)中。

    所以,即使服务端应用程序没有调用accept,三次握手仍然可以成功完成。但是,如果服务端一直不调用accept,那么已经建立的连接会一直停留在连接队列中。当连接队列满了之后,服务器内核可能会开始拒绝新的连接请求。

    如果服务端并未收到第三次握手发送的 ACK 报文,会怎样处理?

    三次握手不是一定成功的,也可能发生丢包。由于前两个报文有对应的应答,如果丢包会触发重传,所以我们重点讨论没有应答的第三个报文丢失:

    • 超时重传:而对于服务端来说,ACK报文是SYN+ACK报文的应答,如果没有收到,就会触发超时重传机制,等待 3 秒、6 秒、12 秒后重新发送 SYN+ACK 包,以便客户端重新发送 ACK 包
    • 重置连接:客户端在接收到 SYN+ACK 包后,就认为TCP连接已经建立,并且无法知道ACK包是否丢失,所以会正常发送数据。但是服务端收到客户端的数据后,会以 RST 包响应,用于重置TCP连接

    一次握手为什么不行?

    理由一:不能防止单一客户端的SYN洪水攻击

    假设一次握手就能建立连接,客户端发出SYN后,客户端建立连接,服务端收到数据直接建立连接。但是假如客户端发出SYN后,并没有在系统中建立相关的数据结构,也就没有建立连接;而服务端收到SYN后会直接建立连接,维护着一个套接字,占用系统资源。

    如果客户端频繁向服务端发起连接申请请求,服务端会为其建立连接,但客户端自己不建立连接。此时服务端的系统资源很快就会被占用完,服务端也就崩溃了,这种现象被叫做SYN洪水

    所以,假设一次握手就能建立连接,服务器很容易被单一客户端的SYN洪水攻击,从而崩溃。

    理由二:不能验证全双工通信能力

    客户端发送SYN,服务器收到,服务器只能确认:客户端的发送能力是正常的

    两次握手为什么不行?

    假设两次握手就能建立连接,客户端向服务端发起建立连接的请求,服务端收到后直接建立连接,并给客户端发送了ACK应答+SYN连接请求,让客户端也建立连接。但如果客户端在 收到服务端的数据后选择不建立连接 或 直接丢弃数据 ,同样容易遭受单一客户端的SYN洪水攻击

    同时,两次握手也不能验证全双工通信能力。

    为什么是三次握手?

    理由一:以最小成本,确认双方通信意愿,防止单一客户端的SYN洪水攻击

    1. 客户端 → 服务器:SYN(请求建立连接)
    2. 服务器 → 客户端:SYN-ACK(确认客户端的请求SYN,并发送自己的请求SYN)
    3. 客户端 → 服务器:ACK(确认服务器的请求SYN)

    这确认了双方通信意愿,确保了客户端先建立连接,服务器后建立连接,避免了服务器单方面建立连接的资源浪费。

    三次握手可以有效防止单一客户端向服务端发起SYN洪水攻击:

    通过三次握手进行SYN洪水攻击,需要客户端先建立连接。而常规服务器的配置往往比客户端高,可用的资源也更多,所以客户端往往耗不过服务端,自己先崩溃了。但是,不法分子可以制造木马,植入多个计算机(肉鸡),使同一时间多个客户端向服务端发起SYN洪水攻击,服务器也会崩溃。

    所以说,三次握手只负责建立连接,不能保证连接的安全性。

    理由二:以最短的方式,验证全双工通信能力

    TCP是面向连接、全双工(Full-duplex)的协议,即双方可以同时发送和接收数据。

    三次握手的本质是验证:“我们两个所处的网络是通畅的,能够支持全双工!”

    1. 第一次握手:客户端发送SYN,服务器收到。这样服务器就能确认:客户端的发送能力是正常的,验证了客户端→服务器路径通畅。
    2. 第二次握手:服务器发送SYN+ACK,客户端收到。这样客户端就能确认:服务器的接收和发送能力都是正常的,验证了服务器→客户端路径通畅(同时确认服务器愿连接)。
    3. 第三次握手:客户端发送ACK,服务器收到。这样服务器就能确认:客户端的接收能力是正常的,再次验证了客户端→服务器路径通畅(并确认客户端收到服务器响应)。

    通过三次握手,双方都确认了彼此的发送和接收能力正常,从而确保了全双工通信。

    三次握手本质上是四次握手

    实际上,如果将三次握手拆分,那么就是:

    1. 客户端 -> 服务器:SYN
    2. 服务器 -> 客户端:ACK(对客户端SYN的确认)
    3. 服务器 -> 客户端:SYN(服务器自己的序列号)
    4. 客户端 -> 服务器:ACK(对服务器SYN的确认)

    但是,中间两次(服务器对客户端SYN的确认ACK和服务器发送的请求SYN)可以合并为一次(可以理解为捎带应答),所以就成了三次。

    所以三次握手本质上就是四次握手,不过第二次将中间两次合并为一次是以最小成本来验证全双工通信。

    四次挥手

    通信双方都调用close主动关闭连接进行四次挥手

    这里以客户端先断开连接为例:当一方发送FIN,表示它不再发送数据,但还能接收数据)

    1. 客户端发送关闭连接请求,将FIN置为1,客户端进入FIN_WAIT_1状态(客户端不再发送数据,可以接收数据)
    2. 服务端接收到报文向客户端发送ACK确认收到,服务端进入CLOSED_WAIT状态(服务端等待应用层处理,发送剩余数据或处理其他任务)
    3. 客户端收到ACK报文后进入FIN_WAIT_2状态(客户端接收剩余数据,等待服务端发送关闭)
    4. 服务端处理完数据后,向客户端发送FIN报文,服务端进入LAST_ACK状态(服务器不再发送数据,可以接收数据)
    5. 客户端接收到FIN报文后,向服务端发送确认ACK收到报文,并且进入TIME_WAIT状态(等待服务端确认收到ACK),在2MSL后完成关闭连接(客户端不再接收数据)
    6. 服务端接收到ACK报文后进入CLOSE状态完成关闭连接(服务端不再接收数据)

    断开连接的一方发送FIN后,最后怎么还能发送ACK应答?

    当一方发送FIN,表示它不再发送应用层的数据,而在底层双方可以有管理报文的交互

    CLOSE_WAIT 状态

    被动断开连接的一方,接收对方的FIN发送ACK后(两次挥手完成),会进入CLOSE_WAIT状态。

    被动关闭方已知对方不会再发送数据,但被动关闭方当前可能还有未发送的数据。所以,需要等待上层应用层进行close()调用,明确不会再传输数据到发送缓冲区后,才会发送FIN。若应用层长时间未调用close(),会导致CLOSE_WAIT状态的连接长期占用系统资源(如文件描述符、端口),最终可能引发 “too many open files” 等错误,导致新连接无法建立。

    服务器出现大量CLOSE_WAIT状态的连接的可能原因:

    • 服务器有bug,没有调用close
    • 服务器繁忙,一直在推送消息给客户端,没来得及调用close

    TIME_WAIT 状态

    MSL是TCP报文段在网络中允许存在的最大时间,也可以理解为报文从发送方到接收方的最长时间。

    主动断开连接的一方,接收对方的FIN发送ACK后(四次挥手完成),进入TIME_WAIT状态。

    在确认断开连接后,为什么客户端还要等待2MSL的时间才进入CLOSE状态?(假设客户端主动断开连接)

    理由一:尽可能保证最后一个ACK应答被服务端收到

    如果第四次挥手的ACK应答报文丢失,未被服务器接收到,服务器就会重传FIN;收到重传的FIN后,客户端再补发ACK应答。而从客户端第一次发出ACK到再次收到服务端的FIN,刚好是两个MSL周期。

    理由二:保证网络中的滞留报文消散

    在断开连接时,可能因为网络原因,网络中还有服务端发送给客户端的报文未被接收。若客户端直接进入CLOSE状态,这些滞留的数据就无法被接收。当相同 IP 和端口的新连接建立时,就可能错误地接收到属于旧连接的残留报文,引起数据错乱。

    查看MSL的值:

    cat /proc/sys/net/ipv4/tcp_fin_timeout

    为什么服务器关闭后,无法使用相同端口号立即重启?

    在平常测试时,如果直接关闭服务器(服务器主动关闭连接),那么服务器会维持TIME_WAIT状态一段时间,原来的端口号仍然被占用,所以,短时间内服务器 绑定相同端口号 就会绑定失败。而如果是客户端主动关闭连接,服务器就可以短时间内使用相同端口号。

    解决TIME_WAIT状态引起的bind失败的方法

    在服务器的 TCP 连接没有完全断开之前不允许重新监听,某些情况下可能是不合理的

    1. 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)
    2. 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量 TIME_WAIT 连接
    3. 由于我们的请求量很大,就可能导致 TIME_WAIT 的连接数很多,每个连接都会占用一个通信五元组(源ip、源端口、目的ip、目的端口、协议)
    4. 如果新来的客户端连接的ip和端口号和 TIME_WAIT 占用的连接重复,就无法绑定,这是不合理的

    使用 setsockopt ()设置 socket 描述符的选项 SO_REUSEADDR为1 ,表示允许创建端口号相同但IP地址不同的多个 socket 描述符

    断开连接为什么要四次挥手?不能合并为三次挥手吗?

    建立连接时,服务器在收到客户端的SYN后,可以将自己的SYN和ACK放在同一个报文中发送,因此可以三次握手。而断开连接时,TCP连接是全双工的,每个方向都必须单独关闭。当一方发送FIN,表示它不再发送数据,但还能接收数据。

    四次挥手的过程(假设客户端先发起关闭):

    1. 客户端发送FIN,进入FIN_WAIT_1状态,表示客户端没有数据要发送了。
    2. 服务器收到FIN,发送ACK,进入CLOSE_WAIT状态。客户端收到ACK后进入FIN_WAIT_2状态。此时,客户端到服务器的连接关闭,但服务器到客户端的连接还可以传输数据。
    3. 服务器发送完剩余数据后,发送FIN,进入LAST_ACK状态。
    4. 客户端收到FIN,发送ACK,进入TIME_WAIT状态,服务器收到ACK后关闭连接。

    为什么不能合并为三次?

    因为服务器在收到客户端的FIN时,可能还有数据要发送,不能立即发送FIN。所以先发送ACK,等数据发送完毕后再发送FIN。因此,ACK和FIN不能像握手那样合并发送。

    但是,如果服务器在收到FIN时,没有数据要发送,那么服务器是否可以将ACK和FIN合并呢?

    理论上是可以的,但TCP协议设计时为了可靠,并没有这样做。因为TCP规范要求每个FIN必须单独确认,所以即使没有数据,也要分开两个步骤。
    不过,在实际中,有时确实可以看到三次挥手,即服务器的ACK和FIN合并,但这不是标准行为,依赖于实现。

    4.流量控制

    接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被填满。这个时候,如果发送端继续发送,就会造成丢包,进而引起丢包重传等等一系列连锁反应。

    所以,TCP需要根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control):

    1. 在接收端的应答报文中,报头中“窗口大小”字段会填入当前接收缓冲区的剩余空间大小
    2. 发送端接收报文后,会根据“窗口大小”调整发送速度
    3. 如果接收端缓冲区满了,就会将窗口大小置为0。这时发送方就不再发送数据,但会定期发送一个窗口探测,使接收端把窗口大小告诉发送端;同时,如果窗口大小更新,接收端也会主动发送窗口更新通知

    发送方如何在正式通信之前,得知接收缓冲区的大小?

    在正式通信之前,通过三次握手,通信双方交换了窗口大小,使发送方第一个报文的大小合适

    TCP首部中窗口大小字段只有16位,那么TCP窗口最大就是2^16-1字节吗?

    实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移M位。

    5.滑动窗口

    滑动窗口的本质:流量控制的具体实现方案

    滑动窗口的概念

    发送缓冲区中的数据可以分为:已发送并收到ACK应答的数据、已发送但未收到ACK应答的数据、还未发送的数据。

    • 对于第一种而言,发送方已经能确定该报文被对方接收,所以这部分数据已经没有存在的必要了。当新数据被拷贝进缓冲区后,这部分数据会被新数据覆盖。
    • 对于第二种而言,发送方虽然发送了数据,但是不知道该数据对方有没有接收到。所以这些数据还需要保留,以支持TCP进行超时重传
    • 对于第三种而言,数据还没发送,等待被处理。

    滑动窗口是发送缓冲区的一部分,其中都是已发送但未收到应答的数据。通过不断发送数据,已发送并收到应答的数据增多,未发送的数据减少,窗口就会向右滑动,所以称为滑动窗口。

    滑动窗口的大小就是发送方一次可以发送的最大数据量,与接收方的接收缓冲区的剩余大小有关,其通过报头中的窗口大小传递给发送方。

    发送缓冲区的构造

    发送缓冲区分为四部分,从左向右序列号依次增大:已发送并收到应答数据的空间、已发送但未收到应答数据的空间,未发送数据的空间,没有任何数据的空间

    • 已发送已确认数据的空间,不需要刻意去清空,让新放入的数据覆盖即可
    • 已发送但未收到应答数据的空间就是滑动窗口
    • 未发送数据的空间后续会逐渐进入滑动窗口范围中
    • 没有任何数据的空间用于接收用户缓冲区中的数据

    滑动窗口的数据结构

    先将“发送缓冲区”理解为一个char类型的数组(char buffer[N]),数组下标start和end明确“滑动窗口”范围,窗口滑动的滑动本质就是start和end下标增加

    滑动窗口大小 = end - start,即窗口右边界减去左边界。

    实际上,发送缓冲区是用数组构造的环形队列,空间可重复利用。

    窗口移动机制

    窗口移动本质上是下标start和end更新:

    1. 当接收方成功接收部分数据后,会发送应答ACK,报头中包含TCP确认号(期望接收的下一个序列号)和窗口大小
    2. start = 收到的TCP确认号,end = start + win(滑动窗口大小)
    3. start不变/增大——滑动窗口的左边界不变/向右移动,end不变/增大——滑动窗口的右边界不变/向右移动

    滑动窗口可以向左滑动吗?

    ❌ 不可以向左滑动!

    滑动窗口只能向右滑动,不能向左滑动。

    原因:

    • 在发送缓冲区中,从左到右序列号是严格递增的
    • 当滑动窗口中的数据被确认,这部分数据就不再需要重传,左边界(start)会根据应答报头中的TCP确认号(“已发送且已确认”的数据的下一个序列号)向右滑动,释放缓冲区空间。

    滑动窗口的工作示例

    假设发送方需要发送300字节的数据,并且每个数据包大小为100字节:

    1. 发送方首先发送三个100字节的数据包(序号1-100、101-200、201-300)。
    2. 接收方收到第一个数据包(序号1-100),并返回ACK(ack=101),表示它准备接收序号101的下一个字节。
    3. 发送方收到ACK后,滑动窗口会向前滑动,继续发送剩余数据包。

      滑动窗口的大小

      窗口大小是浮动的,与接收方缓冲区的空闲空间的大小有关。

      在通信双方三次握手建立连接的过程中,接收方会携带一个window size(窗口大小)字段,告知发送方自己的接收能力,从而让发送方设置初始滑动窗口的大小

      正式通信时,接收方的ACK确认报文都会携带一个window size(窗口大小)字段,告知发送方自己当前还能接收多少数据,发送方根据这个值动态调整滑动窗口大小

      滑动窗口可以变大吗?可以变小吗?可以不变吗?可以为0吗?

      注:win值为TCP报头中的窗口大小值

      ✅ 可以变大!

      条件:

      • 接收方缓冲区的空闲空间变大 → 通过ACK报文通告一个更大的win值。
      • 发送方收到该ACK后,增大窗口大小(end-start增大)。

      示例:

      • 初始窗口大小 = 1000
      • 接收方处理完部分数据,空闲空间增加 → 通告 win=2000
      • 发送方更新窗口 → 新窗口大小 = 2000

      ✅ 可以变小!

      条件:

      • 接收方缓冲区快满了 → 通过ACK报文通告更小的win值。
      • 发送方收到后,减小窗口大小(end-start减小)。

      示例:

      • 当前窗口大小 = 1000
      • 接收方通告 win=500
      • 发送方调整窗口 → 新窗口大小 = 500

      ⚠️ 这种行为称为“窗口缩放”,用于防止接收方溢出。

      ✅ 可以不变!

      当接收方通告的窗口大小没有变化时,滑动窗口大小保持不变。

      场景:

      • 接收方处理速度稳定,缓冲区占用率稳定。
      • 发送方持续发送数据,但每次收到的ACK中win值相同。
      • 窗口只是“滑动”,但大小不变。

      ✅ 可以为0!

      意义:

      • 当接收方缓冲区完全满时,会通告 win=0。

      行为:

      • 发送方停止发送数据,进入“零窗口等待状态”,但会定期发送窗口探测报文(Window Probe)以询问接收方窗口是否打开
      • 接收方缓冲区出现剩余空间,接收端也会主动发送窗口更新通知报文

      处理丢包问题

      如果某个数据包丢失,接收方不会确认丢失的数据包,而是继续确认已收到的数据。发送方会根据丢失数据包的ACK缺失情况触发超时重传机制,重新发送丢失的数据包。

      1. 正常情况下滑动窗口的工作

      应答ACK报头中,如果确认号为X+1,表示X+1之前的所有数据全都收到了,下一个期望字节的确认号为X+1。

      假设发送方发送了多个数据包,每个数据包的序列号分别为1-100,101-200,201-300。接收方收到1-100、101-200、201-300的数据段后,会分别确认接收到的数据,返回ACK=101、ACK=201、ACK=301。

      在这个过程中滑动窗口的变化:

      1. 发送方发出数据后未收到应答前,滑动窗口不发生变化
      2. 收到ACK=101后,滑动窗口的左边界start由1变为101
      3. 收到ACK=201后,滑动窗口的左边界start由101变为201
      4. 收到ACK=301后,滑动窗口的左边界start由201变为301
      5. 滑动窗口的大小始终与最新的应答报头中的窗口大小相关

      2. 丢包的情况

      假设发送方发送了多个数据包,如下所示:

      • 第一个数据包:1到100
      • 第二个数据包:101到200
      • 第三个数据包:201到300

      假设第二个数据包(序号101到200)丢失了,其他数据包正常接收。

      3. 接收方的行为

      接收方不会丢弃已经收到的数据,而是会将它们存储在接收缓冲区中,等待丢失的数据包的到来。

      接收方在确认数据时会发送一个重复的ACK,指示它仍然在等待丢失的数据包。

      此时,接收方已成功接收了序号为1到100和201到300的数据,但不会对序号为201到300的数据段发送确认,返回两次重复的ACK=101

      4. 发送方的行为

      发送方的行为流程:

      1. 发送方已经发送了序号1到300的三个数据包,但它只收到了两个ACK=101继续等待应到的报文ACK=201,不会立即重传
      2. 等待ACK=201的时长超出特定时长,触发超时重传机制,并重新发送101到200的丢失数据包。
      3. 一旦发送方重新发送了丢失的数据包(序号101到200),接收方就会再次确认并返回ACK=301,表示它已经成功接收到序号为1到300的数据。

      在这个过程中滑动窗口的变化:

      1. 发送方发出数据后未收到应答前,滑动窗口不发生变化
      2. 收到第一个ACK=101后,滑动窗口的左边界start由1变为101
      3. 收到第二个ACK=101后,滑动窗口的左边界不发生变化
      4. 触发超时重传机制,发送方重传101到200的数据包,滑动窗口不发生变化
      5. 收到ACK=301后,滑动窗口的左边界start由101变为301
      6. 滑动窗口的大小始终与最新的应答报头中的窗口大小相关

      5. TCP的快速重传(Fast Retransmit)

      如果发送方连续收到3个重复的ACK(即重复ACK的次数大于等于3次),它会立即触发快速重传(Fast Retransmit),而不等待超时。

      快速重传的目的是尽早发现丢包并尽快恢复数据的传输。

      快速重传的过程:

      在我们的例子中,如果发送方连续收到3个ACK=101的报文(即接收方重复确认它已经接收到的部分数据),发送方就会立即重传丢失的序号101到200的数据段,而不需要等待超时

      通过快速重传,发送方可以减少丢包造成的延迟,提高数据传输的效率。

      6. 滑动窗口中的丢包与流量控制的关系

      丢包不仅影响数据的传输速度,还与TCP的流量控制机制密切相关。发送方根据滑动窗口大小来决定是否可以继续发送数据。如果接收方的缓冲区已经满了,它会通过返回一个窗口大小为0的ACK报文,发送方的滑动窗口大小变为0,暂停发送数据。直到接收方有足够的空间继续接收数据,发送方的滑动窗口重新变大,开始发送数据。

      如果发生丢包,发送方没有收到对应的应答,滑动窗口会保持不变,直到丢失的数据包被重新发送并成功确认。

      7.总结

      滑动窗口中的丢包处理是TCP协议确保可靠传输的重要机制之一。通过接收方发送重复ACK和发送方进行超时重传或快速重传,TCP能够有效地恢复丢失的数据,并确保数据按顺序正确到达接收方。

      在滑动窗口协议中,丢包的发生并不会导致整个连接的中断,而是通过这种可靠的重传机制和滑动窗口的控制,保证数据最终能够完整并有序地传输。

      序列号的作用总结

      • 标识数据顺序: 接收方可以根据序列号将乱序到达的数据包重新排序,组装成正确的数据流
      • 消除重复数据: 如果接收到相同序列号的数据,接收方可以识别并丢弃重复的数据包
      • 作为确认的依据: 确认应答ACK正是基于序列号来告知发送方“我已经收到哪个数据了”
      • 支持滑动窗口:滑动窗口正是基于序列号来制定滑动规则

      6.拥塞控制

      由于网络中同时会有大量主机发送消息,网络也可能发生拥堵,导致了丢包问题。

      拥塞控制的概念

      网络拥堵的情况下,在刚开始阶段就贸然发送大量的数据是雪上加霜的。为了根据实际的网络情况动态的控制发送数据量大小,于是就引入了拥塞机制

      为了在发送方调节发送的数据量,引入了拥塞窗口 cwnd ,会根据网络状况动态的变化:

      • 当网络拥塞时该值变小
      • 当网络畅通时该值变大

      发送窗口swnd = min(拥塞窗口cwnd,接收窗口rwnd)也就是两者取最小值

      • swnd(发送方):发送方缓冲区中滑动窗口的大小,发送方一次性发送数据量的大小
      • cwnd(网络):拥塞窗口
      • rwnd(接收方):接收方缓冲区中剩余空间大小,接收方给发送方的应答报头中窗口大小的值

      拥塞控制是TCP尽可能快地将数据传输给对方,又要避免给网络造成太大压力的折中方案。

      拥塞控制的算法

      1.慢启动

      在刚开始时,由于不清楚网络情况,需要一点一点的发送数据进行测试(探路过程),然后拥塞窗口大小再以指数倍进行增长。慢启动只是意味着最初发送的数据较少,但提高发送数据量的速度很快。

      1. 初始时 拥塞窗口cwnd = 1
      2. 在收到第一个ACK应答后,cwnd增加1
      3. 在收到第二个ACK应答后,cwnd增加到4 
      4. 以此类推,指数倍进行增长……

      2. 拥塞避免算法

      但是拥塞窗口大小一直指数增长并不合理,所以我们要及时调整。

      引入慢启动门限ssthresh ,当拥塞窗口大于这个值时就线性增长,减缓速度;小于这个值时继续以指数增长:

      • 当cwnd<=ssthresh时,启用慢启动算法
      • 当cwnd>ssthresh时,启用拥塞避免算法

      当进入拥塞避免算法后,每收到一个ACK,cwnd就增加1,慢慢地进行线性增长。

      3. 拥塞发生算法
      • 少量丢包时,仅仅触发超时重传
      • 大量丢包时,就认为网络拥塞,会触发重传并进入拥塞发生算法

      而重传有两种机制:超时重传和快速重传

      超时重传时

      当发送方没有在一段特定时间内收到ACK报文时(重传计时器超时),就会触发超时重传。

      当网络拥塞发生,触发超时重传时,拥塞控制算法会将 ssthresh 设为 cwnd/2(不小于2) ,然后将 cwnd 设置为1,重新进行慢启动。

      快速重传时

      拥塞发生算法会导致数据传输的波动较大,容易造成网络卡顿,于是有了快速恢复算法

      当发送方收到三个重复的ACK报文时,不会等待重传计时器超时,直接触发快速重传。

      触发快速重传时,拥塞控制通常使用快速恢复算法,发送方能接收到三份同样的ACK报文,说明当前网络状态是不错的。

      算法流程如下:

      1. 收到第3个重复的ACK时,将ssthresh设置为当前cwnd的一半。
      2. 设置cwnd=ssthresh+3
      3. 重传丢失的报文段
      4. 每收到一个重复的ACK,cwnd+1
      5. 确认新数据的ACK到达时,设置cwnd=ssthresh。

      7. 延迟应答和捎带应答

      延迟应答

      延迟应答是一种提高传输效率的机制。接收方在接受到数据后不会立即回应发送方,而是等待一段时间再回复,增加接收方缓冲区的剩余空间,从而能够返回一个更大的窗口值。同时,隔几个包才进行应答,也可以减少应答数据包的数量

      举例说明:

      1. 假设接收端缓冲区为1M,一次收到了500K的数据;如果立刻应答,返回的窗口就是500K
      2. 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了
      3. 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来
      4. 如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M

      窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

      不是所有的包都可以延迟应答的:

      • 数量限制:每隔N个包就必须应答一次
      • 时间限制:超过最大延迟时间就必须应答一次

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

      不一定每个报文都有应答,确认序号提供了支持:

      捎带应答

      通信时一般不会单独发送ACK确认报文。而是在接收方给发送方回复消息时,消息本身作为有效载荷,标志位ACK置为1并填入确认序号,将ACK确认报文捎带传回。

      例如:客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”。那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine, thank you” 一起回给客户端

      三、补充知识

      1.面向字节流和粘包问题

      面向字节流

      在TCP中,数据是以字节流的形式进行传输的,TCP不关心字节流的内容,它只会按顺序将这些字节流从发送方传输到接收方。具体来说:

      • 发送缓冲区:当你调用write()函数写数据时,数据会进入TCP的发送缓冲区。如果缓冲区已满,write()会阻塞,直到缓冲区有足够空间。
      • 接收缓冲区:接收到的数据存放在接收缓冲区,调用read()时会读取这些数据。如果接收缓冲区为空,read()会阻塞,直到数据到达。

      TCP会根据窗口大小拥塞控制流量控制等因素来动态调整数据的发送量,数据可能会被拆分成多个包发送,也可能会被合并在一起

      面向字节流的关键点:

      • TCP看不出数据的边界,只会按顺序传递字节流
      • 你写100个字节的数据,可以通过1次write或者100次write来完成;同样的,读取100个字节的数据也可以通过1次read或者多次read来完成。

      粘包问题

      粘包问题发生在应用层,它通常是因为TCP作为面向字节流的协议,并不知道应用层的数据包的边界。结果,发送的多个数据包可能会被接收方合并成一个数据包,或者一个数据包被拆成多个部分。

      原因:TCP在传输时只是简单地发送字节流,没有像UDP那样有“报文长度”的字段,因此接收方无法准确区分数据包的边界。

      解决办法:明确两个包之间的边界

      • 定长包:可以使用固定长度的包,每次都按固定大小读取
      • 变长包:在包头约定包的总长度,接收方通过读取包头的长度信息,来确定包的边界。比如,HTTP协议中的Content-Length字段
      • 分隔符:为每个包定义一个明确的分隔符,接收方根据分隔符来解析数据包

      UDP没有粘包问题:因为UDP是面向报文的协议,每个数据包是独立的,UDP不会拆分或合并数据包。它只是简单地将数据包从源端传输到目的端。

      • 在UDP报头中有16位UDP长度,由于报头是定长的,能够算出有效载荷的长度,将数据包一个一个地传输给应用层
      • 在TCP报头中有4位首部长度,只能够算出报头的长度,不能算出有效载荷的长度,只能直接将数据包交给应用层,由应用层分离

      2.TCP连接异常问题

      TCP连接过程中可能会出现各种异常,以下是常见的几种情况:

      连接建立异常:

      • 客户端发送SYN包后没有收到服务端的SYN+ACK,可能是网络不通或服务端未监听该端口。
      • 服务端拒绝连接,可能是服务端未启动或端口已关闭。

      连接断开异常:

      • 客户端或服务端发送FIN包后未收到对方的ACK包,可能是网络不稳定或对方宕机。
      • 如果连接已断开,服务端或客户端重新启动后可能会遇到连接建立失败的问题。

      传输数据异常:

      • 数据丢失、乱序、重复、校验和错误等都可能导致数据传输异常,通常通过超时重传、滑动窗口等机制进行修复。

      客户端掉线:

      • 当客户端掉线时,服务器端可能在短时间内不会知道客户端已掉线,连接会一直保持。TCP会使用心跳机制(基于保活定时器)来检查客户端是否还在线,如果多次未收到ACK,服务器会关闭连接。

      3.listen函数的第二个参数

       listen系统调用原型

      #include<sys/socket.h>
      int listen(int sockfd, int backlog);

      功能:设置该文件描述符为监听状态

      参数:

      • int sockfd表示之前使用socket()返回的文件描述符sockfd
      • int backlog表示全连接队列的长度

      返回值:成功返回一个用于通信的文件描述符,失败返回-1并设置错误码errno

      全连接队列

      TCP维护了一个全连接队列,存储状态为 ESTABLISHED 的 TCP 连接,这些连接已通过三次握手,但未由服务器应用层处理(即未调用 accept())。

      作用:

      当服务器应用层繁忙无法及时处理连接时,全连接队列临时存储这些连接。当资源空闲时,这些连接能被立刻使用。

      1. 由于系统资源是有限的,系统最多只能维护一定数量的通信套接字。
      2. 而通信套接字使用完毕后,对应的系统资源就会被释放,处于空闲状态。
      3. 此时,应用层调用accept(),队列中的连接不需要再进行三次握手,会被立即分配通信套接字,开始传输数据
      4. 这样系统资源就不会再出现空闲状态,提高资源利用率

      listen的第二个参数backlog就能指定全连接队列的长度,具体长度等于backlog+1。

      全连接队列的长度不能太长,因为队列太长时,维护队列会消耗系统资源。

      实验证明

      • 用于测试的服务器不调用accpet(),新的客户端请求会被直接放入全连接队列中
      • listen的第二个参数backlog为1,全连接队列的长度为2

      HttpServer.hpp

      #pragma once
      
      #include <iostream>
      #include <string>
      #include <cerrno>
      #include <cstring>
      #include <cstdlib>
      #include <sys/types.h>
      #include <sys/socket.h>
      #include <functional>
      #include <strings.h>
      #include <netinet/in.h>
      #include <arpa/inet.h>
      #include <unistd.h>
      #include <sys/wait.h>
      #include <thread>
      
      using namespace std;
      
      
      enum
      {
          USAGE_ERROR = 1,
          SOCKET_ERROR = 2,
          BIND_ERROR = 3,
          LISTEN_ERROR = 4,
          ACCEPT_ERROR = 5,
          READ_ERROR = 6,
          WRITE_ERROR = 7
      };
      class HttpServer
      {
      private:
          uint16_t _port;     // 端口号
          int _listen_sockfd; // socket描述符(文件描述符)
      
      public:
          HttpServer( const uint16_t &port)
              : _port(port),
                _listen_sockfd(-1)
          {
          }
      
          // 初始化服务器
          void InitServer()
          {
      
              // 1. 创建socket
              _listen_sockfd = socket(AF_INET, SOCK_STREAM, 0); // IPv4协议  TCP模式  默认值
              if (_listen_sockfd == -1)
              {
                  exit(SOCKET_ERROR);
              }
      
              // 2. 绑定bind
              struct sockaddr_in local;           //  IPv4 网络地址结构体
              bzero(&local, sizeof(local));       // 清空结构体
              local.sin_family = AF_INET;         // 表示使用 IPv4 协议
              local.sin_port = htons(_port);      // 端口号 htons 主机字节序转网络字节序
              local.sin_addr.s_addr = INADDR_ANY; // 系统定义的宏(值为 0x00000000,对应 IPv4 地址 0.0.0.0)
      
              int n = bind(_listen_sockfd, (struct sockaddr *)&local, sizeof(local)); // 绑定socket与地址
              if (n < 0)
              {
                  exit(BIND_ERROR);
              }
      
              // 3. 监听listen
              n = listen(_listen_sockfd, 1); // 最大连接数2
              if (n < 0)
              {
                  exit(LISTEN_ERROR);
              }
          }
          // 启动服务器
          void Start()
          {
              while (true)
              {
                 sleep(1);
              }
          }
      
          ~HttpServer()
          {
          }
      };
      

      HttpServer.cpp

      #include <iostream>
      #include <memory>
      #include <string>
      #include <fstream>
      #include "HttpServer.hpp"
      using namespace std;
      
      
      // 输入  ./tcpServer  port
      int main(int argc, char *argv[])
      {
          if (argc != 2)
          {
              cerr << "Usages:" << argv[0] << " port" << endl;
              exit(USAGE_ERROR);
          }
      
          uint16_t _port = atoi(argv[1]);
          unique_ptr<HttpServer> httpserver(new HttpServer( _port));
          httpserver->InitServer();
          httpserver->Start();
          return 0;
      }
      

      运行结果:全连接队列未满时,客户端发起新的请求,进行三次握手,放入队列,客户端和服务器状态为ESTABLISHED

      运行结果:全连接队列已满后,客户端发起新的请求时,客户端状态为SYN_SENT(半连接)

      一段时间后,如果新的请求还没有完成三次握手,连接就会超时,客户端自动退出

      但是如果应用层调用accept()取走了全连接队列中的连接,半连接就会完成三次握手,放入队列中

      操作系统维护了两个队列:

      • 半链接队列,保存处于SYN_SENT和SYN_RECV状态的请求
      • 全连接队列,保存处于ESTABLISHED状态的连接,等待应用层调用accept获取

      四、TCP小结

      1.TCP的机制

      可靠性:

      校验和,序列号,确认应答,超时重发,连接管理,流量控制,拥塞控制

      提高性能:

      滑动窗口,捎带应答,延迟应答,快速重传

      2.TCP/UDP对比

      • TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景
      • UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ、视频传输等,另外UDP可以用于广播

      归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定.

      3.用UDP实现可靠传输(经典面试题)

      参考TCP的可靠性机制,在应用层实现类似的逻辑;
      例如:

      • 引入序列号,保证数据顺序;
      • 引入确认应答,确保对端收到了数据;
      • 引入超时重传,如果隔一段时间没有应答, 就重发数据;
      Logo

      汇聚全球AI编程工具,助力开发者即刻编程。

      更多推荐