【Linux 网络】四十.《网络基础(传输层协议:UDP、TCP)》--中篇

在上篇内容我们学习了传输层的意义,UDP协议的原理,以及TCP协议的协议段格式,接下来我们继续进行后面的学习:

一.TCP 协议(传输控制协议)

1.确认应答(ACK)机制

TCP 保障可靠传输的机制之一就是确认应答机制。

确认应答机制依托 TCP 报头里的 32 位序号与 32 位确认序号完成,收到确认应答就代表确认序号之前的所有数据都已经成功接收。

那该怎么理解 TCP 会对数据流里的每一个字节单独编号?

按照上图所示,我们可以把 TCP 的发送缓冲区看成一个 char outbufN 样的数组,当把应用层的数据拷贝到发送缓冲区时,每个字节的数据就天然的有了一个编号(下标),只不过这个下标不是从 0 开始的,而是从 1 开始往后递增的

发送方发送数据时报头当中所填的序号,实际上就是发送的若干字节数据当中,首个字节数据在发送缓冲区当中对应的下标。

接收方接收到数据进行响应时,响应报头当中的确认序号实际上就是接收缓冲区中接收到的最后一个有效数据的下一个位置所对应的下标。

当发送方收到接收方的响应后,就可以从下标为确认序号的位置继续进行发送了。


超时重传机制

A. 丢包的两种情况

a. 情况一

发送的数据报文丢失了,此时发送端在一定时间内收不到对应的响应报文,就会进行超时重传

  • 主机 A 向主机 B 发送数据后,有可能因为网络拥塞等情况,报文无法送达主机 B。
  • 倘若主机 A 在规定的时间范围内,没有收到主机 B 返回的确认应答报文,主机 A 就会重新发送这份数据。

b. 情况二

对方发来的响应报文丢包了,此时发送端也会因为收不到对应的响应报文,而进行超时重传但是主机 A 未收到 B 发来的确认应答,也可能是因为 ACK 丢失了。

所以主机 B 会收到很多重复数据 ,那么 TCP 协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉,这时候就可以利用前面提到的序列号,就可以很容易做到**去重(通过序号)**的效果。


c. 总结

  • 一旦发生丢包,发送方无法判断丢失的是传输的数据段,还是对方返回的确认应答报文。面对这种不确定性,发送方只能触发超时重传机制。
  • **如果丢失的是确认应答报文,**接收方就会收到重复的数据报文。重复报文同样属于传输不可靠问题,主机 B 依靠序号来识别并剔除重复数据。

正是存在超时重传的需求,数据发送之后不会立刻从发送缓冲区删除,而是暂时保留。只有当发送方收到对应的确认应答报文后,缓冲区里的这部分数据才允许被清除或者覆盖。


B. 超时重传的等待时间

我们依靠超时机制判断报文是否丢失,那这个超时时间该怎么设定?

数据在网络中的传输耗时受网络状态影响,网络环境本身一直在变化,所以超时重传的等待时间不能设置成固定值。

超时时间无法固定。理想状态下,我们希望找到一个最短时间,确保确认应答可以在这段时间内返回。但这个时间会随网络环境改变:超时时间设置过长,会降低重传效率;设置过短,则容易频繁发送重复报文。因此超时时间既不能过大,也不能过小。

为了在各类网络环境下都维持较好的通信性能,TCP 会动态计算超时时间。 在 Linux 系统中(BSD Unix 与 Windows 同理),超时时间以 500ms 作为基本单位,超时重传的等待时间都是 500ms 的整数倍。如果第一次重传之后依旧没有收到应答,就等待 2×500ms 再次重传;若还是没有应答,则等待 4×500ms 继续重传,以此类推,等待时间按指数形式不断增长。当重传累计达到指定次数后,TCP 会判定网络或者对端主机发生故障,主动强制断开连接。


2.连接管理机制

(1)面向连接相关概念

我们口中所谓的面向连接是通过要连接的两台主机分别在自己的主机上开辟一块区域,然后通过 TCP 协议来共同维护这两块区域,来实现网络传输的可靠性。因此,面向连接就是为了保证数据的可靠性。

同时我们再对比一下面向无连接加深理解一下,比如UDP 协议就是典型的无连接协议,在两台主机之间网络通信时,不需要知道目标主机 ip 和目标端口是否存在,可以按照定义的 ip 和端口直接发送到网络中,而面向连接则是先根据给定的目标 ip 和端口号发送到网络中一些消息来确认目标主机是否存在,如果不存在则不能完成接下来的网络通信。

因此,面向连接是需要先建立连接才能进行网络通信的,建立连接就是确定对方存在并协商好一些控制量来确保接下来的通信是可靠的。


A. 无连接协议和面向连接协议的概念

无连接协议传输的分组叫做数据报,每一个分组都携带独立地址,由应用程序发出。从协议层面来说,每一个数据报都是独立单元,同一对等双方传输的其他数据报和它没有关联,这也代表该协议往往是不可靠的。网络会尽最大努力转发每一个数据报,但无法保证数据报不丢失、不延迟,也不能保证报文按顺序到达。

