epoll+线程池:高并发服务器的核心组合与实现
上个月我接手一个内部推送网关一万两千条高并发连接压在上面CPU报警邮件一天能收十几封。最后靠 epoll 处理连接事件、线程池处理业务任务才把机器稳住。这里我把整个踩坑过程、关键选择和可以直接参考的代码整理出来适合正在写网络服务、或者对高性能服务器实现感兴趣的同学。先交代一个背景这个网关本身业务不复杂转发消息、做在线状态维护逻辑量并不大。可问题就出在连接模型上。早期实现是经典的“一连接一线程”连接数一涨线程数跟着涨几千个线程挤在机器上光上下文切换就把CPU吃掉了。后来换成 epoll 线程池才算把连接和高负载分开处理。1. 高并发服务器为什么需要epoll和线程池组合1.1 一连接一线程模型的开销在哪里最直观的并发方案是每来一个连接就创建一个线程线程阻塞在 read 上等待数据。这个模型写起来很容易但连接数一旦上千问题就全都冒出来了。第一个问题是线程本身的资源占用。每个线程默认栈大小通常是 8MB 虚拟内存还有独立的内核线程控制块。1000 个线程意味着线程栈就要占 8GB 虚拟地址空间虽然物理内存不会一次性都分配但地址空间压力、线程创建销毁时的系统调用开销都是实实在在的。第二个问题更隐蔽调度。1000 个线程大部分时间阻塞在 read 上看起来不耗 CPU但只要有几十上百个连接同时来了数据内核就要在这些线程之间频繁切换。每次上下文切换大约几微秒每秒几千次切换CPU 就被白白烧掉。长连接场景下大多数连接其实在 Keep-Alive 空闲你却为每一根空闲连接付一个线程的全价非常浪费。第三个问题是锁。线程多了共享数据几乎都要加锁保护连接管理表、统计计数、日志缓冲处处锁竞争。吞吐没上来锁等待倒是先上来了。连接多并不等于活跃多真正同一时刻有数据要读的连接往往只有一小部分。好的模型应该让“大量空闲连接”几乎不消耗资源让“少量活跃连接”被及时处理。1.2 epoll把等待集中到内核阻塞模型里每个线程自己阻塞等待一个fd。epoll 的思路是把所有fd的等待都交给内核由内核统一告诉我们“哪些fd就绪了”。和 select/poll 相比epoll 的优势是省掉了全量遍历。select 每次调用要把 fd 集合从用户态拷贝到内核再逐个扫描而且 fd 数量还有上限。poll 虽然去掉了数量限制但依然每次都要扫描全部 fd。连接数一两万的时候哪怕只有 5 个连接有数据你也得把一万个 fd 从头到尾过一遍这个成本就非常高了。epoll 通过 epoll_ctl 把关心的 fd 注册到内核维护的红黑树里当某个 fd 有事件时内核把它挂到一个就绪链表上。epoll_wait 只需要从就绪链表拷贝事件出来和总连接数无关只和“真正活跃的连接数”有关。这就是所谓 O(事件数)也是它适合高并发连接的根源。假如有一万个空闲连接挂在服务端select/poll 每次都白折腾一遍而 epoll 几乎可以零开销睡觉直到有连接真的发数据过来才醒来。1.3 线程池解决的是另一半问题epoll 解决了“怎么知道哪个连接有事件”但并没有解决“事件来了之后业务逻辑在哪执行”。如果直接在 epoll_wait 的循环里执行业务逻辑比如解码协议、查询缓存、写日志任何一个慢任务都会卡住整个事件循环。一个连接触发了耗时的业务其他所有连接都跟着等延迟就会暴涨。所以要把“收事件”和“处理逻辑”拆开事件循环只负责读取数据、简单解析然后构造一个任务丢给线程池线程池里的 worker 线程真正执行业务。这就是半同步/半异步模型异步的事件分发加上同步的业务处理。线程池带来的另一个好处是削峰填谷。高并发往往有突发性短时间内任务堆积过一会儿又恢复平静。线程池复用固定数量的线程任务在队列里排队峰值时不慌低谷时不浪费。相比每来一个请求就new一个线程线程池避免了频繁创建销毁的开销也能把并发线程数牢牢控制在预设范围内。可以这么记epoll 决定这台机器能扛住多少连接线程池决定这些连接上的业务能跑得多快多稳。两个东西解决的是不同瓶颈。2. 动手前先定死两个配置epoll触发模式与线程池阻塞队列2.1 epoll的LT和ET不是玄学是两种通知策略epoll 的触发模式是第一个必须做的选择。很多新手写 epoll 直接照抄 ET 模式结果数据读不完整一脸懵。其实两者的差别一句话就能说清。LT水平触发只要这个 fd 上还有没处理完的数据每次 epoll_wait 都会把它的就绪事件返回给你。你今天只读了 100 字节剩下 900 字节还在内核缓冲区明天再调 epoll_wait它还会告诉你这个 fd 可读。ET边沿触发只有 fd 的状态从“无数据”变成“有数据”的那一刻才通知你一次。如果这次通知后你没把数据读完剩余数据会一直躺在内核缓冲区里除非有新的数据到达否则再也不会触发。ET 看着性能更高少了很多重复通知但代价是你必须保证每次可读事件都尽量把数据读完。规范做法是设置非阻塞 fd然后循环调用 read 直到返回 EAGAIN这样才不会漏数据。我的建议是功能第一版无脑用 LT。LT 配合非阻塞 fd逻辑简单不会丢事件性能相比 ET 通常只差几个百分点。ET 适合协议边界清晰、追求极致吞吐的场景比如自研网关或者缓存代理。如果你选择 ET一定要做好协议分包和剩余缓冲区的处理。另外不管是 LT 还是 ET都建议把 fd 设为非阻塞。否则 LT 模式下如果 read 把内核缓冲区读空了下一次 read 会阻塞住事件循环。非阻塞 fd EAGAIN 判断才是 epoll 循环的标准姿势。2.2 线程池的阻塞队列选择有界还是无界线程池的工作队列决定了任务在“事件循环”和“worker线程”之间怎么缓冲。这个选择非常重要因为它直接影响服务在极端情况下的行为。无界队列的意思是任务可以无限堆积。优点是实现简单峰值流量不会丢任务。缺点也很致命一旦生产速度持续大于消费速度队列会越积越长内存被慢慢吃光而且任务的延迟会从几毫秒变成几十秒。这就像高速收费站不限制排队长度车流一大所有车都堵在路上一动不动。有界队列限制了最大缓冲任务数。任务量超过容量时线程池会触发拒绝策略。此时你可以选择丢弃任务、返回错误响应或者通知事件循环暂时不要再往里投。有界队列的本质是实现背压让上游感知到下游处理不过来从而限流或降级而不是打肿脸充胖子。如果不想引入额外框架C 实现里常见三种队列mutex condition_variable deque通用、简单、适合万级QPS无锁 MPSC 队列比如 Vyukov queue减少锁竞争适合几十万高吞吐场景环形缓冲区定长数组有界、避免动态分配延迟稳定。我的第一版实现用的是互斥锁加条件变量加有界 deque。无锁队列虽然听起来很酷但 ABA、内存序这些坑对入门者很不友好而且业务复杂度上来之后队列根本不是主要瓶颈。2.3 线程池核心参数怎么估算热搜词里反复出现“线程池配置”确实这是最容易拍脑袋的部分。我给出一个可落地的估算思路。第一个参数是线程数。如果任务主要是 CPU 计算线程数接近 CPU 核数或核数1就够了再多只会增加切换成本。如果任务大量等待下游IO比如查数据库、调用RPC可以用一个经典经验公式线程数 CPU核数 × (1 等待时间 / 计算时间)假设一个任务平均耗时 1ms其中 0.6ms 在等待下游RPC0.4ms在本地计算。4核机器上理论线程数就是4 × (1 0.6 / 0.4) 10左右。也可以用另一个角度校验并发中的任务数约等于QPS × 平均任务耗时。目标 QPS 5000平均耗时 1ms那么同时执行的任务大概 5 个。再结合 CPU 核数取 8 到 10 个线程作为初始值后续压测再调。第二个参数是队列容量。队列里的任务代表正在等待处理的数据既有内存成本又有延迟成本。假设单个任务平均携带 10KB 内存队列容量 2048最多占 20MB 左右这个量级可以接受。而如果用一个无界队列堆积到 10 万任务内存可能直接到 1GB服务还没等业务出错就已经被内存拖垮了。第三个参数是空闲线程存活时间。为了应对突发流量最大线程数可以大于核心线程数但突发过去之后超出的线程应该及时回收。keepAlive 设置 30 到 60 秒比较常见。还有一个非常容易被忽视的点拒绝策略。Java 的 ThreadPoolExecutor 里有 AbortPolicy、CallerRunsPolicy、DiscardPolicyC 自研线程池也要明确满队列时怎么办。网络服务我一般不建议直接抛异常或者丢弃更稳的做法是让事件循环感知队列已满停止接收新任务或者向客户端返回一个明确的限流响应。3. 完整实现一个epoll线程池的半同步半异步服务器3.1 整体结构与模块划分实现上的核心是把 IO 线程和业务线程分开。主线程或者叫IO线程只干三件事接受新连接、读取客户端数据、把任务投给线程池。worker线程只干一件事从队列取任务执行业务然后把响应写回客户端。为了管理连接上下文我会用一个字典保存 fd 到连接对象的映射。连接对象包含输入缓冲区、输出缓冲区、当前处理状态等。fd 作为连接的唯一标识简单高效。服务启动流程大致如下创建监听 socket设置 SO_REUSEADDR、非阻塞创建 epoll 实例注册监听 fd 的可读事件启动线程池主线程进入 epoll_wait 循环处理事件worker线程从队列拿任务执行。3.2 非阻塞socket与epoll事件循环先看一段启动和事件循环的代码int lfd socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); int one 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, one, sizeof(one)); // bind、listen 省略 int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); struct epoll_event events[1024]; while (running) { int n epoll_wait(epfd, events, 1024, 1000); for (int i 0; i n; i) { handle_event(epfd, events[i]); } }这里有几个细节想提醒一下。SOCK_NONBLOCK在创建 socket 时一步到位省得后续 fcntl。监听 fd 是非阻塞的后面 accept 出来连接自然也是非阻塞更方便后续操作。epoll_wait的 timeout 我设置的是 1000 毫秒而不是 -1。这样主线程每秒钟醒来一次可以顺便检查线程池健康状态、清理超时连接。如果设成 -1就必须依赖 eventfd 等机制主动唤醒事件循环复杂度会高一些。events数组大小不追求覆盖所有就绪 fd每次最多处理 1024 个事件剩余的下一轮继续。实际压测里除非瞬时事件数爆炸否则 1024 足够。handle_event 里只做轻量操作判断事件类型、调用 accept、read 数据、投递任务。任何可能耗时的业务操作都不许出现在这里。3.3 有界任务队列与线程池实现先实现一个有界阻塞队列。这里我故意用简单的互斥锁和条件变量代码容易验证也足够支撑大多数场景。template typename T class BoundedQueue { public: explicit BoundedQueue(size_t cap) : cap_(cap) {} bool push(T t) { std::unique_lockstd::mutex lk(mu_); not_full_.wait(lk, []{ return q_.size() cap_ || closed_; }); if (closed_) return false; q_.push_back(std::move(t)); not_empty_.notify_one(); return true; } bool pop(T out) { std::unique_lockstd::mutex lk(mu_); not_empty_.wait(lk, []{ return !q_.empty() || closed_; }); if (closed_ q_.empty()) return false; out std::move(q_.front()); q_.pop_front(); not_full_.notify_one(); return true; } void close() { std::lock_guardstd::mutex lk(mu_); closed_ true; not_empty_.notify_all(); not_full_.notify_all(); } private: std::mutex mu_; std::condition_variable not_empty_; std::condition_variable not_full_; std::dequeT q_; size_t cap_; bool closed_ false; };not_full_条件变量在这里特别重要。没有它队列满的时候生产者只能忙等或者盲目丢任务。有了它push 会在队列不满时被唤醒天然实现了背压。线程池本身长这样class ThreadPool { public: ThreadPool(size_t core, size_t max, size_t queueCap) : core_(core), max_(max), queue_(queueCap) { for (size_t i 0; i core_; i) { workers_.emplace_back([this] { run(); }); } } bool submit(Task task) { return queue_.push(std::move(task)); } void shutdown() { queue_.close(); for (auto t : workers_) t.join(); } private: void run() { while (true) { Task task; // pop 返回 false 表示队列已关闭且任务取完 if (!queue_.pop(task)) break; task.handler(); } } size_t core_; size_t max_; BoundedQueueTask queue_; std::vectorstd::thread workers_; };这段是静态线程池只有固定 core_ 个线程。真实项目里可以再加动态扩缩容当队列长度持续超过阈值且当前线程数小于 max_ 时创建新线程当线程空闲超过 keepAlive 时间后回收。逻辑不复杂但要注意加线程和回收线程的竞态问题建议用细粒度锁保护工作线程集合。3.4 事件处理与业务投递handle_event 里最关键的是把 read 到的数据变成任务然后投递到线程池void handle_event(int epfd, struct epoll_event ev) { if (ev.data.fd listen_fd) { accept_new_connections(epfd); return; } if (ev.events (EPOLLERR | EPOLLHUP)) { close_conn(ev.data.fd); return; } if (ev.events EPOLLIN) { Conn* c get_conn(ev.data.fd); int n read(ev.data.fd, c-rbuf c-rlen, sizeof(c-rbuf) - c-rlen); if (n 0) { c-rlen n; Task t; t.conn c; t.handler [c] { process_packet(c); }; if (!pool.submit(std::move(t))) { // 队列满做限流比如关闭当前连接 close_conn(ev.data.fd); } } else if (n 0) { close_conn(ev.data.fd); } else if (errno ! EAGAIN errno ! EWOULDBLOCK) { close_conn(ev.data.fd); } } }process_packet 在 worker线程里执行。这里有个很容易忽略的问题谁负责把响应写回客户端我第一版是让 worker 直接 write。这样做的必要条件是一个连接在任何时刻只能有一个任务在跑否则多个 worker 同时写同一个 fdbuffer 会被写乱。我的做法是每个连接维护一个 processing 标记置位后其他任务不会并发投递写响应时加一个 per-conn 的小锁保护。更稳妥的打法是所有写操作都通过 eventfd 唤醒事件循环由 IO 线程统一执行写。这样把并发写的问题收敛到单线程代价是多一次唤醒和队列转发。如果连接数很大、写回很频繁epoll 循环本身可能成为瓶颈但如果你的业务复杂度不高统一写反而更好调试。我最终采用的是折中方案worker 线程完成业务后把响应填到连接对象的写缓冲区然后通过 eventfd 通知事件循环去执行实际的 send。这样 IO 线程和 worker 线程各司其职fd 的关闭和写操作都集中在 IO 线程不会出现 worker 里关掉 fd 而 IO 线程还在用同一个 fd 的竞态。3.5 可配置参数模板经过几轮压测我沉淀了一套初始参数模板你可以直接拿来当起点server: listen_port: 8080 epoll_mode: LT # LT 或 ET thread_pool: core_threads: 8 max_threads: 32 queue_capacity: 2048 keep_alive_ms: 60000 reject_policy: slow_down # abort / discard / slow_downcore_threads 取 8 适合 4 到 8 核的机器max_threads 取 32 是为了应对突发流量。queue_capacity 2048 是综合考虑内存和排队延迟的起点每个任务平均 10KB 内存的话只要 20MB 左右峰值排队 30 到 50ms 是可接受的。这些值一定不要写死上线后要根据监控动态调整。4. 上线后最常踩的四个坑4.1 惊群为什么来了一个连接多个线程都被唤醒有段时间压测发现连接请求一进来整机 CPU 突发飙升system 态很高业务线程却大多空闲。后来查清楚是惊群问题。如果多个线程同时阻塞在同一个监听 fd 的 epoll_wait 上内核在有新连接时可能会唤醒所有等待线程。Linux 内核后来加了 EPOLLEXCLUSIVE 事件标志可以只唤醒一个等待者。另一个更彻底的做法是 SO_REUSEPORT让多个进程各自绑定同一个端口内核负责负载均衡但每个进程只 accept 自己 epoll 实例上的连接。我的经验是单线程 accept 已经能支撑相当高的新连接速率每秒几万新连接完全没问题。优先保证单线程事件循环稳定只有当 accept 本身成为瓶颈时再考虑多 accept 模型。文章里这种“主线程 accept 线程池处理业务”的结构天然不会遇到惊群。4.2 ET模式下读一半导致数据卡死这是一个特别经典的坑。客户端一口气发了 10KB 数据服务端 ET 触发了一次可读事件worker 只读了前 2KB 就去处理业务了剩下的 8KB 一直躺在内核缓冲区里。没有新的数据到达ET 不会再触发这 8KB 就彻底没人管了。解决办法就是非阻塞 fd 加循环读直到 read 返回 EAGAINwhile (true) { ssize_t n read(fd, buf off, bufsize - off); if (n 0) { off n; if (off bufsize) break; // 缓冲区满了下一轮继续 } else if (n 0) { close_conn(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; close_conn(fd); break; } }写这个循环的时候还要注意如果业务模块解析后发现有半包剩余的数据要保留在应用层缓冲区等下一个包到达时拼包解析。ET 模式对协议解析的完整性要求更高。如果你不想跟这些问题缠斗就用 LT。LT 虽然可能一次 read 只读一部分但剩余数据会继续触发可读事件不会卡死。差别真的没有想象中大稳定性优先。4.3 线程池队列积压延迟雪崩而不是立刻崩溃有一个现象让我印象很深请求量从 1 万涨到 2 万时服务 CPU 还没满接口 RT 却从 20ms 涨到 300ms。刚开始怀疑是下游变慢查了之后发现是任务全部堆积在线程池队列里。无界队列会放大这个问题任务只会越堆越多排队时间越来越长客户端不断重试流量进一步放大最终把服务拖死。这种故障的特征是 RT 线性恶化但 CPU 和内存看起来都还能撑一阵子。排查的时候一定要给线程池加上指标队列当前深度、每秒提交任务数、拒绝任务数。队列深度持续上涨说明消费速度跟不上生产速度。这时候先看 CPU如果 CPU 已经打满加线程没用需要限流或扩容如果 CPU 还有余量说明线程数不够可以动态调大 max_threads。队列不是越大越好。过大的队列只是把问题往后推延迟照样会烂掉。我建议队列容量配合超时时间设置让任务在队列里最多等几十毫秒超过就快速失败。4.4 排查工具速查遇到问题不要瞎猜几个命令轮流看一遍基本能定位问题现象常用命令观察重点上下文切换高pidstat -w 1cswch/s 每秒自愿切换次数连接队列溢出ss -lnt 和 netstat -sSend-Q 积压、SYN 重传丢包线程数异常ps -eLf | wc -l线程总数是否超过预期系统调用频繁strace -c -pepoll_wait、read、write 次数锁竞争严重perf record perf report热点是否在锁相关的函数strace 统计系统调用次数是个好办法。如果发现 epoll_wait 每秒被调用几万次每次返回的事件却很少可能就是事件循环被频繁唤醒原因可能是忙轮询或者写事件误报。perf 能看到 CPU 到底耗在哪个函数上是用户态业务逻辑还是内核态锁等待一清二楚。5. 压测对比同样的连接数三种模型差多少5.1 压测方法工具方面HTTP 服务直接用 wrk 最方便wrk -t4 -c2000 -d60s --latency http://127.0.0.1:8080/ping如果服务是自定义 TCP 协议wrk 就不好用了可以写一个简单的长连接客户端每 5 秒发一条心跳模拟空闲连接群体再开一组线程循环发业务请求模拟活跃连接群体。这种混合压测才符合真实场景。压测时记得关注本机 TIME_WAIT。短连接压测会在客户端积累大量 TIME_WAIT 连接导致端口耗尽压测结果非常难看。长连接压测则要注意客户端单机连接数上限必要时用多台机器做压测端。5.2 值得看的指标不要只看 QPS还要看这几个平均 RT 和 P99/P999 延迟反映稳定性CPU 的 us/sy 比例sy 太高说明系统调用或锁等待占了大量资源线程数量这是模型优劣最直观的体现队列深度和拒绝数反映线程池是否饱和。延迟的 P99 比平均值重要得多。平均值好看但 P99 如果暴涨说明有大量请求在排队等资源。5.3 实测记录这是我在内网 4 核 8G 环境、1 万空闲连接加 2 千活跃连接下做过的一组对比数据只做参考重点看趋势模型QPSCPU使用率P99延迟线程数阻塞IO 每连接一线程1.2w65%350ms12000epoll 单线程事件循环1.9w70%120ms1epoll 线程池2.3w38%45ms16阻塞IO模型扛不住是必然的一万多个线程光切换就占了大量 CPU。epoll 单线程事件循环虽然连接数不是瓶颈但业务处理一旦变复杂单线程 CPU 立刻吃满延迟也会波动。epoll 线程池的组合把连接管理和业务计算分开CPU 使用率反而降下来了P99 也稳定很多。这个对比说明epoll 解决的是连接规模问题线程池解决的是业务并行度问题。两者缺一服务都到不了高负载下仍然稳定的状态。做这个项目让我最意外的是线程池的参数配置对稳定性影响比 epoll 本身大得多。上线前花点时间把不同队列容量的表现全压一遍你会发现无界队列的后果不是马上崩溃而是延迟一点一点烂掉。最后说一个建议第一版先把事件循环和业务线程池彻底分离用 LT 模式把功能跑稳再回头优化 ET、无锁队列和动态线程数。不要一上来就追求最极端的模型先把瓶颈找出来再去解决真正值得解决的问题。