简介:基于C++与libevent实现的WebSocket服务器完整源码,面向需要处理高并发双向通信的C++开发者。代码从HTTP Upgrade握手校验入手,实现Sec-WebSocket-Accept生成、帧类型解析与掩码处理,覆盖文本与二进制数据收发;同时利用libevent事件回调管理连接、读写与超时,并内置心跳机制,适合作为生产级服务的参考实现。压缩包共20个文件,包含8个C++头文件、7个cpp实现文件,以及makefile、基础工具模块、聊天演示页面和说明文档,整体大小仅16KB,结构清晰精简,便于直接编译体验。已有616人学习下载。通过该项目可完整看到WebSocket服务器从握手到帧收发的落地过程,包括base64与md5辅助实现、聊天室示例,以及libevent非阻塞事件循环的工程组织方式。对于正在学习网络编程或需要快速搭建实时通信服务的开发者,具备很高的参考价值。
1. 从长连接需求说起:为什么选 C++ 和 libevent
前阵子我需要维护一个旧项目的实时消息推送模块,服务端原本用的是 Node.js 方案,连接数一上来,内存和 CPU 的占用就让我有点绷不住。单台实例扛到几万长连接的时候,GC 停顿开始肉眼可见,运维同学半夜被拉起来加机器的事发生了几次。当时我就在想,能不能用 C++ 配合 libevent 自己实现一个 WebSocket 服务端,把内存占用压下来,同时把每个连接的事件处理做得更可控。于是这个基于 C++ 和 libevent 的 WebSocket 服务器项目就走进了我的视野。
这个项目解决的核心问题很直接:在一台普通云主机上,通过 libevent 的事件循环驱动 WebSocket 协议,省去复杂的线程切换和阻塞 IO,让并发连接数和单连接内存开销都变得可控。适合谁看?适合那些不想引入重框架、希望直接掌控协议细节、需要把长连接服务嵌入自己网络层的 C++ 开发者,也适合刚接触 WebSocket 底层原理、想从零落地一个服务端的人。它不是那种套壳项目,而是把握手、帧解析、事件分发都摊开在源码里,让读代码的人能真正看清一条消息从客户端发到服务端再返回的完整链路。
2. 架构与模块拆解:一个事件驱动的服务端是怎么组织出来的
2.1 整体设计:libevent 事件循环作为心脏
整个项目围绕 libevent 的event_base组织。理解这个设计,先要理解 libevent 的模型:它把 socket 上的读写事件、定时器事件、信号事件统一挂到一个事件循环上,通过非阻塞 IO 实现高并发。这个 WebSocket 服务器本质上是在 libevent 的bufferevent之上做协议编解码。
我用一个粗略的分层来描述它的组织方式:
+-----------------------------+ | 业务回调层(onMessage等) | +-----------------------------+ | WebSocket 协议解析层 | | 握手、帧校验、掩码处理 | +-----------------------------+ | libevent bufferevent 封装 | | 读写事件、水位、连接关闭 | +-----------------------------+ | socket / 事件循环 | +-----------------------------+这个分层是我看源码时自己归纳的,项目源码里并不是严格按目录划分,但代码职责基本是这样递进的。每一层只依赖下一层,业务层不直接碰 socket,协议解析层不感知业务逻辑。这样做的好处是,如果以后想换事件库,比如换 epoll 裸写,只需要替换最底层和 bufferevent 相关的部分。
event_base的生命周期贯穿整个进程。服务启动时创建,然后进入event_base_dispatch循环,后续所有连接的建立、数据收发、定时检查都在这个循环内驱动。这里有个新手容易误解的点:libevent 本身是单线程的,默认情况下event_base_dispatch不会利用多核。项目里保持单线程模型,意味着业务回调里绝对不能做耗时操作,否则会阻塞所有连接的事件处理。
2.2 目录与核心文件:每个文件负责什么
源码从结构上看并不臃肿,核心文件围绕 WebSocket 协议的三个阶段组织。按照我阅读的习惯,我会先把文件按功能分组,避免一头扎进细节里出不来。
| 文件/目录 | 职责 |
|---|---|
websocket_server.h/cpp | 服务端入口封装,负责监听 socket、接受新连接、创建会话对象 |
websocket_connection.h/cpp | 单连接对象,封装 bufferevent 生命周期和状态机 |
http_handshake.h/cpp | 处理 HTTP Upgrade 握手,解析请求头、生成 Sec-WebSocket-Accept |
websocket_frame.h/cpp | 帧解析与生成,处理 opcode、掩码、分片 |
sha1.h/cpp和base64.h/cpp | 握手时需要 SHA-1 和 Base64 编码,这两个是内部工具实现 |
example_server.cpp | 示例入口,展示如何配置端口、绑定回调 |
每个连接对象内部维护一个状态机:CONNECTING、OPEN、CLOSING、CLOSED。握手完成前处于CONNECTING,此时收到的数据按 HTTP 解析;握手成功后切到OPEN,后续数据全部按 WebSocket 帧处理。这种状态机的设计让代码逻辑非常清晰,调试的时候只需要关注当前状态对应的事件处理函数即可。
2.3 连接建立流程:从 accept 到握手完成
服务端启动时创建监听 socket,注册到 libevent 的evconnlistener上。evconnlistener_new_bind是 libevent 提供的封装,它帮你完成 bind、listen、accept 以及新连接 fd 的注册。当新连接到达时,libevent 调用我们传入的回调,在回调里创建WebSocketConnection对象,把客户端 fd 封装成bufferevent。
struct evconnlistener *listener = evconnlistener_new_bind( base, on_accept, // 新连接回调 &server_config, // 用户自定义参数,传给回调 LEV_OPT_REUSEABLE | LEV_OPT_CLOSE_ON_FREE, -1, // backlog,-1 表示用系统默认值 &listen_addr, // 监听地址结构 sizeof(listen_addr) );on_accept回调里拿到的是evutil_socket_t fd,此时需要用bufferevent_socket_new创建 bufferevent,再设置读写回调和水位。连接刚建立时,只允许读,等待客户端发送 HTTP Upgrade 请求。这里我做了一个小设计:握手期间设置读水位为 1 字节,也就是只要有数据就回调,尽快处理握手请求。
握手读到的数据是一次 HTTP 请求,以\r\n\r\n结尾。我先用evbuffer累积数据,查找头部结束标记,找到后再整体解析。解析的核心是拿到Sec-WebSocket-Key请求头,然后拼上固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做 SHA-1 哈希,再 Base64 编码,就是响应里的Sec-WebSocket-Accept。
std::string make_accept_key(const std::string& client_key) { const std::string guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; std::string source = client_key + guid; unsigned char hash[20] = {0}; SHA1(reinterpret_cast<const unsigned char*>(source.data()), source.size(), hash); return base64_encode(hash, 20); }参数说明:client_key是请求头Sec-WebSocket-Key的值,每个客户端请求会带一个随机生成的 16 字节 base64 字符串;guid是 RFC 6455 规定的固定魔数,不能改也不能省略。返回的字符串直接写入Sec-WebSocket-Accept响应头。握手响应还必须带上Upgrade: websocket和Connection: Upgrade两个头字段,浏览器端才会认为握手成功。
3. 核心代码拆解:一个请求从进入到返回的完整链路
3.1 帧解析:处理 FIN、opcode、掩码和长度
WebSocket 帧的格式是协议里最核心的部分。客户端发给服务端的帧,按照 RFC 6455 规定,必须做掩码处理,也就是帧头里MASK位必须为 1,且携带 4 字节的 masking key。服务端回包则不需要掩码。项目里的解析函数一次性读入一个完整帧头,再根据长度字段决定要不要继续读 payload。
bool parse_frame(evbuffer* input, WebSocketFrame* frame) { unsigned char head[2] = {0}; if (evbuffer_copyout(input, head, 2) < 2) { return false; // 数据不够一个帧头 } frame->fin = (head[0] & 0x80) != 0; frame->opcode = head[0] & 0x0F; frame->masked = (head[1] & 0x80) != 0; frame->payload_len = head[1] & 0x7F; if (frame->payload_len == 126) { unsigned char len_buf[2] = {0}; evbuffer_copyout(input, len_buf, 4); // 2字节扩展长度 + 2字节预留位置 frame->payload_len = (len_buf[0] << 8) | len_buf[1]; } else if (frame->payload_len == 127) { // 64位长度,实际场景基本用不到,但协议要求实现 } if (frame->masked) { evbuffer_copyout(input, frame->masking_key, 4); } // 检查 payload 是否完整 size_t bytes_available = evbuffer_get_length(input); size_t header_len = compute_header_len(frame); if (bytes_available < header_len + frame->payload_len) { return false; } // 取出 payload,去掉掩码 frame->payload.resize(frame->payload_len); evbuffer_remove(input, frame->payload.data(), frame->payload_len); if (frame->masked) { for (size_t i = 0; i < frame->payload_len; i++) { frame->payload[i] ^= frame->masking_key[i % 4]; } } return true; }这段代码有几个关键点需要说明。evbuffer_copyout只是拷贝数据而不会从缓冲区移除,所以先用它窥视帧头,判断数据是否足够,再用evbuffer_remove真正消费数据。长度字段的解析是分级的:7 位载荷长度小于 126 时直接使用;等于 126 表示后面有 2 字节的 16 位长度;等于 127 表示后面有 8 字节的 64 位长度。对于普通业务,16 位长度(最大 65535 字节)基本够用,64 位长度主要用来兼容大帧。
掩码还原是必须的,因为客户端帧的 payload 是拿 masking key 逐字节异或过的。掩码算法是把 payload 的第i字节与masking_key[i % 4]异或。这里有个很多新手会翻车的地方:evbuffer_copyout的返回值是实际拷贝的字节数,必须检查它是否小于预取的长度,否则在持续分块到达时,可能只拷贝了半个帧头就继续往下解析,导致长度判断错误。我一般会在拷贝后加一个严格比较,不满足就直接返回false,等下一个read_cb再次触发。
3.2 分片重组:处理连续帧和消息边界
WebSocket 协议允许发送方把一个消息拆成多个帧,第一个帧的 opcode 是0x1(文本)或0x2(二进制),且FIN=0;中间和最后一个帧的 opcode 是0x0(continuation),最后一个帧FIN=1。服务端必须把这些帧拼起来,才能拿到完整消息。
项目里用了一个pending_message_成员累积分片数据,同时在连接对象上维护一个fragment_opcode_记录当前分片消息的类型。每次解析到新帧时,如果frame->opcode == 0x0且当前不在分片状态,说明协议错误,直接关闭连接。
void on_frame_received(WebSocketConnection* conn, const WebSocketFrame& frame) { if (frame.opcode == 0x0) { // continuation 帧必须处于分片状态 if (conn->fragment_opcode_ == 0) { conn->close_with_error(1002); // 协议错误 return; } conn->pending_message_.append(frame.payload); if (frame.fin) { // 分片结束,交给业务层 conn->on_message(conn->fragment_opcode_, conn->pending_message_); conn->fragment_opcode_ = 0; conn->pending_message_.clear(); } } else if (frame.fin) { // 单帧消息 conn->on_message(frame.opcode, frame.payload); } else { // 分片开始 conn->fragment_opcode_ = frame.opcode; conn->pending_message_ = frame.payload; } }这段逻辑里最容易遗漏的是对空消息的处理。有些客户端框架会发一个长度为零的文本帧,比如FIN=1, opcode=1, len=0,这时候业务回调收到的是一个空字符串,如果你的业务逻辑对空消息有依赖,需要单独标记。分片状态下如果收到一个新的数据帧(opcode 不是 0),说明客户端违反了协议,服务端必须返回 1002 错误码并断开连接。控制帧(opcode 为 8、9、10)不受分片影响,即使在一个分片消息中间到达也必须单独处理,项目里在解析循环中是先处理控制帧再处理数据帧。
3.3 控制帧响应:ping、pong 和 close 处理
WebSocket 控制帧包括 close(8)、ping(9)、pong(10)。协议要求收到 ping 时必须回一个 pong,且 pong 的 payload 必须和 ping 完全一致。收到 close 时,服务端可以回一个 close 帧,然后关闭连接。这个逻辑实现起来不复杂,但容易在细节上踩坑。
void handle_control_frame(WebSocketConnection* conn, const WebSocketFrame& frame) { switch (frame.opcode) { case 0x8: // close if (frame.payload.size() >= 2) { uint16_t code = (frame.payload[0] << 8) | frame.payload[1]; // 根据 code 决定错误说明 } conn->send_close(1000, "bye"); conn->mark_closing(); break; case 0x9: // ping conn->send_pong(frame.payload); break; case 0xA: // pong // 业务层可能关心 pong,比如心跳检测 conn->on_pong(frame.payload); break; default: break; } }close 帧的 payload 前两个字节是关闭码,后面可以跟一个 UTF-8 编码的原因字符串。服务端收到 close 后,通常的做法是先回一个 close 帧,然后等一段时间(比如等 5 秒)确保 pong 或 close 响应已经发出,再真正关闭 fd。这里有个经验:不要在收到 close 的瞬间立刻关闭 fd,否则客户端可能收不到回包。项目里用event_base的定时器做了一个优雅关闭,延迟一段时间再释放连接资源。
ping/pong 是心跳的基础。如果客户端设置了空闲超时,可以在服务端定期发 ping,如果连续几次没收到 pong,就判定连接已半死,主动断开。项目示例代码里没有强制定时器,但保留了on_pong回调的钩子,方便二次开发时填入自己的心跳策略。
3.4 发送消息与写事件水位
发送消息相对简单,服务端帧不需要掩码,只需构造帧头并写入 evbuffer。项目里提供了文本和二进制两种发送接口,内部统一走一个send_frame方法。
void WebSocketConnection::send_text(const std::string& text) { send_frame(0x1, text); } void WebSocketConnection::send_frame(uint8_t opcode, const std::string& payload) { evbuffer* output = bufferevent_get_output(buffer_); unsigned char head[10] = {0}; size_t header_len = 0; head[0] = 0x80 | opcode; // FIN=1 + opcode size_t len = payload.size(); if (len < 126) { head[1] = static_cast<unsigned char>(len); header_len = 2; } else if (len <= 0xFFFF) { head[1] = 126; head[2] = (len >> 8) & 0xFF; head[3] = len & 0xFF; header_len = 4; } else { head[1] = 127; // 64位长度,这里简化处理 } evbuffer_add(output, head, header_len); evbuffer_add(output, payload.data(), payload.size()); }写事件的高水位设置是防止慢消费者导致内存无限制增长的关键。比如一个客户端连接速度很慢,而服务端持续发送大消息,evbuffer 的 output 缓冲区会不断膨胀。libevent 提供了bufferevent_setwatermark,可以为写事件设置高水位,当 output 缓冲区超过高水位时,写回调会被暂停,数据会暂存在缓冲里。项目里默认把低水位设为 0,高水位设为 1MB,同时在发送前检查当前 output 长度,超过阈值直接丢弃后续业务消息并记录日志。这个策略比较粗暴,但至少能保护服务端不因个别慢连接拖垮内存。
4. 编译运行与测试:从源码到可用的服务端
4.1 依赖准备与编译配置
项目编译依赖 libevent 开发库。在 Linux 环境,一般通过包管理器安装,比如在 Debian/Ubuntu 系执行apt install libevent-dev,在 CentOS/RHEL 系执行yum install libevent-devel。如果你需要静态链接,可以编译安装 libevent 源码,但多数场景下动态库就够了。另外还需要一个 C++11 及以上编译器,因为代码里大量使用了std::string、std::vector等容器和auto语法。
我通常用 CMake 构建,项目的CMakeLists.txt相对简洁,核心逻辑是找 libevent 库并链接目标。一份最小可用的 CMake 配置大致长这样:
cmake_minimum_required(VERSION 3.10) project(websocket_server) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(PkgConfig REQUIRED) pkg_search_module(LIBEVENT REQUIRED libevent) add_executable(websocket_server websocket_server.cpp websocket_connection.cpp http_handshake.cpp websocket_frame.cpp sha1.cpp base64.cpp example_server.cpp ) target_include_directories(websocket_server PRIVATE ${LIBEVENT_INCLUDE_DIRS}) target_link_libraries(websocket_server PRIVATE ${LIBEVENT_LIBRARIES})编译命令很简单:
mkdir build && cd build cmake .. make -j4编译过程中如果报找不到头文件的错,先检查 libevent 是否安装,以及 pkg-config 的搜索路径是否正常。一个常见问题是 libevent 头文件装到了/usr/include/event2,但 CMake 找到了错误的路径版本,导致编译时使用的头文件与链接库版本不匹配。遇到这类问题,设置PKG_CONFIG_PATH环境变量或直接在 CMake 里硬编码 include 路径可以快速定位。
4.2 启动服务与连接测试
示例程序的入口example_server.cpp默认监听9000端口,启动后会在控制台打印监听信息。如果想要修改端口,直接改入口参数即可。启动命令是:
./websocket_server 9000如果启动时没有报错并显示listening on 0.0.0.0:9000,说明 event_base 初始化成功,连接监听已就绪。
验证服务是否正常工作,我习惯先用命令行工具websocat或写一个简单的 Python 脚本测试。websocat是一个用 Rust 写的 WebSocket 客户端,安装后可以直接连接:
websocat ws://127.0.0.1:9000/连接成功后在终端输入文本,回车发送。服务端示例默认是 echo 行为,也就是收到什么消息就原样返回,所以终端里会回显同样的内容。如果websocat连接失败,按顺序排查:先确认端口有没有被占用,然后看服务端日志是否打印了接收连接的信息,接着用tcpdump或strace看 TCP 层数据是否正常到达。
4.3 用浏览器开发者工具做可视化验证
浏览器本身就是一个优秀的 WebSocket 客户端。打开任意空白页面,在控制台执行以下 JavaScript:
const ws = new WebSocket('ws://127.0.0.1:9000/'); ws.onopen = () => { console.log('connected'); ws.send('hello from browser'); }; ws.onmessage = (event) => { console.log('received:', event.data); ws.close(); };在控制台 Network 面板里切到 WS 标签,可以看到握手的请求头,尤其是Sec-WebSocket-Accept的响应值;发送和接收的帧也会完整列出来,包括 opcode、长度、掩码状态。用浏览器调试的好处是能看到协议层错误,比如如果服务端返回的 Accept 值不对,浏览器会直接报Error during WebSocket handshake,这个错误信息非常明确,能直接定位到握手代码里 SHA-1 或 Base64 的逻辑问题。
4.4 压测简易验证
压测可以用websocat配合多会话,或者直接用 Python 写一个多线程压测脚本。因为 libevent 是单线程的,压测时重点观察 CPU 占用和内存是否持续增长。一个简单的压测指标:在同一台机器上,开启 1000 个连接,每个连接每秒发送一个小文本帧,观察服务端 CPU 占用是否能稳定在单核 50% 以内,内存是否平稳。如果内存持续增长,大概率是缓冲区没有正确释放,或者某个连接关闭时没有清理事件。
压测过程中如果出现连接建立但握手失败,最常见的现象是客户端发来大量连接请求,服务端 accept 了但握手代码里某个字段解析失败。这时我通常会在握手解析函数里加日志,打印收到的请求头全文,看看是换行符不标准还是缺少某字段。浏览器和标准库客户端发送的换行符一般是\r\n,但某些嵌入式客户端可能会只发\n,解析时如果按固定字符串查找,就会查不到。
5. 避坑指南:编解码和资源管理的细节
5.1 握手响应头缺失导致浏览器连接直接失败
现象:命令行客户端用websocat连接可以正常收发消息,但浏览器连接时始终报Error during WebSocket handshake。
原因:浏览器是严格的协议实现,它要求握手响应必须包含Upgrade: websocket和Connection: Upgrade两个字段,且 Accept 值必须完全匹配 RFC 计算规则。命令行工具或一些测试客户端对响应头校验比较宽松,可能只检查状态码和 Accept,漏掉其他字段也能过。
解决:检查握手响应代码,确认三个关键头字段全部输出:HTTP/1.1 101 Switching Protocols、Upgrade: websocket、Connection: Upgrade,以及计算正确的Sec-WebSocket-Accept。一个容易忽略的细节是响应头必须以\r\n\r\n结尾,且不能有额外空格。有的项目里用snprintf拼接响应,稍不留意就多打了一个空格,浏览器就会拒绝。
5.2 分片消息处理不完整导致业务数据错乱
现象:客户端发送一个大消息(超过 64KB),服务端收到的内容只有一半,或者第二次收到消息时内容粘连了上一次的尾部数据。
原因:帧解析函数parse_frame只处理单帧数据,如果evbuffer中一次到达多个分片帧,而调用方只解析了其中一部分,剩余数据会残留在缓冲区中。下一次读事件触发时,残留数据和新到达的数据混在一起,如果长度字段没算对,就会错误解析。
解决:在read_cb中必须用循环解析帧,直到缓冲区剩余字节不足以构成一个完整帧为止。具体做法是parse_frame返回false后,如果是数据不足,就跳出循环等下一次回调;如果是协议错误,直接关闭连接。另外确认compute_header_len里把掩码 key 的 4 字节算进去了,否则 payload 偏移会错 4 字节。
5.3 慢连接拖垮发送缓冲导致内存膨胀
现象:服务端运行一段时间后,连接数没有明显增加,但进程内存持续上升,直到被 OOM killer 杀掉。
原因:某个客户端长时间不收数据,服务端写入 evbuffer 的数据一直堆积。libevent 默认写水位是无限的,也就是说写入从不阻塞,数据全部堆在用户态缓冲区里,几千个连接各自积攒几 MB,内存就爆了。
解决:用bufferevent_setwatermark设置写高水位。当 output 缓冲超过阈值时,libevent 会停止触发可写事件,发送方可以暂停业务发送。同时在连接对象里记录待发送字节数,超过阈值的消息直接丢弃或进入重试队列。项目里的做法是设置高水位 1MB,对大多数业务场景足够。另一种策略是添加应用层心跳检测,长时间没活跃的连接直接断开,回收资源。
5.4 close 后立即释放对象引发悬垂指针
现象:服务端收到 close 帧后马上删除连接对象,然后客户端又返回了一个 pong 帧,程序崩溃在事件回调里。
原因:bufferevent的事件回调触发是基于 fd 的状态,如果连接对象已删除,但 bufferevent 的回调还指向原来的对象,就会访问已释放的内存。这也是典型的 use-after-free 问题。
解决:不要在 close 帧的处理函数里直接 delete 连接对象。正确的做法是调用bufferevent_free先解除事件监听,在event_cb中检测到BEV_EVENT_EOF或BEV_EVENT_ERROR后再释放。或者利用 libevent 的延迟释放,把连接的销毁放到事件循环的下一次迭代。项目里的做法是维护一个待销毁队列,每轮事件循环结束后统一释放资源。
5.5 跨平台编译时字节序和类型宽度不统一
现象:在 32 位嵌入式平台上编译,部分长度字段解析错误,大帧数据异常。
原因:代码里直接用了unsigned long来接收 64 位长度,但 32 位平台上unsigned long只有 4 字节,赋值时会截断。另外网络字节序是大端,直接memcpy到本机整数可能得到错误的值。
解决:所有长度字段都用uint16_t、uint64_t等固定宽度类型。解析 64 位长度时,手动拼接并检查溢出,不要依赖宿主平台的整数宽度。字节序转换用ntohs、ntohl或自研的__builtin_bswap64。这个坑在服务端跑在 x86 上时不会暴露,但如果你把代码移植到 ARM 或 MIPS 平台,就有概率踩到。
6. 二次开发:扩展心跳、SSL 和多线程的思路
这个项目最值得改的部分,是把它从「能跑的 demo」变成「能上生产的服务端」。我梳理了三个常见的扩展方向,每个方向都可以独立落地。
第一个方向是增加内置心跳机制。当前示例代码依赖on_pong回调,但没有强制服务端主动发 ping,这意味着一个客户端断网后,服务端可能永远不知道连接已死。我一般会在连接对象上挂一个event定时器,每 30 秒发送一次 ping,同时记录最近一次 pong 的接收时间。如果连续 3 次没有收到 pong,就判定连接死亡,触发关闭流程。定时器在 libevent 里实现很直接:
event_base* base = bufferevent_get_base(buffer_); struct event* timer = event_new(base, -1, EV_PERSIST, heartbeat_cb, this); struct timeval interval = {30, 0}; event_add(timer, &interval);EV_PERSIST标志让定时器循环触发,heartbeat_cb里检查最后一次活跃时间与当前时间的差值。这里有个细节:event需要随着连接销毁而释放,否则会造成定时器泄漏,每次连接断开都残留一个定时器事件在 base 上。
第二个方向是接入 SSL/TLS。浏览器访问wss://地址时,底层跑的是 TLS,libevent 对 SSL 有原生支持,bufferevent_openssl_socket_new可以创建基于 OpenSSL 的 bufferevent。改动相对集中:接受连接后,根据配置决定是否走 SSL 握手。需要注意的是,TLS 握手是异步操作,在处理BEV_EVENT_CONNECTED之后才能开始 WebSocket 握手,所以状态机里要新增一个TLS_HANDSHAKE状态。证书和私钥的加载代码可以放在服务启动阶段,避免每次连接都重新读取文件。
第三个值得尝试的方向是多线程模型。libevent 本身是单线程的,但项目可以起多个event_base,每个线程一个 base,通过 socket 的负载均衡把连接分配到不同线程。这种模型常见的做法是在 accept 回调里用 round-robin 选择一个 worker 线程,然后把 fd 转发给该线程的 base,在该线程中创建 bufferevent。改动最大的地方是锁:各个连接对象不再共享状态,但全局的统计、会话管理表需要加锁。如果业务场景是 IO 密集而非 CPU 密集,单线程 libevent 配合非阻塞处理往往已经够用,多线程带来的复杂度可能超过收益。
验证改动是否可靠,我的习惯是分三步走。第一步跑 1000 个连接、每连接每秒发 10 条小消息,跑 10 分钟,观察内存和 CPU 曲线,确认没有累计泄漏。第二步模拟异常场景:断网、半开连接、客户端崩溃,观察服务端是否能在心跳超时后回收资源。第三步做长稳测试,跑 24 小时以上,结束后检查日志无明显异常增长。这套流程每改一次代码都强制走一遍,尤其是触碰到连接生命周期和缓冲区的改动,因为这两处最容易引入泄漏或悬垂指针。
这个项目给我的最大收获,是让我明白 WebSocket 协议看似简单,但真正的复杂度全在状态管理和资源生命周期上。从那以后,我每次给网络模块加功能,都会先画一张连接状态变迁图,标注哪些操作在哪些状态合法,再去动代码。希望这个项目的源码和这篇解析能帮你在自己的服务端里少踩几个坑。
本文还有配套的精品资源,点击获取