面向连接协议则会维护分组之间的状态信息,使用这类协议的应用程序通常会进行持续的数据交互。协议依靠保存的状态,来实现可靠传输。比如发送端能够记录哪些数据已经发送、还没有收到确认应答,以及数据的发送时间。如果在规定时间内没有收到确认应答,发送端就会重传数据。接收端会记录已经收到的数据,丢弃重复到达的报文。如果分组乱序到达,接收端会先暂存报文,等到排在前面的分组抵达之后再处理。

典型的面向连接协议分为三个阶段:

  • 第一阶段,在对等实体之间建立连接。
  • 第二阶段是数据传输阶段,对等实体之间互相传输数据。
  • 第三阶段,对等实体完成数据传输之后,拆除连接。

一个经典类比:无连接协议类似寄信,面向连接协议类似打电话。


(2)理解连接

会有大量客户端尝试连接服务端,因此服务端会同时存在很多连接。 操作系统是否需要对这些连接进行管理?

需要。先描述连接,再对连接进行组织管理。 连接本质上属于操作系统内核里的一种数据结构。当连接建立成功,内核会在内存中生成对应的连接对象,再通过特定的数据结构对大量连接对象进行统一管理。

维护这些连接是需要付出开销的,会占用内存与 CPU 资源。


那问题来了,TCP为什么要建立连接?

因为要实现可靠传输,需要明白连接本身并不能直接保障可靠性。一旦连接建立成功,系统就会生成对应的连接结构体,结构体中保存着超时重传、按序交付、流量控制、拥塞控制等相关策略,同时记录通信状态、报文相关属性等信息。**连接结构体是实现数据可靠传输的底层基础,而三次握手的作用就是创建出这个连接结构体,所以三次握手是间接保障可靠性。**UDP 不需要维护通信状态和报文属性这类信息,因此 UDP 不需要建立连接。


(3)三次握手

双方在进行 TCP 通信之前都需要先建立连接,建立连接的这个过程我们称之为三次握手

在下图中为什么,连接的那条线不是横着画过去,而是斜着画的呢?

答案是有一个隐形的成本 ------ 时间。服务端接收的时间一定晚于客户端发送的时间。

  • 第一次握手: 客户端向服务器发送报文,报文中的 SYN 位被置为 1,表示客户端请求与服务器建立连接。
  • 第二次握手: 服务器收到客户端的连接请求后,会向客户端返回响应报文。这个报文有两个作用:一方面确认客户端的连接请求,另一方面服务器也向客户端发起连接建立请求。因此,服务器发送的报文中,SYN 位和 ACK 位都会被置为 1
  • 第三次握手: 客户端收到服务器的报文后,确认服务器已经收到了自己的连接请求,同时也知道服务器希望与自己建立连接。最后,客户端再向服务器发送一个确认报文,对服务器的连接请求进行响应。

建立连接时不是 100% 成功的,三次握手中的任何一次都有可能出现丢包的情况,前两次握手是能够保证被对方收到的,因为它们都有应答,如果没有的话,大不了超时重传。

如果是第三次 ACK 应答丢了呢?

当客户端发出 ACK 报文之后,客户端就认为三次握手完成,连接已经建立。

如果这份 ACK 报文在传输途中丢失,从服务端视角来看连接并没有建立成功,但这个问题可以被处理。服务端没有收到该 ACK 应答,就会对第二次握手的 SYN+ACK 报文进行重传。客户端收到重传的 SYN+ACK 报文后,就会发现连接并未真正建立。即便此时客户端已经向外发送业务数据,由于服务端尚未完成三次握手,不会接收数据,服务端会回复 RST 报文,通知客户端重新发起连接。


一次或者两次握手是否可行?

  • **一次握手:**客户端发送连接请求之后,就直接判定连接建立,服务端开始维护该连接。如果客户端开启多线程,持续向服务端发送大量连接请求,服务端会认为这些连接全部建立并维护它们。大量连接会耗尽服务端资源,该现象叫做 "SYN 洪水"。同时,这种方式也无法验证全双工通信通道是否畅通,客户端无法确认自己发送的数据能被服务端收到。所以一次握手无法完成连接建立。
  • **两次握手:**当服务端发出第二次握手报文之后,就判定连接建立。但客户端有可能根本没有收到这份报文,这就会出现和一次握手相同的 SYN 洪水问题。同时也无法验证全双工通信通道是否畅通,服务端无法确认自己发出的报文可以被对方收到。

这两种方案会出现单机攻击服务器问题的根本原因,就是在客户端还没有确认连接就绪的时候,服务端就已经创建并维护连接。因此必须让客户端完成确认之后,服务端才算正式建立连接。


为什么要三次握手?

  • Server 可以嫁接同等的成本给 Client
  • 验证全双工

三次握手是用最小的成本验证全双工通信信道是通畅的。

