新手写 TCP 服务器,常常本地 echo 一跑就以为大功告成:可一接真实流量,半包粘连、连接堆积、写端 SIGPIPE 直接崩进程会接连找上门。
大多数教程只停在 API 签名和单线程 demo,讲不清 accept 队列为何会满、字节流为何会粘、TIME_WAIT 该不该躲,线上一出问题就只能靠猜。
本文分享一套可直接落地的 TCP 服务端路径:系统调用语义 + 长度前缀分帧 + epoll 事件骨架,照着写即可直接落地。
一、系统调用视角:一条连接的五个必经节点
一条 TCP 连接从无到有,要依次经过五次关键调用:
- socket() :向内核申请描述符,返回负数多半是
ulimit卡住了 fd 上限。 - bind() :把端口钉在本机地址上,漏设
SO_REUSEADDR会让重启报Address already in use。 - listen():转入被动监听并登记 backlog,队列压满后内核会直接丢弃新 SYN。
- accept():从已完成队列摘下一个连接,返回的新 fd 才负责收发数据。
- connect():客户端专属,阻塞态会一直等到握手完成或超时。
各调用职责与常见失败原因对照如下:
| 系统调用 | 核心作用 | 常见失败原因 |
|---|---|---|
socket() |
创建套接字描述符 | fd 耗尽、协议族写错 |
bind() |
绑定本地地址与端口 | 端口被占、未设复用选项 |
listen() |
登记 backlog、转入监听 | 队列过小导致 SYN 被丢 |
accept() |
取出已建立连接 | 队列为空时永久阻塞 |
connect() |
发起三次握手 | 路由不可达、握手超时 |
核心结论: 服务端手里其实有两个 fd------监听 fd 只负责接客 ,已连接 fd 才负责通信,混为一谈是新手最常见的认知错误。

二、字节流不讲武德:粘包拆包的长度前缀方案
TCP 只保证字节有序送达,不保证一次 recv 对应一次 send:
- 粘包成因 :小包被 Nagle 算法 与内核缓冲合并,或一次 send 被拆进多次 recv。
- 半包风险:按结构体硬解,对端只发来半个头时解析直接错位,甚至读越界。
- 分帧三招 :固定长度 、分隔符 、长度前缀,其中长度前缀对二进制最通用。
下面这段 C++ 实现「4 字节大端长度头 + 负载」的打包与读满逻辑,适配 C++17:
cpp
std::string frame(const std::string& body) {
uint32_t n = htonl(body.size()); // 4 字节大端长度头
std::string out(reinterpret_cast<const char*>(&n), 4);
return out + body;
}
bool read_exact(int fd, void* buf, size_t n) {
size_t got = 0;
while (got < n) {
ssize_t r = recv(fd, (char*)buf + got, n - got, 0);
if (r <= 0) return false; // 对端关闭或出错
got += r; // 半包继续补齐
}
return true;
}
先读满 4 字节头 → 按长度读满负载 → 不足则循环补齐,三步把字节流重新缝回了消息。
三、最小可用服务端:C++ 阻塞模型逐行拆解
想先跑通一条链路,最短路径就是一个阻塞版 echo 服务端:
cpp
// g++ -std=c++17 server.cpp -o server && ./server
int main() {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in a{}; a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_ANY); a.sin_port = htons(8080);
int on = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(lfd, (sockaddr*)&a, sizeof(a)); listen(lfd, 128);
char buf[4096];
while (true) {
int cfd = accept(lfd, nullptr, nullptr); // 阻塞等新连接
ssize_t n;
while ((n = recv(cfd, buf, sizeof(buf), 0)) > 0)
send(cfd, buf, n, 0); // 收到什么回什么
close(cfd); // n<=0 即对端关闭
}
}
- 必配选项 :
SO_REUSEADDR决定服务能否秒重启,漏了它连测试都要等 2MSL。 - 状态流转 :
recv返回 0 是 FIN 到达 的正常收尾,返回 -1 才需要查errno。 - 已知短板:单线程下第二个客户端要等第一个断开,这正是下一节要解决的问题。
核心结论: 这份骨架验证的是调用顺序 而非并发能力,先让它跑对再谈性能。
四、并发模型怎么选:三种方案的取舍对照
连接数一旦上来,模型选择直接决定天花板:
| 模型 | 并发上限 | 内存开销 | 适用场景 |
|---|---|---|---|
| 多进程 | 数百 | 高,地址空间隔离 | 需强隔离的老式服务 |
| 多线程 | 数千 | 中,共享地址空间 | 业务重、连接少 |
| epoll 多路复用 | 数十万 | 低,单线程管万级 fd | 高并发网关与长连接 |
- 多进程 :每连接
fork,崩溃互不影响,但切换与内存成本极高。 - 多线程 :
accept后交给线程池,锁与线程安全开始介入,写法必须收敛。 - IO 多路复用 :把 fd 交给内核统一监听,一个线程驱动上万连接。
选型结论: 连接少而逻辑重选线程池 ,连接多而逻辑轻选 epoll,两者也可组合使用。

