小言_互联网的博客

C++协程概述

407人阅读  评论(0)

    本篇文章记录了,我在学习C/C++实现协程封装过程的新得体会,以及对协程的理解。一开始对知道“协程”这个概念实在go语言里面,很多资料对其的描述都是“轻量级的用户态线程”。

    首先,用户态和内核态分别是程序在运行过程中的两种状态,如果线程在用户进程地址空间的状态下中执行程序,则称为用户态,一旦发生系统调用、中断、异常等事件,就会由用户态转换到内核态,进入到内核地址空间去执行函数(系统调用函数、中断处理函数、异常处理函数等)。也就是说协程之间的切换是在用户态下实现的,在用户态下实现了当前执行停止,保存当前状态,引导逻辑流去执行另一个函数等一系列切换过程。

协程概述

    协程与线程的关系可以类比线程与进程的关系:

  1. 一个进程中可以有多个线程;同时一个线程中也可以有多个协程
  2. 每个线程有自己的私有栈,用来保存函数参数、返回地址、寄存器状态等,同时线程也可以共享同一个进程中的资源,如堆、数据段、代码段等;每个协程也可以有自己的栈,也可以共享线程进程的资源。
  3. 每个线程都有控制块存储其属性,对其进行调度;协程同样也需要

    协程和线程最大区别在于,线程是操作系统调度的最小单位,也就是说线程在OS的调度下拥有并发性,而协程只是“用户态的线程“,OS并不会直接对其进行调度干预,也就是说协程是串行执行的,只有一个逻辑流。这时候就会想到,既然是串行的,那协程的高效性体现在哪里呢?实际上,协程最大的用处在于处理IO密集型任务,而非CPU密集型任务,因为当需要大量调用系统调用的时候,就可以在主协程中yield,让cpu逻辑流转而执行其他协程,当有时钟触发或者资源到达时候,再回到主协程继续执行。因此,目前协程主要用于高性能服务器端处理高并发的情景。

    协程可以根据调度方法主要分成两种:对称协程和非对称协程。对称协程顾名思义就是每个协程都是平等的,没有主调协程和被调协程的区别,因此大部分的对称协程都会有一个调度器,调度器按照一定的调度算法处理协程,go语言中的Goroutine即为典型。非对称协程就是存在调用者协程和被调用者协程的区别,只有被调用者主动yield,cpu才会返回调用者,而不会去调用其他的协程,以libco为例。

    在C++中,目前实现的上下文切换主要有三种方式:

  1. ucontext、fiber,采用提供的api,这种方式较为简单便捷,缺点在于不太高效
  2. 用汇编自己实现上下文切换,性能高效,但是较为底层以致于移植性较差
  3. setjump、longjump

    协程栈的方式。线程栈的大小是配置文件决定的,默认情况下为8MB,如果协程也按照线程这种静态栈的方式,如果一个线程中申请千万级别的协程就会出现爆栈,非常不方便。目前协程栈实现主要有三种方式:

  1. 静态栈,固定协程栈的大小
  2. 拷贝栈,先固定协程栈的大小,如果发现栈空间不够,就新扩展一个大内存,将其都拷贝过去
  3. 共享栈,多个协程共享一个内存栈,每次需要运行当前协程的时候,将协程的上下文拷贝到共享栈中,协程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
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场