⭐️在这个怀疑的年代我们依然需要信仰。个人主页 YYYing.⭐️C大型项目系列专栏C大型项目之高性能服务器框架系列上期内容【C项目之高性能服务器框架 (二) 】线程协程篇系列下期内容暂无目录前言一、故事背景二、第一版单线程 while 循环 —— 连 100 人都撑不住2.1 怎么写的2.2 出了什么问题2.3 当时的感受三、第二版一个连接一个线程 —— 内存炸了3.1 怎么改的3.2 出了什么问题3.3 当时的感受四、第三版引入协程但没有调度器 —— 噩梦刚刚开始4.1 怎么改的4.2 出了什么问题4.3 当时的感受五、第四版有了调度器但 IO 还是阻塞 —— 数据库一查全服卡顿5.1 怎么改的5.2 出了什么问题5.3 你尝试的非阻塞方案5.4 你尝试的 epoll 回调方案5.5 当时的感受六、第五版有了 IOManager —— 但僵尸连接吃光内存6.1 怎么改的6.2 出了什么问题6.3 当时的感受七、最终版三者合体才是完全体7.1 场景排行榜查询带超时 非阻塞 IO 调度7.2 场景僵尸连接清理7.3 场景心跳检测7.4 场景技能 CD八、如果没有这三个组件系统会怎样没有 Scheduler没有 TimerManager没有 IOManager三者都缺九、总结十、学习验证清单结语前言本项目是基于小电视里sylar大佬的项目来做的一个项目总结其多为一些项目思考与笔记可能还会有一些图解之类的讲解但光看本专栏学习此项目肯定是不足的多去跟着视频敲敲代码或者自己下去实现实现各个模块。由于小生经验不足这个系列专栏制作周期可能会稍微有点长甚至有可能会出现断更的情况但我尽量往完写望各位大佬多多包涵。但在开始讲今天的设计之前我想先说两句最近的思考。我们从0开始做项目一般都不应该仅仅只是去做一个一成不变的东西也就是说我们是需要迭代、需要针对场景的那么在这个过程中我们项目往往就需要解决一些实际的具体问题比如说我们之前的日志系统如果我们只需要跑动一个简单的服务器那么我们真的需要这个日志系统吗想来必然是不需要的那么我们为什么要去做这个东西因为我们需要在运行的过程中去监控一些我们的运作信息不仅仅是为了我们能运行、监控它更是用户也需要通过日志来验证到底是哪出了问题等等还有我们同步日志系统在高并发运作的时候往往也会有一些性能瓶颈比如磁盘I/O慢锁竞争激烈日志量大时阻塞业务线程也没办法批量写这也是sylar日志系统暂时存在的问题那么我们就可以迭代为双缓冲异步日志系统如有兴趣小电视就可以搜到此处不作赘述。上一次的线程与协程也是如此那么我们今天要讲的协程调度器定时器与IO协程管理器更是如此。所以今天不同以往我们就先来探究探究是因为会在哪些具体的场景里踩坑才会逼着我们把这三个东西一个一个做出来当然后面如有需要我也会按照这个风格去讲。一、故事背景假设你现在接手了一个项目游戏网关服务器Game Gateway。它的职责很简单接受客户端手机/PC 游戏的 TCP 长连接。转发玩家的请求到后端逻辑服战斗、背包、聊天等。把后端的响应回传给对应的玩家。管理连接生命周期心跳检测、空闲超时、异常断开。项目一开始只有几百人在线但老板的目标很明确上线后要达到 10 万同时在线。下面我们就跟着这个项目一路踩坑看看为什么最后必须把 Scheduler协程调度器、TimerManager定时器、IOManagerIO协程管理器 全部做出来。二、第一版单线程 while 循环 —— 连 100 人都撑不住2.1 怎么写的项目赶工期你写了一个最直观的服务器// 第一版单线程循环处理所有连接 int listen_fd socket(...); bind(listen_fd, ...); listen(listen_fd, 128); while (running) { int fd accept(listen_fd, ...); // 阻塞 // 新玩家连上来了 handle_player(fd); // 处理这个玩家——还是阻塞 }handle_player里面void handle_player(int fd) { while (true) { char buf[1024]; int n read(fd, buf, sizeof(buf)); // 阻塞 if (n 0) break; process_request(buf, n); // 可能还要访问数据库也是阻塞 write(fd, response, len); // 阻塞 } close(fd); }2.2 出了什么问题场景玩家 A 在加载大地图资源玩家 A 点击进入副本客户端向网关发送一个 2MB 的资源请求。网关需要把这 2MB 请求转发给资源服等资源服处理完再返回。// 网关转发请求到资源服 send_to_resource_server(req); char response[1024 * 1024 * 2]; int n read(resource_fd, response, sizeof(response)); // 阻塞 3 秒在read(resource_fd)这 3 秒里玩家 B 想登录等着accept在循环里A 没处理完根本轮不到 B。玩家 C 想发个聊天消息等着handle_player卡在 A 的请求里。整个服务器被一个玩家卡死其他所有人都在排队。这就是单线程阻塞模型的致命伤一个请求慢全部请求陪葬。2.3 当时的感受卧槽玩家 A 进个副本全服都登不上去了三、第二版一个连接一个线程 —— 内存炸了3.1 怎么改的你很快想到多线程啊每个玩家一个线程互不干扰while (running) { int fd accept(listen_fd, ...); std::thread([fd]() { // 每个连接开一个线程 handle_player(fd); }).detach(); }3.2 出了什么问题场景在线人数冲到 5000游戏火了同时在线 5000 人。我们打开top一看PID USER PR NI VIRT RES SHR S %CPU %MEM TIME COMMAND 1234 root 20 0 120g 80g ... ... ... ... gateway5000 个线程每个线程栈默认 8MB光是栈就占40GB 虚拟内存。物理内存RSS也有 80GB因为每个线程的栈虽然不能全用但内核要分配页表还要维护线程控制块TCB。CPU 使用率不高但%si软中断爆表时间全花在线程切换上了。更痛苦的场景凌晨 3 点运维打电话来服务器又 OOM 了今天第 3 次一查日志发现连接数只有 8000但内存已经 96GB 撑满。你想我没申请多少内存啊问题不在我们的代码上而在pthread上。每个线程的栈空间、TLS、调度器数据结构累加起来就是天文数字。这就是 C10K 问题的本质线程是有重量的而连接数可以无限增长。3.3 当时的感受一个连接一个线程代码是简单了但 1 万个玩家就要 1 万个线程服务器直接被压垮。 有没有一种东西像线程一样能独立执行但又像普通对象一样轻量四、第三版引入协程但没有调度器 —— 噩梦刚刚开始4.1 怎么改的这时候你听说了协程这个东西用户态轻量级线程切换不需要进内核占用的栈可以很小比如 128KB一个线程上可以跑几千个协程。于是你引入了 sylar 的Fiber改成这样// 主线程里为每个连接创建一个协程 void on_new_connection(int fd) { Fiber::ptr f(new Fiber([fd]() { handle_player(fd); })); f-swapIn(); // 切换到协程执行 }handle_player里面也用协程的方式void handle_player(int fd) { while (true) { char buf[1024]; int n read(fd, buf, sizeof(buf)); // 数据没到阻塞 // ... } }4.2 出了什么问题问题 1协程阻塞了整个线程还是冻住协程里调了read()这是系统调用会阻塞整个线程。一个线程上跑了 1000 个协程其中一个协程阻塞了另外 999 个协程全停摆。协程本身没有解决阻塞问题它只是能切换但你得知道什么时候该切换、切换完谁去执行别的。问题 2没有调度器新连接来了谁处理你开了 4 个线程每个线程上跑 1000 个协程。主循环大概长这样// 第三版没有调度器手动管理协程 std::vectorFiber::ptr fibers; // 所有协程放这里 void thread_main() { while (running) { for (auto f : fibers) { if (f-getState() Fiber::HOLD) { // 这个协程之前让出了现在要不要恢复 // 怎么判断它等待的事情比如IO好了没 // 只能轮询每个协程问一遍你好没 } } // 新连接怎么加入加锁往 fibers 里 push_back // 但 4 个线程都在遍历 fibers加锁竞争严重 } }场景新玩家 E 登录玩家 E 的 TCP 连接建立accept返回了 fd。现在你要把 E 的处理协程交给某个线程执行。但你发现线程 1 上 1000 个协程全阻塞在数据库查询。线程 2 上 1000 个协程全阻塞在资源服请求。线程 3 上有个协程刚让出 CPU但它让出之后线程 3 怎么知道该执行别的协程没有中央调度器 没有任务分发系统。每个线程各自为政新任务来了不知道该给谁。负载严重不均衡有的线程忙死有的线程闲死。问题 3协程让出后谁来唤醒玩家 F 发了一条聊天消息协程处理到一半需要等数据库返回结果。协程YieldToHold()让出了。数据库结果回来了。谁负责找到玩家 F 对应的协程把它重新放入可执行队列没有调度器你只能这样写// 数据库结果回来的回调里 void on_db_result(uint64_t player_id, Data data) { // player_id - 找到协程 - 恢复执行 // 但协程在哪个线程上怎么安全地恢复 // 加锁遍历所有线程的所有协程性能爆炸。 }4.3 当时的感受协程是好东西但用起来比线程还麻烦。 我写了 200 行主循环逻辑就为了决定接下来该执行哪个协程这代码根本维护不了。 我需要一个秘书帮我把任务分配给线程协程让出了自动重新排队。这就是 Scheduler 的诞生背景。五、第四版有了调度器但 IO 还是阻塞 —— 数据库一查全服卡顿5.1 怎么改的你实现了SchedulerScheduler scheduler(4, true, gateway); // 4 个线程 void on_new_connection(int fd) { scheduler.schedule([fd]() { handle_player(fd); // 把这个任务交给调度器 }); }调度器内部有一个m_fibers队列4 个线程竞争取任务负载均衡了。协程让出后会重新入队不需要你手动管理。5.2 出了什么问题场景排行榜查询玩家 G 点击查看全服排行榜请求到网关。网关需要查 MySQL// 在协程里执行的代码 void handle_rank_request(int fd) { send_to_db(SELECT * FROM rank ORDER BY score DESC LIMIT 100); char result[10240]; int n read(db_fd, result, sizeof(result)); // 数据库查询花了 2 秒 // read 阻塞了整个线程 write(fd, result, n); }在read(db_fd)这 2 秒里这个线程上还有 50 个其他协程在等待执行。这 50 个协程里有玩家 H 的登录请求、玩家 I 的购买道具请求、玩家 J 的聊天消息。全被卡住了。玩家 H 点了登录界面转圈 2 秒才进去。场景资源服过载游戏有个功能是下载玩家头像头像存在资源服。资源服今天网络抖动响应从 50ms 变成了 500ms。网关向资源服请求头像int n write(resource_fd, req, len); // 发请求 char buf[1024 * 64]; int n read(resource_fd, buf, sizeof(buf)); // 等 500ms网关开了 4 个线程每个线程处理大量协程。资源服一慢4 个线程上所有等待资源服的协程都把线程阻塞了。结果实际上只有 10% 的请求是访问资源服的。但 100% 的请求都变卡了因为线程被阻塞调度器无线程可用。这就是一个协程的阻塞 IO 拖垮整个调度器的问题。5.3 你尝试的非阻塞方案你想到把 fd 设为非阻塞不就行了fcntl(fd, F_SETFL, O_NONBLOCK);于是代码变成void handle_rank_request(int fd) { send_to_db(SELECT ...); char result[10240]; while (true) { int n read(db_fd, result, sizeof(result)); if (n 0) break; if (errno EAGAIN) { // 数据还没到... 怎么办 // 方案 A忙等 // continue; // CPU 100% // 方案 Bsleep usleep(1000); // 睡 1ms精度差响应慢 continue; // 方案 Cyield但谁来唤醒 // Fiber::YieldToHold(); // 然后... 没人唤醒这个协程它永远沉睡 } } }方案 A忙等CPU 飙到 100%服务器变成了电暖器。方案 Bsleep每次 sleep 1ms累积下来响应延迟巨大。而且 sleep 期间这个线程不能干别的虽然没阻塞内核但也没在干活。方案 Cyield协程让出了但没有机制在数据准备好时唤醒它。协程永远沉睡在HOLD状态直到服务器重启。5.4 你尝试的 epoll 回调方案你学过 C系统编程知道 epoll。于是你写了一套 epoll 回调的机制// epoll 告诉你 fd 可读了回调这个函数 void on_db_fd_readable(int db_fd) { // 但此时你根本不知道这个 fd 对应的是哪个玩家的哪个请求 // 你得自己维护一个 mapfd - player_id - request_state // ... }一个排行榜查询的处理逻辑被你拆成了 4 个回调on_connect连接数据库on_db_writable发送查询请求on_db_readable读取查询结果on_response_sent把结果写给玩家原本 20 行的同步逻辑变成了分布在 4 个文件里的 200 行回调。调试的时候你在日志里看到报错但根本不知道这个回调属于哪个玩家、哪个请求。这就是回调地狱Callback Hell。5.5 当时的感受协程让出后没人唤醒这跟协程没用有什么区别 epoll 是好但回调写法太恶心了一个请求拆成 5 个函数Debug 要命。 我就想写read(fd, buf)数据没到自动挂起数据到了自动继续很难吗这就是 IOManager 的诞生背景。六、第五版有了 IOManager —— 但僵尸连接吃光内存6.1 怎么改的你实现了IOManager它把 epoll、调度器、协程三者粘合在一起。现在你的代码看起来终于正常了void handle_rank_request(int fd) { send_to_db(SELECT ...); char result[10240]; int n read(db_fd, result, sizeof(result)); // 看起来是阻塞 read // 实际上数据没到 → 协程自动挂起 → 线程去干别的 // 数据到了 → epoll 通知 → 协程自动恢复 → 从这行继续执行 write(fd, result, n); }一个请求的处理逻辑完整地写在一个函数里没有回调没有碎片化。6.2 出了什么问题场景 1玩家手机断网服务器一无所知玩家 K 在地铁里玩游戏进隧道了手机没网。但玩家 K 的 TCP 连接还在服务器上挂着。为什么因为 TCP 连接不会自动感知对端断网。除非对端发 RST/FIN 包或者服务器尝试发数据收到超时。玩家 K 在隧道里待了 5 分钟出隧道后重新登录新建了一个连接。但老连接还在服务器上。老连接占用一个 fd。老连接占用一个协程对象128KB 栈。老连接占用一个FdContext。如果每天有 10% 的玩家经历这种闪断重连10 万在线就意味着有 1 万个僵尸连接。一周后运维又打电话来服务器 fd 耗尽了ulimit -n设的 65535但全用完了你去查lsof -p pid | wc -l65535。其中 60% 是TCP ESTABLISHED但这些连接已经几个小时没数据了——它们的主人早就重新登录了。没有定时器你无法做空闲连接超时。场景 2排行榜查询又出事了排行榜查询的 SQL 语句没加索引。今天全服冲榜数据库压力暴增。玩家 L 点了排行榜handle_rank_request协程开始执行向数据库发请求。数据库 30 秒后才返回。在这 30 秒里玩家 L 的协程一直存在占着 128KB 栈内存。玩家 L 的 TCP 连接一直存在占着一个 fd。玩家 L 可能早就关掉排行榜界面了但服务器还在傻等数据库。如果同时有 100 个玩家点了排行榜就是100 个协程 × 30 秒 × 128KB 12.8MB 内存被白白占用。而且这 100 个协程持有的数据库连接也被占着新的查询进不来。没有定时器你无法做请求超时。场景 3心跳检测怎么做你的协议规定客户端每 30 秒发一个心跳包服务端如果 90 秒没收到心跳就断开连接。没有定时器你只能在主循环里写while (running) { for (auto conn : all_connections) { if (now() - conn.last_heartbeat 90000) { close(conn.fd); } } sleep(1); }10 万个连接每秒遍历一次就是 10 万次比较。sleep(1)精度只有 1 秒实际上第 89 秒该断的可能拖到第 90 秒才断。你想做毫秒级超时不可能。连接数涨到 100 万时这遍历循环本身就是性能瓶颈。场景 4游戏技能 CD玩家 M 放了一个大招技能冷却 5 秒。5 秒内不能再次释放。这个5 秒后允许释放怎么实现// 没有定时器的丑陋写法 uint64_t last_cast_time now(); void on_player_cast_skill() { if (now() - last_cast_time 5000) { return 技能冷却中; } // 放技能... last_cast_time now(); }这个写法能工作但它是轮询式的每次玩家点技能都要检查一次。如果你有 1000 个技能的 CD 要管理你不可能为每个技能写一个last_cast_time变量然后在某个地方统一轮询。你需要的是5 秒后自动触发一个回调把技能状态设为可用。6.3 当时的感受连接超时、请求超时、心跳检测、技能 CD、延迟重试…… 到处都是过一段时间再做某事但我没有任何工具能优雅地管理这些定时任务。 我总不能每秒遍历 10 万个连接检查超时吧这就是 TimerManager 的诞生背景。七、最终版三者合体才是完全体现在你把 Scheduler、TimerManager、IOManager 都实现了。让我们看看同样的场景现在怎么处理。7.1 场景排行榜查询带超时 非阻塞 IO 调度void handle_rank_request(int fd) { // 1. 注册一个 5 秒超时的定时器 auto timer IOManager::GetThis()-addTimer(5000, [fd]() { // 5 秒后数据库还没回来强制返回超时 send_error(fd, 查询超时); close_connection(fd); }); // 2. 发送数据库请求 send_to_db(SELECT ...); // 3. 读取结果——同步写法异步执行 char result[10240]; int n read(db_fd, result, sizeof(result)); // 数据没到协程自动 yield线程去处理别的玩家 // 数据到了epoll 唤醒协程恢复继续执行这行 // 4. 数据库回来了取消超时定时器 timer-cancel(); // 5. 返回结果 write(fd, result, n); }发生了什么阶段没有三个组件有了三个组件发请求read阻塞 2 秒线程上 50 个协程全卡住数据没到 →YieldToHold()→ 线程去服务别人数据回来线程阻塞结束read返回epoll 触发 →triggerEvent→schedule→swapIn恢复数据库卡了协程傻等 30 秒占内存占连接5 秒定时器触发强制断开释放资源代码写法回调地狱4 个回调函数一个函数写完像同步代码7.2 场景僵尸连接清理// 连接建立时注册一个 30 秒空闲超时定时器 auto idle_timer iomanager-addTimer(30000, [fd]() { close_connection(fd); log(fd%d 空闲 30 秒自动断开, fd); }, false); // 每次收到数据刷新定时器 void on_data_received(int fd) { idle_timer-refresh(); // 重新计时 30 秒 process_packet(...); }玩家断网重连老连接 30 秒后自动断开fd 被回收。不再轮询 10 万个连接refresh()是 O(log N)。精度是毫秒级的不是sleep(1)的秒级。7.3 场景心跳检测// 每 30 秒检查一次心跳 auto heartbeat_timer iomanager-addConditionTimer(30000, [fd]() { if (now() - last_heartbeat_time 90000) { close_connection(fd); } }, weak_ptr_to_connection, true); // recurring true循环执行weak_ptr_to_connection如果连接对象已经被销毁比如正常断开了定时器回调不会执行。recurring true每 30 秒自动触发一次不需要手动重新注册。7.4 场景技能 CDvoid on_cast_skill(Player* player) { if (player-skill_cooling) { return 技能冷却中; } player-skill_cooling true; // 5 秒后自动解除冷却 iomanager-addTimer(5000, [player]() { player-skill_cooling false; }); // 执行技能... }不需要轮询不需要last_cast_time。5 秒后自动执行回调代码干净、准确、不浪费 CPU。八、如果没有这三个组件系统会怎样没有 Scheduler真实场景游戏开新服5000 人同时涌入 问题表现 ├─ 你开了 4 个线程处理连接但线程 1 上的玩家全在查数据库阻塞 ├─ 线程 2、3、4 上的协程闲着但新连接被分到了线程 1 ├─ 玩家投诉为什么我朋友能进我一直卡加载 90% ├─ 你在主循环里写了 300 行 if-else 来手动分配任务Bug 修不完 └─ 最后被迫改成一个连接一个线程内存又炸了没有 TimerManager真实场景线上运行 7 天 问题表现 ├─ fd 耗尽新玩家无法登录 ├─ 查日志发现 40% 的连接 6 小时没数据玩家早闪退了 ├─ 数据库慢查询堆积因为没有请求超时机制 ├─ 内存缓慢增长因为僵尸协程永远不会释放 ├─ 运维写了个 crontab 每天凌晨 3 点重启服务器传说中的定时重启保平安 └─ 玩家投诉怎么每天凌晨都掉线没有 IOManager真实场景任何涉及 IO 的操作 问题表现 ├─ 玩家查排行榜全服卡顿 2 秒 ├─ 玩家下载头像资源服一抖网关线程全冻结 ├─ 你改成非阻塞但 EAGAIN 后不知道怎么办 ├─ 你改成 epoll 回调一个请求拆成 5 个函数Debug 想死 ├─ 新同事入职看了 3 天代码说这回调链我跟不进去 └─ 协程变成了摆设能切换但切换完没人管三者都缺这就是阻塞多线程服务器的最终形态真实场景10 万同时在线的目标 结果 ├─ 一个连接一个线程 → 10 万个线程 ├─ 每个线程 8MB 栈 → 80GB 虚拟内存 ├─ 线程切换开销 → CPU 80% 时间在切线程 ├─ 阻塞 IO → 线程大部分时间闲着等网络/数据库 ├─ 没有超时 → 内存泄漏 连接堆积 ├─ 没有心跳 → 僵尸连接吃光 fd └─ 实际能支撑的并发几千人C10K 瓶颈九、总结组件它是在哪个真实场景里被逼出来的没有它你会在凌晨 3 点接到什么电话Scheduler一个玩家卡死全服、线程负载不均、协程让出后没人管新服开了玩家说有人能进有人进不去是不是你的负载均衡有问题TimerManagerfd 耗尽、数据库慢查询拖垮全服、每天凌晨必须重启服务器服务器又 OOM 了今天第 3 次你那个遍历 10 万连接的超时检查能不能优化IOManager数据库一查全服卡顿、非阻塞 IO 不会写、epoll 回调地狱查个排行榜全服卡 2 秒还有你那代码一个请求拆 5 个回调我怎么 Debug记住这三个画面Scheduler秘书把任务分配给几个员工没任务时员工休息有任务时自动唤醒。TimerManager厨房里每个锅都有个定时器到点了自动响不用厨师每隔一秒抬头看一次。IOManager服务员等上菜时可以去服务别的桌菜好了厨房自动喊他回来——而不是傻站着等也不是满餐厅跑来跑去看哪个菜好了。十、学习验证清单读完这个故事你应该能向一个没写过服务器的人讲清楚一个连接一个线程为什么撑不到 1 万人。描述回调地狱的具体痛苦一个请求被拆成几个回调Debug 时失去了什么信息解释为什么协程本身不够——有了协程为什么还要 IOManager说出 3 个没有定时器会导致线上事故的具体场景。说明 tickle 管道在真实业务中的作用如果所有线程都在epoll_wait新用户登录的请求谁来处理比较遍历 10 万个连接检查超时和定时器 set在复杂度和精度上的差异。结语我相信各位看完本篇博客对于这三个东西是怎么来的也是非常通透了当然此处还有很多项目优化值得我们拿来说一说比如百万并发的时候怎么优化定时器是set那么这个数据结构能不能优化这些后续有机会的话也会拿来说说。那么下一次博客就该我们这三个东西的代码环节了希望这个专栏能帮到你。我是YYYing后面还有更精彩的内容希望各位能多多关注支持一下主包。无限进步我们下次再见---⭐️封面自取⭐️---