C++20协程项目-高并发Web聊天室,包括协程如何调用MySQL+Redis

📅 2026/6/25 21:57:48
C++20协程项目-高并发Web聊天室,包括协程如何调用MySQL+Redis
来源程序员老廖功能概览用户注册/登录用户名密码注册MySQL 存储INSERT IGNORE 原子去重Token 认证登录后 Redis 存储 token1 小时过期WebSocket 实时聊天RFC 6455 完整实现支持文本帧、Ping/Pong、Close消息持久化Redis List 存储最近 200 条聊天记录历史消息推送WebSocket 连接建立后推送最近 50 条历史消息全员广播消息实时推送给所有在线 WebSocket 客户端HTTP Keep-Alive复用 TCP 连接减少开销嵌入式前端商业 IM 风格 UI左右分栏布局彩色头像连接状态指示器响应式适配系统架构整体架构线程模型数据流用户注册流程WebSocket 聊天消息流程文件结构与模块职责chatapp/ | -- coro_net.h 核心协程网络层 | |-- FireAndForget 协程返回类型启动即忘 | |-- Worker epoll 事件循环 协程句柄管理 跨线程调度 | |-- AsyncRead 异步 socket 读 awaiter | |-- AsyncWrite 异步 socket 写 awaiter | -- SwitchToWorker 跨线程切换 awaiter | -- async_redis.h Redis 异步访问层 | |-- RedisAwaiter Redis 命令结果容器 | |-- WorkerRedis hiredis-async 适配器集成 Worker epoll | -- AsyncRedisCommand co_await Redis 命令 awaiter | -- async_mysql.h MySQL 异步访问层 | |-- ThreadPool 通用线程池条件变量调度 | |-- MySQLPool MySQL 连接池阻塞 acquire/release | |-- MySQLResult SQL 查询结果 | |-- AsyncMySQLQuery co_await SQL 执行 awaiter | -- mysql_escape SQL 参数转义 | -- http_server.h HTTP 协议层 | |-- HttpRequest 请求解析方法、路径、头、body、JSON、Cookie | |-- HttpResponse 响应构建状态码、头、body、JSON、重定向 | -- json_escape JSON 字符串转义 | -- websocket.h WebSocket 协议层 | |-- ws_crypto SHA1 Base64 握手算法 | |-- WSOpcode 帧类型枚举 | |-- WSFrame 帧结构 | |-- ws_encode_frame 服务端帧编码无 mask | |-- WSClient 在线客户端信息 | -- WSManager 连接管理器广播 | -- chat_page.h 嵌入式前端页面从 chat_app.h 分离 | -- HTML_PAGE 商业 IM 风格前端左右分栏、彩色头像、连接状态 | -- chat_app.h 业务逻辑层 | |-- generate_token 随机 token 生成 | |-- hash_password FNV-1a 密码哈希 | |-- handle_http_conn HTTP/WebSocket 主协程 | -- 路由处理 注册/登录/发消息/获取消息/WebSocket | -- main.cpp 入口 | |-- worker_thread Worker 线程函数 | |-- init_mysql_tables 建表 | -- main 参数解析、初始化、accept 循环 | -- Makefile 编译脚本 -- benchmark.cpp HTTP 压测工具 -- ws_bench.cpp WebSocket 压测工具 -- bench_layers.cpp 分层性能测试 -- bench_pool.sh 连接池参数调优脚本 -- test_ws.py WebSocket 功能测试脚本项目源码C20协程项目-高并发Web聊天室包括协程如何调用MySQLRedis核心设计详解协程网络层 coro_net.h这是整个系统的基石将 epoll 事件驱动与 C20 协程无缝结合。FireAndForget —— 协程返回类型class FireAndForget { struct promise_type { FireAndForget get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } // 创建即启动 std::suspend_never final_suspend() noexcept { return {}; } // 结束不挂起 void return_void() {} void unhandled_exception() { std::terminate(); } }; };FireAndForget 是一种 启动即忘 的协程。协程创建后立即开始执行 initial_suspend 返回suspend_never 执行完成后自动销毁 final_suspend 返回 suspend_never 。调用者不需要管理协程生命周期。这种设计适用于 每连接一个协程 的模型accept 后创建协程协程自己管理 fd 的整个生命周期。Worker —— epoll 事件循环Worker 是系统的核心调度器每个 Worker 在独立线程上运行管理epoll 事件循环处理 socket IO 事件协程句柄映射fd → coroutine_handle 的映射表ready_events 缓存处理事件到达但协程未注册 handle 的时序窗口跨线程唤醒eventfd 机制支持从任意线程唤醒 Worker自定义 FdHandler支持 hiredis-async 等第三方库注册自己的 fd 处理逻辑AsyncRead/AsyncWrite —— 异步 IO Awaiter关键优化 await_ready 的先尝试读取fast path避免了在数据已就绪时不必要的 suspend/resume 开销。高吞吐场景下大部分 IO 操作可以在 await_ready 中直接完成。Redis 异步访问 async_redis.hRedis 使用 hiredis-async 库与 Worker 的 epoll 深度集成。每 Worker 一个 Redis 连接的设计Redis 协议天然支持 pipeline —— 多个命令可以在一个 TCP 连接上交错发送hiredis-async 内部维护命令队列自动 pipeline一个连接即可服务该 Worker 上的所有协程无需连接池MySQL 异步访问 async_mysql.hMySQL C API ( libmysqlclient ) 是同步阻塞的无法直接集成到 epoll。解决方案线程池 连接池 协程挂起。关键设计决策1. 为什么不用 MySQL 非阻塞 APIMySQL C API 的非阻塞模式需要 mysql_real_connect_nonblocking 等函数API 复杂且文档不足MariaDB Connector/C 的非阻塞 API 与 MySQL 不兼容线程池方案简单可靠性能损失可控2. 连接池大小如何选择连接数 min(CPU 核数 x 2, 并发协程数)8-16 连接已足够饱和大部分场景MySQL IO 本身是瓶颈连接过多反而增加 MySQL 内部锁竞争3. 协程如何不阻塞 Worker 线程await_suspend 把 SQL 任务提交到 ThreadPool 后立即返回Worker 线程可以继续处理其他协程的 IO 事件SQL 执行完成后通过 worker-schedule eventfd 唤醒 WorkerHTTP 协议层 http_server.h实现了 HTTP/1.1 的核心子集请求解析方法、路径、Query String、Headers、Body、JSON 提取响应构建状态码、Headers、Content-Length 自动计算Keep-Alive通过 Connection header 判断是否复用连接无第三方依赖手写解析器约 230 行代码WebSocket 协议层 websocket.h完整实现 RFC 6455 WebSocket 协议WebSocket 帧格式 (RFC 6455 Section 5.2)Opcode类型用途0x1Text frame聊天消息0x8Close frame关闭连接0x9Ping frame保活检测0xAPong framePing 响应客户端→服务端必须 mask4 字节 XOR 密钥服务端→客户端不能 maskWSManager —— 连接管理与广播class WSManager { mapint, WSClient clients_; // fd - client info mutex mu_; void broadcast(const string message) { string frame ws_encode_frame(WS_TEXT, message); lock_guard lock(mu_); for (auto [fd, client] : clients_) { ::write(fd, frame.data(), frame.size()); // 同步写 } } };业务逻辑层 chat_app.h前端 UI 架构嵌入式前端采用商业 IM 风格设计参考微信 Web / Telegram 等产品的交互范式配色方案元素颜色说明主色调#07c160微信绿背景色#f0f0f0浅灰侧边栏#2e2e2e深色自己的气泡#95ec69绿色他人的气泡#ffffff白色前端特性特性实现方式彩色头像根据用户名哈希从 12 色板选色首字母大写显示消息气泡自己绿色右对齐他人白色左对齐微信同款圆角连接状态绿色/红色/橙色闪烁圆点实时反映 WebSocket 状态多行输入textarea 自动扩展高度ShiftEnter 换行Enter 发送响应式布局768px 以下侧边栏隐藏通过汉堡菜单切换显示断线重连WebSocket 断开后 2 秒自动重连状态指示器联动侧边栏预览最新消息自动更新到聊天室描述区域核心协程生命周期handle_http_connection 是系统的核心协程管理单个 TCP 连接的完整生命周期协程并发模型本项目采用 每个连接一个协程 的并发模型。每当主线程 accept 一个新的客户端 TCP 连接时就会创建一个独立的 handle_http_connection 协程实例。该协程独立管理这个连接的完整生命周期从读取 HTTP 请求、路由分发、到 WebSocket 消息循环、最终关闭连接。一个连接 一个协程关键特征每个连接独立协程 A 的 co_await AsyncRead 挂起时Worker 线程立刻去执行协程 B 的就绪操作不会阻塞M:N 映射M 个协程映射到 N 个 Worker 线程。当前默认 N4可支撑 10,000 并发连接连接亲和性一个协程从创建到销毁始终在同一个 Worker 线程上运行不跨线程迁移FireAndForget —— 启动即忘的协程生命周期handle_http_connection 的返回类型是 FireAndForget 这并不意味着发出去就不管了而是 调用者不需要 co_await 等待它完成 。协程创建后自行运行直到连接关闭时自动销毁协程调度全景图与其他并发模型的对比模型并发方式10K 连接开销代码风格缺点每连接一个线程OS 线程~80GB 栈内存同步、直观线程数受限上下文切换重事件回调回调函数极低回调嵌套、状态机可读性差错误处理复杂每连接一个协程协程 epoll~几十 MB同步风格异步执行需要编译器支持 C20本项目的协程模型兼具了线程的 代码可读性 和事件驱动的 性能。每个连接的处理逻辑看起来就像普通的顺序代 码但在 co_await 处自动让出执行权让 Worker 线程去服务其他连接。请求处理全流程HTTP 请求处理流程用户注册流程为什么用 INSERT IGNORE 而不是 SELECT INSERT原子性没有检查和插入之间的竞态条件单次 SQL一次往返替代两次88% QPS从 5.4K 提升到 10.1K用户登录流程WebSocket 建连流程WebSocket 消息流程协程调度机制Worker 事件循环AsyncRead/AsyncWrite 原理三阶段协议为什么要在 await_ready 中先尝试 syscall高吞吐场景下TCP 接收缓冲区经常有数据try-first 可以跳过整个 suspend/resume 流程。跨线程调度 SwitchToWorkerready_events 缓存机制与 Bug 修复问题场景Stale ready_events Bug Timeline: t0: Main thread accept(fd37), add_client_fd(37, EPOLLIN|EPOLLET) Data is already in TCP buffer (client sent HTTP request) t1: Worker epoll_wait returns EPOLLIN for fd37 But handles_[37] is nullptr (coroutine hasnt registered yet) -- ready_events_[37] EPOLLIN (cached!) t2: Coroutine starts, first AsyncRead: await_ready: ::read(37) succeeds! returns data -- return true (no suspend needed) -- ready_events_[37] is NOT cleared t3: ... HTTP processing, WebSocket handshake ... t4: WebSocket msg loop, AsyncRead for new frame: await_ready: ::read(37) returns EAGAIN (no data) -- return false, proceed to await_suspend t5: await_suspend - set_client_handle(37, h) Worker finds ready_events_[37] has EPOLLIN -- immediately resume coroutine! t6: await_resume: ::read(37) returns -1 (EAGAIN!) -- return -1 t7: WebSocket loop: if (n 0) break; -- Connection closed! BUG!修复方案bool await_ready() { result_ ::read(fd_, buf_, len_); if (result_ 0 || (errno ! EAGAIN errno ! EWOULDBLOCK)) { // 读取成功清除可能残留的 stale ready_events if (t_worker) t_worker-clear_ready_events(fd_); return true; } return false; }同时在 WebSocket 消息循环中增加 EAGAIN 防御性重试ssize_t n; do { n co_await AsyncRead(client_fd, (char*)header, 2); } while (n 0 (errno EAGAIN || errno EWOULDBLOCK || errno EINTR)); if (n 0) break;双层防御clear_ready_events 从根源消除 stale 缓存EAGAIN 重试作为额外保险连接复用策略Redis每 Worker 一个 hiredis-async 连接为什么不用连接池Redis 单线程处理命令pipeline 已达最大吞吐多连接到同一 Redis 实例不会提升性能一个连接简化了管理减少了资源消耗MySQL全局连接池 线程池为什么不直接在协程中使用 MySQLMySQL C API 是同步阻塞的会阻塞整个 Worker 线程一个被阻塞的 Worker 线程意味着该 Worker 上的所有协程都停止调度线程池隔离了阻塞操作Worker 线程永远不会被 MySQL 阻塞编译与运行依赖安装sudo apt install g libhiredis-dev libmysqlclient-dev redis-server mysql-server数据库准备mysql -u root -e CREATE DATABASE IF NOT EXISTS chatapp CHARACTER SET utf8mb4; CREATE USER IF NOT EXISTS chatuserlocalhost IDENTIFIED BY chatpass; GRANT ALL PRIVILEGES ON chatapp.* TO chatuserlocalhost; FLUSH PRIVILEGES; 编译make # 编译 chatserver, benchmark, ws_bench make clean # 清理运行./chatserver --port 9090 --workers 4 --mysql-user chatuser --mysql-pass chatpass浏览器访问 http://127.0.0.1:9090完整参数./chatserver [options] --port N HTTP 端口 (默认: 8080) --workers N Worker 线程数 (默认: 4) --redis-host H Redis 地址 (默认: 127.0.0.1) --redis-port N Redis 端口 (默认: 6379) --mysql-host H MySQL 地址 (默认: 127.0.0.1) --mysql-port N MySQL 端口 (默认: 3306) --mysql-user U MySQL 用户 (默认: root) --mysql-pass P MySQL 密码 (默认: 空) --mysql-db D MySQL 数据库 (默认: chatapp) --mysql-pool N MySQL 连接池大小 (默认: 8) --thread-pool N 线程池大小 (默认: 8)性能测试测试环境单机MariaDB 10.6Redis 7.xg 11.4C204 Worker 线程HTTP 注册/登录性能压测工具./benchmark [options] -h HOST 目标地址 (默认: 127.0.0.1) -p PORT 目标端口 (默认: 9090) -t N 线程数 (默认: 4) -c N 每线程连接数 (默认: 1) -n N 总请求数 (默认: 1000) -m MODE 模式: register 或 login -k 启用 Keep-Alive注册性能方案连接池QPS说明纯 MySQL INSERT, 16连接直连-16,155基准上限INSERT IGNORE HTTP 协程810,163达到基准的 63%INSERT IGNORE HTTP 协程1610,905达到基准的 67%INSERT IGNORE HTTP 协程3212,392达到基准的 77%登录性能连接模式并发连接数QPS短连接649,259Keep-Alive169,461Keep-Alive3210,493Keep-Alive6410,799WebSocket 性能压测工具./ws_bench [options] -h HOST 目标地址 (默认: 127.0.0.1) -p PORT 目标端口 (默认: 9090) -c N 并发连接数 (默认: 100) -n N 总消息数 (默认: 1000) -t N 线程数 (默认: 4) -m MODE 模式: connect, throughput, echo, concurrent连接容量并发连接数成功率连接速率总耗时50100%1,356 conn/s0.15s500100%2,110 conn/s1.24s1,000100%2,469 conn/s1.96s2,000100%1,949 conn/s4.77s5,000100%1,429 conn/s12.3s10,000100%3,144 conn/s20.5s4 Worker 线程可维护 10,000 并发 WebSocket 长连接。连接建立耗时主要来自 HTTP 注册/登录WebSocket握手本身很快。回声延迟单发送者 N-1 旁观者在线连接成功率P50P90P95P99Max1100%0.27ms0.27ms0.28ms0.28ms1.5ms10100%0.27ms0.28ms0.29ms1.65ms3.2ms100100%0.79ms1.10ms1.38ms2.09ms6.3ms50093.8%28.1ms778ms1,323ms1,936ms1,997ms100 连接以内P50 延迟 1ms。500 连接时广播成为瓶颈延迟急剧上升。消息吞吐量发送 广播全接收连接数消息/连接发送 QPS接收率广播 QPS总耗时1010019,856100%2,4514.1s502014,990100%3,51814.2s1001013,387100%3,79626.4s消息零丢失。发送 QPS ~15-20K广播 QPS ~2.5-3.8K计入 N 倍扇出。并发回声多连接同时发收连接数消息总数成功率QPSP50P90P99Max101,000100%20,3870.06ms0.29ms0.58ms1.9ms501,000100%17,5370.26ms0.30ms1.91ms2.2ms1001,0000100%20,3350.26ms0.29ms1.06ms5.5ms并发模式下 QPS 稳定在 ~20K echo/sP50 0.3ms。总结本项目展示了 C20 协程在实际网络应用中的完整实践维度数据代码规模~1,500 行 C并发连接10,000 WebSocketHTTP QPS10K-12K 注册/sWebSocket 延迟P50 0.3ms消息吞吐~20K msg/sWorker 线程4 个外部依赖hiredis, libmysqlclient协程的核心价值用同步代码的可读性获得异步代码的性能。项目源码C20协程项目-高并发Web聊天室包括协程如何调用MySQLRedis