sylar

发布时间 2023-09-08 18:05:55作者: yytarget
# 日志模块 * 这里要实现通过g_logger打印一条INFO级别的消息。那么,首先判断INFO级别是否高于g_logger本身的日志级别(这里的设计与原版sylar相反,数字越小,优先级越高),如果不高于,那if语句执行不到,这条日志也不会打印,否则,临时构造一个LogEventWrap对象,传入日志器g_logger,以及现场构造的日志事件。通过LogEventWrap的getLogEvent()方法拿到日志事件,再用日志事件的流式日志消息成员输出日志消息。由于LogEventWrap是在if语句内部构建的,一旦if语句执行结束,LogEventWrap对象就会析构,日志事件也就会被g_logger进行输出,这个设计可以说是非常巧妙。 ## 待补充与完善 * 目前来看,sylar日志模块已经实现了一个完整的日志框架,并且配合后面的配置模块,可用性很高,待补充与完善的地方主要存在于LogAppender,目前只提供了输出到终端与输出到文件两类LogAppender,但从实际项目来看,以下几种类型的LogAppender都是非常有必要的: 1. Rolling File Appender,循环覆盖写文件 2. Rolling Memory Appender,循环覆盖写内存缓冲区 3. 支持日志文件按大小分片或是按日期分片 4. 支持网络日志服务器,比如syslog # 配置模块 * 简单来说,约定优于配置的背景条件是,一般来说,程序所依赖的配置项都有一个公认的默认值,也就是所谓的约定。这点有可许多可以参考的例子,比如对于一个http网络服务器,服务端口通常都是80端口,对于配置文件夹路径,一般都是conf文件夹,对于数据库目录,一般都是db或data文件夹。对于这些具有公认约定的配置,就不需要麻烦程序员在程序跑起来后再一项一项地指定了,而是可以初始时就将配置项设置成对应的值。这样,程序员就可以只修改那些约定之外的配置项,然后以最小的代价让程序跑起来。 * 约定优于配置的方式可以减少程序员做决定的数量,获得简单的好处,同时兼顾灵活性。 * 在代码上,约定优于配置的思路体现为所有的配置项在定义时都带一个的默认值,以下是一个sylar配置项的示例,这是一个int类型的配置项,名称为tcp.connect.timeout,初始值为5000。 ``` static sylar::ConfigVar::ptr g_tcp_connect_timeout = sylar::Config::Lookup("tcp.connect.timeout", 5000, "tcp connect timeout"); ``` * sylar的配置模块实现的一大难点是类型转换类(仿函数)的偏特化实现。对于每种类型的配置,在对应的ConfigVar模板类实例化时都要提供其FromStr和ToStr两个仿函数,用于实现该类型和YAML字符串的相互转换。由于配置项的类型众多,包括全部的基本数据类型(int, float, double, string等),以及vector/list/set/unordered_set/map/unordered_map这几个复杂数据类型,还有用户自定义的类型。为了简化代码编写,sylar从一个基本类型的转换类开始,特化出了剩余类型的转换类,这个基本类型如下: ``` /** * @brief 类型转换模板类(F 源类型, T 目标类型) */ template class LexicalCast { public: /** * @brief 类型转换 * @param[in] v 源类型值 * @return 返回v转换后的目标类型 * @exception 当类型不可转换时抛出异常 */ T operator()(const F &v) { return boost::lexical_cast(v); } }; ``` * 这里的LexicalCast类是一个仿函数,它支持`LexicalCast()(const F &v)`调用,可将传入的F类型的参数v进行转换,并返回T类型的结果。实际的转换语句是`boost::lexical_cast(v)`。但是,受限于boost::lexical_cast, LexicalCast当前只能实现基本数据类型和std::string的相互转换,不能实现复杂类型的转换,下面的代码可用于演示当前LexicalCast的功能: ``` std::string str1 = LexicalCast()(123); // ok, str1等于"123" int int1 = LexicalCast()("123"); // ok, int1等于123 std::string str2 = LexicalCast()(3.14); // ok,str2等于"3.14" float float2 = LexicalCast()("3.14"); // ok,float2等于3.14 vector v = LexicalCast>()(...); // 错误,LexicalCast目前还不支持实例化T类型为vector的模板参数 std::string s = LexicalCast, std::string>()(...); // 错误,同上 ``` * 为了实现YAML字符串和vector/list/set/unordered_set/map/unordered_map的相互转换,就要对每个类型都进行特化,分别实现其转换类,下面是YAML字符串和vector的相互转换实现: ``` /** * @brief 类型转换模板类片特化(YAML String 转换成 std::vector) */ template class LexicalCast> { public: std::vector operator()(const std::string &v) { YAML::Node node = YAML::Load(v); typename std::vector vec; std::stringstream ss; for (size_t i = 0; i < node.size(); ++i) { ss.str(""); ss << node[i]; vec.push_back(LexicalCast()(ss.str())); } return vec; } }; /** * @brief 类型转换模板类片特化(std::vector 转换成 YAML String) */ template class LexicalCast, std::string> { public: std::string operator()(const std::vector &v) { YAML::Node node(YAML::NodeType::Sequence); for (auto &i : v) { node.push_back(YAML::Load(LexicalCast()(i))); } std::stringstream ss; ss << node; return ss.str(); } }; ``` * 上面分别实现了LexicalCast>和LexicalCast, std::string>,其中在转换单个的数组元素时,再次用到了LexicalCast和LexicalCast,如果这里T是基本数据类型,那么就可以用最开始的基本类型的转换类进行模板实例化并完成转换了,下面是针对vector和YAML字符串相互转换的示例: ``` std::vector v = LexicalCast>()("[1, 2, 3]"); // ok, v等于[1, 2, 3] std::string s = LexicalCast, std::string>()(std::vector{1, 2, 3}); // ok,s等于YAML格式的数组[1,2,3],如下: // - 1 // - 2 // - 3 ``` * 另外,由于这里的模板实例化是可以嵌套的,由vector和vector组合出来的全部类型都可以顺利地实现和YAML的转化,以下是一个二维数组的示例: ``` std::vector> vv = LexicalCast>>()("[[1,2,3],[4,5,6]]"); std::string ss = LexicalCast>, std::string>()(vv); ``` 其他复杂类型的偏特化与vector类型,参考源码理解即可。 * 每实现一个新类型的转换,那这个类型和之前已实现的类型组合出的数据类型也可以顺利实现转换,比如vector, set,set, map, map这种。这种基于偏特化实现类型转换的方法可以说是非常巧妙了,代码可以做到高度简化,但功能却非常强大,这也变相展示了泛型程序设计的强大之处吧。 ## 待补充与完善 1. 整合配置文件与命令行参数,配置项可以用命令行选项进行覆盖,并且在导出时进行标注。 2. 配置项支持校验,参考gflags。 # 线程模块 `CASLock`: 原子锁,基于std::atomic_flag实现 1. 为什么不直接使用C++11提供的thread类。按sylar的描述,因为thread其实也是基于pthread实现的。并且C++11里面没有提供读写互斥量,RWMutex,Spinlock等,在高并发场景,这些对象是经常需要用到的,所以选择自己封装pthread。 2. 关于线程入口函数。sylar的线程只支持void(void)类型的入口函数,不支持给线程传参数,但实际使用时可以结合std::bind来绑定参数,这样就相当于支持任何类型和数量的参数。 3. 关于子线程的执行时机。sylar的线程类可以保证在构造完成之后线程函数一定已经处于运行状态,这是通过一个信号量来实现的,构造函数在创建线程后会一直阻塞,直到线程函数运行并且通知信号量,构造函数才会返回,而构造函数一旦返回,就说明线程函数已经在执行了。 4. 关于线程局部变量。sylar的每个线程都有两个线程局部变量,一个用于存储当前线程的Thread指针,另一个存储线程名称,通过Thread::GetThis()可以拿到当前线程的指针。 5. 关于范围锁。sylar大量使用了范围锁来实现互斥,范围锁是指用类的构造函数来加锁,用析造函数来释放锁。这种方式可以简化锁的操作,也可以避免忘记解锁导致的死锁问题,以下是一个范围锁的示例和说明: ``` sylar::Mutex mutex; { sylar::Mutex::Lock lock(mutex); // 定义lock对象,类型为sylar::Mutex::Lock,传入互斥量,在构造函数中完成加锁操作,如果该锁已经被持有,那构造lock时就会阻塞,直到锁被释放 //临界区操作 ... // 大括号范围结束,所有在该范围内定义的自动变量都会被回收,lock对象被回收时触发析构函数,在析构函数中释放锁 } ``` # 协程模块 ## 理解协程 最简单的理解,可以将协程当成一种看起来花里胡哨,并且使用起来也花里胡哨的函数。 每个协程在创建时都会指定一个入口函数,这点可以类比线程。协程的本质就是函数和函数运行状态的组合 。 协程和函数的不同之处是,函数一旦被调用,只能从头开始执行,直到函数执行结束退出,而协程则可以执行到一半就退出(称为yield),但此时协程并未真正结束,只是暂时让出CPU执行权,在后面适当的时机协程可以重新恢复运行(称为resume),在这段时间里其他的协程可以获得CPU并运行,所以协程也称为轻量级线程。 协程能够半路yield、再重新resume的关键是协程存储了函数在yield时间点的执行状态,这个状态称为协程上下文。协程上下文包含了函数在当前执行状态下的全部CPU寄存器的值,这些寄存器值记录了函数栈帧、代码的执行位置等信息,如果将这些寄存器的值重新设置给CPU,就相当于重新恢复了函数的运行。在Linux系统里这个上下文用ucontext_t结构体来表示,通getcontext()来获取。 ### 与线程区别 搞清楚协程和线程的区别。协程虽然被称为轻量级线程,但在单线程内,协程并不能并发执行,只能是一个协程结束或yield后,再执行另一个协程,而线程则是可以真正并发执行的。其实这点也好理解,毕竟协程只是以一种花里胡哨的方式去运行一个函数,不管实现得如何巧妙,也不可能在单线程里做到同时运行两个函数,否则还要多线程有何用? 因为单线程下协程并不是并发执行,而是顺序执行的,所以不要在协程里使用线程级别的锁来做协程同步,比如pthread_mutex_t。如果一个协程在持有锁之后让出执行,那么同线程的其他任何协程一旦尝试再次持有这个锁,整个线程就锁死了,这和单线程环境下,连续两次对同一个锁进行加锁导致的死锁道理完全一样。 同样是单线程环境下,协程的yield和resume一定是同步进行的,一个协程的yield,必然对应另一个协程的resume,因为线程不可能没有执行主体。并且,协程的yield和resume是完全由应用程序来控制的。与线程不同,线程创建之后,线程的运行和调度也是由操作系统自动完成的,但协程创建后,协程的运行和调度都要由应用程序来完成,就和调用函数一样,所以协程也被称为用户态线程。 所谓创建协程,其实就是把一个函数包装成一个协程对象,然后再用协程的方式把这个函数跑起来;所谓协程调度,其实就是创建一批的协程对象,然后再创建一个调度协程,通过调度协程把这些协程对象一个一个消化掉(协程可以在被调度时继续向调度器添加新的调度任务);所谓IO协程调度,其实就是在调度协程时,如果发现这个协程在等待IO就绪,那就先让这个协程让出执行权,等对应的IO就绪后再重新恢复这个协程的运行;所谓定时器,就是给调度协程预设一个协程对象,等定时时间到了就恢复预设的协程对象。 ## ucontext_t ucontext_t接口 sylar的协程模块基于ucontext_t实现,在学习之前,必须对ucontext_t和ucontext_t的操作函数非常熟悉。关于ucontext_t的定义和相关的接口如下: ``` // 上下文结构体定义 // 这个结构体是平台相关的,因为不同平台的寄存器不一样 // 下面列出的是所有平台都至少会包含的4个成员 typedef struct ucontext_t { // 当前上下文结束后,下一个激活的上下文对象的指针,只在当前上下文是由makecontext创建时有效 struct ucontext_t *uc_link; // 当前上下文的信号屏蔽掩码 sigset_t uc_sigmask; // 当前上下文使用的栈内存空间,只在当前上下文是由makecontext创建时有效 stack_t uc_stack; // 平台相关的上下文具体内容,包含寄存器的值 mcontext_t uc_mcontext; ... } ucontext_t; // 获取当前的上下文 int getcontext(ucontext_t *ucp); // 恢复ucp指向的上下文,这个函数不会返回,而是会跳转到ucp上下文对应的函数中执行,相当于变相调用了函数 int setcontext(const ucontext_t *ucp); // 修改由getcontext获取到的上下文指针ucp,将其与一个函数func进行绑定,支持指定func运行时的参数, // 在调用makecontext之前,必须手动给ucp分配一段内存空间,存储在ucp->uc_stack中,这段内存空间将作为func函数运行时的栈空间, // 同时也可以指定ucp->uc_link,表示函数运行结束后恢复uc_link指向的上下文, // 如果不赋值uc_link,那func函数结束时必须调用setcontext或swapcontext以重新指定一个有效的上下文,否则程序就跑飞了 // makecontext执行完后,ucp就与函数func绑定了,调用setcontext或swapcontext激活ucp时,func就会被运行 void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); // 恢复ucp指向的上下文,同时将当前的上下文存储到oucp中, // 和setcontext一样,swapcontext也不会返回,而是会跳转到ucp上下文对应的函数中执行,相当于调用了函数 // swapcontext是sylar非对称协程实现的关键,线程主协程和子协程用这个接口进行上下文切换 int swapcontext(ucontext_t *oucp, const ucontext_t *ucp); ``` ## sylar协程模块设计 sylar使用**非对称协程模型**,也就是子协程只能和线程主协程切换,而不能和另一个子协程切换,并且在程序结束时,一定要再切回主协程,以保证程序能正常结束: ![](https://img2023.cnblogs.com/blog/2390290/202309/2390290-20230908175723828-2037865357.jpg) sylar借助了线程局部变量的功能来实现协程模块。线程局部变量与全局变量类似,不同之处在于声明的线程局部变量在每个线程都独有一份,而全局变量是全部线程共享一份。 sylar使用线程局部变量(C++11 thread_local变量)来保存协程上下文对象,这点很好理解,因为协程是在线程里运行的,不同线程的协程相互不影响,每个线程都要独自处理当前线程的协程切换问题。 对于每个线程的协程上下文,sylar设计了两个线程局部变量来存储上下文信息(对应源码的t_fiber和t_thread_fiber),也就是说,一个线程在任何时候最多只能知道两个协程的上下文。又由于sylar只使用swapcontext来做协程切换,那就意味着,这两个线程局部变量必须至少有一个是用来保存线程主协程的上下文的,如果这两个线程局部变量存储的都是子协程的上下文,那么不管怎么调用swapcontext,都没法恢复主协程的上下文,也就意味着程序最终无法回到主协程去执行,程序也就跑飞了。 如果将线程的局部变量设置成一个类似链表的数据结构,那理论上应该也可以实现对称协程,也就是子协程可以直接和子协程切换,但代码复杂度上肯定会增加不少,因为要考虑多线程和公平调度的问题。 sylar的非对称协程代码实现简单,并且在后面实现协程调度时可以做到公平调度,缺点是子协程只能和线程主协程切换,意味着子协程无法创建并运行新的子协程,并且在后面实现协程调度时,完成一次子协程调度需要额外多切换一次上下文。 ### 协程原语 对于非对称协程来说,协程除了创建语句外,只有两种操作,一种是resume,表示恢复协程运行,一种是yield,表示让出执行。协程的结束没有专门的操作,协程函数运行结束时协程即结束,协程结束时会自动调用一次yield以返回主协程。 # 协程调度模块 实现协程调度之后,可以解决前一章协程模块中子协程不能运行另一个子协程的缺陷,子协程可以通过向调度器添加调度任务的方式来运行另一个子协程。 协程调度最难理解的地方是当caller线程也参与调度时调度协程和主线程切换的情况,注意对照源码进行理解。 ## 概述 当你有很多协程时,如何把这些协程都消耗掉,这就是协程调度。 在前面的协程模块中,对于每个协程,都需要用户手动调用协程的resume方法将协程运行起来,然后等协程运行结束并返回,再运行下一个协程。这种运行协程的方式其实是用户自己在挑选协程执行,相当于用户在充当调度器,显然不够灵活. 引入协程调度后,则可以先创建一个协程调度器,然后把这些要调度的协程传递给调度器,由调度器负责把这些协程一个一个消耗掉。 从某种程度来看,协程调度其实非常简单,简单到用下面的代码就可以实现一个调度器,这个调度器可以添加调度任务,运行调度任务,并且还是完全公平调度的,先添加的任务先执行,后添加的任务后执行。 ``` /** * @file simple_fiber_scheduler.cc * @brief 一个简单的协程调度器实现 * @version 0.1 * @date 2021-07-10 */ #include "sylar/sylar.h" /** * @brief 简单协程调度类,支持添加调度任务以及运行调度任务 */ class Scheduler { public: /** * @brief 添加协程调度任务 */ void schedule(sylar::Fiber::ptr task) { m_tasks.push_back(task); } /** * @brief 执行调度任务 */ void run() { sylar::Fiber::ptr task; auto it = m_tasks.begin(); while(it != m_tasks.end()) { task = *it; m_tasks.erase(it++); task->resume(); } } private: /// 任务队列 std::list m_tasks; }; void test_fiber(int i) { std::cout << "hello world " << i << std::endl; } int main() { /// 初始化当前线程的主协程 sylar::Fiber::GetThis(); /// 创建调度器 Scheduler sc; /// 添加调度任务 for(auto i = 0; i < 10; i++) { sylar::Fiber::ptr fiber(new sylar::Fiber( std::bind(test_fiber, i) )); sc.schedule(fiber); } /// 执行调度任务 sc.run(); return 0; } ``` 首先是关于调度任务的定义,对于协程调度器来说,协程当然可以作为调度任务,但实际上,函数也应可以,因为函数也是可执行的对象,调度器应当支持直接调度一个函数。这在代码实现上也很简单,只需要将函数包装成协程即可,协程调度器的实现重点还是以协程为基础。 接下来是多线程,通过前面协程模块的知识我们可以知道,一个线程同一时刻只能运行一个协程,所以,作为协程调度器,势必要用到多线程来提高调度的效率,因为有多个线程就意味着有多个协程可以同时执行,这显然是要好过单线程的。 既然多线程可以提高协程调度的效率,那么,能不能把调度器所在的线程(称为caller线程)也加入进来作为调度线程呢?比如典型地,在main函数中定义的调度器,能不能把main函数所在的线程也用来执行调度任务呢?答案是肯定的,在实现相同调度能力的情况下(指能够同时调度的协程数量),线程数越小,线程切换的开销也就越小,效率就更高一些,所以,调度器所在的线程,也应该支持用来执行调度任务。甚至,调度器完全可以不创建新的线程,而只使用caller线程来进行协程调度,比如只使用main函数所在的线程来进行协程调度。 接下来是调度器如何运行,这里可以简单地认为,调度器创建后,内部首先会创建一个调度线程池,调度开始后,所有调度线程按顺序从任务队列里取任务执行,调度线程数越多,能够同时调度的任务也就越多,当所有任务都调度完后,调度线程就停下来等新的任务进来。 接下来是添加调度任务,添加调度任务的本质就是往调度器的任务队列里塞任务,但是,只添加调度任务是不够的,还应该有一种方式用于通知调度线程有新的任务加进来了,因为调度线程并不一定知道有新任务进来了。当然调度线程也可以不停地轮询有没有新任务,但是这样CPU占用率会很高。 接下来是调度器的停止。调度器应该支持停止调度的功能,以便回收调度线程的资源,只有当所有的调度线程都结束后,调度器才算真正停止。 通过上面的描述,一个协程调度器的大概设计也就出炉了: 调度器内部维护一个任务队列和一个调度线程池。开始调度后,线程池从任务队列里按顺序取任务执行。调度线程可以包含caller线程。当全部任务都执行完了,线程池停止调度,等新的任务进来。添加新任务后,通知线程池有新的任务进来了,线程池重新开始运行调度。停止调度时,各调度线程退出,调度器停止工作。 解决单线程环境下caller线程主协程-调度协程-任务协程之间的上下文切换,是sylar协程调度实现的关键。 其实,子协程和子协程切换导致线程主协程跑飞的关键原因在于,每个线程只有两个线程局部变量用于保存当前的协程上下文信息。也就是说线程任何时候都最多只能知道两个协程的上下文,其中一个是当前正在运行协程的上下文,另一个是线程主协程的上下文,如果子协程和子协程切换,那这两个上下文都会变成子协程的上下文,线程主协程的上下文丢失了,程序也就跑飞了。如果不改变这种局部,就只能线程主协程去充当调度协程,这就相当于又回到了让用户充当调度器的情况。 那么,如何改变这种情况呢?其实非常简单,只需要给每个线程增加一个线程局部变量用于保存调度协程的上下文就可以了,这样,每个线程可以同时保存三个协程的上下文,一个是当前正在执行的协程上下文,另一个是线程主协程的上下文,最后一个是调度协程的上下文。有了这三个上下文,协程就可以根据自己的身份来选择和每次和哪个协程进行交换,具体操作如下: 1. 给协程类增加一个bool类型的成员m_runInScheduler,用于记录该协程是否通过调度器来运行。 2. 创建协程时,根据协程的身份指定对应的协程类型,具体来说,只有想让调度器调度的协程的m_runInScheduler值为true,线程主协程和线程调度协程的m_runInScheduler都为false。 3. resume一个协程时,如果如果这个协程的m_runInScheduler值为true,表示这个协程参与调度器调度,那它应该和三个线程局部变量中的调度协程上下文进行切换,同理,在协程yield时,也应该恢复调度协程的上下文,表示从子协程切换回调度协程; 4. 如果协程的m_runInScheduler值为false,表示这个协程不参与调度器调度,那么在resume协程时,直接和线程主协程切换就可以了,yield也一样,应该恢复线程主协程的上下文。m_runInScheduler值为false的协程上下文切换完全和调度协程无关,可以脱离调度器使用。 经过上面的改造了,就可以解决单线程环境下caller线程主协程-调度协程-任务协程之间的上下文切换问题了,假设caller线程主协程的上下文为main_ctx,调度协程的上下文为scheduler_ctx,任务协程上下文为child_ctx,那么单线程下的协程切换将像下面这样(图片显示不全的话请缩小显示比例): ![](https://img2023.cnblogs.com/blog/2390290/202309/2390290-20230908175723914-958753229.jpg) ## 注意事项 sylar的协程调度模块因为存任务队列空闲时调度线程忙等待的问题,所以实际上并不实用,真正实用的是后面基于Scheduler实现的IOManager。由于任务队列的任务是按顺序执行的,如果有一个任务占用了比较长时间,那其他任务的执行会受到影响,如果任务执行的是像while(1)这样的循环,那线程数不够时,后面的任务都不会得到执行。另外,当前还没有实现hook功能,像sleep和等待IO就绪这样的操作也会阻塞协程调度。 # IO协程调度模块 1. 总得来说,sylar的IO协程调度模块可分为两部分,第一部分是对协程调度器的改造,将epoll与协程调度融合,重新实现tickle和idle,并保证原有的功能不变。第二部分是基于epoll实现IO事件的添加、删除、调度、取消等功能。 2. IO协程调度关注的是FdContext信息,也就是描述符-事件-回调函数三元组,IOManager需要保存所有关注的三元组,并且在epoll_wait检测到描述符事件就绪时执行对应的回调函数。 3. epoll是线程安全的,即使调度器有多个调度线程,它们也可以共用同一个epoll实例,而不用担心互斥。由于空闲时所有线程都阻塞的epoll_wait上,所以也不用担心CPU占用问题。 4. addEvent是一次性的,比如说,注册了一个读事件,当fd可读时会触发该事件,但触发完之后,这次注册的事件就失效了,后面fd再次可读时,并不会继续执行该事件回调,如果要持续触发事件的回调,那每次事件处理完都要手动再addEvent。这样在应对fd的WRITE事件时会比较好处理,因为fd可写是常态,如果注册一次就一直有效,那么可写事件就必须在执行完之后就删除掉。 5. cancelEvent和cancelAll都会触发一次事件,但delEvent不会。 6. FdContext的寻址问题,sylar直接使用fd的值作为FdContext数组的下标,这样可以快速找到一个fd对应的FdContext。由于关闭的fd会被重复利用,所以这里也不用担心FdContext数组膨胀太快,或是利用率低的问题。 7. IO协程调度器的退出,不但所有协程要完成调度,所有IO事件也要完成调度。 # 定时器模块 ## 几种定时器的实现 ### 基于升序链表 # Hook模块 hook系统底层和socket相关的API,socket IO相关的API,以及sleep系列的API。hook的开启控制是线程粒度的,可以自由选择。通过hook模块,可以使一些不具异步功能的API,展现出异步的性能,如MySQL。 ## 理解Hook hook实际上就是对系统调用API进行一次封装,将其封装成一个与原始的系统调用API同名的接口,应用在调用这个接口时,会先执行封装中的操作,再执行原始的系统调用API。 hook技术可以使应用程序在执行系统调用之前进行一些隐藏的操作,比如可以对系统提供malloc()和free()进行hook,在真正进行内存分配和释放之前,统计内存的引用计数,以排查内存泄露问题。 还可以用C++的子类重载来理解hook。在C++中,子类在重载父类的同名方法时,一种常见的实现方式是子类先完成自己的操作,再调用父类的操作,如下: ``` class Base { public: void Print() { cout << "This is Base" << endl; } }; class Child : public Base { public: /// 子类重载时先实现自己的操作,再调用父类的操作 void Print() { cout << "This is Child" << endl; Base::Print(); } }; ``` ## Hook功能 hook的目的是在不重新编写代码的情况下,把老代码中的socket IO相关的API都转成异步,以提高性能。hook和IO协程调度是密切相关的,如果不使用IO协程调度器,那hook没有任何意义,考虑IOManager要在一个线程上按顺序调度以下协程: 协程1:sleep(2) 睡眠两秒后返回。 协程2:在scoket fd1 上send 100k数据。 协程3:在socket fd2 上recv直到数据接收成功。 在未hook的情况下,IOManager要调度上面的协程,流程是下面这样的: * 调度协程1,协程阻塞在sleep上,等2秒后返回,这两秒内调度线程是被协程1占用的,其他协程无法在当前线程上调度。 * 调度协徎2,协程阻塞send 100k数据上,这个操作一般问题不大,因为send数据无论如何都要占用时间,但如果fd迟迟不可写,那send会阻塞直到套接字可写,同样,在阻塞期间,其他协程也无法在当前线程上调度。 * 调度协程3,协程阻塞在recv上,这个操作要直到recv超时或是有数据时才返回,期间调度器也无法调度其他协程。 上面的调度流程最终总结起来就是,协程只能按顺序调度,一旦有一个协程阻塞住了,那整个调度线程也就阻塞住了,其他的协程都无法在当前线程上执行。像这种一条路走到黑的方式其实并不是完全不可避免,以sleep为例,调度器完全可以在检测到协程sleep后,将协程yield以让出执行权,同时设置一个定时器,2秒后再将协程重新resume。这样,调度器就可以在这2秒期间调度其他的任务,同时还可以顺利的实现sleep 2秒后再继续执行协程的效果,send/recv与此类似。 在完全实现hook后,IOManager的执行流程将变成下面的方式: * 调度协程1,检测到协程sleep,那么先添加一个2秒的定时器,定时器回调函数是在调度器上继续调度本协程,接着协程yield,等定时器超时。 * 因为上一步协程1已经yield了,所以协徎2并不需要等2秒后才可以执行,而是立刻可以执行。同样,调度器检测到协程send,由于不知道fd是不是马上可写,所以先在IOManager上给fd注册一个写事件,回调函数是让当前协程resume并执行实际的send操作,然后当前协程yield,等可写事件发生。 * 上一步协徎2也yield了,可以马上调度协程3。协程3与协程2类似,也是给fd注册一个读事件,回调函数是让当前协程resume并继续recv,然后本协程yield,等事件发生。 * 等2秒超时后,执行定时器回调函数,将协程1 resume以便继续执行。 * 等协程2的fd可写,一旦可写,调用写事件回调函数将协程2 resume以便继续执行send。 * 等协程3的fd可读,一旦可读,调用回调函数将协程3 resume以便继续执行recv。 * 上面的4、5、6步都是异步的,调度线程并不会阻塞,IOManager仍然可以调度其他的任务,只在相关的事件发生后,再继续执行对应的任务即可。并且,由于hook的函数签名与原函数一样,所以对调用方也很方便,只需要以同步的方式编写代码,实现的效果却是异步执行的,效率很高。 总而言之,在IO协程调度中对相关的系统调用进行hook,可以让调度线程尽可能得把时间片都花在有意义的操作上,而不是浪费在阻塞等待中。 **hook的重点是在替换API的底层实现的同时完全模拟其原本的行为**,因为调用方是不知道hook的细节的,在调用被hook的API时,如果其行为与原本的行为不一致,就会给调用方造成困惑。比如,所有的socket fd在进行IO调度时都会被设置成NONBLOCK模式,如果用户未显式地对fd设置NONBLOCK,那就要处理好fcntl,不要对用户暴露fd已经是NONBLOCK的事实,这点也说明,除了IO相关的函数要进行hook外,对fcntl, setsockopt之类的功能函数也要进行hook,才能保证**API的一致性**。 ## Hook实现 hook的实现机制非常简单,就是通过动态库的全局符号介入功能,用自定义的接口来替换掉同名的系统调用接口。由于系统调用接口基本上是由C标准函数库libc提供的,所以这里要做的事情就是用自定义的动态库来覆盖掉libc中的同名符号。 ## 注意事项 1. 由于定时器模块只支持毫秒级定时,所以被hook后的nanosleep()实际精度只能达到毫秒级,而不是纳秒级。 2. 按照 man 2 socket 的描述,自2.6.27版本的内核开始socket函数支持直接在type中位或SOCK_NONBLOCK标志位以创建非阻塞套接字,sylar的hook模块未处理这种情况。 3. 按sylar hook模块的实现,非调度线程不支持启用hook。 # Address模块 ![](https://img2023.cnblogs.com/blog/2390290/202309/2390290-20230908175724710-1101790577.jpg) # Socket模块 ![](https://img2023.cnblogs.com/blog/2390290/202309/2390290-20230908175724763-1303855145.jpg) # ByteArray模块 ## zig-zag算法 ## TLV编码结构 # Stream模块 ![](https://img2023.cnblogs.com/blog/2390290/202309/2390290-20230908175724263-1552444666.jpg) ## SocketStream类 套接字流结构,将套接字封装成流结构,以支持Stream接口规范,除此外,SocketStream还支持套接字关闭操作以及获取本地/远端地址的操作。 # TCP Server模块 ![](https://img2023.cnblogs.com/blog/2390290/202309/2390290-20230908175724492-583011911.jpg) * TcpServer类采用了Template Pattern设计模式,它的HandleClient是交由继承类来实现的。使用TcpServer时,必须从TcpServer派生一个新类,并重新实现子类的handleClient操作,这点可以参考test_tcp_server.cc。 # HTTP模块