从三次握手到 epoll:C++ 写出可落地的 TCP Socket 服务端

新手写 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 到工程的距离。

相关推荐
卡卡~~1 小时前
C++学习
开发语言·c++
比兔代理1 小时前
静态 IP 的粘性会话原理,以及连接稳定性解析
网络·网络协议
杜 硕2 小时前
从零开始的C++生活——第四讲
开发语言·c++·生活
j7~2 小时前
【Linux网络加餐】《HTTP 会话保持:Cookie 与 Session 原理及实验验证》---详解
linux·网络·网络协议·http·cookie·session
SHENGWEI胜为2 小时前
服务器 IP 地址冲突如何排查?从 DHCP 租约到 ARP 缓存定位故障
服务器·tcp/ip·缓存
箓维2 小时前
网络基础(2)IP和MAC的区别与报文封装
linux·网络
lisanmengmeng2 小时前
pushgateway
linux·运维·服务器·网络
努力实干2 小时前
Qt/C++ 模块化设计与工程组织
开发语言·c++·qt
zbtlink2 小时前
从 DDR4 颗粒到路由器价签:一轮存储涨价的三层传导与复算
网络·网络安全·智能路由器