【Linux之旅】Linux TCP Socket 编程实战:从单连接到线程池,构建高并发服务端

请君浏览

    • 前言
    • [一、TCP API 详解------服务端的五步套路](#一、TCP API 详解——服务端的五步套路)
    • [二、V1:TCP Echo 服务器------发现单连接瓶颈](#二、V1:TCP Echo 服务器——发现单连接瓶颈)
      • [2.1 基础框架](#2.1 基础框架)
      • [2.2 TCP 客户端](#2.2 TCP 客户端)
      • [2.3 V1 的致命缺陷:只能服务一个客户端](#2.3 V1 的致命缺陷:只能服务一个客户端)
    • [三、V2:多进程版本------用 fork 解耦 accept 和 Service](#三、V2:多进程版本——用 fork 解耦 accept 和 Service)
    • 四、V3:多线程版本------更轻量的并发模型
    • 五、V3-1:远程命令执行------给多线程加点业务
    • 六、V4:线程池版本------从"每连接一线程"到"任务队列"
    • 七、常见问题与避坑指南
      • [7.1 服务端重启后 bind: Address already in use](#7.1 服务端重启后 bind: Address already in use)
      • [7.2 子进程忘记关闭监听 fd](#7.2 子进程忘记关闭监听 fd)
      • [7.3 read 返回 0 不是错误](#7.3 read 返回 0 不是错误)
      • [7.4 多线程下 ThreadData 用栈变量](#7.4 多线程下 ThreadData 用栈变量)
      • [7.5 listen 的 backlog 设太小](#7.5 listen 的 backlog 设太小)
    • [八、多进程版本的补充------SIGCHLD 信号处理](#八、多进程版本的补充——SIGCHLD 信号处理)
    • [九、V1~V4 性能对比与选型建议](#九、V1~V4 性能对比与选型建议)
      • [9.1 性能维度对比](#9.1 性能维度对比)
      • [9.2 一个实际的计算](#9.2 一个实际的计算)
      • [9.3 选型决策树](#9.3 选型决策树)
    • [十、TCP 连接的生命周期与调试工具](#十、TCP 连接的生命周期与调试工具)
      • [10.1 一条 TCP 连接的三个阶段](#10.1 一条 TCP 连接的三个阶段)
      • [10.2 ss 和 netstat------查看 TCP 连接状态](#10.2 ss 和 netstat——查看 TCP 连接状态)
      • [10.3 tcpdump------抓包验证 TCP 握手](#10.3 tcpdump——抓包验证 TCP 握手)
      • [10.4 常见 TCP 状态异常排查](#10.4 常见 TCP 状态异常排查)
    • 十一、优雅关闭与生产化改造
      • [11.1 信号驱动的优雅退出](#11.1 信号驱动的优雅退出)
      • [11.2 有界队列------防止内存耗尽](#11.2 有界队列——防止内存耗尽)
    • 总结
    • 尾声

前言

回顾:Linux UDP Socket 编程实战:从 Echo 服务器到多线程聊天室

上一篇我们掌握了 UDP 编程------无连接、直发直收,简单但不可靠。本篇进入 TCP Socket 编程,TCP 的核心差异在于"连接管理":三次握手建立连接、四次挥手断开连接、确认重传保证可靠。多出来的复杂度换来了可靠的字节流传输,这是绝大多数互联网应用(HTTP、SSH、数据库协议)选择 TCP 的原因。

本文同样采用渐进式版本迭代 :V1 实现最简单的 TCP Echo 服务器(发现单连接问题)→ V2 引入多进程解决并发 → V3 升级为多线程 → V3-1 实现远程命令执行 → V4 引入线程池优雅管理资源。读完本文,你将彻底理解 TCP 服务器从单机玩具演进到高并发生产级架构的完整思维路径。

一、TCP API 详解------服务端的五步套路

TCP 相比 UDP 多了三个核心 API:listen()accept()connect()。我们先逐一拆解。

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 创建套接字
int socket(int domain, int type, int protocol);
//   domain:   AF_INET(IPv4) / AF_INET6(IPv6)
//   type:     SOCK_STREAM(TCP/流式) / SOCK_DGRAM(UDP/数据报)
//   protocol: 传 0,由前两个参数自动决定
//   返回值:   成功返回文件描述符,失败返回 -1

// 绑定地址和端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//   sockfd:  socket() 返回的 fd
//   addr:    本地地址(IP+端口),强转为 sockaddr*
//   addrlen: sizeof(sockaddr_in)
//   返回值:  成功 0,失败 -1(EADDRINUSE 表示端口被占用)

socket() 和 bind() 与 UDP 完全一致,唯一的区别:type 传 SOCK_STREAM 而非 SOCK_DGRAM

以下三个是 TCP 独有的 API

cpp 复制代码
// 开始监听(将 socket 从主动模式切换为被动模式)
int listen(int sockfd, int backlog);
//   sockfd:  已 bind 的 socket fd
//   backlog: 全连接队列的最大长度(已三次握手、等待 accept 的连接数),通常设为 5~128
//   返回值:   成功 0,失败 -1

// 从全连接队列取出一个客户端连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//   sockfd:  处于 LISTEN 状态的 socket(监听 fd)
//   addr:    输出参数,保存客户端地址信息(可传 NULL)
//   addrlen: 输入输出参数,传入 addr 缓冲区大小,传出实际地址大小
//   返回值:   成功返回新的文件描述符(用于与该客户端通信),失败返回 -1
//   关键点:   如果全连接队列为空,accept() 会阻塞等待

// 客户端发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//   sockfd:  客户端 socket() 返回的 fd
//   addr:    目标服务器地址
//   addrlen: sizeof(sockaddr_in)
//   返回值:   成功 0,失败 -1(ECONNREFUSED=端口无人监听)
//   connect() 会触发三次握手,客户端首次调用时 OS 自动 bind 随机端口

核心理解:两个文件描述符

fd 类型 来源 生命周期 用途
监听 fd socket() 创建,bind() + listen() 激活 服务启动到关闭 只用于 accept(),不用于通信
通信 fd accept() 返回 每个客户端独立,用完 close() 用于 read()/write()

饭店拉客的类比: listen() 后的监听 fd 就像饭店门口的迎宾员------他只负责把客人引进来(accept),自己不和客人吃饭。accept() 返回的通信 fd 才是带客人上桌的服务员------一个服务员服务一桌客人,服务完就离开(close)。

二、V1:TCP Echo 服务器------发现单连接瓶颈

2.1 基础框架

cpp 复制代码
// TcpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstdio>
#include "nocopy.hpp"
#include "Comm.hpp"

// 简单日志宏:直接打印到 stderr
#define LOG_FATAL(fmt, ...)   fprintf(stderr, "[FATAL] " fmt "\n", ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...)   fprintf(stderr, "[ERROR] " fmt "\n", ##__VA_ARGS__)
#define LOG_WARNING(fmt, ...) fprintf(stderr, "[WARNING] " fmt "\n", ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)    fprintf(stderr, "[INFO] " fmt "\n", ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...)   fprintf(stderr, "[DEBUG] " fmt "\n", ##__VA_ARGS__)

const static int default_backlog = 6;

class TcpServer : public nocopy
{
public:
    TcpServer(uint16_t port) : _port(port), _isrunning(false) {}

    void Init()
    {
        // ① 创建 socket
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listensock < 0)
        {
            LOG_FATAL("create socket error, errno: %d, error: %s",
                      errno, strerror(errno));
            exit(Fatal);
        }
        // setsockopt: 允许端口复用,重启服务器时避免 TIME_WAIT 导致 bind 失败
        int opt = 1;
        setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        LOG_DEBUG("create socket success, sockfd: %d", _listensock);

        // ② bind 绑定本地信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = htonl(INADDR_ANY);
        if (bind(_listensock, (struct sockaddr*)&local, sizeof(local)) != 0)
        {
            LOG_FATAL("bind error");
            exit(Bind_Err);
        }
        LOG_DEBUG("bind socket success, sockfd: %d", _listensock);

        // ③ listen:TCP 特有,将 socket 设为监听状态
        if (listen(_listensock, default_backlog) != 0)
        {
            LOG_FATAL("listen error");
            exit(Listen_Err);
        }
        LOG_DEBUG("listen socket success, sockfd: %d", _listensock);
    }

    // TCP 是全双工的------同一个 fd 既可读也可写
    void Service(int sockfd)
    {
        char buffer[1024];
        while (true)
        {
            ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
            if (n > 0)
            {
                buffer[n] = 0;
                std::cout << "client say# " << buffer << std::endl;
                std::string echo = "server echo# ";
                echo += buffer;
                write(sockfd, echo.c_str(), echo.size());
            }
            else if (n == 0)  // read 返回 0 = 对端关闭连接
            {
                LOG_INFO("client quit...");
                break;
            }
            else
            {
                LOG_ERROR("read error");
                break;
            }
        }
    }

    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // ④ accept:阻塞等待客户端连接,返回新 fd
            int sockfd = accept(_listensock, (struct sockaddr*)&peer, &len);
            if (sockfd < 0)
            {
                LOG_WARNING("accept error");
                continue;
            }
            LOG_DEBUG("accept success, new sockfd: %d", sockfd);
            // ⑤ 提供服务
            Service(sockfd);
            close(sockfd);  // 服务完关闭通信 fd
        }
    }

    ~TcpServer() {}
private:
    uint16_t _port;
    int _listensock;
    bool _isrunning;
};

Start() 主循环拆解:

步骤 代码 做了什么 关键点
④ accept accept(_listensock, &peer, &len) 从全连接队列取连接,返回新 fd 阻塞等待;peer 输出客户端地址
⑤ Service Service(sockfd) 在一个循环里反复 read/write 这里阻塞了回到 accept 的路径!
⑥ close close(sockfd) 释放通信 fd 只有 Service 退出才会执行

2.2 TCP 客户端

cpp 复制代码
![QQ20260528-130845](C:\Users\hou\Pictures\博客\Linux\20\QQ20260528-130845.png)// TcpClient.cc
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

void Usage(const std::string &process)
{
    std::cout << "Usage: " << process << " server_ip server_port" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 3) { Usage(argv[0]); return 1; }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    // ① 创建 socket(SOCK_STREAM = TCP)
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) { std::cerr << "socket error" << std::endl; return 1; }

    // ② connect:发起三次握手,首次调用时 OS 自动 bind 随机端口
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    inet_pton(AF_INET, serverip.c_str(), &server.sin_addr);

    int n = connect(sockfd, (struct sockaddr*)&server, sizeof(server));
    if (n < 0) { std::cerr << "connect error" << std::endl; return 2; }

    // ③ 连接成功后用 write/read 收发数据
    while (true)
    {
        std::string inbuffer;
        std::cout << "Please Enter# ";
        std::getline(std::cin, inbuffer);

        ssize_t n = write(sockfd, inbuffer.c_str(), inbuffer.size());
        if (n > 0)
        {
            char buffer[1024];
            ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
            if (m > 0) { buffer[m] = 0; std::cout << "get echo -> " << buffer << std::endl; }
            else break;
        }
        else break;
    }
    close(sockfd);
    return 0;
}

客户端流程拆解:

步骤 代码 说明
① socket socket(AF_INET, SOCK_STREAM, 0) 与服务端一样
② connect connect(sockfd, &server, ...) 触发三次握手;OS 自动 bind 随机端口
③ IO write()/read() TCP 已建立连接,无需每次指定目标地址

与 UDP 客户端的关键区别:UDP 每次 sendto 都要带目标地址,TCP 的 write 不需要------因为连接建立后,内核已经记住了四元组 {源IP, 源端口, 目的IP, 目的端口}。

2.3 V1 的致命缺陷:只能服务一个客户端

启动第二个客户端连接服务器时,发现第二个客户端无法正常通信。原因在 Start() 中一目了然:

cpp 复制代码
while (_isrunning) {
    int sockfd = accept(...);   // 接受连接 A
    Service(sockfd);            // 陷入死循环 read/write,直到 A 断开
    close(sockfd);
    // 只有 A 断开后,才能回到 accept 接受连接 B
}

Service() 是一个 while(true) 死循环 ------在读-写循环中出不来。"accept → 服务 → 等断开 → accept → 服务"的串行模式,注定只能一次处理一个连接。要解决这个问题,必须在 accept 新连接后,将 Service 交给独立的执行流去处理。

三、V2:多进程版本------用 fork 解耦 accept 和 Service

思路:accept 拿到新连接后,fork() 一个子进程,让子进程去 Service(),父进程立刻回到 accept 等待下一个连接。

cpp 复制代码
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
    pid_t id = fork();
    if (id < 0)
    {
        close(sockfd);
        return;
    }
    else if (id == 0)
    {
        // child
        close(_listensock);         // 子进程用不到监听 fd,必须关闭
        if (fork() > 0) exit(0);    // 创建孙子进程,儿子立即退出
        // 孙子进程------孤儿进程,被 init 领养,无需父进程 wait
        InetAddr addr(peer);
        LOG_INFO("process connection: %s:%d",
                 addr.Ip().c_str(), addr.Port());
        Service(sockfd);
        close(sockfd);
        exit(0);
    }
    else
    {
        // parent
        close(sockfd);              // 父进程用不到通信 fd,必须关闭
        waitpid(id, nullptr, 0);    // 等待儿子退出(很快,因为儿子 fork 后立即退出)
    }
}

关键设计决策:

设计 原因
子进程关闭 _listensock 子进程只负责通信,不应持有监听 fd------减少资源占用,避免误操作
父进程关闭 sockfd 通信 fd 已交给子进程,父进程不持有------引用计数减 1,等子进程 close 后真正释放
孙子进程处理 Service 儿子 fork() 后立即 exit(0),孙子变孤儿被 init 领养。这样父进程只需 waitpid 儿子(极短等待),不会阻塞主循环

多进程版本的代价: 每个客户端 fork 一个新进程,进程创建和销毁开销大;客户端断开后留下僵尸进程需要处理;进程间不共享地址空间,数据交换需要 IPC。

四、V3:多线程版本------更轻量的并发模型

线程共享进程地址空间,创建开销远小于进程。思路:accept 拿到新连接后,pthread_create 一个新线程执行 Service

cpp 复制代码
class ThreadData  // 向线程传递参数
{
public:
    ThreadData(int sockfd, struct sockaddr_in addr) : _sockfd(sockfd), _addr(addr) {}
    int _sockfd;
    InetAddr _addr;
};

// Service 改为 static,从 ThreadData 中取参数
static void Service(ThreadData &td)
{
    char buffer[1024];
    while (true)
    {
        ssize_t n = read(td._sockfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
            std::string echo = "server echo# ";
            echo += buffer;
            write(td._sockfd, echo.c_str(), echo.size());
        }
        else if (n == 0)
        {
            LOG_INFO("client[%s:%d] quit...",
                     td._addr.Ip().c_str(), td._addr.Port());
            break;
        }
        else { break; }
    }
}

static void *threadExcute(void *args)
{
    pthread_detach(pthread_self());  // 线程分离,无需主线程 join
    ThreadData *td = static_cast<ThreadData *>(args);
    TcpServer::Service(*td);
    close(td->_sockfd);
    delete td;
    return nullptr;
}

void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
    InetAddr addr(peer);
    pthread_t tid;
    ThreadData *td = new ThreadData(sockfd, peer);  // new 保证堆上分配,线程结束前不被销毁
    pthread_create(&tid, nullptr, threadExcute, (void*)td);
}

线程版本的改进点:

对比项 多进程 V2 多线程 V3
创建开销 大(复制整个地址空间) 小(共享地址空间)
内存占用 每个进程独立内存 线程共享,仅独立的栈
fd 数量限制 每个进程独立 fd 表 共享 fd 表
数据共享 需要 IPC 直接访问全局变量(需同步)

五、V3-1:远程命令执行------给多线程加点业务

在 V3 的多线程框架基础上,替换 Service() 的内容,实现一个远程命令执行服务器(限定在白名单内的安全命令):

cpp 复制代码
// Command.hpp
#pragma once
#include <iostream>
#include <string>
#include <set>
#include <unistd.h>

class Command
{
public:
    Command(int sockfd) : _sockfd(sockfd)
    {
        // 安全白名单:只允许执行这些命令
        _safe_command.insert("ls");
        _safe_command.insert("pwd");
        _safe_command.insert("ls -l");
        _safe_command.insert("ll");
        _safe_command.insert("touch");
        _safe_command.insert("who");
        _safe_command.insert("whoami");
    }

    bool IsSafe(const std::string &command)
    {
        return _safe_command.find(command) != _safe_command.end();
    }

    std::string Execute(const std::string &command)
    {
        if (!IsSafe(command)) return "unsafe command";
        FILE *fp = popen(command.c_str(), "r");  // popen: 执行命令并捕获输出
        if (fp == nullptr) return std::string();
        char buffer[1024];
        std::string result;
        while (fgets(buffer, sizeof(buffer), fp))
            result += buffer;
        pclose(fp);
        return result;
    }

    std::string RecvCommand()
    {
        char line[1024];
        ssize_t n = recv(_sockfd, line, sizeof(line) - 1, 0);
        if (n > 0) { line[n] = 0; return line; }
        return std::string();
    }

    void SendCommand(std::string result)
    {
        if (result.empty()) result = "done";
        send(_sockfd, result.c_str(), result.size(), 0);
    }

    ~Command() {}
private:
    std::set<std::string> _safe_command;
    int _sockfd;
};

Service 改写------三行驱动整个命令执行流程:

cpp 复制代码
static void Service(ThreadData &td)
{
    while (true)
    {
        Command command(td._sockfd);
        std::string commandstr = command.RecvCommand();    // ① 收命令
        if (commandstr.empty()) return;
        std::string result = command.Execute(commandstr);  // ② 执行
        command.SendCommand(result);                        // ③ 回结果
    }
}

设计亮点: Command 类封装了命令的接收、安全检查、执行和结果返回。Service() 不需要知道命令怎么执行的,也不需要知道白名单里有什么------它只是"收→处理→回"的管道。这和 V2 DictServer 用回调解耦的思路一脉相承。

六、V4:线程池版本------从"每连接一线程"到"任务队列"

V3 在连接数激增时会暴露一个问题:每个连接创建一个线程,线程数无上限。引入线程池,核心改动只有一处------ProcessConnection

cpp 复制代码
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
    using func_t = std::function<void()>;
    InetAddr addr(peer);
    // 将 Service 包装成任务,投递到线程池
    func_t func = std::bind(&TcpServer::Service, this, sockfd, addr);
    ThreadPool<func_t>::GetInstance()->Push(func);
    // 主线程立即返回 accept 循环,不等待 Service 完成
}

四版对比:

版本 并发模型 主循环等待 适用场景
V1 串行 accept 阻塞 + Service 死循环 Service 完成后才回到 accept 调试、演示
V2 多进程 fork 子进程 per 连接 waitpid 短暂等待(孙子进程) 需要强隔离,进程互相独立
V3 多线程 pthread_create per 连接 立即回到 accept 中小规模并发(< 1000 连接)
V4 线程池 任务投递 + 工作线程复用 立即回到 accept 大规模并发,线程数可控

七、常见问题与避坑指南

7.1 服务端重启后 bind: Address already in use

现象: 关闭服务器后立刻重启,bind() 失败报 EADDRINUSE

原因: TCP 主动关闭方进入 TIME_WAIT 状态(持续 2MSL ≈ 2 分钟),端口仍被占用。

解决:socket() 之后、bind() 之前调用 setsockopt 设置端口复用:

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

7.2 子进程忘记关闭监听 fd

现象: 客户端断开后,服务端端口仍然无法被新进程 bind

原因: fork() 后子进程继承了父进程的 _listensock。即使父进程 close,子进程仍持有引用,fd 的引用计数不为 0,端口不释放。

解决: 子进程中第一时间 close(_listensock)------用不到就别持有。

7.3 read 返回 0 不是错误

现象: 误把 read() 返回 0 当作接收了空数据,继续循环导致死循环。

原因: TCP 中 read() 返回 0 表示对端关闭了连接(发送了 FIN)。继续读只会不断返回 0。

解决: 检查 n == 0 的分支,立即 break 并 close 通信 fd:

cpp 复制代码
if (n == 0) {  // 对端关闭连接
    LOG_INFO("client quit");
    break;
}

7.4 多线程下 ThreadData 用栈变量

现象: 线程中读取 ThreadData 内容全是乱码。

原因: ProcessConnection 返回后,栈上的 ThreadData 已被销毁,但线程还在用这个地址。

解决: new 在堆上分配,由线程自行 delete

cpp 复制代码
ThreadData *td = new ThreadData(sockfd, peer);  // 堆上分配
pthread_create(&tid, nullptr, threadExcute, td);
// 在 threadExcute 中 delete td;

7.5 listen 的 backlog 设太小

现象: 高并发下客户端 connect 偶尔被拒绝(ECONNREFUSED)。

原因: backlog 限制了全连接队列大小。新连接完成三次握手后若队列满,直接丢弃或被拒绝。

解决:backlog 设为合理值(如 128 或 SOMAXCONN)。内核实际值会受 net.core.somaxconn 参数上限影响。


八、多进程版本的补充------SIGCHLD 信号处理

V2 多进程版本中有一个隐含问题:如果服务端被 kill 或者异常退出,子进程会变成僵尸进程(defunct)。正规的做法是注册 SIGCHLD 信号处理器:

cpp 复制代码
#include <signal.h>
#include <sys/wait.h>

// SIGCHLD 信号处理器------子进程退出时内核发送此信号
void SigChildHandler(int signo)
{
    // waitpid(-1, ...) 回收任意子进程
    // WNOHANG: 非阻塞------如果有多个子进程同时退出,
    // 信号可能被合并,需要循环回收直到没有更多退出的子进程
    while (waitpid(-1, nullptr, WNOHANG) > 0)
        ;
}

int main()
{
    // 在主函数最开始注册信号处理
    signal(SIGCHLD, SigChildHandler);
    // ... 后续 socket/bind/listen/accept 流程不变
}

关键点:SIGCHLD 信号可能被合并。 如果两个子进程几乎同时退出,内核可能只发送一次 SIGCHLD。所以信号处理器中要用 while (waitpid(-1, ..., WNOHANG) > 0) 循环回收,而不是只调用一次 wait。这是 C++ 服务端面试中的高频考点。


九、V1~V4 性能对比与选型建议

理解每个版本的瓶颈,才能在工程中做出正确选择:

9.1 性能维度对比

版本 并发数上限 内存占用(per 连接) 创建/销毁开销 适合场景
V1 串行 1 极低 调试、演示、单客户端工具
V2 多进程 ~几千(受 PID 和内存限制) 高(独立地址空间,~几MB per 进程) 大(fork 复制页表) 需要进程级隔离、安全性优先
V3 多线程 几千几万 中(共享地址空间,~几MB per 线程栈) 中(内核线程创建) 中等并发、业务简单
V4 线程池 线程数固定,队列无界 → 受内存和 CPU 限制 低(线程数固定,任务在堆上) 极低(复用线程) 高并发生产环境

9.2 一个实际的计算

假设每个连接处理耗时 10ms(纯计算耗时),线程池有 4 个线程:

复制代码
单线程处理能力:1000ms / 10ms = 100 连接/秒
4 线程处理能力:400 连接/秒
如果客户端以 500 连接/秒的速度涌入:每秒积压 100 个任务

线程池不是魔法------它不增加 CPU 算力,只是避免线程创建/销毁的开销。如果业务处理本身慢(磁盘 IO、数据库查询),线程池的队列会越来越长,最终内存耗尽。解决方案:用 非阻塞 IO + epoll(下一篇 HTTP 服务器中会涉及)。

9.3 选型决策树

复制代码
是否需要严格的进程隔离(安全性第一)?
  ├─ 是 → V2 多进程(如 sshd:每个 ssh 会话一个独立进程)
  └─ 否 → 并发量多大?
           ├─ < 100 并发 → V3 多线程(简单直接)
           └─ > 1000 并发 → V4 线程池 + epoll(生产级)

十、TCP 连接的生命周期与调试工具

理解 TCP 连接从建立到关闭的完整生命周期,是排查网络问题的基本功。

10.1 一条 TCP 连接的三个阶段

复制代码
阶段一:建立连接(三次握手)
  CLIENT → [SYN, seq=x]        → SERVER (LISTEN)
  CLIENT ← [SYN+ACK, seq=y, ack=x+1] ← SERVER (SYN_RCVD)
  CLIENT → [ACK, ack=y+1]      → SERVER (ESTABLISHED)
  双方进入 ESTABLISHED 状态,可以开始收发数据

阶段二:数据传输
  CLIENT ⇄ write/read ⇄ SERVER
  数据双向流动,TCP 保证有序、可靠

阶段三:断开连接(四次挥手)
  CLIENT → [FIN, seq=u]        → SERVER
  CLIENT ← [ACK, ack=u+1]      ← SERVER (CLOSE_WAIT)
  ... 服务器处理完剩余数据 ...
  CLIENT ← [FIN, seq=v]        ← SERVER (LAST_ACK)
  CLIENT → [ACK, ack=v+1]      → SERVER
  主动关闭方进入 TIME_WAIT(等待 2MSL≈2分钟)

10.2 ss 和 netstat------查看 TCP 连接状态

bash 复制代码
# 查看所有 TCP 连接及其状态
$ ss -tanp | grep 9999
State      Recv-Q Send-Q Local Address:Port   Peer Address:Port  Process
LISTEN     0      5      0.0.0.0:9999         0.0.0.0:*          ./tcp_server
ESTAB      0      0      192.168.1.10:9999     192.168.1.20:55123 ./tcp_server
TIME-WAIT  0      0      192.168.1.10:9999     192.168.1.20:55124 -

# 查看每个状态的连接数量
$ ss -s
Total: 156
TCP:   45 (estab 12, closed 20, orphaned 0, timewait 13)

如果你看到大量 TIME-WAIT 状态的连接,说明你的程序频繁创建和关闭 TCP 连接------这是 HTTP/1.0 短连接式的设计的自然结果,不是 bug。但如果想减少 TIME-WAIT,要么用长连接(keep-alive),要么用 SO_REUSEADDR(我们已经在 V1 的 Init 中做了)。

10.3 tcpdump------抓包验证 TCP 握手

bash 复制代码
# 抓取本地回环 9999 端口的 TCP 包,显示详细信息
$ sudo tcpdump -i lo port 9999 -S -vvv

# 启动客户端后观察输出:
# 1. IP localhost.55123 > localhost.9999: Flags [S], seq 123456789    ← SYN
# 2. IP localhost.9999 > localhost.55123: Flags [S.], seq 987654321, ack 123456790 ← SYN+ACK
# 3. IP localhost.55123 > localhost.9999: Flags [.], ack 987654322   ← ACK (握手完成)
# 4. IP localhost.55123 > localhost.9999: Flags [P.], seq ..., ack ..., length 5  ← 客户端发送 "hello"
# 5. IP localhost.9999 > localhost.55123: Flags [P.], seq ..., ack ..., length 13 ← 服务端回 "server echo# hello"
# 6. IP localhost.55123 > localhost.9999: Flags [F.], seq ...         ← 客户端发起挥手
# ...

tcpdump 是 TCP 协议学习的终极工具。没有什么比亲眼看到三次握手四次挥手的原始报文更直观的了。每一个网络开发者都应该至少用 tcpdump 完整地看过一次 TCP 连接的全过程。

10.4 常见 TCP 状态异常排查

问题 症状 tcpdump/ss 看到的 可能原因
客户端连不上 connect 返回 -1, ECONNREFUSED 服务端端口不在 LISTEN 状态 服务端没启动、端口号写错、防火墙拦截
连接超时 connect 很长时间后返回 -1, ETIMEDOUT 客户端发 SYN 后无响应 网络不通、防火墙丢弃 SYN
服务端假死 客户端能连上但无响应 连接状态 ESTABLISHED,但 Recv-Q 积压 服务端业务逻辑阻塞,read 被卡住
大量 CLOSE_WAIT --- ss 显示大量 CLOSE_WAIT 服务端收到 FIN 后没有调用 close()------程序忘了在 read 返回 0 后关闭 socket
大量 TIME_WAIT --- ss 显示大量 TIME_WAIT 每秒创建/销毁大量短连接,正常现象,用 SO_REUSEADDR 或长连接优化

十一、优雅关闭与生产化改造

11.1 信号驱动的优雅退出

目前 V1~V4 的 Start()while (_isrunning) 循环,但没有机制将 _isrunning 设为 false。注册信号处理实现 Ctrl+C 时的优雅关闭:

cpp 复制代码
#include <signal.h>

bool g_running = true;

void SigIntHandler(int signo) {
    g_running = false;  // 指示主循环退出,不强制 kill 当前连接
}

int main() {
    signal(SIGINT, SigIntHandler);   // Ctrl+C
    signal(SIGTERM, SigIntHandler);  // kill 命令

    TcpServer server(9999);
    server.Init();
    while (g_running) {
        int sockfd = accept(...);
        // ...
    }
    LOG_INFO("Server shutting down gracefully...");
}

"优雅"意味着完成当前连接的处理后才退出------不是在 read/write 中途被强制 kill,导致客户端收到不完整的响应。

11.2 有界队列------防止内存耗尽

V4 的线程池队列是无界的------不管积压多少任务,Push 都不会拒绝。10000/s 涌入时内存会爆:

cpp 复制代码
class BoundedThreadPool {
    size_t _max_size;
    bool Push(task_t task) {
        std::unique_lock<std::mutex> lock(_mutex);
        if (_tasks.size() >= _max_size) {
            return false;  // 拒绝新任务
        }
        _tasks.push(std::move(task));
        _cv.notify_one();
        return true;
    }
};
// 拒绝后返回 503 Service Unavailable,而不是让系统崩溃

有界队列 + 拒绝策略是生产级线程池的标配。"Fail Fast"------快速失败比慢慢耗尽资源然后全面崩溃要好得多。


总结

从 V1 到 V4 的演进,本质是将"accept 等待连接"和"Service 服务连接"这一对矛盾不断解耦的过程:

版本 并发模型 核心机制 瓶颈
V1 串行 单进程 accept → Service → close 串行 只能服务一个连接
V2 多进程 fork per 连接 孙子进程 + 孤儿进程回收 进程创建开销大
V3 多线程 pthread_create per 连接 ThreadData 传参 + detach 线程数无限增长
V4 线程池 任务投递 + 工作线程复用 std::bind 包装 Service 线程数可控,生产可用

动手试试

  1. 在 V3 多线程版本的 Service 中加入心跳检测 ------如果 30 秒内没有收到客户端任何数据,主动 close 连接(提示:使用 setsockopt 设置 SO_RCVTIMEO 读超时)。
  2. 在 V4 线程池版本的基础上,加入最大连接数限制 ------当活跃连接数超过阈值时,新的 accept 直接返回"服务器繁忙"消息然后 close(提示:维护一个全局 atomic<int> 计数器,accept 后 +1,线程退出前 -1)。

之后我们将深入 TCP 协议细节与底层机制,理解三次握手四次挥手的完整状态机、TIME_WAIT 的本质,以及如何用 tcpdump 和 Wireshark 抓包验证。

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!

更多内容可见主页

相关推荐
H Journey14 小时前
总结Linux下查看IP地址的相关命令
linux·运维·ip address
Cloud_Shy61814 小时前
Linux 系统定时任务Cron(d)服务应用实践(三:定时任务调试技巧及故障分析解决)
linux·网络·centos·云计算·github·运维开发
晚风予卿云月14 小时前
【Linux】初步构建框架—虚拟地址空间(三)—进程与内存管理的解耦优势、深入理解vm_area_struct
linux·运维·服务器·面试
2401_8685347814 小时前
华为系OSPF 配置命令全总结(2026 精简版
网络·数据结构
Oll Correct14 小时前
实验二十七:VLAN间单播通信实现方法——单臂路由
网络·笔记
sbjdhjd15 小时前
从 0 到 1 构建高可用企业级 NoSql 数据库 Redis 集群
linux·运维·redis·云原生·kubernetes·开源·云计算
不知名的老吴15 小时前
WebSocket启用实时消息传递关键要点
网络·websocket·网络协议
梦奇不是胖猫15 小时前
[ 计算机网络 | 第三章 ] 数据链路层 06 无线局域网
网络·网络协议·计算机网络
zincsweet15 小时前
进程间通信入门:匿名管道的使用、阻塞场景与避坑指南
linux