要想服务端建立连接,客户端必须先建立连接 ,所以可以有效的规避单主机对服务器攻击问题。


A. ddos 攻击

注意:三次握手并不能解决安全问题,比方说大量的主机同时发送 TCP 请求也会导致服务端崩溃。

假如黑客黑掉了很多主机同时给服务端发送 TCP 连接请求:

此刻再有客户端发送连接请求,服务端就提供不了服务了(连不上),这种攻击手段就是 ddos 攻击(服务拒绝攻击)


四次握手行不行?

答案是可以,但没必要,会降低效率。

服务端是把第二次握手的 SYN 和 ACK 分开发送,既然这两个可以合并发送就没有必要分开分两次来发送。

所以,出于优化的目的,四次握手中的第二、三次可以合并为一次


B. 三次握手的状态变化

最开始的时候,客户端和服务器都处于 CLOSED 状态。

  1. 服务器准备接收客户端的连接请求,会从 CLOSED 状态切换为 LISTEN 状态。
  2. 此时客户端可以发起三次握手,客户端发出第一次握手的 SYN 报文后,自身状态变为 SYN_SENT。
  3. 处于 LISTEN 状态的服务器收到客户端的 SYN 请求,把这个连接放入内核等待队列,并且回复 SYN+ACK 报文,服务器状态切换为 SYN_RCVD。
  4. 客户端收到服务器发来的 SYN+ACK 报文,随即发送第三次握手的 ACK 报文,客户端此时连接建立完成,状态变为 ESTABLISHED。
  5. 服务器收到客户端发来的 ACK 报文之后,连接正式建立,服务器状态也切换为 ESTABLISHED。

C. 套接字和三次握手之间的关系

在客户端发起连接请求之前,服务端需要先进入 LISTEN 状态,这就需要服务端调用 listen 函数来设置套接字,开启监听。当服务端进入 LISTEN 状态之后,客户端才可以发起三次握手,客户端这边对应的系统调用是 connect 函数。

注意**:connect 函数本身不会直接参与底层三次握手报文的收发,connect 的作用只是触发内核去发起三次握手流程。**

当 connect 函数返回时,代表底层的三次握手要么成功完成、连接建立,要么握手失败。如果客户端和服务端顺利完成三次握手,服务端内核中就会生成对应的连接,这个连接会存放在内核的等待队列里**。服务端需要调用 accept 函数,把这个已经建立好的连接从内核队列中取出。**服务端拿到这个连接之后,通信双方就可以调用 read/recv 和 write/send 函数收发数据,完成数据交互。


(4)四次挥手

由于双方维护连接都是需要成本的,因此当双方 TCP 通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手

哪边不想给对方发消息了就要发送断开连接请求,比如说客户端要断开连接:

  • 客户端发送断开连接请求,服务端返回 ACK 应答,这就已经两次挥手了。
  • 服务端也要断开连接,发送请求,客户端返回 ACK 应答,一共就是四次挥手。

既然前面客户端已经说明不给服务端发送数据了,为什么后边还会发送确认应答呢?

这里的不发送数据的数据指的是用户数据(应用层不发数据了),但并不代表底层没有报文交互。

小细节前面我们也提到过 :这里的二三次挥手是有可能合并为一次的。


A. 四次挥手时的状态变化

在挥手前客户端和服务器都处于连接建立后的 ESTABLISHED 状态。

  1. 客户端主动发起断开连接请求,向服务器发送 FIN 报文,此时客户端状态变为 FIN_WAIT_1。

  2. 服务器收到客户端的断开请求后,返回 ACK 报文进行确认,服务器状态切换为 CLOSE_WAIT。 当服务器不再有数据需要发送给客户端时,向客户端发送 FIN 报文,等待客户端的最后一次确认,此时服务器状态变为 LAST_ACK。

  3. 客户端收到服务器的 FIN 报文后,向服务器回复最后的 ACK 确认报文,客户端进入 TIME_WAIT 状态。

  4. 服务器收到客户端发来的这个 ACK 报文后,连接彻底关闭,服务器状态变为 CLOSED。

  5. 客户端需要等待一个 2MS


    L(Maximum Segment Lifetime,报文最大生存时间)时长,确认所有报文都在网络中消散之后,才会切换到 CLOSED 状态。


B. 套接字和四次挥手之间的关系

  • 客户端发起断开连接请求,对应客户端主动调用 close 函数关闭套接字。
  • 服务器发起断开连接请求,对应服务器调用 close 函数关闭套接字。

一次 close 调用会触发一次 FIN 报文的发送,对应两次挥手。通信双方都需要调用 close,因此总共形成四次挥手。

  • 主动发起断开连接的一方,最后会进入 TIME_WAIT 状态。
  • 被动断开连接的一方两次挥手完成后的状态是 CLOSE_WAIT。

a. CLOSE_WAIT 状态

  • 双方进行四次挥手时,如果仅客户端调用 close 函数,而服务器没有调用 close 函数,不会发送 FIN 报文。
  • 此时服务器进入 CLOSE_WAIT 状态,客户端进入 FIN_WAIT_2 状态。

