ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

epoll + 线程池:高并发服务器架构的进阶实践

epoll + 线程池:高并发服务器架构的进阶实践 干这一行的人应该都有体会服务器从几百连接到几万连接代码结构完全不是一回事。很多人一开始习惯用“一个连接一个线程”的模型测试的时候没什么问题上了生产环境连接数一上来线程一多CPU光顾着切换上下文了真正的业务逻辑反而跑不动。我做了几个高并发项目之后最大的体会就是epoll负责“看得过来”线程池负责“忙得过来”两者配合才能真正撑起高并发连接。这篇文章想讲的就是一套我自己反复用过、也踩过不少坑的方案用 epoll 做事件驱动配合线程池处理业务逻辑构建一个能扛住高并发连接的高性能服务器。适合那些已经会用 socket 写简单服务、但想往高性能方向进阶的开发者也适合正在设计长连接、消息推送、网关这类场景的朋友参考。你不需要懂特别高深的内核知识但我默认你至少用过 Linux、写过 C/C 或者 Java能看懂基本的 socket 编程。1. 为什么要用 epoll从阻塞模型说起1.1 传统阻塞 IO 的瓶颈到底在哪先从最原始的模型说起。早期写网络服务典型做法是 accept 之后直接 recv 阻塞等待客户端发数据。这个模型逻辑最简单但有一个致命问题一个线程在 recv 上卡住了其他客户端的数据就全部排队等着。你想解决这个问题只能在每个连接上开一个线程去阻塞读。这个方案的瓶颈很快就出现了。线程本身要占栈空间默认 8MB 虚拟内存实际也要数 MB线程多了之后操作系统光顾着做线程调度和上下文切换CPU 的有效利用率直线下降。我在一台 8 核机器上做过简单压力测试线程数超过 500 之后每秒处理请求数不升反降切换开销已经把业务处理的时间全部吃掉了。这还只是连接数几千的情况如果连接数上万这种模型基本就废了。另一个更隐蔽的问题是资源浪费。大多数网络连接是“空闲为主”的——客户端连上来大部分时间不发数据。你用阻塞模型这些空闲连接也要各占一个线程纯属浪费。实际上你需要的是有没有一种机制让一个线程同时看着成千上万个 socket谁有数据来了就处理谁1.2 epoll 与 select/poll 的本质差异答案就是 IO 多路复用。Linux 下先后出现了 select、poll、epoll 三套方案。select 的问题在于它对监听的 fd 数量有上限默认 1024而且每次调用都要把完整的 fd 集合从用户态拷贝到内核态然后线性扫描所有 fd 看哪个有事件复杂度是 O(n)连接越多越慢。poll 解决了 1024 的上限但每次调用仍然要整个集合拷贝和线性扫描。epoll 不一样它的设计是事件驱动。你用 epoll_ctl 把关心的 fd 注册进内核后内核维护一棵红黑树来管理这些 fd同时给每个 fd 挂一个回调函数。当某个 fd 上有数据到达时内核直接把这个 fd 加入一个就绪链表。用户调用 epoll_wait 时内核只需要把这个就绪链表里面的 fd 拷给用户不需要再扫描全部连接复杂度是 O(就绪连接数)。做个简单对比就清楚了指标selectpollepollfd 数量上限1024 左右无硬编码上限与内存相关轻松十万级每次调用拷贝整个 fd 集合整个 fd 集合仅就绪的 fd查找效率线性扫描 O(n)线性扫描 O(n)回调 红黑树O(就绪数)底层通知机制轮询轮询事件回调跨平台Windows/Linux基本只有 LinuxLinux 专属项目里如果连接数长期维持在几千以上选 epoll 是唯一合理的路径。实际生产中我还见过有人在 Linux 上硬用 select 去扛一万连接结果 fd 集合超出上限直接报错这种问题是结构性的不是调参数能解决的。1.3 水平触发与边缘触发我推荐怎么选epoll 有两种工作模式水平触发LT和边缘触发ET。两者的区别在于通知方式。LT 模式下只要 socket 缓冲区里还有没读完的数据每次 epoll_wait 都会通知你ET 模式下数据到达只通知一次你必须一次性把数据全部读干净否则剩下的数据不会再触发通知。ET 模式效率更高因为减少了很多无意义的重复通知。但 ET 有一个硬性要求你必须把 socket 设为非阻塞并且循环读直到返回 EAGAIN。如果漏掉了这个细节缓冲区的数据没读完后面就不会再触发事件了服务端会一直等客户端那头看着是连接正常但数据就是没有回复。我个人的习惯是如果是新写项目优先考虑 ET如果是改造旧代码LT 更稳妥。LT 模式下漏读数据不会出大问题下一次 epoll_wait 还会通知你只是性能差点。真正关键的其实是“非阻塞 循环读取”这个套路是否贯彻到底模式选择倒在其次。2. 线程池不是可选项是必需品2.1 单线程 epoll 能干什么、不能干什么很多初学者看完 epoll 的文档会觉得这玩意儿太强了干脆一个进程加一个 epoll 循环就完事了。确实如果业务逻辑只是把数据从 A 转发到 B比如一些代理服务器这种纯 IO 操作的场景下单线程 epoll 完全够用效率也极高。但真实业务基本不会只有转发。假设客户端发来一条消息服务端需要先解码、查数据库、调外部接口、再编码、写回客户端这一段逻辑可能耗时几十毫秒甚至更久。如果直接在 epoll_wait 返回后的主循环里做这些事那么第 1 万个连接的数据到达时必须等前一个连接的业务处理完才能轮到你。我实测过一个案例单线程 epoll 的服务一旦加入耗时 50ms 的模拟业务逻辑总吞吐量直接掉到每秒几百请求这是一个典型的事件循环被阻塞案例。原因也很简单epoll 解决的只是“感知事件”的高效性它不能把一个慢操作变成快操作。凡是需要等待的地方磁盘 IO、数据库查询、RPC 调用、加锁排队都会让事件循环卡住。这时候就需要把“读数据”和“处理数据”剥离开来。2.2 线程池凭什么能提升吞吐量线程池解决的核心问题是线程的复用和数量的管控。如果没有线程池你可能会想每个 fd 上来了数据我就动态创建一个线程去处理它。这个思路的坑在于线程的创建和销毁本身就是昂贵的操作创建要分配栈空间、初始化线程管理结构销毁要回收资源如果并发任务一来就是几千个光创建线程的时间就能把系统拖垮。线程池的做法是预先创建好一批线程任务来了丢进队列线程们排队去队列里取任务执行。线程不用反复创建销毁而且线程数量是受控的不会因为瞬时的请求高峰而无限增长把系统打爆。所以架构上应该分离关注点主线程或少数 IO 线程负责 epoll_wait 等待事件收到数据后封装成任务丢进线程池的队列。工作线程池负责真正执行业务逻辑处理完的结果再通过 epoll 的写事件返回给客户端。这套分工的本质是IO 事件的高频感知和业务逻辑的低频计算解耦。epoll 千好万好它只管事件不管业务线程池千好万好它不感知连接状态。两者天然互补。2.3 我最初踩过的坑直接在回调里做业务早期我写过一版很“爽”的代码epoll_wait 返回后拿到 fd 和读到的数据直接在主循环里调一个处理函数那个函数里面查了数据库还调了一个第三方 HTTP 接口。压测的时候并发一上来CPU 立刻打满但吞吐量卡在几百。更诡异的是延迟从几十毫秒一路飙到好几秒整个服务像被堵死了一样。排查之后发现数据库查询那 50ms 的空闲等待期间epoll 主循环完全被占用新连接没人 accept已有连接的数据也没人读。客户端一多内核接收缓冲区很快就满了于是开始丢包重传然后雪崩。后来我把所有非本地内存操作全部丢进线程池吞吐量瞬间从几百涨到近万。那次之后我给自己定了一条规矩epoll 循环里只做读数据、写数据、封装任务这三件事任何可能阻塞的逻辑一律丢线程池。3. 线程池配置与阻塞队列选择3.1 核心线程数、最大线程数总不能拍脑袋很多人写线程池参数都是随便填核心线程 10 个最大 20 个队列大小 1000。这种配置跑起来没问题纯属运气。线程池的参数是有讲究的核心问题取决于你的任务是 CPU 密集型还是 IO 密集型。CPU 密集型的任务比如视频编码、复杂计算、数据加解密理论上最优线程数等于 CPU 核心数跑满了也就是让 CPU 一直忙线程再多反而要切换。IO 密集型的任务比如查数据库、调外部接口、读写文件线程大部分时间在阻塞等待这时候线程数可以多一些。一个常用估算公式是线程数 CPU核心数 / (1 - 阻塞系数)阻塞系数一般在 0.8 到 0.9 之间。比如 8 核机器、阻塞系数 0.8那线程数约为 8 / (1 - 0.8) 40。这只是初始估算实际要根据压测结果调整。我的经验是先按这个公式算出初始值然后压测逐步往上加线程数观察吞吐量和延迟的变化曲线找到拐点。拐点之后线程数再增加吞吐量不升反降那就是太多了。还有一点容易被忽略最大线程数是给突发流量兜底的不是让你日常跑满的。核心线程数才是常态水位。如果核心线程数设成 40最大 200日常流量压不进线程池紧急流量进来后线程数会从 40 一路涨到 200线程数激增会带来上下文切换开销所以核心线程数和最大线程数之间的差距应该反映的是“你预期的流量波动幅度”。3.2 四种阻塞队列的适用场景线程池内部的任务队列选择看起来是个小决策实际影响很大。常见队列就四种SynchronousQueue、LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue优先级队列用得少先不展开。我按日常使用频率排序来讲。SynchronousQueue这队列比较特殊它里面不存任务每个任务必须直接被一个线程取走否则提交任务的线程就阻塞。适合任务非常轻量、希望没有积压、来了就干完的场景。如果核心线程已满新任务就会把线程数直接拉到最大线程数再超就触发拒绝策略。它的好处是不会积压坏处是完全没有缓冲流量稍微抖一下就疯狂扩张线程。LinkedBlockingQueue无界队列的默认选择。任务可以无限堆积线程数最多到核心线程数就不会再增加了因为核心线程忙不过来时任务先进队列排队。这是最不容易出问题的选择但有一个隐藏风险任务无限制堆积会导致内存持续增长。如果业务方疯狂提交任务而每个任务又都涉及数据库连接、文件句柄等有限资源最终会拖垮系统。我见过生产事故就是这么来的业务代码一个 for 循环错误地提交了几百万个任务内存直接涨到 OOM。ArrayBlockingQueue有界队列这是我最常用的选择。有界意味着当队列满了线程池才会去创建新线程顶上去顶到最大线程数还是满就触发拒绝策略。有了边界系统在任何时刻的积压任务量都是可控的内存不会无限膨胀。你需要根据业务压测数据填一个合理的大小太小容易导致拒绝太大会吞掉削峰的能力一般我习惯从“核心线程数 × 每个任务平均耗时 ÷ 可容忍排队时间”这个角度去估算。队列类型是否有界线程增长模式适用场景主要风险SynchronousQueue不存任务直奔最大线程数轻量任务、无积压容忍线程数快速膨胀LinkedBlockingQueue无界锁定核心线程数任务量小、无峰值内存无限增长ArrayBlockingQueue有界满后再扩线程生产环境最稳需合理设置边界3.3 拒绝策略与连接超时处理线程池都满了、队列也满了新任务怎么处理一般有几种策略直接抛异常、调用方自己执行CallerRunsPolicy、丢弃最旧的任务、丢弃当前任务。生产环境我几乎不用默认的抛异常策略因为一瞬间的大流量会导致大量请求直接失败用户感受就是“服务挂了”。我比较推荐CallerRunsPolicy它让提交任务的线程自己去执行这个任务。用在网络服务上效果相当于“背压”线程池满的时候主线程被迫去干活自然就无法继续 accept 新连接或者处理新事件从而从源头限制新任务的产生速度。这种方式比直接拒绝更温和至少任务不丢。代价是主线程被占住新连接可能短暂进不来但这总比任务直接失败要好。还有一个容易踩的坑线程池里的任务如果内部有阻塞操作比如等锁、等 IO而且没设置超时一旦上游服务变慢线程池的所有线程就会被“半死不活”的任务占住新任务全在队列里等着。表现就是线程池看起来没满但响应时间飙到几十秒。给所有阻塞操作设置超时或者用带超时机制的调用方式是必须养成的习惯。4. 完整架构设计与核心代码实现4.1 架构总体设计两级流水线整个服务器的运行流程可以用一个“两级流水线”来理解第一级事件读取主线程执行 epoll_wait拿到就绪的 fd 列表对每个有数据可读的 fd调用 read 把数据从内核缓冲区搬到用户缓冲区封装成一个 Job 对象交给线程池。第二级业务处理线程池的工作线程从队列中取出 Job执行真正的业务逻辑生成响应数据。响应数据不需要直接写 socket而是丢回主线程的事件队列由主线程注册 EPOLLOUT 写事件在可写时统一写出。这里有一个重要的实现细节socket 的读写不应该在工作线程里直接做。因为多个工作线程同时写同一个 fd 会造成数据交织必须在 socket 上做加锁或者干脆把写操作统一收归到主线程。统一收归的做法虽然多了一次任务传递但避免了大量的锁竞争代码逻辑也清晰很多。4.2 epoll 核心实现代码下面是 epoll 事件循环的核心骨架我用 C 写了注释版重点是展示结构而非绝对可编译的完整工程#include sys/epoll.h #include fcntl.h #include unistd.h #include string.h #include errno.h #define MAX_EVENTS 1024 int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } // 主事件循环只负责接收连接、读取数据、投递任务 void event_loop(int listen_fd, ThreadPool *pool) { int epoll_fd epoll_create(1); // 参数在 2.6.8 之后被忽略传任意正整数即可 struct epoll_event ev, events[MAX_EVENTS]; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev); while (true) { int n epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { // 新连接到达accept 后注册进 epoll设置为非阻塞 int conn_fd accept(listen_fd, nullptr, nullptr); if (conn_fd 0) continue; set_nonblocking(conn_fd); ev.events EPOLLIN | EPOLLET; // 边缘触发模式 ev.data.fd conn_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, ev); } else { // 读数据边缘触发必须循环读到 EAGAIN char buf[4096]; std::string data; while (true) { ssize_t r read(fd, buf, sizeof(buf)); if (r 0) { data.append(buf, r); } else if (r -1 errno EAGAIN) { break; // 数据读完了 } else if (r 0) { // 对端关闭连接 epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, nullptr); close(fd); break; } else { break; } } if (!data.empty()) { // 把数据封装成 Job丢给线程池处理 pool-submit(fd, std::move(data)); } } } } }关于这段代码有几个细节必须说明。第一epoll_wait 的最后一个参数 -1 表示无限超时这会阻塞主线程所以主线程只做事件循环绝对不能在里面做业务。第二ET 模式下 read 必须循环读到 EAGAIN这是前面强调过的硬规则。第三pool-submit必须是非阻塞的如果线程池队列满了可以选择临时在工作线程中执行但绝不能把主线程卡在 submit 上。4.3 线程池核心实现代码线程池我用 C 实现逻辑集中在三块任务队列、工作线程循环、提交接口。核心预处理就是把线程数、队列类型、拒绝策略这些决策固化成配置参数。#include thread #include vector #include queue #include mutex #include condition_variable #include functional #include atomic class ThreadPool { public: using Job std::functionvoid(int fd, std::string data); ThreadPool(int core_threads, int max_threads, int queue_capacity) : core_threads_(core_threads), max_threads_(max_threads), queue_capacity_(queue_capacity), running_(true) { // 预先创建核心线程 for (int i 0; i core_threads_; i) { workers_.emplace_back([this] { worker_loop(); }); } current_threads_ core_threads_; } ~ThreadPool() { running_ false; cv_.notify_all(); for (auto w : workers_) w.join(); } void submit(int fd, std::string data) { std::unique_lockstd::mutex lock(mutex_); // 核心线程未满直接创建新线程直到 max_threads_ if (queue_.size() queue_capacity_ current_threads_ max_threads_) { workers_.emplace_back([this] { worker_loop(); }); current_threads_; } // 队列也满了采取背压策略当前线程直接执行 if (queue_.size() queue_capacity_) { lock.unlock(); // 注意这里是在主线程直接执行会短暂阻塞主线程 // 属于 CallerRuns 策略的简化实现 auto job Job([this, fd, data](int, std::string) mutable { process_job(fd, std::move(data)); }); job(fd, std::move(data)); return; } queue_.emplace([this, fd, data](int, std::string) mutable { process_job(fd, std::move(data)); }); cv_.notify_one(); } private: void worker_loop() { while (running_) { Job job; { std::unique_lockstd::mutex lock(mutex_); cv_.wait(lock, [this] { return !queue_.empty() || !running_; }); if (!running_ queue_.empty()) return; job std::move(queue_.front()); queue_.pop(); } job(-1, ); } } // 实际业务处理工作线程里执行 void process_job(int fd, std::string data) { // 这里替换成你的真实业务逻辑 std::string response handle_request(data); // 注意这里不直接 write(fd)而是把响应丢回主线程 // 再由主线程注册 EPOLLOUT 写出 main_loop_-post_response(fd, std::move(response)); } std::vectorstd::thread workers_; std::queueJob queue_; std::mutex mutex_; std::condition_variable cv_; std::atomicbool running_; int core_threads_, max_threads_, queue_capacity_; int current_threads_; };这个实现省略了 main_loop_ 的细节但核心思想已经完整任务队列用 std::queue锁 条件变量的组合是经典的生产者消费者模式。submit 函数里的背压逻辑是关键队列满了之后不让主线程干等而是直接让主线程先干一个任务自然就放慢了生产速度。4.4 编码层面的关键细节封装、内存、错误处理写这种服务器代码代码结构比技术选型更容易被忽视但这恰恰是后期维护的生死线。我总结三个重要经验第一连接必须要封装。裸的 fd 会带来灾难性的管理问题。至少需要封装一个 Connection 结构包含 fd、读缓冲区、写缓冲区、协议解析状态、上次活跃时间等字段。fd 一旦关闭后又被复用如果你还在用裸 fd 操作很容易操作到其他连接的 fd。正确的做法是epoll 的 data 字段里存一个指向 Connection 对象的指针fd 只是对象的一个属性。这样即使 fd 被复用你操作的是对象不会盲目关错连接。第二内存和生命周期管理是重灾区。任务在多个线程之间流转一个指向 Connection 的裸指针可能在业务线程还没用完时就被主线程释放了。我的方案是用 shared_ptr 管理 Connection主线程持有一份提交任务时把 shared_ptr 拷贝进 Job 里确保任务执行期间连接对象一定存活。在 GC 语言Java、Go里这事天然安全写 C / C 尤其要小心。第三错误的处理必须集中。不要把 errno 的判断散落在各处比如对 EAGAIN、EINTR、ECONNRESET 的处理最好封装成独立的工具函数。否则一旦线上出现大量半关闭连接你会看到 epoll_wait 反复触发 EPOLLINread 返回 0然后你的代码在十来个地方做同样的 close 操作出了一次 bug 就是 double close 导致崩溃。5. 压测数据与性能调优实录5.1 压测工具和方法我自己习惯用两套工具短连接压测用 wrk长连接压测用自写的小工具模拟大量客户端保持连接、间歇性发送消息。wrk 的好处是简洁一条命令就能测出简单的吞吐量和延迟分布wrk -t8 -c1000 -d30s http://127.0.0.1:8080/但 wrk 只适合 HTTP 短连接场景。如果是长连接比如即时通讯、推送网关客户端连接后大部分时间不发数据然后突然集中发消息这种场景必须自己写模拟客户端。我写过一版开 5000 个协程每个协程建立一条 TCP 连接连接建立后每 5 秒发一条消息统计服务端的处理延迟和吞吐。这种自写压测工具虽然开发成本高一点但能精确复现生产环境的连接模型非常值得投入。5.2 我的压测调优实录从 2000 到 35000有一台 8 核 16G 的虚拟机部署了 epoll 线程池的服务端。业务逻辑是模拟查缓存每次请求读一条 key构造一个 JSON 响应返回。我初始配置核心线程 16最大线程 32ArrayBlockingQueue 容量 10000。用 wrk 压测 1000 并发结果是 QPS 约 2000延迟 P99 到 800ms明显不对劲离谱地慢。排查后发现两个问题。第一个业务逻辑里用了一个全局互斥锁保护一个缓存 map所有线程读缓存都要加锁8 核机器上锁竞争太激烈。我把全局锁改成读写锁读操作不再互相阻塞。第二个主线程用了一个 sleep(1) 在做定时超时扫描这本身没问题但我在循环外面错误地加了 sleep(1)导致 epoll_wait 还没来得及处理完事件就被睡了。去掉之后QPS 从 2000 涨到了 7000。继续观察发现线程池在压测过程中线程数一直徘徊在 8 附近说明任务量并没有让队列满到扩张线程的地步。我把核心线程数从 16 调到 32QPS 涨到 11000 左右。再往上加到 64 线程时 QPS 反而回落到 10000这说明 32 线程就是这台机器上的拐点线程切换成本开始超过并行收益。最终把核心线程数稳定在 32最大线程 64应对突发用队列容量 5000内存可控最终 QPS 稳定在 35000 左右P99 延迟降到 40ms 以内。这个过程的启示是线程池参数不是配一次就完事的必须根据压测数据和你的实际业务耗时动态调整。尤其要注意别以为一个参数就能通吃所有机器同样的代码部署到 16 核机器上拐点就完全不同。5.3 时间都去哪了用火焰图定位性能瓶颈压测到 35000 QPS 之后我想继续往上提但没头绪。后来用 perf 采样生成了火焰图发现 CPU 时间分布里面epoll_wait 和 read/write 的系统调用占了将近 60%剩下的业务逻辑只占了 20%。这让我意识到系统调用才是瓶颈业务逻辑根本没有跑满。我做了两个优化。第一把 read 的缓冲区从 4KB 加大到 16KB减少 read 系统调用的次数。第二把响应的 write 合并对同一个 fd 的多次小数据写操作积攒到一定量后再一次写出本质上就是减少系统调用。这两个改动让 QPS 从 35000 提升到了 60000 左右又是接近一倍的提升。做性能调优最忌讳没有数据就瞎猜。你在优化之前最好先采样读一下火焰图或者 perf top看看 CPU 到底花在哪个函数上。否则你可能花了一整天优化业务代码结果发现瓶颈根本不在那里。6. 常见问题与避坑指南6.1 惊群问题怎么处理用多线程 epoll 时如果多个线程都在同一个 epoll fd 上 epoll_wait新连接到达时所有线程都会被唤醒但只有一个线程能 accept 成功其他线程白白空跑一圈。内核 4.5 之后引入了 EPOLLEXCLUSIVE 标志可以解决这个问题把事件只唤醒一个线程。但这只解决了 accept 的惊群如果多个线程各自有独立的 epoll fd监听同一个 listen_fd还是会有类似问题nginx 用的是 accept_mutex 锁来解决。我的建议是如果你的服务是单进程模型就用单线程 epoll这是最省心的方案。真想多核利用优先考虑 SO_REUSEPORT 多进程方案每个进程一个 epoll让内核自己分发连接简单又稳。6.2 epoll_wait 空轮询与 CPU 打满有一个非常隐蔽的问题epoll_wait 在没有任何事件时返回 0如果你的循环里没有处理这种情况就会变成死循环空转CPU 直接打满。常见原因有两种一是被信号中断EINTR二是设置了超时时间且确实超时了。处理方法是检查返回值如果为 0 或者 -1 且 errno EINTR就 continue 或者 sleep 一小会儿不能让循环进入紧转状态。还有旧版本内核的 epoll 有 bug会在 fd 被 close 时出现“幽灵事件”表现为 spurious wakeup这个在新内核里已经修复但如果你在维护老系统需要在事件处理里做好 fd 有效性判断。6.3 队列堆积任务积压的监控与背压线程池队列积压是最常见的问题但很多人不监控它等到内存 OOM 才恍然大悟。我习惯在每个 Job 入队时给队列长度加一个计数在出队时减一个计数每过一秒记录一次最大值。如果队列长度持续大于线程池核心线程数乘以某个阈值比如 2说明你的处理速度跟不上流入速度需要考虑扩容、限流或者优化业务耗时。背压的典型方案是前面提到的 CallerRunsPolicy但有个代价主线程一旦去执行任务epoll 循环就停了新连接和已连接的数据都会被暂时搁置。如果积压太多这会导致“事件读不过来 → 内核缓冲区满 → 客户端超时重传 → 更多事件 → 更严重的积压”的雪崩。更稳妥的方案是在入口处直接拒绝新请求并快速返回失败或者可控的排队等待不要硬扛。高并发系统里“快速失败”远比“无限等待”来得健康。6.4 锁竞争看似小问题实则是大瓶颈高并发下锁是性能杀手。我在压测一开始就踩过全局互斥锁的坑。锁的粒度、类型、持有时间都直接影响性能。有一些经过验证的经验尽可能用原子操作比如 std::atomic 或者 Java 的 LongAdder 来维护计数器。读多写少的数据结构用读写锁但要注意读写锁在写频繁时会造成写线程饥饿这时候要评估无锁方案。多线程写同一个 fd 是找死解决方案就是回写统一收归到单线程或者按 fd 分段加锁。真到了需要非常极致性能的场景可以用无锁队列比如 boost::lockfree::spsc_queue 或者 Java 的 Disruptor但无锁队列的调试成本极高不要轻易引入线上先用简单方案跑通业务再说。6.5 关于边缘触发的一个大坑EPOLLOUT 的注册时机ET 模式下注册 EPOLLOUT 事件要非常谨慎。如果你一开始就把 EPOLLOUT 注册进去了只要 socket 发送缓冲区没满epoll_wait 就会一直通知你可写然后你会一直拿到空转事件CPU 消耗巨大。正确做法是默认只注册 EPOLLIN需要写数据时发现发送缓冲区不足write 返回 EAGAIN才临时注册 EPOLLOUT等可写事件触发后写完了再立刻移除 EPOLLOUT。这是 ET 模式最核心的用法之一我见过很多新手在这上面栽跟头服务什么业务都没跑起来CPU 先飙到 100%。7. 结尾一点个人经验之谈写了这么多最后分享两个实际开发中的体会。第一个是高并发架构一旦定型后续调优的方向基本都是围绕“减少系统调用、减少锁竞争、控制队列长度”这三件事理论看起来不难但每一个坑都是在生产环境里踩出来的。第二个是线上故障并不可怕可怕的是你的系统没有留下足够的观测手段。我后来几乎在所有线程池相关代码里都加了计数器每秒任务提交数、队列瞬时长度、工作线程平均执行耗时、拒绝策略触发次数。这些指标比任何理论分析都能更快地帮你定位问题。如果你刚接触这套架构建议先从单进程单线程 epoll 跑通全链路再逐步引入线程池、背压策略、监控指标一次只动一个变量这样出了问题你知道是哪一步改出来的。毕竟性能调优这件事慢就是快。
返回列表