
做 Linux 高性能服务器编程这块我踩过最贵的一个坑不是 epoll 用错而是框架分层压根没想清楚。当时手里那套服务端代码业务逻辑、协议解析、连接管理、定时器回收全挤在同一个类里单机压到三千连接就开始冒毛刺——平均延迟还行P99 直接翻三倍。一开始怀疑是网络库不给力换了两套库、重写了读写缓冲区毛刺照旧后来拿工具挂上去看瓶颈落在连接对象析构和定时器扫描抢同一把锁上。而连接析构和定时扫描这两件事本来就不该由业务代码操心它们天生属于服务器框架的职责范围。这篇想聊的就是这个一个 Linux 高性能服务器框架的骨架到底该怎么搭哪些东西必须由框架兜住哪些东西放进框架反而变成负担。内容偏实战适合已经能用 socket 写出可运行服务、但一上量就各种诡异问题的同学也适合准备自己写一套内部框架、或者正打算读懂某个成熟框架源码但看得很痛苦的朋友。我不会给你一堆教科书式名词而是把每个选择背后的取舍、真实参数、踩过的坑摊开来说。1. 从能跑通到扛得住框架真正该兜住的是什么1.1 业务代码里那层看不见的胶水先给框架下一个务实点的定义它是一层把 IO 就绪通知、并发调度、内存归属、超时回收这四件事从业务里剥离出来的胶水。粘得好业务代码只剩收到一条完整报文该干什么粘得不好就是你现在看到的这种局面——每个业务模块都在自己维护缓冲区、自己算超时、自己在回调里 delete 对象。判断一个框架有没有价值有个很土但很好用的标准业务代码里还能不能看到 fd 这个字。如果业务层还在直接对文件描述符做读写、还在自己判断 EAGAIN、还在关心 EPOLLIN 和 EPOLLOUT 的区别那这层胶水基本等于没粘。业务层应该只面对连接和消息两个概念其余细节由框架消化。我见过一些团队的做法是反过来框架做到极薄只封装 epoll其他全交给业务。短期看代码量小、上手快长期看每个新业务模块都要重写一遍相同的烂代码而且每个人写的超时逻辑都对不齐最后连这个连接到底是死是活都说不清。1.2 框架该管的与坚决不该管的这一步是最容易过度设计的。我自己的经验是画一条明确的分界线框架管资源业务管语义。职责归框架归业务理由文件描述符的创建与关闭是否生命周期必须集中否则必然泄漏读写缓冲区的分配与扩容是否与 IO 模型强耦合业务做不了最优事件循环调度是否全局唯一入口业务接管就乱套超时与心跳是否需要统一的定时结构线程模型与任务投递是否涉及线程安全边界协议帧切分部分是框架提供编解码接口具体格式业务定业务状态机否是框架不该感知任何业务字段序列化格式否是同上这张表看起来平平无奇但真正落地时很多团队会在协议帧切分这一栏反复横跳。有的框架把 HTTP 解析也塞进去结果新接一个自定义二进制协议就要改框架源码有的框架干脆一点不管每个业务都重写一遍粘包处理逻辑代码重复率高得离谱。我倾向于提供一个编解码器接口框架负责在缓冲区里问够不够一帧切出来这一帧有多长具体怎么解析由业务注册的实现决定。这样框架不用改业务也不用重写。1.3 一条连接的完整生命线把框架职责想清楚的最好办法是跟着一条连接走一遍。从accept拿到 fd 开始框架要做的事依次是注册到事件循环、分配连接对象、挂上超时计时、等待可读事件、把数据读进缓冲区、交给编解码器切帧、把完整消息投递给业务回调、监测写缓冲是否排空、处理对端半关闭、最终回收对象并关闭 fd。这条链上任何一环漏了都会在大流量下以崩溃或者 fd 泄漏的形式还回来。比如处理对端半关闭这一环很多实现只处理了 EPOLLIN忘了 EPOLLRDHUP结果对端发完 FIN 之后连接状态一直挂着直到超时扫描才回收。单个连接看不出问题几万连接同时如此定时器队列就会爆掉扫描一次耗时从毫秒级涨到几十毫秒事件循环直接被拖慢。把这条链在纸上画一遍你就知道自己那个框架到底缺哪一块了。2. 事件循环的骨架epoll 用错比不用更致命2.1 LT 与 ET 的取舍不是风格问题关于水平触发和边缘触发网上的讨论多到泛滥但结论往往被简化成一句ET 性能更好。这个结论在你把读写循环写对的前提下才成立写错了就是灾难。LT 的语义是只要缓冲区里还有数据每次 epoll_wait 都会通知你好处是容错高——你这次少读了一点下次还会被提醒代价是就绪事件可能被重复上报在极高并发下有额外的系统调用开销。ET 的语义是状态变化才通知一次你必须在一个回调里循环读到EAGAIN为止否则剩下的数据可能永远不会再触发通知。关键差异在于读干净的责任转移了。用 ET 的时候读循环必须长这样void onReadable(int fd) { while (true) { ssize_t n ::read(fd, buf, sizeof(buf)); if (n 0) { conn-append(buf, n); // 继续累积 continue; } if (n 0) { // 对端关闭 conn-handlePeerClose(); return; } if (errno EINTR) continue; // 被信号打断重试 if (errno EAGAIN || errno EWOULDBLOCK) { break; // 读干净了正常退出 } conn-handleError(errno); // 真错误 return; } }看起来简单坑在后半段如果把连接从 epoll 里摘掉EPOLL_CTL_DEL之后又重新加回去ET 下容易丢事件。因为摘除再添加的瞬间内核里的就绪状态会被重置如果这期间数据到达新加回去的注册可能拿不到那次通知。正确的做法是用EPOLLONESHOT配合重注册或者干脆不去动注册状态用应用层的开关变量控制是否处理。我的建议是没有把握把读写循环和重注册逻辑都写对就老老实实用 LT。LT 在绝大多数场景下够用实测单机几万连接、几十万 QPS 的量级LT 和 ET 的差距远没有传说中那么夸张反而是 ET 写错的代价高得多。真要上 ET先把压力测试做扎实。2.2 一次 epoll_wait 返回后的完整职责很多人写事件循环epoll_wait返回后就只遍历事件数组处理读写其余时间什么都不干。这种写法在低负载下没问题一旦连接数上来就会暴露两个毛病定时器永远不触发、跨线程投递的任务延迟极高。一个负责任的事件循环每轮至少要完成四件事处理就绪事件遍历返回的事件数组分发到对应的连接对象。执行跨线程任务队列其他线程投递过来的任务在这一轮集中执行。推进定时器检查有没有超时的连接或定时任务。重算下一次等待的超时时间不能用-1无限等待否则定时器永不触发。第四点是最容易被忽略的。epoll_wait的最后一个参数要动态计算——取最近一个定时任务的到期时刻与当前时刻的差值并且设一个上限比如 100 毫秒防止系统时间跳变导致的异常等待。我见过因为固定传-1导致心跳永远不发、连接被中间设备批量断开的案例排查了大半天才定位到这一个数字上。2.3 惊群、EPOLLONESHOT 与 accept 的边界多线程或多进程同时监听同一个 fd 时连接到达会唤醒全部等待者这就是惊群。老办法是加锁锁住 accept 过程只有一个线程能真正拿到连接。这在连接建立频率不高时没问题但在短连接密集的场景下锁本身就成了瓶颈。现在有更好的选择SO_REUSEPORT。让每个工作线程各自持有独立的监听 fd绑定同一端口由内核按四元组哈希分发连接。这样彻底避免了用户态竞争每个线程只处理属于自己的那部分连接。我在几个短连接压测场景里实测过SO_REUSEPORT相比加锁 accept吞吐能提升两到三成而且延迟分布更平稳。需要注意两个前提一是内核版本要支持3.9 之后就有了现在基本不是问题二是分发是哈希决定的如果某些四元组天然更热负载可能不均这时需要配合连接数的周期性均衡比如定期检查各监听 fd 的连接数必要时重建。另外SO_REUSEPORT下每个监听 fd 是独立的进程重启做平滑升级时新旧进程同时监听会有一段时间的流量分摊这个特性可以被善用也可能引发短暂的连接分配错乱设计时要想清楚。EPOLLONESHOT是另一条路注册时标记为一次性事件处理完后必须重新注册。好处是同一个连接在任意时刻只会被一个线程处理天然免锁坏处是每次都要多一次EPOLL_CTL_MOD系统调用。连接数多、事件少的情况下这个开销不可忽略。我个人的偏好是用SO_REUSEPORT做分发用固定线程绑定做归属尽量不用 ONESHOT减少系统调用次数。3. 线程模型的取舍one loop per thread 为什么成了默认答案3.1 三种模型的适用面线程模型这事儿没有银弹但有默认答案。常见的三档模型结构适合场景主要风险单线程 Reactor一个线程跑事件循环 业务连接数少、逻辑轻、延迟要求极致任何阻塞调用都会拖垮全场主从 Reactor主线程 accept多个子线程各自跑循环通用场景绝大多数业务适用跨线程数据传递需要严格约定线程池 阻塞 IO每连接一线程或固定池阻塞读写连接数少、逻辑复杂、有阻塞依赖线程数上不去上下文切换成本高one loop per thread每个线程一个事件循环连接固定归属于某个循环之所以成为主流是因为它把并发做成了空间隔离而不是锁保护。每个连接只会在一个线程里被处理连接内部的缓冲区、状态机、定时器全都不用加锁需要跨连接交互的地方才引入锁或者消息投递。这个模型下你的代码心智负担会大幅下降——写业务时只需要关心我这个连接的状态不用时刻提防别人同时改它。主从 Reactor 的分工也要说清楚。主线程只负责accept拿到新 fd 后用轮询或者最少连接数策略分配给某个子线程然后通过任务队列投递过去。主线程绝不能做任何业务处理哪怕只是打个日志——日志落盘可能阻塞一旦阻塞accept 就停了新连接会堆积在 backlog 里直到被丢弃。这是我在生产环境里真实遇到过的故障模式。3.2 跨线程投递任务与唤醒机制把任务投递给另一个线程的循环本质是往队列里塞东西然后通知对方别睡了。通知方式有两种主流做法管道和eventfd。管道简单通用但要管两个 fd而且写端满了会阻塞虽然是极端情况eventfd只有一个 fd内核维护一个 64 位计数器读一次清零非常适合做唤醒。class EventLoop { public: void runInLoop(std::functionvoid() task) { if (isInLoopThread()) { task(); // 同线程直接执行避免排队延迟 } else { { std::lock_guardstd::mutex lk(mtx_); pending_.push_back(std::move(task)); } wakeup(); // 写 eventfd } } void loop() { while (!quit_) { int n epoll_wait(epfd_, events_, kMaxEvents, nextTimeoutMs()); for (int i 0; i n; i) { dispatch(events_[i]); } wakeupFd_.drain(); // 读掉 eventfd 的计数 doPendingTasks(); // 执行排队任务 runExpiredTimers(); } } private: std::mutex mtx_; std::vectorstd::functionvoid() pending_; };这里有两个细节值得抠。第一runInLoop里判断是否同线程、同线程就直接执行——这个短路很重要否则所有回调都会绕一圈队列延迟凭空多出一轮循环。判断方法可以用thread_local缓存线程 id比每次调pthread_self便宜。第二唤醒用的eventfd要用非阻塞模式写满的情况下直接丢弃这次唤醒因为计数器已经有值对方一定会醒不能阻塞在写操作上。还有一个常见坑pending 任务执行时不要持锁。正确做法是在锁内把队列swap到一个局部容器解锁后再逐个执行。持锁执行的话任务里如果再投递任务直接死锁。3.3 绑核的实际收益与被夸大的部分CPU 亲和性绑定在教科书里被讲得很美实际用了才知道要分场景。对于纯计算型、无系统调用的场景绑核能显著减少缓存失效收益可观。但对于服务器框架这种频繁做系统调用、频繁访问共享数据结构的场景收益就没那么确定了。我的实测结论是绑核主要价值在于降低抖动而不是提升吞吐。把一个事件循环固定在一个核上能避免它被调度器在核之间来回搬L1/L2 缓存命中率稳定P99 延迟的毛刺会明显减少但平均吞吐提升通常只有个位数百分比。还有个反效果要注意如果绑核绑得不满比如只绑了物理核没绑超线程的逻辑核或者绑核之后某个核上跑着其他高优先级任务反而会出现单个循环被饿死的情况。稳妥做法是留出至少一个核给系统别把全部核都分给工作线程。4. 连接对象的生命周期析构时机错了崩溃只在流量大时才来4.1 为什么必须延迟析构这是并发服务器最经典的坑没有之一。场景是这样的你在读回调里发现对端关闭了顺手把连接对象删掉然后事件循环返回继续遍历剩下的就绪事件——结果某个事件的处理函数里还引用着那个已经被释放的对象于是 segment fault或者更糟内存没立刻被复用访问到脏数据程序带着错误状态继续跑。根因是事件循环持有的连接引用比对象的生命周期更长。解法有两种延迟析构或者引用计数。延迟析构的写法是在事件循环里维护一个待销毁队列所有删除连接的请求都只是把对象从活跃集合里摘出来、推进队列等这一轮事件全部处理完后再统一真正释放。这样保证在一轮循环内任何被引用过的对象都还活着。void Connection::forceClose() { if (state_ kClosed) return; state_ kClosed; channel_-disableAll(); // 从 epoll 摘除 loop_-queueForDestroy(shared_from_this()); // 延迟到本轮结束 }用引用计数配合shared_from_this就更稳一些但要注意循环引用——连接对象持有定时器定时器回调里又持有连接的shared_ptr两者互相引用就永远不会释放。解决办法是定时器那边持有weak_ptr回调时先lock()判断还活着没。这个细节不写清楚跑上几天内存就悄悄涨上去了。4.2 shutdown 与 close 的分工很多实现把关闭连接做成一个close调用就完事结果是写缓冲区里还有没发完的数据直接丢了。对端收到的是截断的数据流业务层解析失败报一堆莫名其妙的协议错误。正确的关闭流程分三步先shutdown(SHUT_WR)通知对端我不再发送数据了但对端可能还在发然后继续读直到读到0表示对端也关了最后才close释放 fd。中间的这段半关闭状态框架必须能够处理——这也是为什么前面强调要监听EPOLLRDHUP而不是只监听EPOLLIN。还有个更隐蔽的问题主动关闭方会进入 TIME_WAIT。高并发短连接场景下TIME_WAIT 堆积会耗尽本地端口导致无法建立新连接。应对方法有两个一是尽量由服务端在处理完请求后主动关闭并复用连接长连接优先二是在确实需要短连接时通过连接池控制速率。至于调整内核参数缩短 TIME_WAIT那属于治标且可能引发旧连接的残留报文串扰慎重。实践中我会在框架里加一个优雅关闭的接口先停止接受新请求等现有请求处理完、写缓冲排空再关闭连接。这在做滚动升级时特别重要粗暴关闭会让上游看到大量 5xx。5. 定时器与超时管理最小堆够用时间轮不必迷信5.1 三种定时结构的真实差异结构插入删除取最近到期适用连接规模备注有序链表O(n)O(1)O(1)千级以下实现最简单规模一大就退化最小堆O(log n)O(log n)O(1)万级到十万级需要惰性删除配合时间轮O(1)O(1)O(1)十万级以上精度受槽位粒度限制红黑树O(log n)O(log n)O(1)通用标准库 map 直接可用时间轮经常被吹成必备组件但实际算一下就知道对十万连接做一次最小堆的插入或调整log2(100000) 约等于 17 次比较和一次内存访问的开销差不多量级。真正到百万连接级别时间轮的优势才显现。所以我的一般建议是先用最小堆压测确认定时器扫描是瓶颈了再换时间轮。过早引入时间轮多出来的槽位管理和指针维护代码经常成为新的 bug 温床。更重要的不是数据结构而是删除策略。连接关闭时如果每次都去堆里精确删除对应节点就得维护从连接对象到堆节点的映射实现复杂且容易出错。业界通用的做法是惰性删除关闭连接时只做标记等堆顶元素到期时检查标记若已失效则直接弹出跳过业务回调。这样关闭连接是 O(1)堆的清理成本被摊销到到期扫描里。5.2 心跳与空闲连接回收的实现细节连接超时的判定不能只看多久没数据。真实场景里很多长连接长时间没有业务数据但必须保活。所以要区分两种定时器空闲定时器和心跳定时器。空闲定时器的逻辑是每收到一次数据就把到期时间往后推重置。实现上用惰性删除最省事——不去修改堆里的旧节点而是插入一个新节点旧节点到期时检查发现版本号对不上就丢掉。版本号可以用一个自增的uint64_t每次续期加一堆里存的是旧版本号。这样续期成本是 O(log n) 插入但不用做精确删除。心跳定时器的逻辑是到点了主动发一个探测包然后等回应。这里必须设一个发出探测后多久没响应就判定死亡的宽限时间否则一次网络抖动就会误杀。我一般设心跳间隔 30 秒、宽限 10 秒实际跑下来误杀率很低。有个坑一定要提超时扫描不能一次性扫完整个堆。如果积压了大量到期节点一轮扫下来可能耗时几十毫秒事件循环直接被卡住。正确做法是给单次扫描设一个时间上限比如 5 毫秒或者数量上限扫到限制就停下一轮继续。把长任务切片是在事件循环里处理任何批量工作的通用套路。6. 缓冲区设计与数据搬运少一次拷贝胜过优化十行代码6.1 读写缓冲区的两段式结构读写缓冲区的设计看似琐碎实则直接影响性能。一个比较好用的结构是读写双指针 可自动前移的可变数组class Buffer { public: size_t readableBytes() const { return writer_ - reader_; } void append(const char* data, size_t len) { ensureWritable(len); std::memcpy(data_ writer_, data, len); writer_ len; } void retrieve(size_t len) { reader_ len; if (reader_ writer_) { reader_ writer_ 0; } } void ensureWritable(size_t len) { if (writableBytes() len) return; if (reader_ writableBytes() len) { // 空间够只是碎片化前移即可 size_t readable readableBytes(); std::memcpy(data_, data_ reader_, readable); reader_ 0; writer_ readable; } else { expand(len); // 真扩容按 2 倍或按需 } } };关键在ensureWritable里的两段判断先尝试原地前移不行才扩容。不做这个优化缓冲区会一路膨胀到峰值就再也降不下来长跑服务的内存曲线会非常难看。另一个细节是初始容量。我一般给 1KB读事件到了之后用readv配合栈上的临时空间和缓冲区剩余空间一起读减少一次中间拷贝。单次读的上限也要设比如 64KB避免一个恶意连接用超大数据块把内存撑爆。6.2 零拷贝的适用与不适用零拷贝这个词被滥用得厉害。真正意义上的零拷贝是指数据不经过用户态直接从内核页缓存发到网卡。sendfile和splice能做到这一点但它们有明确的使用前提你要发的数据已经在文件里或者可以直接在 fd 之间搬运。对于动态生成的响应内容比如序列化后的 JSON、拼装出来的协议包零拷贝基本用不上因为你得先在用户态把数据造出来。这时候能做的是减少拷贝次数把业务层的缓冲区里拼好的数据直接交给写操作中间不要再来一次复制到发送队列的动作。用writev把头部和体部分散在多个缓冲区一次系统调用发出去也是一种有效的优化。writev有个限制要记住单次调用最多 1024 个 iovecIOV_MAX超过会返回错误。写循环时要处理这种情况分批发送。还有一个常被忽略的点关闭 Nagle 算法。低延迟交互型服务里Nagle 会把小包攒起来导致几十毫秒的额外延迟。设TCP_NODELAY是基本操作但它会增加小包数量在吞吐优先的场景下未必划算。这需要在压测里看具体数字不能凭感觉。7. 压测与观测怎么证明框架真的快7.1 指标要看哪几个数只看 QPS 是自欺欺人。一套完整的观测至少要看这几个维度指标采集方式健康阈值参考说明QPS压测工具统计视业务而定单独看没意义P50 / P99 / P999 延迟压测工具直方图P99 3 倍 P50长尾才是真问题活跃连接数框架内部计数接近上限要告警判断是否有泄漏fd 数量/proc/pid/fd计数稳定不增长增长即泄漏事件循环单轮耗时框架埋点多数轮次 1ms超 10ms 就要查各线程 CPU 分布top -H大致均衡单核打满说明分发有问题内存 RSS/proc/pid/status长跑平稳持续上涨即泄漏事件循环单轮耗时这个指标特别值得加。测量方法很简单每轮开头记一次单调时钟结尾再记一次超过阈值就打印一条慢日志带上这一轮处理的事件数和到期定时器数。这个埋点几乎零成本但能帮你抓住绝大多数卡顿。7.2 用 perf 定位热点别靠猜压测发现延迟上不去第一反应不该是改代码而是先看热点在哪。perf top挂上去看几秒通常就有答案了。我遇到过的几次典型情况一次是发现大量时间花在memcpy上追下去是缓冲区在扩容时反复前移拷贝把初始容量调大、加上前移复用之后降下来了。另一次是热点在pthread_mutex_lock查下来是日志模块用了一把全局锁改成每线程独立缓冲、定期 flush 之后立刻好转。还有一次热点在某个看起来无害的std::string构造上因为每次心跳都要构造一个完整的 JSON 串改成预分配后写开销降了七成。火焰图也是好东西perf record -g采样后用脚本生成 SVG能一眼看出调用栈哪一层占得最宽。它比perf top更有价值的地方是能看到调用关系——很多时候热点函数本身没错错的是调用它的时机太频繁。有个经验先优化调用次数再优化单次开销。把每秒调用一百万次的函数从 100 纳秒优化到 50 纳秒收益是 50 毫秒每秒而把调用次数降一半收益是 50 毫秒每秒——但后者往往只需要改一行判断前者可能要重写整个数据结构和算法。找前者。8. 上线前我必过的那几条检查项这套清单是我踩了足够多坑之后固化下来的每次把框架改大动都要过一遍。第一fd 泄漏检查。跑一个 24 小时的长稳测试期间不断建立和断开连接然后看/proc/pid/fd的目录项数量。只要呈现缓慢上涨趋势就一定有地方漏了 close。常见的漏点是在accept之后异常路径上直接 return没有关 fd。第二内存增长检查。同样的长稳测试里看 RSS。注意要区分缓存占用和泄漏——缓冲区池、对象池会占着内存不还这属于正常可以给个上限。但如果是无界的持续上涨那是泄漏多半是延迟析构的队列没清空或者定时器的弱引用没处理干净。第三半关闭路径验证。写一个测试客户端在发送完数据后立刻shutdown(SHUT_WR)但保持读。服务端应该正常处理完请求、发完响应、然后关闭。很多实现在这条路径上会丢掉最后的响应因为一看到 EOF 就把连接整个关掉了。第四慢连接防护。用一个故意读得很慢的客户端压测试看服务端的写缓冲区会不会无限增长。防护手段是设一个写缓冲上限超了就断开连接。这个上限要按业务的最大响应体来定设太小会误杀正常的大响应设太大等于没设。第五优雅关闭验证。发一个停止信号观察服务端是否先停止 accept、等现有请求处理完、排空写缓冲再退出。如果直接 abort上游会看到一批失败的请求。第六压力下的日志验证。把日志级别调到最详细然后跑满负载看日志会不会成为瓶颈。如果会说明日志是同步落盘的需要改成异步。第七配置的边界值验证。把最大连接数、缓冲区上限、定时器数量这些参数都调到临界值附近跑一遍看会不会出现断言失败或越界。这类问题在正常参数下永远不会暴露一旦真实流量超预期就直接崩。最后说一个体会框架的价值不在于它用了多先进的技术而在于它替你消灭了多少种可能会犯的错误。我那套代码从最初的三千连接就毛刺到后来稳定跑到十几万连接中间做的所有重构本质上都是在把业务可能写错的地方变成框架自动做对的地方。这个方向想清楚了架构上的很多纠结其实自己就解开了。