
简介面向C网络编程开发者此压缩包演示了基于libevent构建WebSocket服务器的完整实现涵盖HTTP升级握手、帧的编解码、事件循环集成以及连接超时管理等环节适合需要为系统增加低延迟双向通信能力的工程技术人员。包内共20个文件其中8个头文件与7个C源文件构成核心代码另附Makefile构建脚本、README说明文档和可直接运行的聊天示例整体大小仅16KB结构干净便于快速定位关键模块。已有616人下载学习代码框架简洁示例可直接编译运行适合作为课堂项目或面试准备材料。通过研读源码可以掌握Sec-WebSocket-Accept的生成原理、base64与MD5工具的使用方法以及如何利用libevent的非阻塞I/O管理大量并发连接是一份能直接借鉴甚至作为自研服务器起点的实用参考也为进一步加入多线程或TLS加密留出了清晰的扩展思路。1. websocket 服务器项目解析当 C 遇上 libevent单线程如何扛住万人长连接做过即时通讯或实时推送的后端开发者大概率都经历过这种场景用阻塞 socket 加多线程处理 WebSocket连接数一涨线程爆炸内存飙升最后连 CPU 都耗在线程切换上。而用 C 配合 libevent 实现 WebSocket 服务器走的是另一条路——事件驱动、单线程撑起上万并发连接既不靠多线程也不靠异步框架而是把每个连接的读写事件交给 libevent 的事件循环统一调度。这套方案特别适合对延迟敏感、连接数大但单连接吞吐不高的场景比如消息推送、在线状态同步、行情分发。本篇文章就把这份源码包里 C 与 libevent 结合实现的 WebSocket 服务器完整拆开从事件模型到帧解析再到心跳保活和二次开发 每一步都给你讲透。2. 架构设计与事件驱动libevent 如何撑起 WebSocket 服务器2.1 事件循环为什么单线程反而更高效传统阻塞式 socket 服务器每个连接分配一个线程线程在 read 上阻塞等待数据。连接数一多上下文切换开销和内存占用就成了瓶颈。而这份源码里的 WebSocket 服务器基于 libevent 的事件循环把所有 socket 文件描述符注册到 event_base 上由 libevent 内部统一监听读写事件事件触发时回调对应处理函数单个线程就能驱动成千上万个连接。核心初始化代码如下struct event_base* base event_base_new(); struct evhttp* http_server evhttp_new(base); evhttp_bind_socket(http_server, 0.0.0.0, 8080);这里evhttp_bind_socket把 HTTP 服务绑定到 8080 端口这是 WebSocket 握手的前置步骤——WebSocket 协议规定连接必须通过 HTTP Upgrade 请求完成握手升级。libevent 自带的 evhttp 模块帮你处理了 HTTP 层面的解析省去了手写 HTTP 请求解析的重复劳动。event_base_new创建事件循环的根基后续所有 socket、定时器、信号事件都会挂在这个 base 上。事件循环的启动代码是event_base_dispatch(base)它会陷入一个无限循环内部调用epoll或kqueue等系统多路复用接口等待事件到来。整个过程不阻塞在任何一个 socket 上所以单线程可以同时服务大量连接这就是事件驱动模型的核心优势。2.2 线程模型与连接管理每个连接绑定一个缓冲区结构源码里为每个 WebSocket 连接维护了一个独立的结构体包含缓冲区、连接状态、心跳计时器等字段。libevent 通过bufferevent对这个结构体进行封装bufferevent帮你管理了读写缓冲区的自动扩容和数据的按需读取。struct websocket_client { struct bufferevent* bev; char recv_buffer[8192]; size_t recv_len; int handshake_done; time_t last_ping_time; void* user_data; };每个连接的handshake_done标记是否完成握手last_ping_time记录最后一次收到心跳包的时间user_data留给业务层挂载自定义数据。一个连接从建立到销毁生命周期里的读事件、写事件、超时事件全部由 libevent 分发到对应回调函数不需要为每个连接创建线程。3. 核心模块拆解从握手到帧解析的完整链路3.1 HTTP 握手升级WebSocket 的第一道关卡当一个客户端发起 WebSocket 连接时首先发送的是标准的 HTTP 请求带Upgrade: websocket头。服务器需要识别这个头返回101 Switching Protocols状态码完成协议切换。源码中在 evhttp 的回调里判断请求头核心逻辑如下static void https_cb(struct evhttp_request* req, void* arg) { const char* upgrade evhttp_find_header(req-input_headers, Upgrade); if (upgrade ! NULL strcasecmp(upgrade, websocket) 0) { handle_websocket_handshake(req); } else { evhttp_send_reply(req, HTTP_OK, OK, NULL); } }evhttp_find_header从 HTTP 请求头里查找 Upgrade 字段如果匹配 websocket就进入握手处理否则当成普通 HTTP 请求返回。这段代码是对Sec-WebSocket-Key的验证与响应static void handle_websocket_handshake(struct evhttp_request* req) { const char* key evhttp_find_header(req-input_headers, Sec-WebSocket-Key); char accept_key[64]; compute_accept_key(key, accept_key); struct evbuffer* evb evbuffer_new(); evbuffer_add_printf(evb, HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Accept: %s\r\n\r\n, accept_key); evhttp_send_reply_start(req, 101, Switching Protocols); evhttp_send_reply_chunk(req, evb); evhttp_send_reply_end(req); }compute_accept_key将客户端传来的Sec-WebSocket-Key拼接固定 GUID再做 SHA-1 哈希和 Base64 编码得到Sec-WebSocket-Accept响应头。这个流程是 RFC 6455 协议规定的标准握手过程缺了哪一步浏览器都会拒绝建立连接。3.2 帧解析掩码处理与数据分片WebSocket 数据帧格式是二进制结构第一个字节的高四位表示操作码低四位表示是否分片第二个字节包含掩码位和长度信息。源码中最核心的函数是websocket_parse_frame每次从缓冲区读取数据时都会调用int websocket_parse_frame(struct websocket_client* client) { unsigned char* data (unsigned char*)client-recv_buffer; unsigned char opcode data[0] 0x0F; int masked (data[1] 0x80) 7; uint64_t payload_len data[1] 0x7F; int offset 2; if (payload_len 126) { payload_len (data[2] 8) | data[3]; offset 2; } else if (payload_len 127) { payload_len 0; for (int i 0; i 8; i) { payload_len (payload_len 8) | data[offset i]; } offset 8; } if (masked) { unsigned char mask_key[4]; memcpy(mask_key, data offset, 4); offset 4; for (uint64_t i 0; i payload_len; i) { data[offset i] ^ mask_key[i % 4]; } } // 此时 data offset 就是解密后的业务数据 }代码里先读操作码判断消息类型再读长度字段。当长度小于 126 时直接使用等于 126 时按 16 位长度读等于 127 时按 64 位长度读。掩码处理是 WebSocket 协议里客户端发送数据时必须做的操作服务器收到的数据是掩码异或后的必须用密钥逐字节还原。源码里用mask_key[i % 4]对 payload 逐字节异或还原出原始数据。3.3 分片消息重组TCP 粘包与半包处理的破解思路WebSocket 协议允许大消息分成多个帧传输第一个帧的 FIN 位为 0最后一个帧的 FIN 位为 1中间的数据需要攒起来拼接。源码里用一个动态缓冲区暂存分片数据struct fragment_buffer { char* data; size_t len; size_t capacity; int opcode; };每次收到新的分片时把 payload 追加到这个缓冲区尾部检查 FIN 位决定是否结束拼接。这个机制的处理逻辑在handle_websocket_data中实现static void handle_websocket_data(struct websocket_client* client, unsigned char opcode, char* payload, size_t len) { if (opcode 0x00) { // 续帧追加到 fragment_buffer append_fragment(client-frag, payload, len); } else if (opcode 0x01) { // 文本帧可能是起始帧也可能是完整消息 if (client-frag-len 0) { client-frag-opcode opcode; append_fragment(client-frag, payload, len); } else { process_complete_message(client, payload, len); } } else if (opcode 0x08) { // 关闭帧 handle_close_frame(client); } else if (opcode 0x09) { // ping 帧 send_pong_frame(client); } }TCP 是流式协议收到的数据可能一次包含多个帧也可能一个帧被拆成多次到达。源码里通过bufferevent的回调机制每次把可读数据全部取出循环解析帧头和数据直到缓冲区数据处理完。这就是粘包拆包问题的根本解法——不依赖一次 recv 拿全而是按帧边界循环消费缓冲区。3.4 心跳保活WebSocket 层级的 ping 与 pong 互搏网络不稳定或客户端掉线服务器可能无法立即感知。源码实现了 ping/pong 心跳机制服务器定期向客户端发送 ping 帧客户端收到后必须回复 pong 帧服务器根据 pong 响应判断连接是否存活。这个定时逻辑挂在 libevent 的定时器事件上static void heartbeat_cb(evutil_socket_t fd, short events, void* arg) { struct websocket_client* client (struct websocket_client*)arg; time_t now time(NULL); if (now - client-last_ping_time HEARTBEAT_INTERVAL) { send_ping_frame(client); client-last_ping_time now; } if (now - client-last_ping_time HEARTBEAT_TIMEOUT) { // 超过超时时间未收到 pong判定连接死亡 websocket_close_connection(client); } struct timeval tv {HEARTBEAT_INTERVAL, 0}; event_add(client-heartbeat_ev, tv); }每次定时器触发时检查上次收到 pong 的时间超时则直接关闭连接。使用event_add重新注册定时器实现周期性调用这种模式比event_persist更灵活因为你可以根据业务动态调整下一次检查的间隔。3.5 消息回调分发业务代码如何接入服务器核心功能是消息路由与分发。源码里维护一个连接池struct websocket_server { struct event_base* base; struct evhttp* http; std::mapint, struct websocket_client* clients; int next_client_id; };每个新连接加入时分配一个客户端 ID通过clients映射管理。当收到完整消息时服务器查找消息中携带的目标 ID 或房间号将消息转发给对应连接void websocket_server_broadcast(struct websocket_server* server, const char* message) { for (auto pair : server-clients) { struct websocket_client* client pair.second; if (client-handshake_done) { websocket_send_text(client, message); } } }广播的实现简单粗暴遍历连接池逐个发送单播则需要业务层传入目标 ID从clients里查询对应连接再发送。实际生产场景里这种全遍历的模式在连接数突破万级时会带来不小的延迟源码中为覆盖高频场景预留了 ID 路由接口让使用者自行扩展哈希索引与房间分组。4. 编译运行与避坑指南从源码包到可用的 WebSocket 服务器4.1 环境准备与编译参数源码包基于 CMake 构建依赖系统安装 libevent 开发库。在 Ubuntu 系系统上安装依赖sudo apt-get install libevent-dev cmake g然后进入源码根目录执行编译流程mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j4CMake 会自动查找 libevent 头文件和动态库如果找不到就去/usr/local/include和/usr/local/lib路径下面翻。源码包里附带一个CMakeLists.txt其中关键配置是链接event和event_extra两个库evhttp相关接口在event_extra中编译时漏链接会出现undefined reference to evhttp_new。运行服务端./websocket_server 8080启动后没有任何输出这是正常的libevent 的事件循环一旦启动就阻塞在event_base_dispatch上。想确认是否正常工作下一个请求测试握手curl -i -H Connection: Upgrade -H Upgrade: websocket -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ http://127.0.0.1:8080/返回101 Switching Protocols表示握手成功。4.2 避坑这个项目里最常见的五个深坑坑一掩码处理遗漏客户端直接断开连接现象服务器能完成握手但收到的文本消息解析出来全是乱码或者浏览器直接报WebSocket connection failed。原因客户端发送的帧数据全部经过掩码异或处理如果服务器不按 mask key 还原解析出的数据就是混淆后的。解决确认帧解析代码中masked位判断是否完整掩码还原必须用payload_len循环异或mask_key[i % 4]遗漏一个字节都不行。这个坑在初学阶段几乎必踩最直接的排查方式是在解帧后打印 raw data 和还原后数据的对比结果一眼就能发现问题。坑二TCP 粘包导致消息粘连现象连续快速发送多条消息服务器收到时几条消息拼接在同一个缓冲区里解析出第一条后剩余数据留在缓冲区但被忽略或错位解析。原因TCP 是流协议多条 WebSocket 帧可能同时被内核送达bufferevent_read一口气读出来。如果只解析一帧就返回剩余数据会在下一次触发 read 时才处理但此时缓冲区状态已经乱了。解决解析函数必须循环处理判断当前缓冲区消费了多长剩下的继续按帧边界解析。我给这个函数加了一个while (client-recv_len FRAME_HEADER_LEN)循环直到缓冲区剩余长度不足一个帧头才退出。坑三64 位长度字段解析错误现象发送超过 2GB 的大文件时消息截断或者解析出错误长度。原因当 payload length 标记为 127 时协议规定后续 8 字节是 64 位无符号整数。如果代码里用一个int类型去存高位截断后长度变成负数或完全错乱后续缓冲区的 memcpy 必然越界。解决用uint64_t存储长度循环移位 8 次拼接完整值。这个问题在常规场景不一定触发但一旦做大文件传输就是致命的。反正我改完以后都会用伪造的 8GB 超大帧做过一次压力测试验稳。坑四心跳超时误杀正常连接现象连接在不联网的办公区或移动网络环境频繁掉线服务器日志里大量连接被超时关闭。原因心跳间隔和超时阈值设置不合理ping 和 pong 的往返时间在弱网环境可能超过超时阈值服务器误以为客户端失活。源码里HEARTBEAT_TIMEOUT默认设成 10 秒在公共 WiFi 下经常误杀。解决把超时阈值放宽到心跳间隔的三倍以上比如每 30 秒发一次 ping90 秒未收到 pong 才关闭连接。这参数属于生产环境必须调的那种本地调试看不出问题上真实公网环境才暴露。坑五服务器主动关闭连接时没发关闭帧现象客户端收到 TCP FIN 包但没有收到协议层的关闭帧前端 WebSocket 的close事件不触发表现为页面卡在连接中。原因服务器调用close(socket)直接断开 TCP 连接没有先发送0x08关闭帧走完协议握手式的关闭流程。解决关闭连接前先发关闭帧然后等客户端回关闭帧或超时才真正关闭 socket。WebSocket 协议跟 HTTP 一样关闭也要讲究一个善始善终不然前端的网络状态判断机制会混乱。4.3 性能调优参数怎么改才能吃满事件循环影响 WebSocket 服务器吞吐量的核心参数其实不在 libevent 里而在系统层面。源码包根目录提供了一个sysctl.conf参数参考直接写入系统的/etc/sysctl.conf后生效net.core.somaxconn 1024 net.ipv4.tcp_max_syn_backlog 1024 net.ipv4.ip_local_port_range 1024 65000 net.ipv4.tcp_tw_reuse 1somaxconn和tcp_max_syn_backlog决定 TCP 连接的 accept 队列长度连接数上来以后队列满了新连接会被直接丢弃表现为握手超时。tcp_tw_reuse让 TIME_WAIT 状态的端口快速回收短连接频繁建立的场景下能明显减少端口耗尽的问题。5. 二次开发实战给这个 WebSocket 服务器加上认证与转发能力5.1 加一个协议层的握手鉴权原版握手过程不校验任何身份信息任何客户端只要能建立 TCP 连接就能完成 WebSocket 升级。在实际项目中这个裸奔状态几乎是不可接受的。我们可以在handle_websocket_handshake里加一个 token 校验步骤const char* token evhttp_find_header(req-input_headers, Authorization); if (token NULL || strcmp(token, Bearer my-secret-token) ! 0) { evhttp_send_reply(req, HTTP_UNAUTHORIZED, Unauthorized, NULL); return; }这里直接从请求头取 Authorization 字段跟预设 token 做比对。你也可以在这个位置把 token 丢给后端的 Redis 做一次查询校验用户会话是否有效。需要注意的是token 放在查询参数里时会被 WebSocket 连接串在 URL 里需要在服务端单独拆出来处理逻辑会多出几步。5.2 扩展一个 JSON 格式的业务消息分发原版的广播接口是纯文本透传改成 JSON 协议只需要在消息入口做一个简单序列化和反序列化。引入一个轻量的 json 解析库把收到的消息体解析成结构体typedef struct { char type[16]; char target[64]; char payload[4096]; } ws_message_t; static int parse_json_message(const char* data, size_t len, ws_message_t* msg) { // 解析 JSON提取 type、target、payload 字段 // 校验必填字段缺一不可 return 0; }拿到解析后的ws_message_t按type字段分发到不同的业务处理函数send类型查target对应的连接做单播room类型查房间成员列表做群发broadcast类型走全量广播。这就是从 echo 服务器变成一个真正业务服务端的最小改造路径。5.3 多线程改造思路原版是单线程事件循环CPU 密集型的消息处理任务会阻塞整个事件循环。简单粗暴的解法是开多个event_base每个线程跑一个事件循环用accept锁或SO_REUSEPORT把新连接分散到不同线程。libevent 本身不提供连接分配机制常见做法是主线程负责 accept然后把bufferevent通过event_base_add挂到工作线程的事件循环上。这个改造的关键是注意线程安全问题所有客户端对象的读写都要加锁或者用无锁队列把消息传递到对应线程处理。5.4 验证改造效果改完以后拿浏览器控制台跑一段 WebSocket 压力测试脚本先验证功能再验证性能const ws new WebSocket(ws://127.0.0.1:8080/); ws.onopen () { ws.send(JSON.stringify({type: send, target: user-1, payload: hello})); };功能验证通过后再连接多个客户端测群发和心跳最后用 ab 或者 iperf 压一把看吞吐和延迟曲线。整个过程跑完你会发现这套架构的伸缩性边界基本都在事件循环的阻塞点上动画和确认日志打得太狠对性能影响特别大。从那以后我每次给这个服务端加新功能都强制走一遍先压测再上日志最后调心跳参数。不然改完的功能可能在低并发下看不出问题一到真实环境就翻车。希望这些踩坑经验对你上手这个项目能有点帮助。本文还有配套的精品资源点击获取