服务器出现大量 CLOSE_WAIT 状态连接,是什么原因造成的?

服务器没有及时关闭对应的套接字文件描述符 sockfd,就会堆积大量处于 CLOSE_WAIT 状态的连接。每一条连接都会占用服务器资源,最终造成服务器可用资源持续减少。

因此编写网络套接字代码时,如果发现服务器存在大量 CLOSE_WAIT 连接,就需要检查代码,确认服务器是否没有及时调用 close 函数关闭对应的文件描述符。

来看代码演示:

Sock.hpp(封装的 TCP socket 类)

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

class Sock {
public:
    int Socket() {
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd < 0) {
            std::cerr << "socket create failed" << std::endl;
        }
        return sockfd;
    }

    void Bind(int sockfd, uint16_t port) {
        struct sockaddr_in addr;
        memset(&addr, 0, sizeof(addr));
        addr.sin_family      = AF_INET;
        addr.sin_port        = htons(port);
        addr.sin_addr.s_addr = INADDR_ANY;

        if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
            std::cerr << "bind failed" << std::endl;
        }
    }

    void Listen(int sockfd) {
        if (listen(sockfd, 5) < 0) {
            std::cerr << "listen failed" << std::endl;
        }
    }

    int Accept(int listenfd, std::string* clientip, uint16_t* clientport) {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int sockfd = accept(listenfd, (struct sockaddr*)&peer, &len);
        if (sockfd > 0) {
            *clientip   = inet_ntoa(peer.sin_addr);
            *clientport = ntohs(peer.sin_port);
        }
        return sockfd;
    }
};

TcpServer.cc(服务端主程序)

cpp 复制代码
#include "Sock.hpp"
#include <iostream>
#include <unistd.h>
#include <cstdint>

int main() {
    Sock sock;
    int listensock = sock.Socket();
    sock.Bind(listensock, 8080);
    sock.Listen(listensock);

    while (true) {
        std::string clientip;
        uint16_t    clientport;
        int sockfd = sock.Accept(listensock, &clientip, &clientport);

        if (sockfd > 0) {
            std::cout << "[" << clientip << ":" << clientport << "]# "
                      << sockfd << std::endl;

            sleep(10);           // 10 秒后关闭(模拟图中的"4 closed"延迟)
            close(sockfd);
            std::cout << sockfd << " closed" << std::endl;
        }
    }
    return 0;
}

3.Makefile

cpp 复制代码
TcpServer: TcpServer.cc
	g++ -std=c++17 $^ -o $@

.PHONY: clean
clean:
	rm -f TcpServer

1.三个终端

左上角终端 1 :服务端 ./TcpServer

左下角终端 2 :客户端 telnet 127.0.0.1 8080

右侧终端 3 :观察工具 netstat -ntp


2.操作流程(按编号)

./TcpServer(终端 1)

启动服务端,监听 8080 端口。


telnet 127.0.0.1 8080(终端 2)

客户端连接服务端。telnet 显示:

bash 复制代码
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
telnet>

三次握手完成,连接建立。


③ 服务端打印 [127.0.0.1:49326]# 4

服务端 Accept 返回,打印客户端信息。4 是内核为这条连接分配的文件描述符,49326 是客户端的临时端口。

此时 netstat 显示:

bash 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:49326 ESTABLISHED 17512/./TcpServer

右侧标注了这条记录:

  • Local Address = 127.0.0.1:8080(服务端)

  • Foreign Address = 127.0.0.1:49326(客户端)

  • State = ESTABLISHED(连接已建立)

  • PID/Program = 17512/./TcpServer(服务端进程 17512)


④ 服务端打印 4 closed

服务端 sleep(10) 结束后,主动调用 close(4),然后打印 4 closed

close 会触发 TCP 四次挥手的第一步:服务端作为主动关闭方,发送 FIN 给客户端。

此时 netstat 显示:

bash 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:49326 FIN_WAIT2 -

状态变化:ESTABLISHEDFIN_WAIT2


3.状态为什么会变成 FIN_WAIT2

服务端主动关闭的完整过程:

bash 复制代码
服务端              客户端
   │                  │
   │ ─── FIN ────────►│     服务端:FIN_WAIT1
   │                  │
   │◄─── ACK ─────────│     客户端:CLOSE_WAIT
   │                  │     服务端:FIN_WAIT2
   │                  │
   │◄─── FIN ─────────│   (客户端也 close 后)
   │                  │
   │ ─── ACK ────────►│     服务端:TIME_WAIT
   │                  │     客户端:CLOSED
   │                  │
   │ 2MSL 后 CLOSED    │

此时服务器进入了 CLOSE_WAIT 状态,结合我们四次挥手的流程图,可以认为四次挥手没有正确完成。

小结**:对于服务器上出现大量的 CLOSE_WAIT 状态,原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是一个 BUG,只需要加上对应的 close 即可解决问题**


