4.串行接受多个客户端

文章目录

  • [一次串行 TCP 通信的实验复盘:从代码到运行,亲手验证同步交互模型](#一次串行 TCP 通信的实验复盘:从代码到运行,亲手验证同步交互模型)
    • [1. 实验目的](#1. 实验目的)
    • [2. 架构与设计思路](#2. 架构与设计思路)
      • [2.1 服务端:单进程串行模型](#2.1 服务端:单进程串行模型)
      • [2.2 客户端:串行短连接模型](#2.2 客户端:串行短连接模型)
    • [3. 完整代码实现](#3. 完整代码实现)
      • [3.1 服务端代码 (`server.cpp`)](#3.1 服务端代码 (server.cpp))
      • [3.2 客户端代码 (`client.cpp`)](#3.2 客户端代码 (client.cpp))
    • [4. 运行实录与现象观察](#4. 运行实录与现象观察)
      • [4.1 服务端输出](#4.1 服务端输出)
      • [4.2 客户端输出](#4.2 客户端输出)
    • [5. 深度解析:现象背后的底层逻辑](#5. 深度解析:现象背后的底层逻辑)
      • [💡 现象 1:客户端的 `fd` 为什么总是 3?](#💡 现象 1:客户端的 fd 为什么总是 3?)
      • [💡 现象 2:客户端的源端口为什么每次都不一样?](#💡 现象 2:客户端的源端口为什么每次都不一样?)
      • [💡 现象 3:`SO_REUSEADDR` 到底在保护什么?](#💡 现象 3:SO_REUSEADDR 到底在保护什么?)
    • [6. 进阶思考:从串行走向并发](#6. 进阶思考:从串行走向并发)
    • [7. 结语](#7. 结语)

一次串行 TCP 通信的实验复盘:从代码到运行,亲手验证同步交互模型

导语:在深入 epoll、多线程、高并发之前,我们必须先弄懂最基础的 TCP 同步交互模型。本文通过一次极简的 C++ 串行通信实验,带你从代码设计、运行实录到底层原理,彻底打通 TCP Socket 编程的"任督二脉"。


1. 实验目的

在复杂的网络框架(如 Netty、Reactor 模型)中,底层的连接建立与数据流转往往被封装得很深。为了回归本质,本次实验用最纯粹的 C++ 系统调用,实现一个串行 TCP 服务端 与一个串行 TCP 客户端,旨在验证以下核心机制:

  1. 服务端的串行处理 :通过 while(1) + accept() 循环,观察服务端如何依次处理多个客户端连接。
  2. 客户端的串行请求 :通过 for 循环依次建立、通信、断开,模拟多个独立的短连接请求。
  3. TCP 状态机的自然节奏:在代码的运行轨迹中,映射三次握手、数据收发、四次挥手的完整生命周期。

2. 架构与设计思路

2.1 服务端:单进程串行模型

服务端采用最经典的"主循环 + 阻塞处理"架构:

  1. 初始化socket() 创建监听套接字 → \rightarrow → setsockopt(SO_REUSEADDR) 允许端口快速重用 → \rightarrow → bind() 绑定 0.0.0.0:8888 → \rightarrow → listen() 设置 backlog 队列。
  2. 主循环 :外层 while(1) 阻塞调用 accept(),每次从内核的已完成连接队列中取走一个连接。
  3. 业务处理 :进入 do_connect() 处理当前连接(打印信息 → \rightarrow → read() 接收 → \rightarrow → write() 回复 → \rightarrow → close() 断开)。
  4. 循环往复 :处理完毕后,回到 accept() 等待下一个客户端。

核心特征 :服务端在同一时刻只能处理一个客户端 。如果此时有新客户端连接,它们会在内核的 backlog 队列中排队等待;如果队列满了,新的 connect() 将被拒绝或超时。

2.2 客户端:串行短连接模型

客户端采用"创建 → \rightarrow → 通信 → \rightarrow → 销毁"的循环架构:

  1. 主循环 :外层 for 循环控制请求次数(如 3 次)。
  2. 单次请求socket() 创建新套接字 → \rightarrow → connect() 发起三次握手 → \rightarrow → write() 发送数据 → \rightarrow → read() 阻塞等待回复 → \rightarrow → close() 发起四次挥手。
  3. 节奏控制 :每次循环末尾 sleep(1),给服务端留出回到 accept() 状态的时间,避免连接堆积。

核心特征 :每次循环都重新创建和销毁套接字,属于典型的短连接模型,完美匹配服务端的串行处理能力,不会产生并发冲突。


3. 完整代码实现

3.1 服务端代码 (server.cpp)

cpp 复制代码
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
#include <unistd.h>

using namespace std;

// 处理单个客户端连接的函数
void do_connect(int cfd, const sockaddr_in &client_addr);

int main() {
    // 1. 创建 TCP 监听套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1) {
        cerr << "socket() failed" << endl;
        return -1;
    }

    // 2. 设置端口复用 (核心:防止重启时 TIME_WAIT 导致 bind 失败)
    int opt = 1;
    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
        cerr << "setsockopt() failed" << endl;
        close(lfd);
        return -1;
    }

    // 3. 绑定地址和端口
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
    addr.sin_port = htons(8888);
    if (bind(lfd, (sockaddr *)&addr, sizeof(addr)) == -1) {
        cerr << "bind() failed (port 8888 may be in use)" << endl;
        close(lfd);
        return -1;
    }

    // 4. 开始监听,设置 backlog 为 5
    if (listen(lfd, 5) == -1) {
        cerr << "listen() failed" << endl;
        close(lfd);
        return -1;
    }
    cout << "Server listening on port 8888..." << endl;

    // 5. 主循环:持续接受客户端连接
    sockaddr_in client_addr{};
    socklen_t len = sizeof(client_addr);

    while (true) {
        // accept 会阻塞,直到有新的连接进入已完成队列
        int cfd = accept(lfd, (sockaddr *)&client_addr, &len);
        if (cfd == -1) {
            cerr << "accept() failed" << endl;
            continue;
        }
        // 处理当前连接 (串行阻塞,处理完才会 accept 下一个)
        do_connect(cfd, client_addr);
    }

    close(lfd);
    return 0;
}

// 处理客户端连接的具体逻辑
void do_connect(int cfd, const sockaddr_in &client_addr) {
    // 解析并打印客户端 IP 和端口
    char ip_str[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &client_addr.sin_addr, ip_str, sizeof(ip_str));
    cout << "[+] Client connected from " << ip_str << ":"
         << ntohs(client_addr.sin_port) << endl;

    // 读取客户端消息
    char buf[64] = {};
    ssize_t n = read(cfd, buf, sizeof(buf) - 1);
    if (n == -1) {
        cerr << "[-] read() failed" << endl;
    } else if (n == 0) {
        cout << "[-] Client closed connection unexpectedly" << endl;
    } else {
        buf[n] = '\0'; // 确保字符串安全终止
        cout << "[>] Received: " << buf << endl;

        // 发送回复消息
        const char *msg = "Hello from server";
        ssize_t sent = write(cfd, msg, strlen(msg));
        if (sent == -1) {
            cerr << "[-] write() failed" << endl;
        } else {
            cout << "[<] Sent: " << msg << endl;
        }
    }

    // 关闭连接,触发四次挥手
    close(cfd);
    cout << "[x] Connection closed.\n" << endl;
}

3.2 客户端代码 (client.cpp)

cpp 复制代码
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <sys/socket.h>
#include <unistd.h>

using namespace std;

int main() {
    const int CLIENT_NUM = 3; // 模拟 3 次独立的连接请求

    cout << "=== Serial Client: Connect, Communicate, Disconnect ===" << endl;

    for (int idx = 0; idx < CLIENT_NUM; ++idx) {
        cout << "\n--- Client " << idx << " starting ---" << endl;

        // 1. 创建新的套接字
        int fd = socket(AF_INET, SOCK_STREAM, 0);
        if (fd == -1) {
            cerr << "Client " << idx << " socket() failed" << endl;
            continue;
        }

        // 2. 配置服务端地址
        sockaddr_in addr{};
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = inet_addr("127.0.0.1");
        addr.sin_port = htons(8888);

        // 3. 发起连接 (三次握手)
        if (connect(fd, (sockaddr *)&addr, sizeof(addr)) == -1) {
            cerr << "Client " << idx << " connect() failed" << endl;
            close(fd);
            continue;
        }
        cout << "[+] Connected (fd=" << fd << ")" << endl;

        // 4. 发送数据
        char msg[64];
        snprintf(msg, sizeof(msg), "Hello from client %d", idx);
        ssize_t sent = write(fd, msg, strlen(msg));
        if (sent == -1) {
            cerr << "[-] write() failed" << endl;
            close(fd);
            continue;
        }
        cout << "[>] Sent: " << msg << endl;

        // 5. 接收回复
        char buf[64] = {};
        ssize_t n = read(fd, buf, sizeof(buf) - 1);
        if (n == -1) {
            cerr << "[-] read() failed" << endl;
        } else if (n == 0) {
            cout << "[-] Server closed connection" << endl;
        } else {
            buf[n] = '\0';
            cout << "[<] Received: " << buf << endl;
        }

        // 6. 关闭连接 (四次挥手)
        close(fd);
        cout << "[x] Connection closed." << endl;

        // 间隔 1 秒,给服务端留出处理时间
        if (idx < CLIENT_NUM - 1) {
            sleep(1);
        }
    }

    cout << "\n=== All clients finished ===" << endl;
    return 0;
}

4. 运行实录与现象观察

4.1 服务端输出

text 复制代码
$ ./server
Server listening on port 8888...
[+] Client connected from 127.0.0.1:46638
[>] Received: Hello from client 0
[<] Sent: Hello from server
[x] Connection closed.

[+] Client connected from 127.0.0.1:46644
[>] Received: Hello from client 1
[<] Sent: Hello from server
[x] Connection closed.

[+] Client connected from 127.0.0.1:38462
[>] Received: Hello from client 2
[<] Sent: Hello from server
[x] Connection closed.

4.2 客户端输出

text 复制代码
$ ./client
=== Serial Client: Connect, Communicate, Disconnect ===

--- Client 0 starting ---
[+] Connected (fd=3)
[>] Sent: Hello from client 0
[<] Received: Hello from server
[x] Connection closed.

--- Client 1 starting ---
[+] Connected (fd=3)
[>] Sent: Hello from client 1
[<] Received: Hello from server
[x] Connection closed.

--- Client 2 starting ---
[+] Connected (fd=3)
[>] Sent: Hello from client 2
[<] Received: Hello from server
[x] Connection closed.

=== All clients finished ===

5. 深度解析:现象背后的底层逻辑

通过对比运行结果,我们可以挖掘出几个非常有价值的底层知识点:

💡 现象 1:客户端的 fd 为什么总是 3?

在客户端输出中,每次新建连接的 fd 都是 3。这并不是巧合,而是由 Linux 的文件描述符分配机制决定的:

  • 进程启动时,默认占用 0 (stdin)、1 (stdout)、2 (stderr)。
  • 当调用 socket() 时,内核会分配当前最小的可用描述符 ,即 3
  • 每次循环末尾调用了 close(fd),内核回收了描述符 3
  • 下一次循环再次调用 socket() 时,3 又是最小的可用描述符,因此被再次分配。

💡 现象 2:客户端的源端口为什么每次都不一样?

在服务端输出中,客户端的源端口分别是 466384664438462

  • 客户端代码中没有调用 bind(),因此内核在 connect() 时会自动为其分配一个临时端口(Ephemeral Port)
  • TCP 连接由四元组 (源IP, 源端口, 目的IP, 目的端口) 唯一标识。为了保证每次连接的四元组不冲突,内核每次都会挑选一个未使用的临时端口。
  • 这也是为什么客户端可以频繁发起短连接,而不会受到 TIME_WAIT 状态的严重影响(因为端口池很大)。

💡 现象 3:SO_REUSEADDR 到底在保护什么?

如果去掉服务端的 setsockopt(SO_REUSEADDR),当你 Ctrl+C 强杀服务端并立即重启时,大概率会遇到 bind() failed: Address already in use

  • 这是因为服务端主动 close() 连接后,会进入 TIME_WAIT 状态(持续约 2MSL,即 60 秒)。
  • SO_REUSEADDR 告诉内核:允许新 socket 绑定到处于 TIME_WAIT 状态的端口。它是服务端开发中必不可少的"护身符"。

6. 进阶思考:从串行走向并发

本次实验的串行模型虽然简单清晰,但在生产环境中存在致命缺陷:一个慢客户端会阻塞整个服务器 (因为 read() 是阻塞的)。

如果要支持并发,我们可以沿着以下路径演进:

演进阶段 技术方案 特点与适用场景
V1. 多进程 accept()fork() 子进程处理 隔离性好,但进程创建/切换开销大。适合连接数较少的场景。
V2. 多线程 accept() 后创建 std::thread 处理 共享内存,开销较小。适合中等并发(数百到数千连接)。
V3. 线程池 预先创建线程池,accept() 后丢入任务队列 避免频繁创建/销毁线程。适合中高并发。
V4. IO 多路复用 epoll / select / poll 单线程管理数万连接,事件驱动。现代高并发网络框架的基石(如 Nginx, Redis, Netty)

7. 结语

万丈高楼平地起。无论是 Nginx 的百万并发,还是 Redis 的极速响应,其最底层的基石依然是 socketbindlistenacceptreadwrite 这几个朴素的系统调用。

通过这次串行 TCP 通信的实验,我们不仅验证了代码的正确性,更在微观层面观察了文件描述符的分配、临时端口的变化以及 TCP 状态机的流转。把基础打牢,后续学习 epoll 和 Reactor 模型时,才能做到知其然,更知其所以然。

下一步建议 :尝试修改服务端代码,引入 pthreadstd::thread,将 do_connect 放入新线程中执行,亲手实现一个多线程并发服务器!

相关推荐
bush45 小时前
嵌入式linux学习记录十八,KGDB使用例
linux·学习
小则又沐风a5 小时前
库的原理:目标文件,ELF格式,程序加载
java·linux·前端
Cx330❀14 小时前
【MySQL基础】一文吃透“表的约束”:从 Null/Default 到主外键的终极安全法则
linux·服务器·数据库·c++·mysql·安全
程序员JerrySUN15 小时前
Jetson 刷机深度解析:flash.sh vs l4t_initrd_flash.sh(含安全与磁盘加密对比)
linux·网络·arm开发·安全·系统安全
六点_dn15 小时前
Linux学习笔记-printf命令
linux·运维·算法
蜡台15 小时前
Linux Python 安装使用
linux·运维·服务器
欧神附体12316 小时前
在虚拟机中添加网卡,修改网卡名并关闭SElinux
linux·服务器·网络
brave_zhao18 小时前
openEuler下安装tar包解压工具
linux·服务器·数据库
小宏运维有点菜18 小时前
JumpServer
linux·运维