资讯动态

epoll accept接入线程池:高并发网络服务实战改造

发布时间:2026/10/17 14:25:04 来源:尧图企业网站定制
把业务逻辑从 accept 的循环里拿出来丢到一个线程池里去跑听起来是个再自然不过的改造。我在自己维护的网络服务骨架上做这步改动之前也觉得只是把处理函数往队列里一塞那么简单。真正动手之后才发现连接 fd 怎么交接、worker 线程谁负责关闭、任务队列要不要设上限这些问题一个比一个具体书上看不到现成答案只能一个个踩过去才踏实。上一篇我写到了骨架的基础版本一条 epoll 事件循环监听 fd 上的新连接accept 之后直接在当前线程里处理数据。单连接、低负载的时候完全够用但一旦某个连接的业务耗时稍微上来一点问题立刻暴露——accept 停在那里其他连接全部在排队。这一篇就围绕接入线程池、把 accept 和 worker 拆开这件事展开把我在代码层面做的改动、踩过的坑、以及压测里看到的真实数据都整理出来。1. 单线程accept的瓶颈到底出在哪里1.1 串行处理的一笔时间账先看最简单的时间模型。假设主线程的循环长这样epoll_wait 等到可读事件accept 拿到新连接然后立刻在当前线程里做业务处理。注意这个处理包含读请求、算逻辑、写响应整个过程都是串行的。我拿一个典型的短线事务计算过读几十字节的请求、做一次签名校验大概几十微秒到几百微秒、写回响应。看起来很快对吧但如果校验逻辑稍微重一点比如要做一次较长的哈希运算或者去查询一份存在内存里的映射表单次耗时很容易到 5ms 甚至 50ms。这时候问题就来了在这 5ms 里主线程根本没有回到 epoll_wait也就接收不到任何新连接。套一下公式单线程模式下一个连接的占线时间等于接受连接 完整业务处理的总耗时。如果处理一条连接需要 50ms那么即使你的网卡和内核吞吐再高这个服务每秒最多也只能处理 20 个连接。这不是网络带宽的问题也不是缓冲区太小的问题纯粹是因为 accept 和业务处理抢同一个执行线程互相拖累。1.2 用压测直观感受连接饿死理论推到这儿不如直接压一把。我写了一个很小的压测客户端模拟 200 个并发连接不停发请求。在业务处理里人为加了一个 30ms 的延时模拟耗时的校验逻辑。压测跑起来之后现象非常明显客户端的连接建立时间从几百微秒直接飙到几百毫秒大量 connect 超时服务端通过 strace 或者 /proc/net 观察accept 队列里的积压数一直在涨处理完的连接平均延迟从 1ms 跳到 300ms 以上波动极大。这个现象让我意识到一件很重要的事瓶颈不在处理能力而在入场速度。业务处理还没轮到发挥新连接就被卡在外面了。更严重的是内核里半连接队列和 accept 队列一旦溢出客户端连 TCP 握手都可能失败。这就是我要引入线程池的直接原因——让 accept 只负责接客业务处理交给后面的 worker 去做两条线并行。2. 线程池接入任务队列、条件变量与工作线程2.1 一个可以直接抄走的线程池实现线程池的实现方案不少但我希望尽量轻量不引入额外依赖就用 C 标准库。核心思路一个互斥锁保护的任务队列N 个工作线程在队列上等待主线程把任务塞进队列并通知工作线程。下面这段代码我直接贴在骨架里用了编译需要 C14 及以上。#include atomic #include condition_variable #include deque #include functional #include mutex #include thread #include utility #include vector class ThreadPool { public: explicit ThreadPool(size_t threads) : stop_(false) { for (size_t i 0; i threads; i) { workers_.emplace_back([this] { for (;;) { std::functionvoid() task; { std::unique_lockstd::mutex lock(mutex_); cond_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ tasks_.empty()) { return; } task std::move(tasks_.front()); tasks_.pop_front(); } task(); } }); } } ~ThreadPool() { { std::unique_lockstd::mutex lock(mutex_); stop_ true; } cond_.notify_all(); for (auto worker : workers_) { worker.join(); } } template typename F void enqueue(F f) { { std::unique_lockstd::mutex lock(mutex_); if (stop_) { throw std::runtime_error(enqueue on stopped ThreadPool); } tasks_.emplace_back(std::forwardF(f)); } cond_.notify_one(); } private: std::vectorstd::thread workers_; std::dequestd::functionvoid() tasks_; std::mutex mutex_; std::condition_variable cond_; bool stop_; };这段代码有一个我后来很在意的细节条件变量的 wait 带了一个 lambda 谓词判断条件既包括停止标志也包括队列非空。一开始我图省事直接写cond_.wait(lock)结果出现了两个问题一是假唤醒会让 worker 空转到下一次判断二是如果在enqueue的notify_one和 worker 的wait之间存在竞态有概率丢唤醒。加上谓词之后每次被唤醒都会重新检查条件这两个隐患就都堵上了。2.2 为什么这里用条件变量而不是自旋等待在设计线程池的时候有些朋友会倾向于用一个空循环 sleep的方式等任务或者干脆用无锁队列加自旋。我的观点是在这个阶段条件变量是最稳妥的答案。自旋等待会让所有 worker 线程在没事可做的时候占满 CPU压测的时候我试过一次任务队列一空机器负载直接拉满完全不能接受。条件变量则会让 worker 在等待时真正休眠不占 CPU。还有一个细节值得单独说一下enqueue里面用的是notify_one()而不是notify_all()。新任务只需要唤醒一个空闲 worker没必要把所有线程都叫起来抢一个任务那样只是白白增加系统调用和锁竞争。只有在线程池析构、需要所有 worker 收工的时候才用notify_all()。3. accept与worker之间的连接交接3.1 谁拥有fd谁负责关闭线程池搭好之后下面是我觉得整篇改造里最容易出问题的地方主线程 accept 拿到一个 fd这个 fd 到底归谁管我在代码里先踩了一套错误做法——主线程 accept 之后把 fd 写进一个全局数组worker 再去数组里取。结果两个线程同时操作同一个 fd关闭时我根本说不清楚谁该 close程序跑了一会儿就开始出现bad file descriptor。想清楚之后我给自己定了一条铁律fd 的所有权跟着任务走谁把 fd 装进任务谁就放弃了所有权worker 把任务处理完由 worker 负责 close。用代码来表达就是每个任务里装着一个连接对象这个对象的生命周期完全归 worker 管。struct Connection { int fd; // 可以在这里放客户端的地址、协议状态等 explicit Connection(int f) : fd(f) {} ~Connection() { if (fd 0) { close(fd); } } };主线程 accept 得到一个 fd 后立刻把它封装成std::unique_ptrConnection然后通过 C14 的 lambda init capture 交给线程池。这样从代码结构上就保证了任何一个连接对象在同一时刻只有一个 owner职责边界清清楚楚。void accept_loop(int listen_fd, ThreadPool pool) { while (running_) { int cfd accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 本次没有新连接回到事件循环 break; } if (errno EINTR) { continue; } continue; } // 组装连接对象直接交给线程池 std::unique_ptrConnection conn(new Connection(cfd)); pool.enqueue([conn std::move(conn)]() mutable { handle_connection(std::move(conn.get())); }); } }我在accept4里用了SOCK_NONBLOCK | SOCK_CLOEXEC这两个标志位非常有用。前者保证 accept 出来的 fd 不会阻塞在某个连接上后者保证 exec 新进程时 fd 不会被意外继承。建议你在自己的代码里直接用 accept4省得之后再单独调 fcntl。3.2 主循环改造accept只做一件事改造之后主循环的职责变得非常单一从监听 fd 上尽可能多地 accept把 fd 装进任务丢给线程池。至于业务逻辑、校验、响应全部在 worker 线程里做。有个细节我很早就发现既然 accept 已经不做任何业务了那么主循环根本不需要每次都 epoll_wait 等一个事件再 accept。可以在 epoll 返回 EPOLLIN 之后用非阻塞 accept 循环把当前积压的连接全部取完直到返回 EAGAIN。这样可以显著降低 accept 的系统调用次数和延迟抖动。处理完一批再回 epoll_wait 等下一批。3.3 长连接场景先别急着把IO也丢进线程池上面这套代码最舒服的应用场景是一个连接只服务一次请求的模型比如短连接协议、HTTP/1.0 风格的一次请求一次响应。worker 在handle_connection里读完请求、处理、写响应、关闭一条龙结束天然没有并发访问同一个连接的问题。但如果你做的是长连接比如 WebSocket 或者自定义的持续会话一个连接的读、写、业务处理都会横跨很长时间这时候把整个连接交给一个 worker 就不再合适了。我的处理方式是这一版先不碰长连接让连接生命周期短一些把线程池接入做扎实后续再引入状态机和 IO 事件分离机制。如果一上来就想着把 epoll 事件循环和线程池彻底融合很容易把自己绕进去。关于 worker 里读 fd 的方式我在实测时用的是非阻塞 fd 配合循环读取。因为刚才 accept4 已经设置了 SOCK_NONBLOCKworker 里 recv 时如果没有数据可读会返回 EAGAIN。最简单的处理是把读取包在一个带有超时上限的循环里避免糟糕的客户端连接不发数据把 worker 占死。比如用一个单调时钟判断等待时间超过 5s 就关闭连接。这个超时机制也是我后期压测发现必备的——不然有限的 worker 会被一堆只连不发数据的客户端活活拖住。4. 线程数、队列长度与压测数据4.1 一次改造前后的实测对比改完线程池之后我把之前那套 30ms 延时压测重新跑了一遍。同一个客户端工具同样 200 个并发连接结果对比非常直观指标单线程串行处理线程池分离后4 worker每秒完成请求数约 33约 12090% 延迟320ms62msaccept 队列积压持续上涨基本为 0客户端 connect 超时常见几乎消失数据说明线程池接入之后连接入场速度恢复了正常worker 的并行度直接提升了整体吞吐。另外压测的时候我特意看了主线程的 CPU 占用分离前差不多 100%分离后降到了不到 10%。这说明主线程终于从业务处理里解放出来了。4.2 线程数不是拍脑袋定的线程池应该创建多少个线程我的经验是不要迷信等于 CPU 核数这种一刀切的答案它只对纯 CPU 密集型任务有意义。网络服务里的 worker 既有 CPU 计算又有 IO 等待比如 recv、send、数据库访问。IO 等待的时候线程是空闲的所以线程数可以比核数多一些。我的做法是先用一个比较保守的配置起步比如 4 到 8 个线程然后在压测环境下逐步调大观察两个指标一个是吞吐量是否还在增长另一个是 CPU 是否已经全部打满。如果线程数继续增加但吞吐几乎不动说明瓶颈已经转移到了别处——比如锁竞争、调度开销这时候就不是加线程能解决的了。另外还要考虑一点线程太多上下文切换的成本会反噬性能这个同样要观察。4.3 队列背压与内存保护线程池这部分我最开始写成的是无界队列表面上看代码简单了实际上埋着内存炸弹。如果业务处理速度跟不上请求到达速度任务队列会无限膨胀内存越吃越多最后整个进程被 OOM。我在压测时有一次故意把 worker 处理时间调得很长结果 queue 里的任务堆到了几十万条机器内存告警这才意识到必须给队列加背压。我后来在enqueue里加了一个长度判断超过上限直接拒绝并关闭相应连接或者让上层降级处理。核心思路其实和 TCP 的流量控制一样接收端处理不过来的时候要么丢弃、要么让发送端减速绝对不能无限制地缓冲下去。队列长度上限怎么选我一般会按预期最大积压数来估算。比如每秒最大新请求数乘以业务处理耗时的秒数再乘以一个 2 到 3 的裕量。这样既不会频繁拒连又能在突发流量下保护进程。还有一个很小的细节任务队列用std::deque而不是std::vector是因为这里需要频繁在尾部入队、头部出队deque 在这两个操作上是常数时间的。一开始我用 vector 也跑得动但队列变大之后头部的 pop_front 会导致整体搬移浪费十分明显。5. 这次改造之后的一些新想法线程池接入之后骨架已经能扛住中等并发压测数据也说得过去。但我自己在后续使用中发现目前这种一个连接进入 worker 后从头处理到尾的模式仍然有天花板。最典型的场景是如果一个连接的请求还没读完worker 就被占住了而这个连接上的数据包迟迟不来worker 就白白浪费了。更长远的解法是把连接的 IO 事件和业务处理拆成更细粒度的状态机让同一个连接的不同阶段可以在不同线程之间切换。这应该是这个系列的下一篇要动刀的地方。另外我最后还是想唠叨一句线程池不是银弹。如果你的业务逻辑本身就是 CPU 密集操作线程池能带来的提升上限就是核数决定的倍数如果你的业务逻辑是 IO 密集或者大部分时间在等待下游线程池才有更大的发挥空间。动手之前先用 profiler 或者简单的日志统计把业务耗时分布摸清楚再决定线程池的规模和策略比直接套模板要靠谱得多。这一版骨架到这里我跑了大半个月线上表现稳定队列背压触发过几次没有一次假死或 fd 泄漏。希望这个accept 与 worker 分离的过程记录能帮你少踩几个坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