五、epoll 提吞吐:非阻塞 I/O 的注册与轮询
要让一个线程管住上万连接,关键是把「被动等」换成「事件驱动」:
cpp
int epfd = epoll_create1(0);
epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); // 注册监听 fd
epoll_event evs[64];
while (true) {
int n = epoll_wait(epfd, evs, 64, -1); // 阻塞到有事件
for (int i = 0; i < n; ++i) {
if (evs[i].data.fd == lfd) { // 监听就绪:收连接
int cfd = accept(lfd, nullptr, nullptr);
fcntl(cfd, F_SETFL, O_NONBLOCK); // 新连接设为非阻塞
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
} else handle(evs[i]); // 已连接就绪:读写
}
}
- LT 与 ET :默认水平触发 只要缓冲不空就反复报,边缘触发只报一次,必须一次读干。
- 非阻塞配套 :缓冲满时
recv返回EAGAIN,应挂回 epoll 等可写,而非空转重试。 - 生命周期 :读到 0 或出错要
EPOLL_CTL_DEL并关闭,否则 fd 泄漏迟早打满。
核心结论: 注册 → 就绪通知 → 读干 → 注销,四步闭环才不漏事件、不漏 fd。
六、排错速查:常见 errno 与内核参数处理
线上炸了先看 errno,八成问题都落在这张表里:
| 现象 / errno | 根因 | 处理方式 |
|---|---|---|
EPIPE、SIGPIPE |
对端已关闭仍去 send | 忽略信号或用 MSG_NOSIGNAL |
EAGAIN |
非阻塞 fd 缓冲区满 | 挂回 epoll 等可写再重试 |
TIME_WAIT 堆积 |
主动关闭方停留 2MSL | 设 SO_REUSEADDR、改长连接 |
ECONNRESET |
对端粗暴断开 | 校验帧长,防半包越界写 |
- 先看状态 :
ss -tan state time-wait | wc -l比netstat快,能立刻判断关闭方是谁。 - 再看限额 :
ulimit -n决定 fd 上限,容器里忘了调会稳定复现EMFILE。 - 最后看参数 :
somaxconn对齐listen的 backlog,tcp_max_syn_backlog决定 SYN 队列深度。
排错心法: 现象 → errno → 队列状态 → 内核参数,顺着这条链查,比反复改代码重编译快得多。

结语
从 socket() 到 accept(),从长度前缀分帧到 epoll 事件循环,链路上每一步都对应内核的一个真实行为:fd 的语义、队列的深度、字节流的边界、事件的触发方式。把它们串起来,才能写出既跑得通也扛得住的服务端。
这套骨架可直接改造成业务服务:套上线程池处理逻辑、加上长度前缀协议、补上 errno 分支兜底,就是一个可上线的 TCP 服务雏形。
能跑通只是起点,知道每一步为什么这么写,才是从 demo 到工程的距离。