b. TIME_WAIT 状态

在四次挥手过程中,前三次报文如果出现丢包,都可以依靠超时重传机制进行修复。而最关键、最容易出问题的是第四次 ACK 应答报文丢包。

如果客户端发送完第四次挥手的 ACK 后,直接立刻进入 CLOSED 状态,服务端超时重传 FIN 报文时,将无法收到客户端的任何响应,因为客户端已经彻底关闭连接。

服务端多次超时重传依旧得不到响应,最终虽然会关闭连接,但重传期间会持续维护这条已经失效的连接,严重占用服务器资源,对服务端十分不友好。

为了解决该问题,客户端发送完最后一次 ACK 后,不会直接进入 CLOSED 状态,而是进入 TIME_WAIT 状态并等待 2MSL 时长。 如果第四次 ACK 报文发生丢包,服务端会重传 FIN 报文,处于 TIME_WAIT 状态的客户端可以正常接收并重发 ACK 应答,保证服务端正常关闭连接。

同时 TIME_WAIT 等待机制可以确保网络中残留的过期报文全部消散,避免旧报文干扰后续新连接,最终保障四次挥手流程可靠结束。


c. TIME_WAIT 的等待时长

TCP 协议规定,主动关闭连接的一方在四次挥手结束后,会进入 TIME_WAIT 状态,等待两个 MSL(maximum segment lifetime,报文最大生存时间),之后才会切换到 CLOSED 状态。

MSL 指报文在网络中从发送方传输到接收方所能存在的最大时长。 如果使用 Ctrl+C 终止服务端程序,那么服务端就是主动关闭连接的一方。在 TIME_WAIT 等待阶段,该端口无法再次被服务端程序绑定监听。 RFC793 规范中定义 MSL 为两分钟,但 Linux 内核(Ubuntu 系统)做了简化实现,Ubuntu 中 TIME_WAIT 状态整体等待时长固定为 60 秒,等价于 MS=30 秒。该数值写死在内核代码,无法通过系统配置文件修改


为什么 TIME_WAIT 的等待时长设置为 2MSL?

MSL 是 TCP 报文在网络中的最大生存时间,TIME_WAIT 持续 2MSL,有两个原因。

第一个原因是让旧连接的报文彻底消亡。本次连接的所有报文,最长活 MSL 秒,一来一回是 2MSL。等足 2MSL 之后,网络中就不会再残留本次连接的任何报文。这样主动关闭方也就是客户端,如果立刻用相同的 IP 和端口重启新连接,就不会收到旧连接的迟到脏数据。如果不等待,旧报文可能被新连接误认,导致数据错乱。

**第二个原因是保证最后一个 ACK 可靠到达。**四次挥手的最后一步,客户端发送 ACK 给服务端。如果这个 ACK 丢失,服务端会超时重发 FIN。这时候如果客户端已经彻底关闭,TCP 连接销毁,就无法再回 ACK,服务端只能一直重发到超时,这不是优雅关闭。而在 TIME_WAIT 状态下,客户端的 TCP 连接还在,可以重新回 ACK,让服务端正常关闭。

**补充一点时间上的推算。**在 Linux 中 MSL 默认是 30 秒,所以 2MSL 就是 60 秒,TIME_WAIT 状态通常持续 60 秒。

Sock.hpp(含 exit(3) 的 Bind)

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

class Sock {
public:
    int Socket() {
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd < 0) {
            std::cerr << "socket create failed" << std::endl;
            exit(2);
        }
        return sockfd;
    }

    void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0") {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port   = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);

        if (bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            exit(3);   // 退出码为 3:绑定失败
        }
    }

    void Listen(int sockfd) {
        if (listen(sockfd, 5) < 0) {
            std::cerr << "listen failed" << std::endl;
            exit(4);
        }
    }

    int Accept(int listenfd, std::string* clientip, uint16_t* clientport) {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int sockfd = accept(listenfd, (struct sockaddr*)&peer, &len);
        if (sockfd > 0) {
            *clientip   = inet_ntoa(peer.sin_addr);
            *clientport = ntohs(peer.sin_port);
        }
        return sockfd;
    }
};

TcpServer.cc(服务端主程序)

cpp 复制代码
#include "Sock.hpp"
#include <iostream>
#include <unistd.h>
#include <cstdint>

int main() {
    Sock sock;
    int listensock = sock.Socket();
    sock.Bind(listensock, 8080);
    sock.Listen(listensock);

    while (true) {
        std::string clientip;
        uint16_t    clientport;
        int sockfd = sock.Accept(listensock, &clientip, &clientport);

        if (sockfd > 0) {
            std::cout << "[" << clientip << ":" << clientport << "]# "
                      << sockfd << std::endl;

            // sleep(10);
            // close(sockfd);
            // std::cout << sockfd << " closed" << std::endl;
        }
    }
    return 0;
}

Makefile

cpp 复制代码
TcpServer: TcpServer.cc
	g++ -std=c++17 $^ -o $@

