文章目录
前言
对协程库项目的总结与思考
关于协程
搞清楚协程和线程的区别。协程虽然被称为轻量级线程,但在单线程内,协程并不能并发执行,只能是一个协程结束或yield后,再执行另一个协程,而线程则是可以真正并发执行的。其实这点也好理解,毕竟协程只是以一种花里胡哨的方式去运行一个函数,不管实现得如何巧妙,也不可能在单线程里做到同时运行两个函数,否则还要多线程有何用?
因为单线程下协程并不是并发执行,而是顺序执行的,所以不要在协程里使用线程级别的锁来做协程同步,比如pthread_mutex_t。如果一个协程在持有锁之后让出执行,那么同线程的其他任何协程一旦尝试再次持有这个锁,整个线程就锁死了,这和单线程环境下,连续两次对同一个锁进行加锁导致的死锁道理完全一样。
同样是单线程环境下,协程的yield和resume一定是同步进行的,一个协程的yield,必然对应另一个协程的resume,因为线程不可能没有执行主体。并且,协程的yield和resume是完全由应用程序来控制的。与线程不同,线程创建之后,线程的运行和调度也是由操作系统自动完成的,但协程创建后,协程的运行和调度都要由应用程序来完成,就和调用函数一样,所以协程也被称为用户态线程。
所谓创建协程,其实就是把一个函数包装成一个协程对象,然后再用协程的方式把这个函数跑起来;所谓协程调度,其实就是创建一批的协程对象,然后再创建一个调度协程,通过调度协程把这些协程对象一个一个消化掉(协程可以在被调度时继续向调度器添加新的调度任务);所谓IO协程调度,其实就是在调度协程时,如果发现这个协程在等待IO就绪,那就先让这个协程让出执行权,等对应的IO就绪后再重新恢复这个协程的运行;所谓定时器,就是给调度协程预设一个协程对象,等定时时间到了就恢复预设的协程对象。
一、协程模块
1.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;
// 获取当前的上下文 成功返回0,失败返回-1
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);
2.协程模块设计
使用非对称协程模型,也就是子协程只能和线程主协程切换,而不能和另一个子协程切换,并且在程序结束时,一定要再切回主协程,以保证程序能正常结束,像下面这样:
将线程的主协程想像成线程的调度协程,每个子协程执行完了,都必须切回线程主协程,由主协程负责选出下一个要执行的子协程。
非对称协程里不允许子协程可以和子协程切换。
借助了线程局部变量的功能来实现协程模块。用C++11 thread_local
变量来保存协程上下文对象。线程局部变量与全局变量类似,不同之处在于声明的线程局部变量在每个线程都独有一份,而全局变量是全部线程共享一份。
3.协程模块实现
协程状态
对每个协程,设计了3种状态,分别是
- READY,代表就绪态
- RUNNING,代表正在运行
- TERM,代表运行结束
对于非对称协程来说,协程除了创建语句外,只有两种操作,一种是resume
,表示恢复协程运行,一种是yield
,表示让出执行。协程的结束没有专门的操作,协程函数运行结束时协程即结束,协程结束时会自动调用一次yield
以返回主协程。
线程局部变量
对于每个线程,设计了以下两个线程局部变量用于保存协程上下文信息:
// 线程局部变量,当前线程正在运行的协程
static thread_local Fiber *t_fiber = nullptr;
// 线程局部变量,当前线程的主协程,切换到这个协程,就相当于切换到了主线程中运行,智能指针形式
static thread_local Fiber::ptr t_thread_fiber = nullptr;
t_fiber
:保存当前正在运行的协程指针,必须时刻指向当前正在运行的协程对象。协程模块初始化时,t_fiber
指向线程主协程对象。t_thread_fiber
:保存线程主协程指针,智能指针形式(ptr 为 typedef std::shared_ptr ptr)。协程模块初始化时,t_thread_fiber
指向线程主协程对象。当子协程resume时,通过swapcontext将主协程的上下文保存到t_thread_fiber
的ucontext_t成员中,同时激活子协程的ucontext_t上下文。当子协程yield时,从t_thread_fiber中取得主协程的上下文并恢复运行。
4.协程切换
子协程的resume操作一定是在主协程里执行的,主协程的resume操作一定是在子协程里执行的,这点完美和swapcontext匹配,通过
- 在
Fiber::resume()
调用swapcontext(&(t_thread_fiber->m_ctx), &m_ctx)
- 在
Fiber::yield()
调用swapcontext(&m_ctx, &(t_thread_fiber->m_ctx))
实现主协程与子协程的切换。
二、协程调度器
实现了一个N-M的协程调度器,N个线程运行M个协程,协程可以在线程之间进行切换,也可以绑定到指定线程运行
线程池由vector实现,任务队列由list实现
1.协程切换
分两种典型情况来讨论一下调度协程的切换情况,其他情况原理一致
线程数为1,且use_caller为false
有单独的线程用于协程调度,那只需要让新线程的入口函数作为调度协程,从任务队列里取任务执行就行了,main函数与调度协程完全不相关,main函数只需要向调度器添加任务,然后在适当的时机停止调度器即可。当调度器停止时,main函数要等待调度线程结束后再退出。
线程数为1,且use_caller为true
因为没有额外的线程进行协程调度,那只能用main函数所在的线程来进行调度,而梳理一下main函数线程要运行的协程,会发现有以下三类协程:
- main函数对应的主协程
- 调度协程
- 待调度的任务协程
在main函数线程里这三类协程运行的顺序是这样的:
- main函数主协程运行,创建调度器
- 仍然是main函数主协程运行,向调度器添加一些调度任务
- 开始协程调度,main函数主协程让出执行权,切换到调度协程,调度协程从任务队列里按顺序执行所有的任务
- 每次执行一个任务,调度协程都要让出执行权,再切到该任务的协程里去执行,任务执行结束后,还要再切回调度协程,继续下一个任务的调度
- 所有任务都执行完后,调度协程还要让出执行权并切回main函数主协程,以保证程序能顺利结束。
上面的过程也可以总结为:main函数先攒下一波协程,然后切到调度协程里去执行,等把这些协程都消耗完后,再从调度协程切回来
但是问题出现:
在非对称协程里,子协程只能和线程主协程切换,而不能和另一个子协程切换,像上文可能会导致跑飞。
因此,解决单线程环境下caller线程主协程-调度协程-任务协程之间的上下文切换,是协程调度实现的关键。
2.单线程中三协程切换
其实,子协程和子协程切换导致线程主协程跑飞的关键原因在于,每个线程只有两个线程局部变量用于保存当前的协程上下文信息。也就是说线程任何时候都最多只能知道两个协程的上下文,其中一个是当前正在运行协程的上下文,另一个是线程主协程的上下文,如果子协程和子协程切换,那这两个上下文都会变成子协程的上下文,线程主协程的上下文丢失了,程序也就跑飞了。如果不改变这种局部,就只能线程主协程去充当调度协程,这就相当于又回到了让用户充当调度器的情况。
那么,如何改变这种情况呢?其实非常简单,只需要给每个线程增加一个线程局部变量用于保存调度协程的上下文就可以了,这样,每个线程可以同时保存三个协程的上下文,一个是当前正在执行的协程上下文,另一个是线程主协程的上下文,最后一个是调度协程的上下文。有了这三个上下文,协程就可以根据自己的身份来选择和每次和哪个协程进行交换,具体操作如下:
- 给协程类增加一个bool类型的成员
m_runInScheduler
,用于记录该协程是否通过调度器来运行。 - 创建协程时,根据协程的身份指定对应的协程类型,具体来说,只有想让调度器调度的协程的
m_runInScheduler
值为true,线程主协程和线程调度协程的m_runInScheduler
都为false。 - resume一个协程时,如果如果这个协程的
m_runInScheduler
值为true,表示这个协程参与调度器调度,那它应该和三个线程局部变量中的调度协程上下文进行切换,同理,在协程yield时,也应该恢复调度协程的上下文,表示从子协程切换回调度协程; - 如果协程的
m_runInScheduler
值为false,表示这个协程不参与调度器调度,那么在resume协程时,直接和线程主协程切换就可以了,yield也一样,应该恢复线程主协程的上下文。m_runInScheduler
值为false的协程上下文切换完全和调度协程无关,可以脱离调度器使用。
假设caller线程主协程的上下文为main_ctx,调度协程的上下文为scheduler_ctx,任务协程上下文为child_ctx,那么单线程下的协程切换将像下面这样:
协程切换方法改为:
- 如果协程参与调度器调度
- 在
Fiber::resume()
调用swapcontext(&(t_thread_fiber->m_ctx), &m_ctx)
- 在
Fiber::yield()
调用swapcontext(&m_ctx, &(t_thread_fiber->m_ctx))
- 在
- 如果协程不参与调度器调度
- 在
Fiber::resume()
调用swapcontext(&(Scheduler::GetMainFiber()->m_ctx), &m_ctx)
- 在
Fiber::yield()
调用swapcontext(&m_ctx, &(Scheduler::GetMainFiber()->m_ctx))
实现主协程与子协程的切换。
- 在
三、IO协程调度
IO协程调度支持协程调度的全部功能,因为IO协程调度器是直接继承协程调度器实现的。
除了协程调度,IO协程调度还增加了IO事件调度的功能,这个功能是针对描述符(一般是套接字描述符)的。IO协程调度支持为描述符注册可读和可写事件的回调函数,当描述符可读或可写时,执行对应的回调函数。(这里可以直接把回调函数等效成协程,所以这个功能被称为IO协程调度)
1.IO协程调度模块设计
对每个fd,sylar支持两类事件,一类是可读事件,对应EPOLLIN,一类是可写事件,对应EPOLLOUT,sylar的事件枚举值直接继承自epoll。
当然epoll本身除了支持了EPOLLIN和EPOLLOUT两类事件外,还支持其他事件,比如EPOLLRDHUP, EPOLLERR, EPOLLHUP等,对于这些事件,sylar的做法是将其进行归类,分别对应到EPOLLIN和EPOLLOUT中,也就是所有的事件都可以表示为可读或可写事件,甚至有的事件还可以同时表示可读及可写事件,比如EPOLLERR事件发生时,fd将同时触发可读和可写事件。
对于IO协程调度来说,每次调度都包含一个三元组信息,分别是描述符-事件类型(可读或可写)-回调函数,调度器记录全部需要调度的三元组信息,其中描述符和事件类型用于epoll_wait,回调函数用于协程调度。这个三元组信息在源码上通过FdContext
结构体来存储,在执行epoll_wait时通过epoll_event的私有数据指针data.ptr
来保存FdContext
结构体信息。
IO协程调度器在idle时会epoll_wait所有注册的fd,如果有fd满足条件,epoll_wait返回,从私有数据中拿到fd的上下文信息,并且执行其中的回调函数。(实际是idle协程只负责收集所有已触发的fd的回调函数并将其加入调度器的任务队列,真正的执行时机是idle协程退出后,调度器在下一轮调度时执行)
与协程调度器不一样的是,IO协程调度器支持取消事件。取消事件表示不关心某个fd的某个事件了,如果某个fd的可读或可写事件都被取消了,那这个fd会从调度器的epoll_wait中删除。
四、定时器
1.几种定时器实现
基于升序链表的定时器
- 所有定时器组织成链表结构,链表成员包含超时时间,回调函数,回调函数参数,以及链表指针域。
- 定时器在链表中按超时时间进行升序排列,超时时间短的在前,长的在后。每次添加定时器时,都要按超时时间将定时器插入到链表的指定位置。
- 程序运行后维护一个周期性触发的tick信号,比如利用alarm函数周期性触发ALARM信号,在信号处理函数中从头遍历定时器链表,判断定时器是否超时。如果定时器超时,则记录下该定时器,然后将其从链表中删除。
- 执行所有超时的定时器的回调函数。
以上就是一个基于升序链表的定时器实现,这种方式添加定时器的时间复杂度是O(n),删除定时器的时间复杂度是O(1),执行定时任务的时间复杂度是O(1)。
tick信号的周期对定时器的性能有较大的影响,当tick信号周期较小时,定时器精度高,但CPU负担较高,因为要频繁执行信号处理函数;当tick信号周期较大时,CPU负担小,但定时精度差。
当定时器数量较多时,链表插入操作开销比较大。
时间轮
与上面的升序链表实现方式类似,也需要维护一个周期性触发的tick信号,但不同的是,定时器不再组织成单链表结构,而是按照超时时间,通过散列分布到不同的时间轮上,像下面这样:
上面的时间轮包含N个槽位,每个槽位上都有一个定时器链表。时间轮以恒定的速度顺时针转动,每转一步,表盘上的指针就指向下一个槽位。每次转动对应一个tick,它的周期为si,一个共有N个槽,所以它运转一周的时间是N*si。
每个槽位都有一条定时器链表,同一条链表上的每个定时器都具有相同的特征:前后节点的定时时间相差N*si的整数倍。时间轮正是利用这个关系将定时器散列到不同的链表上。假如现在指针指向槽cs,我们要添加一个定时时间为ti的定时器,则该定时器将被插入槽ts(time slot)对应的链表中:
ts = (cs + (ti / si)) % N
时间轮通过哈希表的思想,将定时器散列到不同的链表上,每个链表的定时器数目都明显少于原来的排序链表,插入效率基本不受定时器数目的影响。
和升序链表一样,tick的周期将影响定时器精度和CPU负载,除此外,时间轮上的槽数量N还对定时器的效率有影响,N越大,则散列越均匀,插入效率越高,N越小,则散列越容易冲突,至N等于1时,时间轮将完全退化成升序链表。
上面的时间轮只有一个轮子,而复杂的时间轮可能有多个轮子,不同的轮子拥有不同的粒度。相邻的两个轮子,精度高的转一圈,精度低的仅往前移动一槽,就像水表一样。
注意点:
单个槽上的定时器链表仍然是按升序链表来组织的,只不过前后两个节点的时间差一定是Nsi的整数倍。注意这里前后节点的时间差不一定是1个Nsi,也有可能是好几个N*si,所以不能通过定时器所在的槽位和链表位置直接推算出定时器的超时时间。或者换个说法,表盘指针转到某个槽时,仍需要按升序链表的方式遍历这个链表的节点,并判断是否超时。
时间堆
上面的两种定时器设计都依赖一个固定周期触发的tick信号。设计定时器的另一种实现思路是直接将超时时间当作tick周期,具体操作是每次都取出所有定时器中超时时间最小的超时值作为一个tick,这样,一旦tick触发,超时时间最小的定时器必然到期。处理完已超时的定时器后,再从剩余的定时器中找出超时时间最小的一个,并将这个最小时间作为下一个tick,如此反复,就可以实现较为精确的定时。
最小堆很适合处理这种定时方案,将所有定时器按最小堆来组织,可以很方便地获取到当前的最小超时时间,sylar采取的即是这种方案。
2.定时器设计
本项目的定时器采用最小堆设计,所有定时器根据绝对的超时时间点进行排序,每次取出离当前时间最近的一个超时时间点,计算出超时需要等待的时间,然后等待超时。超时时间到后,获取当前的绝对时间点,然后把最小堆里超时时间点小于这个时间点的定时器都收集起来,执行它们的回调函数。
注意,在注册定时事件时,一般提供的是相对时间,比如相对当前时间3秒后执行。sylar会根据传入的相对时间和当前的绝对时间计算出定时器超时时的绝对时间点,然后根据这个绝对时间点对定时器进行最小堆排序。因为依赖的是系统绝对时间,所以需要考虑校时因素。
sylar定时器的超时等待基于epoll_wait,精度只支持毫秒级,因为epoll_wait的超时精度也只有毫秒级。
关于定时器和IO协程调度器的整合。IO协程调度器的idle协程会在调度器空闲时阻塞在epoll_wait上,等待IO事件发生。在之前的代码里,epoll_wait具有固定的超时时间,这个值是5秒钟。加入定时器功能后,epoll_wait的超时时间改用当前定时器的最小超时时间来代替。epoll_wait返回后,根据当前的绝对时间把已超时的所有定时器收集起来,执行它们的回调函数。
由于epoll_wait的返回并不一定是超时引起的,也有可能是IO事件唤醒的,所以在epoll_wait返回后不能想当然地假设定时器已经超时了,而是要再判断一下定时器有没有超时,这时绝对时间的好处就体现出来了,通过比较当前的绝对时间和定时器的绝对超时时间,就可以确定一个定时器到底有没有超时。
五、hook
hook实际上就是对系统调用API进行一次封装,将其封装成一个与原始的系统调用API同名的接口,应用在调用这个接口时,会先执行封装中的操作,再执行原始的系统调用API。
hook技术可以使应用程序在执行系统调用之前进行一些隐藏的操作,比如可以对系统提供malloc()和free()进行hook,在真正进行内存分配和释放之前,统计内存的引用计数,以排查内存泄露问题。
还可以用C++的子类重载来理解hook。在C++中,子类在重载父类的同名方法时,一种常见的实现方式是子类先完成自己的操作,再调用父类的操作,如下:
1.hook模块设计
关于hook模块和IO协程调度的整合。一共有三类接口需要hook,如下:
- sleep延时系列接口,包括sleep/usleep/nanosleep。对于这些接口的hook,只需要给IO协程调度器注册一个定时事件,在定时事件触发后再继续执行当前协程即可。当前协程在注册完定时事件后即可yield让出执行权。
- socket IO系列接口,包括read/write/recv/send…等,connect及accept也可以归到这类接口中。这类接口的hook首先需要判断操作的fd是否是socket fd,以及用户是否显式地对该fd设置过非阻塞模式,如果不是socket fd或是用户显式设置过非阻塞模式,那么就不需要hook了,直接调用操作系统的IO接口即可。如果需要hook,那么首先在IO协程调度器上注册对应的读写事件,等事件发生后再继续执行当前协程。当前协程在注册完IO事件即可yield让出执行权。
- socket/fcntl/ioctl/close等接口,这类接口主要处理的是边缘情况,比如分配fd上下文,处理超时及用户显式设置非阻塞问题。