尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

sylar源码精读:从协程调度到epoll异步IO,掌握C++高性能服务器框架设计

sylar源码精读:从协程调度到epoll异步IO,掌握C++高性能服务器框架设计 先聊点实在的。作为一个写了好几年业务C的后端我很久都没找到那种“读起来会忍不住拍大腿”的源码项目直到完整过了一遍sylar这个开源的高性能服务器框架才找回当年看STL源码时那种“原来还能这么设计”的兴奋感。Sylar不是某个商业项目的阉割版它是一位资深C工程师完全从零手写的一套服务端基础设施把日志、配置、线程、协程、IO调度、hook、HTTP解析这些后端开发天天打交道的组件全部用一套自洽的设计串了起来。如果你正处在“能写业务接口但说不出Web服务底层到底怎么跑”的阶段或者打算从事后端中间件、游戏服务器、网关方向的工作这个项目值得你花几周时间精读。这篇系列的第一篇我先不讲某个具体模块的源码逐行注释而是把整个项目当作一个“黑盒里的白盒”来拆它解决了什么问题、模块之间怎么协同、怎么把它编译运行起来、以及最重要的——按什么顺序去读源码才能不走弯路。1. 为什么说sylar是C后端开发的“第二本教科书”1.1 它到底解决了什么问题先忘掉代码想一想服务器开发最核心的痛点在哪里。你写一个HTTP服务本质上就是在反复处理“等待”——等待新连接、等待请求数据、等待数据库返回、等待下一个定时任务。传统阻塞式写法一个线程只能处理一条连接线程多了上下文切换成本就爆炸事件驱动写法比如非阻塞IO加回调性能上去了但为了不让回调地狱把逻辑打碎你还得自己设计状态机。Sylar给出的方案是把这两条路合并成一条底层用epoll做真正的事件监听上层却用协程把异步逻辑“伪装”成同步代码。你写业务的时候看起来是一行一行顺序执行实际上在等待IO的瞬间调度器已经悄悄把这个协程挂起切到别的任务上去了。用户态协程的切换成本远低于线程切换所以它既能保持高并发又能让代码像同步阻塞一样好读好写。1.2 比学框架本身更重要的是学习设计思路市面上有很多服务器框架但大多数是“面向业务”的重框架里面已经帮你把路由、拦截器、序列化都定死了。你学完之后往往学到的是“这个框架怎么用”而不是“这个框架为什么这么设计”。Sylar不一样它是一整套服务端的“基础设施层”换句话说它更像你在造轮子之前先要搭的那套底座。我读这个项目最大的收获不在于某个函数的实现而是它让我看到了一个有经验的工程师是怎么把工程上的脏活累活合理安排的。比如日志模块要支持多线程并发写他是怎么用线程局部变量加格式缓存来减少锁竞争配置模块要能监听yaml文件热更新他是怎么定义配置项的变更回调协程一旦被调度器踢出去之后栈空间又是怎么复用的。这些设计背后都是真实的生产问题不是教科书里的空理论。1.3 学完能获得什么能力层级我自己梳理了一下完整啃完sylar技术和视野上至少能提升这么几层第一层能自己画出协程调度的状态迁移图知道协程什么时候被创建、被挂起、被销毁。第二层能解释为什么加了hook之后普通的socket读写在不改业务代码的情况下就变“异步”了。第三层遇到线上连接数上不去、CPU空转一类问题时脑子里会自然浮现出“是不是事件循环空转”“是不是epoll超时设置有问题”这类排查方向。第四层自己动手写小工具或者内部服务时能直接用上它那一套线程池加协程调度的骨架而不是每次都从pthread_create开始造。这不只是一个框架更像是一堂服务端系统的完整实践课。2. 框架初印象sylar的整体模块划分2.1 核心模块总览刚开始打开sylar仓库的人往往会先被目录里的那一堆模块名吓到。其实它内部模块之间的依赖关系很清晰我整理了一张简表方便你先建立全局印象。模块核心职责关键技术与数据结构log模块日志分类、格式化、多输出目标继承自Logger的LogAppender体系、线程局部变量缓存config模块yaml配置文件解析、配置项变更回调基于yaml-cpp、配置项注册表、自定义类型lexical_castthread模块线程、互斥量、信号量、读写锁封装pthread封装、RAII风格锁、线程局部指针fiber模块协程创建、切换、栈管理ucontext上下文切换、协程栈按需分配/复用scheduler模块协程调度线程池任务队列、调度线程、idle协程、线程局部调度器指针iomanager模块基于epoll的IO事件调度epoll红黑树、fd事件上下文、Timer定时器最小堆hook模块系统调用拦截改写函数指针替换、fd上下文管理、非阻塞IO核心socket模块socket封装与地址解析基于iomanager的异步connect/read/writehttp模块HTTP解析、HTTP服务器http_parser状态机、HttpSession、HttpServerstream模块字节流抽象、socket流实现面向流的接口抽象适配http、bytearray等上层场景这十个模块之间的关系简单来说就是底层线程模块负责“真正干活的人”协程模块负责“干活的人怎么切换”调度器负责“谁在什么时候干活”IO调度器负责“怎么知道活来了”hook则把“活来了”的信号接到业务代码里让业务代码感知不到调度的存在。2.2 不只是一个Web服务器有人看到sylar里带了HTTP解析、HTTP服务器示例就以为这是一个Web框架。这么理解就把格局看小了。HTTP服务在sylar里只是用来验证整套调度系统可用性的一个“形态演示”真正的内核是那套协程调度加IO事件循环的底座。你完全可以用它在上面改出RPC框架、即时通讯网关、游戏逻辑服甚至消息推送系统。我后来的实践也验证了这一点我把它的scheduler和iomanager剥离出来套到自己一个内部采集代理上只是替换了业务回调整个并发模型几乎是白嫖过来的。底层底座设计得稳上层套什么业务都稳。2.3 协程调度器框架的心脏学习sylar一定要抓住“协程调度器”这条主线。它决定了三个关键问题多少个线程在跑任务对应线程池的大小。任务是怎么被取出来执行的对应调度队列sylar用的是多个无锁队列加原子操作避免多线程同时pop时锁竞争。没有任务的时候调度线程在干什么一般情况下会跑一个idle协程原地等待或者阻塞在epoll_wait上把CPU让出来。换句话说你写业务代码的时候调用的scheduler-schedule()只是在任务队列里塞了一个待执行协程真正触发它执行要等某个工作线程从队列里把它抢出来。这套模型理解透了后面看几个关键类的代码都会顺畅很多。3. 把sylar跑起来环境、编译、第一个示例3.1 依赖环境准备Sylar不是那种一键安装的库但它对环境的依赖并不复杂我在Ubuntu 20.04上从头到尾跑通过整个过程比较顺。需要准备的依赖有编译器gcc 5.4以上推荐gcc 7以上因为部分代码用了较新的C11/14特性。cmake3.5以上即可。boost库sylar用到boost的智能指针、类型转换等基础功能通常boost库路径在/usr/include/boost。yaml-cpp配置模块强依赖这是最容易缺的一个。openssl编译支持HTTPS等相关功能时会用到。Ubuntu下装依赖直接一行命令搞定sudo apt update sudo apt install -y build-essential cmake libboost-all-dev libyaml-cpp-dev libssl-dev如果你的系统是CentOS或者RHEL包名略有差别对应大概是yum install boost-devel yaml-cpp-devel openssl-devel。如果libyaml-cpp-dev装不上也可以从源码编译yaml-cpp再把它的安装路径通过CMAKE_PREFIX_PATH指给sylar的构建系统。3.2 克隆、构建、遇到坑怎么办我习惯把第三方项目统一放在~/workspace下然后执行cd ~/workspace git clone https://github.com/sylar-yin/sylar.git cd sylar mkdir build cd build cmake .. make -j$(nproc)这里有两个我实际踩过的小坑。第一个是如果只装了base的build-essential没有装libyaml-cpp-dev编译到config模块时会直接报“找不到yaml.h”解决办法就是回到依赖安装那一步把yaml-cpp装上。第二个是boost版本如果太老可能会在编译thread模块时报一些模板实例化错误不用纠结直接升级boost或者改用系统自带的完整boost包。另外编译产物默认不大因为sylar编译出来的主要是静态库和测试程序。如果你只是先验证编译不看测试代码那编译时间大概在一两分钟以内里面有很多test文件会被一起编译所以如果你的机器核数少建议把-j后面的数字调小一点避免内存打满。3.3 写一个最小的调度器验证程序编译通过之后最好再写个最简程序确认运行时调度器是正常的。直接编译出来的测试程序里其实也有类似功能但自己动手写一遍印象会深得多。新建一个test_scheduler.cpp#include sylar/sylar.h #include iostream static sylar::Logger::ptr g_logger SYLAR_LOG_ROOT(); void test_fiber() { static int s_count 0; SYLAR_LOG_INFO(g_logger) test_fiber begin, count s_count; sleep(1); // 这里会被hook让出CPU给其他协程 SYLAR_LOG_INFO(g_logger) test_fiber end, count s_count; s_count; } int main(int argc, char** argv) { sylar::Scheduler sc(3, true, test_scheduler); sc.start(); for (int i 0; i 20; i) { sylar::Fiber::ptr fiber(new sylar::Fiber(std::bind(test_fiber))); sc.schedule(fiber); } sc.stop(); return 0; }编译运行时记得链接sylar库和依赖库g -stdc11 test_scheduler.cpp -I../ -L./build -lsylar -lyaml-cpp -lboost_system -lpthread -ldl -o test_scheduler ./test_scheduler如果一切正常你应该能在终端看到每个test_fiber begin和end成对出现而且能看到日志里表示调度器启动和停止的输出。这行日志看着不起眼但它是后面理解一切的基础——“调度线程起来了”“协程被调度执行了”“协程执行完被回收了”都会在这套日志体系里被体现。3.4 编译和运行时的注意事项这里分享一下我实际使用中的几个小经验。首先是编译release版本时建议把优化级别从默认改成-O2因为sylar自身代码里做了一些基于编译器优化的行为比如强制内联、分支预测如果用-O0跑压力测试性能差别还是蛮明显的。其次如果程序一启动就段错误大概率不是代码问题而是栈空间设置的问题协程默认栈大小在sylar里是128KB如果你的业务里递归很深需要调大。还有一点sylar的日志系统默认输出到stdout如果跑压力测试时日志量太大会把IO拖慢。测试阶段可以在配置里把日志级别调成ERROR或者直接关掉日志输出避免刷屏影响你观察真实性能。4. 一份务实的源码阅读路线图4.1 千万不要按目录顺序读很多第一次接触sylar的人拿到源码后喜欢从sylar.h开始一路点进去结果很快就迷失在宏、类型定义和智能指针的海洋里。我一开始也是这样后来换了策略效率明显提升。源码阅读必须一条主线往深挖我的建议是按这个顺序走工具与基础类bytearray、endian、macro、singleton、noncopyable日志与配置log模块、config模块线程与锁thread、mutex、semaphore协程fiber调度器scheduler事件循环iomanager系统调用改造hook fd_manager网络串联socket address tcp_server http这个顺序的底层逻辑是后一个模块的代码里一定会用到前一个模块的接口。你按照依赖关系读读到新模块时不会觉得突兀。4.2 第一阶段先把“地基”扫清楚很多人在读log和config时觉得枯燥我恰恰觉得这部分最适合入门。因为日志模块里几乎用到了整个项目所有基础模式单例、宏定义、继承、多态、线程局部变量。你把log模块啃下来后面读任何模块看到类似的宏或者接口风格都不会懵。config模块也很值它不仅教你用yaml-cpp还教你怎么设计一个“能热更新”的配置系统。它会读取yaml配置文件把每个配置项映射成一个ConfigVar对象业务代码通过ConfigVar去拿值。如果配置文件发生了变化它还能触发你的回调。这个模式在大型服务端项目里非常实用你可以直接抄到自己的项目里。读这一阶段时不要求背代码但要搞清楚三个问题宏SYLAR_LOG_INFO展开后到底做了什么单例模板的GetInstance是怎么获得实例的ConfigVar是怎么根据名称从配置中心查值的这三个问题想明白基础层就算过。4.3 第二阶段抓住fiber和scheduler这对核心搭档协程模块是sylar的灵魂。这个阶段我会建议精读fiber.cpp里的Fiber类重点关注五个方法构造函数、swapIn、swapOut、reset、kill。看懂它们你就能画出协程的状态转换图创建协程后它是READY状态还没有实际运行。调度器选中它后swapIn把当前上下文保存下来切换进协程运行。协程里发生IO等待或者主动让出CPU时swapOut切回调度器上下文。协程函数跑完状态变成TERM栈空间可以被重置复用。scheduler模块则是在协程之上的调度层。它维护一个工作线程数组每个线程里跑着一个调度协程这个调度协程的循环体逻辑是不断从任务队列拿任务拿到一个Fiber就swapIn跑完或者挂起就换下一个。没有任务时调度协程会变成一个idle协程让出CPU或者阻塞等待。读到这里你可以盯着代码想一个问题为什么sylar的调度器能用较少的线程跑海量协程答案就是线程不需要一对一绑定协程线程可以反复调度执行不同的协程。这就是用户态协程相对内核线程最大的优势之一。4.4 第三阶段iomanager加hook理解“看不见的异步”如果说scheduler解决了“谁去执行任务”那iomanager解决的是“什么时候知道任务能执行”。它基于epoll你在iomanager上注册一个fd的读写事件epoll会在事件就绪时通知调度器调度器再把对应协程重新唤醒。hook模块是sylar最巧妙的设计。它的原理不复杂在程序启动时把read、write、connect、sleep这些可能阻塞的系统的调用通过动态链接篡改替换成sylar自己实现的版本。替换后的函数会检查当前fd是不是非阻塞模式如果是它就不真的傻等而是向iomanager注册事件后让出CPU等数据可读或者可写时调度器再唤醒协程从当初挂起的地方继续往下执行。这样一来你代码里写的read(fd, buf, size)表面上没有变背后却已经是异步的了。这也是sylar最适合做网络业务的原因业务代码可以用同步思维去写性能却拿到了异步事件驱动的收益。这个阶段读代码时可以自己写一个很小的echo server测试体会一下“没改业务代码并发却上去了”的感觉。4.5 第四阶段从socket到http把整条链路串起来走到这一步你已经具备把上层网络组装起来的能力。socket模块封装了socket的创建、监听、连接、读写全部和iomanager互通。当你在一个协程里调用socket-accept()时底层会自动注册可读事件有连接来了再唤醒。http模块则是这套底层能力的“落地演示”。它提供一个HttpServer类内部主要是TcpServer加HttpSession的组合收到连接后解析请求、构造响应、再通过socket返回。到这里你脑子里要形成一张完整链路图连接进来 - epoll告诉我们可读 - 调度器唤醒协程 - 协程里解析HTTP - 业务逻辑处理 - 写回muduo、libevent这类库好像是“别人的故事”但sylar是你可以亲手拆开、再自己组装回去的实验场。5. 我在阅读和运行sylar时踩过的坑5.1 编译阶段的几个高频报错sylar的编译整体比较顺畅但我也在群里看到过不少新手卡在编译上。我整理了一下几个典型场景。报错现象常见原因解决办法fatal error: yaml.h: No such file or directory系统没有安装yaml-cpp开发库sudo apt install libyaml-cpp-dev或源码编译yaml-cppundefined reference toboost::...boost库版本太老或缺少基础库安装libboost-all-dev编译时显式加-lboost_system找不到ucontext相关函数glibc版本太低或缺少头文件确保#include ucontext.h链接时加-ldl程序能编译运行时立刻死循环可能调度器没有正确初始化检查是否调用了scheduler-start()再schedule任务5.2 协程相关崩溃的排查方法协程阶段最容易遇到的是段错误。我遇到过一次比较典型的在子协程内部又创建了一个新的Scheduler。sylar的设计里每个线程最多只能有一个调度器实例你在线程里的协程内部再套一个调度器会破坏线程局部存储的调度器指针一执行就crash。排查方法是在崩溃处bt看调用栈如果看到fiber相关函数基本就能往这个方向想。另一个是栈空间问题。sylar默认协程栈大小是128KB对嵌套递归很深的业务来说可能不够。你可以通过修改Fiber类里的栈大小参数或者直接改用mmap的方式分配栈。如果只是测试建议先把优化关掉、加上-g选项编译再用gdb定位比瞎试快得多。5.3 hook后的一些隐蔽问题hook并不是万能的它有几个使用上的前提。比如hook的read/write只对非阻塞fd生效。你如果自己创建一个socket但没有设置成非阻塞模式那么即使在协程里调用read它仍然会真的阻塞住整个线程进而阻塞其他协程。这个坑非常隐蔽因为我一开始测试时也没注意日志看起来像是调度器卡住了实际上是被某个协程的阻塞read给堵死了。解决方法是创建socket后记得设置非阻塞或者用sylar::Socket类型而不是裸socket。Sylar自己在Socket模块里封好了setNonBlock正常情况下不会踩这个坑但你自己写原生socket测试时最容易碰到。5.4 调度器不退出、CPU空转有段时间我跑一个测试程序发现进程根本不退出CPU占用还很高。用perf一看卡在epoll_wait还是忙等。原因是我的调度器里有接近0的定时任务每隔几十毫秒就触发一次导致epoll_wait频繁超时返回。如果定时器过于密集调度器就会一直在“超时-重新epoll_wait”的循环里跑CPU自然降不下来。这个问题的排查思路先看日志里是不是有大量定时器触发的记录如果有检查timer的触发间隔是否合理如果没有去iomanager的epoll_wait超时时间设置上找原因。一般来说没活干的时候idle协程应该阻塞在epoll_wait上而不是忙转看到CPU跑满就说明阻塞逻辑可能被hook影响或者超时设置太短。5.5 常见问题速查最后给一张快速定位表适合你在实际开发中对照排查。症状可能原因优先排查项协程被调度后不执行调度器没有启动或任务队列为空检查scheduler-start()和schedule调用顺序程序卡住不返回有协程在阻塞式IO上等待检查socket是否非阻塞调度线程退出异常线程局部调度器指针为空确认该线程是调度器创建的工作线程日志刷新很慢日志输出到stdout且量太大调高日志级别或用日志文件输出定时器不触发Timer插入时间不合法检查是否用了相对时间对比绝对时间的转换逻辑5.6 调试技巧背靠img工具不如把手动日志打好跟踪协程调度问题时gdb虽然能用但它对用户态协程的栈结构支持并不太好因为协程切换是发生在用户态上下文gdb并不总能展示出正在运行协程的调用栈。我的经验是在Fiber类的swapIn/swapOut关键路径上临时加日志打印当前线程id、协程id、状态比看汇编靠谱得多。等你定位到具体方向后再把日志摘掉。另外sylar自带的日志分类很多你可以在启动程序时通过配置项把对应类别的日志级别调低这样就不需要到处改代码去加输出。真要走到gdb那一步也建议打开core dump先拿到core文件再说。写在最后的学习节奏建议我个人从开始接触到比较顺畅地读懂sylar大致花了两个多星期的业余时间每天晚上读两三个小时。前期最耗时间的是fiber和scheduler两个文件反复看了好几遍后面顺着iomanager、hook延伸到socket和http时速度明显加快因为很多设计套路已经在前面的模块里见过了。如果你也想认真吃透这个项目我的建议是别急着跑通所有demo先确保自己的环境能编译、能运行最小示例然后一次只啃一个模块边读边写验证代码。你可以试着把sylar的log模块换成自己实现的简单日志再把fiber替换成自己写的ucontext版本真到这一步你就从一个“读源码的人”变成了“手写框架的人”。下一篇系列文章我打算直接进到协程模块里把fiber从构造函数到上下文切换的每个细节拉出来过一遍再配合两个可以运行的最小示例把协程的创建、切换、终止讲清楚。
返回列表