.PHONY: clean
clean:
	rm -f TcpServer

1.三个终端

左上角终端 1 :运行 ./TcpServer(服务端)

左下角终端 2 :运行 telnet 127.0.0.1 8080(客户端)

右侧终端 3 :运行 netstat -ntp(观察连接状态)


2.操作流程

第一步:服务端启动

终端 1 执行 ./TcpServer,服务端启动并监听 8080 端口。

启动后没有任何输出,说明程序正在 Accept 处阻塞,等待客户端连接。


第二步:客户端连接

终端 2 执行 telnet 127.0.0.1 8080,客户端连上服务端。

telnet 显示:

cpp 复制代码
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
telnet>

说明三次握手完成,连接建立。


第三步:观察 ESTABLISHED 状态

终端 3 执行 netstat -ntp,右上角红圈标出这条记录:

bash 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:50000 ESTABLISHED 20924/./TcpServer

含义:

  • 服务端地址是 127.0.0.1:8080

  • 客户端地址是 127.0.0.1:50000

  • 连接状态是 ESTABLISHED(已建立,可以收发数据)

  • PID/进程名是 20924/./TcpServer(处理这个连接的服务端进程)


第四步:Ctrl+C 杀掉服务端

终端 1 按 Ctrl+C,服务端进程被终止。

操作系统会关闭进程持有的所有 socket,TCP 协议栈主动发送 FIN 给客户端,服务端进入 FIN_WAIT1,收到客户端 ACK 后进入 FIN_WAIT2


第五步:观察 FIN_WAIT2 状态

终端 3 再执行 netstat -ntp,右下角标出:

cpp 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:50000 FIN_WAIT2 -

含义:

  • 连接状态从 ESTABLISHED 变成 FIN_WAIT2

  • 服务端已经发了 FIN,收到 ACK,正在等客户端发 FIN

  • PID 显示 -,因为服务端进程已经不存在了


第六步:客户端感知

终端 2 的 telnet 会显示:

bash 复制代码
Connection closed by foreign host.

这是客户端感知到服务端关闭连接后的提示。


3.状态流转说明

cpp 复制代码
服务端 LISTEN
    ↓
客户端连接,三次握手
    ↓
双方 ESTABLISHED(右上角红圈)
    ↓
Ctrl+C 杀掉服务端
    ↓
服务端主动发 FIN → FIN_WAIT1
    ↓
收到客户端 ACK → FIN_WAIT2(右下角红圈)
    ↓
客户端也发 FIN → 服务端 TIME_WAIT
    ↓
2MSL 后服务端 CLOSED,8080 消失

虽然 4 次挥手已经完成,但是主动断开连接的一方要维持一段时间的 TIME_WAIT 状态。在该状态下,连接其实已经结束,但是它的地址信息(比如 ip)、port 依旧是被占用的。


(5)bind 绑定失败(端口复用)

为什么会出现 bind 绑定失败?

前面的代码实验可以看到,如果服务端主动断开连接,短时间内无法重新 bind 绑定端口。这并不是 CLOSE_WAIT 状态造成的,而是服务端进入 TIME_WAIT 状态,该端口对应的连接资源还没有被系统释放,所以再次绑定端口时提示端口被占用。

服务器不能立刻重启会带来严重问题:例如双十一,淘宝高并发场景下,大量连接压垮服务器,此时如果想要立刻重启服务,却需要等待 60 秒才能重新绑定端口,这段等待时间会造成巨大业务损失。

  • 在 Server 的 TCP 连接没有完全断开之前,不允许重新监听该端口,在某些业务场景下并不合理。 服务器需要处理海量客户端连接,每个连接存活时间可能很短,但每秒都会收到大量客户端请求。 这时如果由服务端主动关闭连接(例如清理长时间不活跃的客户端),就会产生大量 TIME_WAIT 状态的连接。
  • 高并发场景下会堆积大量 TIME_WAIT 连接,每一条连接都会占用一组五元组(源 IP、源端口、目的 IP、目的端口、协议)。
  • 其中服务端 IP、端口、协议是固定不变的。如果新到来的客户端连接的源 IP 与源端口,恰好和处于 TIME_WAIT 状态连接的五元组重复,就会引发绑定冲突问题。

如何解决这个问题呢?

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

连接建立 + 状态观察 + 端口复用后的现象

终端 1(左上角):

执行 ./TcpServer 启动服务端,程序监听 8080 端口。接着收到一个客户端连接,打印:

bash 复制代码
[127.0.0.1:51610]# 4

说明有一个客户端从本机 51610 端口连上来了,服务端为它分配的文件描述符是 4。

然后按 Ctrl+C 杀掉服务端进程,再次执行 ./TcpServer 重启。重启后又能正常接受连接,打印:

bash 复制代码
[127.0.0.1:51628]# 4

说明:加了端口复用后,服务端重启后能立即重新监听 8080 端口。


终端 2

