
请君浏览
-
- 前言
- [一、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
// 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 | 线程数可控,生产可用 |
动手试试
- 在 V3 多线程版本的 Service 中加入心跳检测 ------如果 30 秒内没有收到客户端任何数据,主动 close 连接(提示:使用
setsockopt设置SO_RCVTIMEO读超时)。- 在 V4 线程池版本的基础上,加入最大连接数限制 ------当活跃连接数超过阈值时,新的
accept直接返回"服务器繁忙"消息然后 close(提示:维护一个全局atomic<int>计数器,accept 后 +1,线程退出前 -1)。
之后我们将深入 TCP 协议细节与底层机制,理解三次握手四次挥手的完整状态机、TIME_WAIT 的本质,以及如何用 tcpdump 和 Wireshark 抓包验证。
尾声
本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!