本篇文章记录了,我在学习C/C++实现协程封装过程的新得体会,以及对协程的理解。一开始对知道“协程”这个概念实在go语言里面,很多资料对其的描述都是“轻量级的用户态线程”。
首先,用户态和内核态分别是程序在运行过程中的两种状态,如果线程在用户进程地址空间的状态下中执行程序,则称为用户态,一旦发生系统调用、中断、异常等事件,就会由用户态转换到内核态,进入到内核地址空间去执行函数(系统调用函数、中断处理函数、异常处理函数等)。也就是说协程之间的切换是在用户态下实现的,在用户态下实现了当前执行停止,保存当前状态,引导逻辑流去执行另一个函数等一系列切换过程。
协程概述
协程与线程的关系可以类比线程与进程的关系:
- 一个进程中可以有多个线程;同时一个线程中也可以有多个协程
- 每个线程有自己的私有栈,用来保存函数参数、返回地址、寄存器状态等,同时线程也可以共享同一个进程中的资源,如堆、数据段、代码段等;每个协程也可以有自己的栈,也可以共享线程进程的资源。
- 每个线程都有控制块存储其属性,对其进行调度;协程同样也需要
协程和线程最大区别在于,线程是操作系统调度的最小单位,也就是说线程在OS的调度下拥有并发性,而协程只是“用户态的线程“,OS并不会直接对其进行调度干预,也就是说协程是串行执行的,只有一个逻辑流。这时候就会想到,既然是串行的,那协程的高效性体现在哪里呢?实际上,协程最大的用处在于处理IO密集型任务,而非CPU密集型任务,因为当需要大量调用系统调用的时候,就可以在主协程中yield,让cpu逻辑流转而执行其他协程,当有时钟触发或者资源到达时候,再回到主协程继续执行。因此,目前协程主要用于高性能服务器端处理高并发的情景。
协程可以根据调度方法主要分成两种:对称协程和非对称协程。对称协程顾名思义就是每个协程都是平等的,没有主调协程和被调协程的区别,因此大部分的对称协程都会有一个调度器,调度器按照一定的调度算法处理协程,go语言中的Goroutine即为典型。非对称协程就是存在调用者协程和被调用者协程的区别,只有被调用者主动yield,cpu才会返回调用者,而不会去调用其他的协程,以libco为例。
在C++中,目前实现的上下文切换主要有三种方式:
- ucontext、fiber,采用提供的api,这种方式较为简单便捷,缺点在于不太高效
- 用汇编自己实现上下文切换,性能高效,但是较为底层以致于移植性较差
- setjump、longjump
协程栈的方式。线程栈的大小是配置文件决定的,默认情况下为8MB,如果协程也按照线程这种静态栈的方式,如果一个线程中申请千万级别的协程就会出现爆栈,非常不方便。目前协程栈实现主要有三种方式:
- 静态栈,固定协程栈的大小
- 拷贝栈,先固定协程栈的大小,如果发现栈空间不够,就新扩展一个大内存,将其都拷贝过去
- 共享栈,多个协程共享一个内存栈,每次需要运行当前协程的时候,将协程的上下文拷贝到共享栈中,协程yield的时候,再把上下文拷贝出来并保存好。缺点在于,协程切换较慢,需要多次协程栈拷贝工作。
Libco解析
libco是微信最早开源的c++协程库,利用汇编实现上下文的方式,同时协程栈同时采用静态栈和共享栈并存的方式。
源码分析
首先来看几个重要的函数声明和结构体。下图的几个函数都是最常用的协程调用接口,关于其具体使用,详见例子example_*。首先libco采用了类pthread的接口设计,使得用过poxis线程的人很容易理解接口含义。
int co_create( stCoRoutine_t **co,const stCoRoutineAttr_t *attr,void *(*routine)(void*),void *arg ); //创建协程,是分配并初始化stCoRoutine_t结构体、设置任务函数指针、分配一段“栈”内存,以及分配和初始化coctx_t
void co_resume( stCoRoutine_t *co ); //开启非对称协程,将当前协程挂起,运行目标协程,本质上是串行操作,并没有并发
void co_yield( stCoRoutine_t *co ); //将cpu让给当时调用本协程的协程,也就是相当与回退
void co_yield_ct(); //ct = current thread,功能和co_yield一致
void co_release( stCoRoutine_t *co ); //销毁协程
void co_reset(stCoRoutine_t * co); //重置协程
stCoRoutine_t *co_self(); //返回当前协程
int co_poll( stCoEpoll_t *ctx,struct pollfd fds[], nfds_t nfds, int timeout_ms ); //定时等待,内部也用epoll_wait定时
void co_eventloop( stCoEpoll_t *ctx,pfn_co_eventloop_t pfn,void *arg ); //循环等待,内置epoll_wait,等待事件触发
接下来,看一下关键结构体。 注释都标得很明确了。
struct stCoRoutineEnv_t //线程级的资源,同一个线程上的协程共享
{
stCoRoutine_t *pCallStack[ 128 ]; //该线程上,每个协程的指针
int iCallStackSize;
stCoEpoll_t *pEpoll; //epoll指针
//for copy stack log lastco and nextco
stCoRoutine_t* pending_co; //pending的协程
stCoRoutine_t* occupy_co; //正在占用cpu的协程
};
struct stCoRoutine_t //协程结构体
{
stCoRoutineEnv_t *env;
pfn_co_routine_t pfn; //执行函数
void *arg; //函数参数
coctx_t ctx; //上下文转换
char cStart;
char cEnd;
char cIsMain;
char cEnableSysHook;
char cIsShareStack;
void *pvEnv;
//char sRunStack[ 1024 * 128 ];
stStackMem_t* stack_mem;
//save satck buffer while confilct on same stack_buffer;
char* stack_sp;
unsigned int save_size;
char* save_buffer;
stCoSpec_t aSpec[1024];
};
共享栈
共享栈这个概念最早源自于这篇论文《A Portable C++ Library for Coroutine Sequencing》。在libco中,用户在创建新的协程时,可以选择让其拥有一个独占的协程栈,或者是与其它任意数量的协程一起共享一个执行栈。共享栈的优点在于不用每个协程都占有独立的空间,当一个线程中协程数目激增的时候,共享栈不会爆栈,以此同时,共享栈的缺点在于,协程之间的上下文切换较为花费时间,因为每次在切换的时候通过把协程栈的内容copy-in/copy-out来实现栈的切换。
共享栈的结构是一个数组,它里面有 count 个元素,每个元素都是一个指向一段内存的指针 stStackMem_t 。在新分配协程时 (co_create_env) ,它会从刚刚分配的 stShareStack_t 中,按 RoundRobin 的方式(alloc_idx++)取一个 stStackMem_t 出来,然后就算作是这个协程自己的栈。显然,这个时候这个空间是与其它协程共享的,因此叫"共享栈"。
struct stStackMem_t //私有栈
{
stCoRoutine_t* occupy_co; //每个私有栈都对应一个协程
int stack_size;
char* stack_bp; //stack_buffer + stack_size
char* stack_buffer;
};
struct stShareStack_t //共享栈
{
unsigned int alloc_idx; //index,指向数组中的某个元素
int stack_size;
int count;
stStackMem_t** stack_array; //一维数组,数组大小为count,数组中的每个元素都是一个stStackMem_t的指针
};
hook
这个方法对于每个协程库来说都是至关重要,由于协程只是执行在线程上,并没有并发的特性,所以如果不hook住函数的话,其系统调用依旧会花费大量时间,这样并没有体现出协程的高效性。hook的意思就是对于这些系统调用进行重载,让其能适合我们自己编写的协程库,并用定时器、epoll_wait等方法将阻塞在系统调用的协程yield,转而执行另一个协程,当事件发生时候,再回头继续执行。libco中只hook了几个重要的IO函数,可以发现,协程能体现出性能的地方在于IO,所以说协程主要用于网络编程,高性能服务器,其对于IO密集型程序有较大的帮助,对于CPU密集型的程序并没有明显的帮助。
//5.hook syscall ( poll/read/write/recv/send/recvfrom/sendto )
void co_enable_hook_sys(); //开启hook
void co_disable_hook_sys(); //关闭hook
bool co_is_enable_sys_hook(); //返回是否开启hook
ucontext解析
另一种c++实现上下文切换的就是ucontext方法。
ucontext系列四个函数
getcontext(ucontext_t *ucp) 获取当前的上下文保存到ucp
setcontext(const ucontext_t *ucp) 直接到ucp所指向的上下文中去执行
makecontext(ucontext_t *ucp, void (*func)(), int argc, ...) 创建一个新的上下文
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp) 将当前上下文保存到oucp,然后跳转到ucp上下文执行
typedef struct ucontext{
struct ucontext* uc_link; 当这个上下文终止后,指向运行的下一个上下文,如果为NULL则终止当前线程
sigset_t uc_sigmask; 为该上下文的阻塞信号集合
stack_t uc_stack; 该上下文使用的栈
mcontext_t uc_mcontext; 保持上下文的特定寄存器
...
}ucontext_t
ucontext例子实现交替打印10次,考虑协程之间的切换问题,可以从这里看出,api使用较方便,但是效率较低。
#include <ucontext.h>
#include <stdio.h>
int stack[1024];
static int num = 1;
ucontext_t mn_cont,func_cont;
void func(){
while(1){
swapcontext(&mn_cont,&func_cont);
printf("func_context\n");
if(num++ >= 10){
break;
}
}
printf("outof func\n");
}
int main(){
getcontext(&mn_cont);
mn_cont.uc_link=nullptr;
mn_cont.uc_stack.ss_sp=stack;
mn_cont.uc_stack.ss_size=sizeof(stack);
makecontext(&mn_cont,func,0);
while(1){
swapcontext(&func_cont,&mn_cont);
printf("main_context\n");
}
printf("out of main\n");
return 0;
}
参考博客:
https://github.com/Tencent/libco
https://www.jianshu.com/p/837bb161793a
https://www.zhihu.com/question/52193579
转载:https://blog.csdn.net/puliao4167/article/details/101173732