执行 telnet 127.0.0.1 8080 连接服务端。连接成功后输入 quit 退出,终端显示:

bash 复制代码
Connection closed.

接着再次执行 telnet 127.0.0.1 8080,又能连上,进入 telnet> 提示符。


终端 3

执行 netstat -ntp 观察连接状态。

第一次观察时,看到一条记录:

cpp 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:51610 ESTABLISHED 29204/./TcpServer

含义:服务端 8080 与客户端 51610 之间是一条已建立的连接,PID 是 29204。

第二次观察时,看到:

cpp 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:51610 FIN_WAIT2 -

含义:连接已经进入 FIN_WAIT2 状态,PID 显示 -,说明服务端进程已经不在了。


TIME_WAIT 与端口复用后的重启

上部分:

netstat -ntp 看到:

cpp 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:51610 TIME_WAIT -

标注 TIME_WAIT。说明连接已经进入 TIME_WAIT 状态,占用着 8080 端口。此状态下服务端进程已经不存在,PID 显示 -

同时下面还有一条:

bash 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:51628 ESTABLISHED 29285/./TcpServer

说明这时服务端已经重启成功,PID 是 29285,并接上了新的连接 51628。

关键点:老连接的 TIME_WAIT 还在,但新服务端已经能正常 bind 8080 ------ 这正是端口复用(SO_REUSEADDR)在起作用。


下部分:

再执行一次 netstat -ntp,标出:

bash 复制代码
tcp 0 0 127.0.0.1:8080 127.0.0.1:51628 ESTABLISHED 29285/./TcpServer

说明当前活动连接只有新服务端 29285 的这条,老连接的 TIME_WAIT 已经消失。


setsockopt 设置端口复用

这是 Sock.hpp 里的 Socket() 函数。标注的是新增的一行:

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

这一行的作用:让 socket 允许绑定处于 TIME_WAIT 状态的地址。

为什么加这一行?

因为没加之前,服务端一旦关闭,8080 会被 TIME_WAIT 占用约 60 秒,期间无法重启。加上之后,服务端可以立即重启,bind 不再失败。

上面的 if (listensock < 0) exit(2); 是 socket 创建失败时退出,退出码 2。


man 手册说明

这是 man setsockopt 的内容,说明这个系统调用的原型:

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

标出 setsockopt,以及返回值说明:

  • 成功返回 0

  • 失败返回 -1,并设置 errno

这一段是为了让读者理解 setsockopt 的参数和返回值含义。


完整操作时间线

第一步,服务端启动,监听 8080。

第二步,telnet 客户端连接,连接进入 ESTABLISHED

第三步,telnet 客户端 quit 退出,服务端出现 FIN_WAIT2

第四步,Ctrl+C 杀掉服务端,连接进入 TIME_WAIT,8080 被占用约 60 秒。

第五步,此时如果没加 端口复用,重启服务端会 bind 失败(退出码 3)。

第六步,加上 setsockopt(SO_REUSEADDR) 之后,重启服务端能立即绑定成功,bind 不再报错。

第七步,新服务端启动,接受新连接,netstat 显示新的 ESTABLISHED 记录,老连接的 TIME_WAIT 逐渐消失。
总结:TCP 连接关闭后端口会进入 TIME_WAIT(约 60 秒),导致服务端无法立即重启,bind 失败退出码 3。通过在 socket 创建时加上 setsockopt(SO_REUSEADDR | SO_REUSEPORT),允许绑定处于 TIME_WAIT 状态的地址,服务端就能立即重启,新连接顺利建立------这就是端口复用的作用和演示。


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

服务端状态转化:

  • 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,并发出最后一个 ACK。
  • TIME_WAIT -\> CLOSED:客户端要等待一个 2MSL,也就是报文最大生存时间的两倍,才会进入 CLOSED 状态。

下图是 TCP 状态转换的一个汇总:

这张图是 TCP 完整状态机 ,画出了 TCP 连接从建立到关闭的所有状态 以及状态之间的转换条件

建立连接 走 LISTEN/SYN_SENT/SYN_RCVD,主动关闭 走 FIN_WAIT_1/2/TIME_WAIT,被动关闭 走 CLOSE_WAIT/LAST_ACK,所有路径最终都回到 CLOSED。


1.图的整体结构

两个主体

  • 左边(上方):服务端(被动打开)

  • 右边(下方):客户端(主动打开)

三大部分(图里虚线框):

  • 建立连接:CLOSED → LISTEN / SYN_SENT → ESTABLISHED

  • 主动关闭:ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED

  • 被动关闭:ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED


2.建立连接(三次握手)

服务端CLOSED → LISTEN

  • 调用 listen() 后进入 LISTEN,等待连接

客户端CLOSED → SYN_SENT

  • 调用 connect(),发送 SYN

服务端LISTEN → SYN_RCVD

  • 收到 SYN,回 SYN + ACK

客户端SYN_SENT → ESTABLISHED

  • 收到 SYN + ACK,回 ACK

