十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C++实战:基于libevent从零构建轻量级WebSocket服务器

C++实战:基于libevent从零构建轻量级WebSocket服务器 简介围绕C与libevent构建WebSocket服务器的完整源码包面向需要实现高性能、低延迟双向通信的C开发者覆盖从HTTP Upgrade握手到WebSocket帧编解码、连接管理与超时心跳等关键环节。压缩包共20个文件含8个h头文件、7个cpp源文件以及makefile构建脚本、README说明和演示页面整体仅16KB结构紧凑便于直接阅读与二次开发。目前已吸引614人学习查看适合正在研究事件驱动网络编程或准备在项目中引入WebSocket的读者参考。代码中包含base64、MD5、工具函数、帧处理与连接管理等模块并附带demo示例可帮助快速理解libevent事件回调与WebSocket协议的配合方式也可作为基础骨架扩展出TLS加密、多线程等高级特性。1. 为什么选libevent先聊清楚技术选型我接触C网络编程这些年对“要不要自己封一层WebSocket”这件事一直很谨慎。WebSocket看起来只是个协议但真要处理握手、分帧、粘包、心跳、断线重连这些细节写起来绝不轻松。这次项目需要一个实时推送通道服务端用C写前端浏览器直接连消息要能随时从业务模块里推出去。我评估了几条路最终选了C配合libevent自己实现一个轻量WebSocket服务器这篇博文就是这个项目的完整复盘。先说结论libevent官方本身不提供WebSocket协议实现它只给你事件循环和buffer管理协议部分全靠自己写。听起来麻烦但好处是协议逻辑完全可控不会被上层框架的黑盒封装卡住。仔细想了想我需要的只是TCP上的一套固定格式封包解析协议栈主体几百行能搞定没必要引入一个重度依赖。而且后面如果要做心跳、丢线检测、消息广播libevent的回调模型天然好扩展自己维护一套连接状态机反而比硬套框架更顺手。1.1 和裸epoll相比libevent帮我省了什么如果用裸epoll写你得自己管理socket状态、读缓冲、写缓冲、触发条件还要处理各种边界情况。这些代码写多了之后你会发现绝大多数网络服务需要的都是同一套骨架监听新连接、收发字节流、处理断开事件。libevent把这套骨架封装成了事件回调你只需要关注业务逻辑省掉大量重复劳动。更关键的是bufferevent这个组件在WebSocket场景下特别好用。它自带一个输入缓冲区和输出缓冲区读事件触发时你从输入缓冲区里消费数据写数据时把内容塞进输出缓冲区libevent负责底层非阻塞socket的批量和时机控制。WebSocket是流式协议一帧数据可能被拆成多个TCP段到达也可能多帧一次性到达没有这个缓冲层自己拼包很容易出错。我第一次用裸epoll做类似功能时为了处理半包写了一个几百行的拼接逻辑换成bufferevent之后这部分直接消失了。还有一个被低估的点libevent对跨平台的支持。同一套代码在Linux下用epoll在macOS和BSD上用kqueueWindows上还能退回到select或者IOCP方案。虽然我这次只在Linux上部署但开发机是macOS能跑同一套代码省了交叉编译的麻烦。1.2 和libwebsockets、uWebSockets等方案对比选型时我特意对比了几个现成的WebSocket库这里拆开说下libwebsockets功能最完整TLS、压缩、多协议都支持但API设计得很繁琐回调函数多且参数复杂。最让我劝退的是它的构建系统交叉编译时依赖项多集成进现有C项目要折腾半天。如果只是快速跑通一个Demo它确实省事但想深度定制协议细节时反而被它的框架绑住手脚。uWebSockets性能强、接口现代基于它改造的uWebSockets.js在前端生态里很流行。但它是偏高度封装的库内部网络层逻辑一整套出了问题不好排查而且它是C17风格很重的代码团队里如果C水平参差review成本不低。个别场景下想拿到某个连接底层fd做点事情接口层根本没有暴露。Boost.Asio网络层做得非常优秀但Boost的依赖本身就很重而且它的模型是异步操作处理器和libevent的回调模型相比各有优劣。对于我这种只想要一个事件循环加buffer管理的人来说Asio的抽象层级太厚了编译时间也感人。除非项目本来就在用Boost否则没有理由为了一个WebSocket服务器引入它。对比下来libevent的优势在于恰到好处比裸socket高级又比大型库低层。协议逻辑自己写网络IO交给它边界清晰可控。当然如果只想几天内跑通一个原型直接用libwebsockets确实更快但如果目标是做一个能长期维护、能按自己需求裁剪的服务端libevent这条路线风险更低。1.3 整体架构单线程还是多线程WebSocket服务器的并发模型我反复权衡过最终选了“单线程事件循环 多线程业务分发”的折中方案。对于项目里这种几千并发连接、每秒几次心跳推送的场景单线程事件循环完全能扛住。libevent的event_base本身不是线程安全的如果多个线程往同一个event_base里塞事件需要外部加锁锁竞争反而会拖慢性能。我建议的做法是不需要刻意追求多线程事件循环先把单线程版本跑通等压测确实出现CPU单核瓶颈时再改成“多event_base 连接哈希分发”的模型。每个event_base跑一个线程把连接按fd哈希分配到不同线程同一连接永远只属于一个线程从根上避免锁问题。我这次项目连到这一步都没走到单线程下CPU占用还不到40%。如果你要做的不是推送服务而是一个高吞吐的IM服务器那就得一开始就规划好多线程模型。常见的做法是主线程负责accept新连接然后通过event_base的event_active接口把新连接分发给工作线程工作线程各自跑自己的事件循环互不干扰。这种模型下瓶颈基本只剩内存带宽和网卡中断处理了几千并发不在话下。2. WebSocket协议的三个关键实现点WebSocket协议的水比看起来深。握手、帧解析、心跳这三块是核心任何一处处理不严谨线上就会出各种莫名其妙的问题。下面逐一拆开讲。2.1 握手HTTP Upgrade到WebSocket的转换握手阶段本质是一次HTTP协议升级。客户端先发一个GET请求带Upgrade: websocket和Connection: Upgrade头同时还有个Sec-WebSocket-Key字段一个随机生成的base64字符串。服务端如果同意升级需要计算Sec-WebSocket-Accept并返回101状态码。计算规则非常简单把Sec-WebSocket-Key拼上固定GUID字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”做一次SHA1哈希再base64编码。别看就这么几步网上不少教程抄来抄去GUID写错、大小写搞反的版本到处都是。我最初写的时候就是从某个教程直接贴的常量结果和Python的websocket-client联调时死活握手失败排查了半天才用Wireshark抓包发现accept值对不上。还有一点容易被忽略握手响应一定要带Connection: Upgrade头有些客户端库会比较严格地校验这个字段。状态码必须是101 Switching Protocols其他任何状态码都表示握手失败。另外如果请求里没有Sec-WebSocket-Key按RFC6455规范应该直接回400并关闭连接别尝试给它算出个响应来。在libevent里握手逻辑放在读回调的第一阶段。我先判断当前连接状态是不是HandshakePending如果是就在输入缓冲区里查找\r\n\r\n。搜不到说明HTTP头还没收全不做任何处理等下一次读事件把剩余数据带过来搜到了就解析头部并校验响应。这个过程的核心是别在数据不完整时做任何业务逻辑判断。2.2 帧格式掩码、长度编码与粘包半包完成握手之后连接就进入数据帧模式。WebSocket的帧格式是二进制的结构固定第一个字节高4位是FIN标志表示这是不是消息的最后一帧低4位是Opcode1表示文本帧、2表示二进制帧、8表示关闭连接、9是Ping、10是Pong第二个字节最高位是Mask标志服务端接收的数据帧Mask必须为1紧接着的7位或扩展位是Payload长度再往后是掩码密钥和实际数据。掩码这块特别容易搞混。规范要求客户端发送给服务端的帧必须加掩码服务端发送给客户端的帧不能加掩码。很多初学者在这里栽跟头客户端发来没有掩码的帧不回错误还傻傻当成正常数据服务端回消息时又画蛇添足加了掩码结果浏览器直接报错断开。掩码的作用是防止缓存污染攻击实现上就是4字节的密钥和数据逐字节异或逻辑不复杂但方向千万不能反。长度字段有3种编码方式7位能表示125以内的长度时直接用126表示后面跟着2字节的16位长度127表示后面跟着8字节的64位长度。写解析器时一定要把三种情况都覆盖不能图省事只处理前两种。我见过有个项目只实现了16位长度发个100KB的消息就直接解析错乱。粘包和半包的处理是所有网络协议的必修课。libevent的bufferevent输入缓冲帮我省了不少事但判断帧边界还是得自己做。解析逻辑其实就是一个状态机先读2字节头根据头里的长度字段决定后面要再读多少字节数据够了就取出来处理不够就原地等待。每一帧处理完要继续循环解析剩余数据因为一个TCP包里可能包含多帧。这段解析代码建议写得保守一点payload长度上限做限制比如默认最大允许1MB超过就主动断开。不然恶意客户端发一个64位长度的超大数据帧你的内存可能直接被吃满。2.3 心跳和连接生命周期管理WebSocket有Ping/Pong帧。服务端可以定期发Ping帧客户端回Pong帧证明自己还活着。规范没有强制要求必须在多少秒内发一次但实践中这是保活和检测死连接的最有效手段。我在项目里用libevent的evtimer做定时器每30秒对所有连接发一次Ping。如果60秒内没有收到对应Pong就判定该连接已死主动关闭并清理内存。这个超时策略可以灵活调整但注意心跳间隔不要太短有些移动端网络环境下NAT超时可能比你心跳间隔还短容易产生不必要的反复重连。连接的生命周期管理还有个细节主动关闭和被动断开要区分对待。客户端发Close帧请求关闭属于正常流程回一个Close帧后双方关闭TCP连接如果直接是BEV_EVENT_EOF或BEV_EVENT_ERROR说明底层连接已经断了直接清理本地状态即可。两种情况都要释放连接对象、从全局连接表里删除、触发业务层的断开回调让上层及时更新在线状态。3. libevent实操从监听socket到消息收发架构和协议都定好了写代码就顺理成章。这里给出核心实现片段正好覆盖从监听、连接到消息处理的完整链路。3.1 初始化监听器与连接管理监听部分直接用libevent的evconnlistener_new_bind它封装了socket、bind、listen这一套流程还自动处理非阻塞模式。struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8080); evconnlistener* listener evconnlistener_new_bind( base, on_accept, this, LEV_OPT_CLOSE_ON_FREE | LEV_OPT_REUSEABLE, -1, (sockaddr*)addr, sizeof(addr) );on_accept回调里我创建一个WebSocketConnection对象内部持有bufferevent句柄、当前状态握手阶段还是数据阶段、解析状态机、以及业务层的连接上下文。同时把fd作为key插进全局连接表方便之后按连接推送消息。事件回调里统一处理BEV_EVENT_EOF、BEV_EVENT_ERROR和BEV_EVENT_TIMEOUT分别对应客户端断开、异常错误、读超时三种情况都走同一个资源回收函数避免内存泄露。3.2 读写回调里的协议状态机读回调是整个服务器最核心的入口。这里用状态机区分握手和数据阶段void on_read(bufferevent* bev, void* ctx) { WebSocketConnection* conn (WebSocketConnection*)ctx; if (conn-state HANDSHAKE_PENDING) { conn-try_handshake(); // 内部查找\r\n\r\n完成后切换状态 } else { conn-parse_frames(); // 循环解析直到输入缓冲为空 } }try_handshake里先检查输入缓冲有没有完整的HTTP头没有就直接返回等下一轮读事件。parse_frames里循环调用单帧解析函数把解析出来的消息通过回调传给业务层。写回调通常不用单独处理只要把数据塞给bufferevent_write就行。发送时要注意帧头格式包括FIN、Opcode、长度编码。我封装了一个send_text函数内部自动计算长度是7位、16位还是64位编码省得每个调用点都手工计算。3.3 广播推送与多线程扩展业务层需要广播时遍历全局连接表对每个连接调用send_text。如果连接表很大遍历本身会消耗CPU。更优的做法是维护一个订阅列表服务端只推送给感兴趣的前端连接我在项目里给连接对象加了一个渠道ID按渠道分组推送实测下来效率高很多。如果之后要扩展到多线程思路是把全局连接表按线程分组每个线程维护自己的一份。推送消息时先按渠道找到目标连接所在的线程再通过线程安全的队列把任务post过去由那个线程完成实际发送。这样既避免跨线程操作bufferevent又不会堵住业务线程。4. 踩坑实录与性能优化协议看起来简单跑起来才知道坑在哪。这一节记录我实际踩过的坑和压测数据比任何理论都更有参考价值。4.1 新手最容易踩的四个坑第一mask标志校验缺失。有个测试客户端库用Go写的默认不带掩码发送我一开始也没校验结果数据解析出来全是乱的。后来严格按照规范服务端接收时遇到Mask为0的帧直接判定协议错误关闭连接。这个问题早发现早好等上生产再遇到就麻烦。第二长度字段覆盖不全。早期我只实现了7位和16位长度发了一个300KB的文本消息服务端解析彻底错乱整个连接状态崩溃。如果你服务端要支持传输图片或者大段日志务必把64位长度编码也实现了。别节省这几十行代码。第三close帧之后不关闭TCP连接。有些浏览器会先发Close帧然后等服务端回如果服务端只解析不回复浏览器会一直挂在那里等白白占用内存。正确做法是收到Close帧后回一个同Opcode的帧然后主动关闭连接。第四忘记处理Ping帧。如果客户端发了Ping帧比如某些现代浏览器的保活机制服务端必须回复Pong帧否则连接会在莫名的时间点断开。把Ping/Pong处理放在帧解析函数的前几个分支里优先级最高。4.2 压测结果与瓶颈定位测试环境4核8G的云服务器CentOS 7默认网卡配置。压测工具用了自写的小型客户端模拟1000个并发连接每个连接每5秒发一条业务消息并等待服务端的广播响应。实测结果单线程libevent事件循环下吞吐量稳定在每秒约6万条小消息文本帧约64字节CPU占用在50%左右。整个服务内存占用稳定在200MB上下包含1000个连接的缓冲区和连接对象。瓶颈定位很明确不在协议解析而在事件循环的调度开销。每个消息进出都要经过两次事件分发IO回调、解析、业务回调、写回调这一整套流程其实CPU时间都花在这上面。如果消息体变大比如1KB以上吞吐量会明显下降这时需要减少内存拷贝比如用引用计数的buffer传递数据或者直接使用libevent的zero-copy特性。多线程扩展后4线程模型下吞吐量翻了3倍多一点达到每秒22万条左右。但连接数超过2000后线程间锁竞争又开始显现说明扩展也要适度不是简单加线程就线性提升。4.3 调试与线上排障技巧调试WebSocket服务器最趁手的工具是Wireshark。抓包时设置过滤条件为tcp.port 8080然后按follow TCP stream可以直观看到WebSocket帧的解析结果。注意Wireshark需要勾选“允许WebSocket协议解析”才能自动解码帧结构。线上排障我遇到过最诡异的一个问题服务运行一段时间后内存缓慢增长稳定在某个值不下降。排查下来是超时连接没被回收因为我把超时事件只挂在读回调上如果一个连接既不发数据也不回Ping读超时永远触发不了对象就一直在全局表里躺着。后来我改成同时设置读写超时事件问题就解决了。另一个经验LINUX下如果连接数很多注意调整/etc/sysctl.conf里的net.ipv4.tcp_keepalive_time等参数不然系统默认的TCP keepalive时间太久服务端感知不到断线的周期会很长。调短一点能保证断线检测及时但也别太激进避免正常连接被误杀。5. 项目心得与可扩展方向项目跑通之后我对这套方案的定位更清晰了它适合做轻量级实时推送、小规模IM、监控数据面板这类场景不适合直接拿去支撑千万级连接的大型系统。5.1 重新审视这个项目如果时间倒流我会把更多精力花在协议状态机的设计上而不是赶进度先跑通再说。状态机设计好了后续加功能只是添分支的事设计潦草每加一个特性都要回头改解析逻辑越改越乱。另外解析代码一定要写单元测试特别是边界情况长度恰好等于125、126、65535、65536这些临界值最容易出问题。没有测试保护哪次重构都提心吊胆。还有一点我的体会是WebSocket协议本身并不复杂真正花时间的是和其他系统的配合比如和前端浏览器怎么配合和业务方的消息格式怎么对齐和部署环境里的负载均衡怎么配置。协议是技术问题但技术问题解决之后工程问题才是真正决定项目成败的地方。5.2 后续路线TLS、集群与业务解耦当前版本跑的是明文WebSocket生产环境必须换成wssTLS加密。libevent的bufferevent_openssl可以和openssl无缝对接代码改动量不大但要注意证书链、会话复用这些细节。集群扩展方面多实例部署时要考虑消息路由。我用的是简单按连接所在实例直接推送的模式消息广播时先查渠道对应的连接分布再做跨实例转发。这需要引入一个中心化的消息层比如Redis Pub/Sub或者Kafka每个WebSocket服务器实例订阅自己的渠道收到消息后推送给本地连接。业务和网络层解耦这块我建议提前做一层消息分发接口连接收到数据后不要直接处理业务而是塞进一个任务队列由业务线程池消费。这样网络层和业务层各自扩展互不干扰。虽然代码架构上多绕一步但对长期维护来说很值。最后分享一个个人经验如果你也打算动手写一个WebSocket服务器第一版别追求功能完整先把握手、文本帧收发、Ping/Pong、关闭连接这四条最基础的链路跑通再逐步加上二进制帧、压缩、TLS、集群这些进阶能力。协议这种东西一经跑通就一通百通后面的优化都是在细节上打磨不会再推倒重来。我当时上手太快直接照着RFC把整帧协议写完结果测试阶段一半时间都在修没考虑到的边界情况。把范围缩到最小反而走得更稳。本文还有配套的精品资源点击获取
返回列表