服务端SYN_RCVD → ESTABLISHED

  • 收到 ACK,连接建立

双方进入 ESTABLISHED,可以收发数据。


3.主动关闭(主动方:客户端)

ESTABLISHED → FIN_WAIT_1

  • 客户端调用 close(),发送 FIN

FIN_WAIT_1 → FIN_WAIT_2

  • 收到服务端的 ACK

FIN_WAIT_2 → TIME_WAIT

  • 收到服务端的 FIN,回 ACK

TIME_WAIT → CLOSED

  • 等待 2MSL 后,彻底关闭

4.被动关闭(被动方:服务端)

ESTABLISHED → CLOSE_WAIT

  • 收到客户端的 FIN,回 ACK

CLOSE_WAIT → LAST_ACK

  • 服务端调用 close(),发送 FIN

LAST_ACK → CLOSED

  • 收到客户端的 ACK,彻底关闭

5.几个特殊路径

同时打开 :双方同时发 SYN,会出现 SYN_RCVD ↔ SYN_SENT 的交叉。

同时关闭 :双方同时发 FIN,会出现 CLOSING 状态,再进入 TIME_WAIT。

RST(重置)LISTEN → SYN_RCVD 过程中如果收到 RST,会回到 LISTEN。

  • 较粗的虚线表示服务端的状态变化情况。
  • 较粗的实线表示客户端的状态变化情况。
  • CLOSED 是一个假想的起始点,不是真实状态。

3.TCP 流量控制

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

TCP 支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制(Flow Control)。

前面在讲 16 位窗口大小的时候,说传输数据的时候速度要适中,所以报头中有 16 位窗口大小来控制传输速度,通过填写 16 位窗口大小告诉对端自己的接收能力,也就是接收缓冲区还剩多少。

在进行流量控制时,发送方是如何在第一次发送数据的时候,得知对方的接收能力的呢?

需要通过交换报文。第一次发送数据不等同于第一次交换报文。在通信之前,就已经三次握手了,第一次交换报文时,TCP 报文里就带有窗口大小。所以在握手期间,就可以互相交换窗口大小了。

当发送端得知接收端接收数据的能力为 0 时,就会停止发送数据。此时发送端会通过以下两种方式来得知何时可以继续发送数据。

  • 第一种是等待告知。接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个 TCP 报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了。
  • 第二种是主动询问。发送端每隔一段时间向接收端发送报文,这个报文叫窗口探测,不携带有效数据,只是为了询问接收端的窗口大小,直到接收端的接收缓冲区有空间后,发送端就可以继续发送数据了。

这两种策略在实际中是同时使用的,哪个先到就先处理哪个。

  1. 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段,通过 ACK 端通知发送端。
  2. 窗口大小字段越大,说明网络的吞吐量越高。
  3. 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值来通知给发送端。
  4. 发送端接收到这个窗口之后,就会减慢自己的发送速度。
  5. 如果接收端缓冲区满了,就会将窗口置为 0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。

接收端如何把窗口大小告诉发送端呢?

TCP 首部中有一个 16 位窗口字段,用来存放窗口大小信息,接收端通过它告诉发送端自己接收缓冲区的剩余空间。

16 位数字最大只能表示 65535,但这只是不使用窗口扩大因子时的上限,不是 TCP 窗口的绝对上限。实际上 TCP 首部的选项字段中还有一个窗口扩大因子 M,实际窗口大小 = 窗口字段的值 × 2^M(也就是左移 M 位)。

M 在三次握手时由双方协商确定,连接期间保持不变。但窗口字段的值每次报文都可以变,接收端按自己接收缓冲区的实时剩余空间填写,所以实际窗口大小也是动态变化的。

窗口大小还受操作系统接收缓冲区大小的约束。缓冲区越大,接收端才能通告越大的窗口。所以如果想用更大的窗口,需要调整操作系统的 TCP 接收缓冲区参数,而不是重新编译内核。

相关推荐
牢姐与蒯1 小时前
Linux进程间通信(三).基于匿名管道的进程池的实现
linux·运维·服务器·ubuntu
凤年徐1 小时前
C++ 仿 muduo 高并发服务器:用户态缓冲区 Buffer 模块实现
linux·c++
Java红桃峰峰日拱一卒3 小时前
在jdk8的centos上安装jdk17
linux·jdk17·jdk8
M78佐菲9 小时前
ARM学习笔记(1)
linux·arm开发·笔记·嵌入式硬件·学习
益达丫10 小时前
Socket编程揭秘:端口、IP与进程通信的底层逻辑
linux·笔记
chicheese13 小时前
Linux 面试速查表:从初级到高级,附高频场景答题模板
linux·运维
Linux-lucky13 小时前
32-38-Linux学习之旅之MySQL综合
linux·运维·学习·mysql·ubuntu
vortex514 小时前
常用终端模拟器全面对比:Kitty、WezTerm、Ghostty 与 Konsole
linux
INGNIGHT15 小时前
270 · 电话号码的字母组合II(Trie)
linux·算法