深入理解qemu协程

简介

  • CPU硬件上下文和堆栈的切换,在CPU各个优先级上都可以实现,只要在内存中准备好一段存放上下文的空间并且将要执行代码的堆栈空间分配好,在切换前保存当前上下文和堆栈指针到内存,切换回来时再从内存中加载之前存放的上下文和堆栈指针,就可以完成上下文切换,从而实现CPU任务的切换
  • linux下任务就是进程,任务切换就是进程切换,在设计时放在内核态中实现,但其实,用户态也可以实现硬件上下文的切换。协程就是用于实现用户态的上下文切换

ucontext API实现协程

数据结构

  • 用户态栈 stack_t
typedef struct sigaltstack {
    void *ss_sp;		// 栈空间起始地址
    int ss_flags;
    size_t ss_size;		// 栈空间大小
} stack_t;
  • 用户态线程上下文 ucontext_t
    只要保存了CPU的寄存器和堆栈指针两大类数据,就可以实现上下文切换。ucontext_t就是用来保存协程切换时当前CPU的寄存器和堆栈指针的。所以,ucontext_t结构中必须要有存放寄存器的域和存放堆栈指针的域
typedef struct ucontext
  {
    unsigned long int uc_flags;
    struct ucontext *uc_link;			// 上下文运行终止时要恢复的上下文
    stack_t uc_stack;					// 上下文使用的栈       
    mcontext_t uc_mcontext;				// 存放硬件上下文(就是寄存器)
    __sigset_t uc_sigmask;				// 阻塞在线程上下文的信号
    struct _libc_fpstate __fpregs_mem;
  } ucontext_t;

ucontext函数族

  • int getcontext (ucontext_t *__ucp)
    获取当前CPU的上下文,保存到ucp指向的内存中,包括寄存器和堆栈指针。对用户态程序而言,如果要执行一个任务,完全构造一个CPU执行的上下文环境比较困难,glibc提供了getcontext接口协助构造,它获取当前CPU上下文,用户态程序只需要修改其中的eip,让其指向要执行的函数地址,修改堆栈指针,让其指向用户分配好的堆栈,其它东西不用变,就可以定制一个上下文了
  • void makecontext (ucontext_t __ucp, void (__func) (void), int __argc, …)
    通过getcontext获取当前CPU上下文后,我们需要替换eip和堆栈,堆栈指针放在uc_stack成员的ss_sp中,可以直接替换,但是eip,我们要获取和替换比较困难,这里glibc又提供了一个接口makecontext协助用户替换eip。输入参数ucp时要修改的上下文,func是eip要指向的地址,argc是执行func是要传入的参数
  • int setcontext(const ucontext_t *ucp)
    要执行的任务上下文定制完成后,就可以切换到该上下文执行代码了,glibc提供setcontext接口用户跳转到ucp上下文,执行任务,但是这个接口有一个问题,就是不能返回,它直接抛弃掉当前CPU上下文跳转到ucp指向的上下文了,没有保存当前上下文到内存,所以这个函数只适合执行一次性的任务,如果当前任务没有执行完成就切换,任务是无法再切换回来的
  • int swapcontext (ucontext_t *__restrict __oucp, const ucontext_t *__restrict __ucp)
    为了方便任务的切换,glibc还提供了一个swapcontex接口,它在跳转到ucp指向的上下文之前,还会将当前上下文保存到oucp指向的内存中,这样就位任务再切换回来提供了可能性
  • 保存上下文的getcontext和swapcontext函数,他们的返回过程都比较特殊,它可能是自身执行完之后的返回(比如第一次执行),也可能时其它函数跳转到这里的,只要其它函数拿到了这个上下文,就可以跳转到这里。

一个简单的例子

#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
 
int main(int argc, const char *argv[]){
    ucontext_t context;
 
    getcontext(&context);
    puts("Hello world");
    sleep(1);
    setcontext(&context);
    return 0;
}

这段代码每个1秒中就会打印1次hello world,无限循环下去
结果如下:
在这里插入图片描述

一个协程的实现

ucontext函数族只是提供了上下文保存和切换的函数接口,这是协程实现的基础。但协程的目的是实现用户态的任务调度,所以还需要一套实现逻辑。协程,可以有不同风格的实现,下面介绍一个比较简单的实现,原创实现见这里

  • 协程主要由两个模块构成:调度器,协程
    调度器实现任务的组织,管理和调度,负责取任务,发起任务和切换任务,任务管理器的管理单元就是一个协程,数据结构如下:
struct schedule {
    char stack[STACK_SIZE];		//当前CPU执行任务使用的栈空间
    ucontext_t main;			//每个任务执行完,或者主动让出后,都返回到这个上下文
    int nco;					//记录调度器当前管理的协程个数
    int cap;					//记录调度器当前最多可以管理的协程个数
    int running;				//记录当前正在运行的协程子在协程数组中的索引
    struct coroutine **co;		//调度器管理的数组,每个单元就是一个协程
};
struct coroutine {
    coroutine_func func;		//协程要执行的函数
    void *ud;					//协程执行函数时传入的参数
    ucontext_t ctx;				//当协程主动让出后,用于保存当前协程中所在上下文,方便下一次再切换回来时可以继续执行
    struct schedule * sch;		//协程所在的调度器
    ptrdiff_t cap; 				//当协程主动让出后,记录当前协程堆栈最大容量,超过此需要重新分配内存
    ptrdiff_t size;				//当协程主动让出后,记录当前协程堆栈大小
    int status;					//协程状态,可以有4种:ready(待调度),running(正在执行),suspend(挂起)和dead(结束)
    char *stack;				//当协程主动让出后,保存当前协程的堆栈指针,方便下一次切换回来时重新加载
}; 
  • 为了操作和使用调度器和协程数据结构,实现了以下函数
  1. 调度器初始化,返回一个可以管理DEFAULT_COROUTINE个任务的调度器
struct schedule *
scheduler_open(void) {
    struct schedule *S = malloc(sizeof(*S));	//分配空间 		
    S->nco = 0;									//初始化当前管理的任务个数为0
    S->cap = DEFAULT_COROUTINE;					//调度调度器管理任务的最大值
    S->running = -1;							//当前没有任务运行
    S->co = malloc(sizeof(struct coroutine *) * S->cap);	//空间分配
    memset(S->co, 0, sizeof(struct coroutine *) * S->cap);	//空间初始化
    return S;
}
  1. 协程初始化,在调度器的协程数组中申请一个协程,返回该协程在数组中的索引
struct coroutine *
_co_new(struct schedule *S , coroutine_func func, void *ud) {
    struct coroutine * co = malloc(sizeof(*co));
    co->func = func;					//协程入口函数
    co->ud = ud;						//入口函数参数
    co->sch = S;						//协程所属调度器
    co->cap = 0;						//协程栈空间的最大值
    co->size = 0;						//协程栈空间当前值
    co->status = COROUTINE_READY;		//协程状态,待调度
    co->stack = NULL;					//协程堆栈空间指针
    return co;
}

int
coroutine_new(struct schedule *S, coroutine_func func, void *ud) {
    struct coroutine *co = _co_new(S, func , ud);	//组装一个协程
    if (S->nco >= S->cap) {	//分配的协程数超过调度器管理协程的最大值,重新分配2倍的当前协程数,作为最大值
        int id = S->cap;
        S->co = realloc(S->co, S->cap * 2 * sizeof(struct coroutine *));
        memset(S->co + S->cap , 0 , sizeof(struct coroutine *) * S->cap);
        S->co[S->cap] = co;
        S->cap *= 2;
        ++S->nco;
        return id;
    } else {				//协程数在最大值范围内,从调度器协程数组中查找空闲的协程,指向新分配的协程,最后返回找到的数组索引
        int i;
        for (i=0;i<S->cap;i++) {
            int id = (i+S->nco) % S->cap;
            if (S->co[id] == NULL) {
                S->co[id] = co;
                ++S->nco;
                return id;
            }
        }
    }
    assert(0);
    return -1;
}
  1. 调度器负责调度协程任务,实现任务发起的功能
    调度器针对任务的两种状态,有不同的任务执行流程:
    a. 如果任务是待调度状态,说明这个任务还没有执行过,需要首先设置任务的主入口,保证所有任务的都从此入口进入,结束都从次入口推出,就算中途任务有挂起,最后都会从此入口结束
    b. 如果任务是挂起状态,说明之前,这个任务的函数执行过程中,主动发起了yield,让出了cpu,这时候重新被调度到了,调度器在这里负责恢复上一次该任务yield时的上下文,让其切回到那个上下文中,继续执行代码,直到代码执行结束,从调度器设置的主入口退出
    实现接口如下:
static void
mainfunc(uint32_t low32, uint32_t hi32) {	//每个任务执行前的入口
    uintptr_t ptr = (uintptr_t)low32 | ((uintptr_t)hi32 << 32);
    struct schedule *S = (struct schedule *)ptr;
    int id = S->running;					//取出协程在协程数组中的id
    struct coroutine *C = S->co[id];		//取出要执行的协程
    C->func(S,C->ud); 						//执行协程任务,过程中可能调用coroutine_yield主动让出,但最后仍然会回到这里
    _co_delete(C);							//释放执行完成的协程的空间
    S->co[id] = NULL;						//将协程从协程数组中删掉
    --S->nco;								//当前正在执行的协程减1
    S->running = -1;						//但前没有执行的协程
}       
        
void
coroutine_resume(struct schedule * S, int id) {	//协程任务发起,参数是调度器和要执行协程任务的id
    assert(S->running == -1);
    assert(id >=0 && id < S->cap);
    struct coroutine *C = S->co[id];			//根据id取出协程
    if (C == NULL)
        return;
    int status = C->status;						//取出协程的状态,根据状态判断协程之前没有执行过还是执行到一半让出
    switch(status) {
    case COROUTINE_READY:						//协程之前没有执行过
        getcontext(&C->ctx);					//取出当前上下文的协程,放到ctx中
        C->ctx.uc_stack.ss_sp = S->stack;		//给上下文分配堆栈空间
        C->ctx.uc_stack.ss_size = STACK_SIZE;	//设置堆栈空间的大小
        C->ctx.uc_link = &S->main;				//设置执行上下文返回后要切换到的下一个上下文,main在后面会被设置成切换协程前的上下文
        S->running = id;						//设置当前调度器正在执行的协程id
        C->status = COROUTINE_RUNNING;			//设置协程状态从READY变成RUNNING
        uintptr_t ptr = (uintptr_t)S;
        makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));	//将ctx中的eip替换成mainfunc地址
        swapcontext(&S->main, &C->ctx);			//开始执行协程,跳转到C->ctx指向的上下文并报错当前上下文到S->main
        break;
    case COROUTINE_SUSPEND:						//协程之前主动让出,处于挂起状态,现在被重新调度了
        memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);	//将挂起前保存的堆栈重新拷贝到调度器的堆栈上
        S->running = id;						//设置当前调度器运行的协程id
        C->status = COROUTINE_RUNNING;			//修改协程状态从SUSPEND到RUNNING
        swapcontext(&S->main, &C->ctx);			//开始执行协程,跳转到C->ctx指向的上下文并报错当前上下文到S->main
        break;
    default:
        assert(0);
    }
}
  1. 任务中途如果有耗时的异步操作,可以主动发起yield,请求调度器将自身任务挂起,执行其它任务
static void
_save_stack(struct coroutine *C, char *top) {	//保存任务挂起时当前任务的堆栈
    char dummy = 0;
    assert(top - &dummy <= STACK_SIZE);	
    if (C->cap < top - &dummy) {				//计算当前运行的协程的堆栈空间大小,如果大于协程的堆栈空间,保存不下,需要重新分配
        free(C->stack);							//释放原来的空间
        C->cap = top-&dummy;					//设置新的堆栈空间大小的最大值
        C->stack = malloc(C->cap);				//分配新的堆栈空间
    }
    C->size = top - &dummy;		//设置堆栈空间大小
    							//cap是协程执行所有任务中占用的最大堆栈空间的大小,size时上一次挂起时任务的堆栈空间大小
    memcpy(C->stack, &dummy, C->size);			//将当前调度器运行的任务的堆栈保存到协程的堆栈空间中,下一次运行时再从这儿取
}

void
coroutine_yield(struct schedule * S) {		//挂起任务
    int id = S->running;					//取出当前调度器运行的协程索引,用于取出协程数据结构
    assert(id >= 0);
    struct coroutine * C = S->co[id];
    assert((char *)&C > S->stack);
    _save_stack(C,S->stack + STACK_SIZE);	//保存当前任务的堆栈到协程的堆栈中
    C->status = COROUTINE_SUSPEND;			//设置协程的状态为挂起状态
    S->running = -1;						//设置当前调度器运行的协程索引无效
    swapcontext(&C->ctx , &S->main);		//将当前上下文保存到协程的上下文数据结构,跳转到调度器的主入口,使调度器可以调度别的协程
}
  • 协程实现的流程图
    在这里插入图片描述
  • 代码见这里

qemu实现协程

  • qemu协程混合使用了ucontext 接口和setjmp接口,两类接口功能类似,但有细微差别,setjmp接口可以处理信号相关的东西,并且性能较ucontext高,但缺点是没有switch接口保存上下文,因此除了switch需求时用ucontext接口,其它地方qemu尽量使用setjmp接口

数据结构

  • 协程
    协程数据结构需要保存几个基本信息:任务函数指针,任务函数参数,任务挂起时当前cpu的寄存器和使用的堆栈
    qemu的协程有两个数据结构,一个是Coroutine,保存了协程的基本信息,另一个是CoroutineUContext,继承自Coroutine,保存了协程的堆栈和cpu寄存器信息
1)协程任务信息,存放任务函数指针,参数
struct Coroutine {
    CoroutineEntry *entry;	//任务函数指针
    void *entry_arg;		//任务参数
    Coroutine *caller;		//协程调用者,当协程让出时,主动跳转到调用者发起调用时保存的上下文

    /* Used to catch and abort on illegal co-routine entry.
     * Will contain the name of the function that had first
     * scheduled the coroutine. */
    const char *scheduled;

    QSIMPLEQ_ENTRY(Coroutine) co_queue_next; //协程通过这个成员将自己加入到pending等待队列中

    /* Coroutines that should be woken up when we yield or terminate.
     * Only used when the coroutine is running.
     * 当前协程正在运行时,其余协程也想要执行,通过co_queue_next将自身加入到co_queue_wakeup等待队列中
     * 当前协程执行完或者主动让出cpu之后,如果co_queue_wakeup等待队列中有成员,就将其取出加到pending队列中,继续运行协程
     */
    QSIMPLEQ_HEAD(, Coroutine) co_queue_wakeup;
};
2)协程切换前需要保存的信息
typedef struct {
    Coroutine base;		//协程
    void *stack;		//栈空间
    size_t stack_size;	//保存栈大小
    jmp_buf env;		//保存cpu寄存器
} CoroutineUContext;	//协程上下文

setjmp函数族

  • int setjmp(jmp_buf env)和int sigsetjmp(sigjmp_buf env, int savesigs)
    保存当前cpu的上下文到env中,包括栈指针,寄存器的值等,功能与getcontext类似;sigsetjmpsetjmp功能一样,但还提供了保存进程信号集合的功能
  • void longjmp(jmp_buf env, int val)和void siglongjmp(sigjmp_buf env, int val)
    跳转到env指向的上下文中,功能与setcontext类似,但和setcontext存在同样的不足,就是没有保存当前上下,跳转到env指向的上下文后不能返回。参数env用于保存当前上下文,参数val传递setjmp的返回值
  • setjmp保存cpu当前上下文到env中,那么上下文必然保存了程序下一条要指令的地址。longjmp利用env跳转到setjmp保存的上下文,那必然从setjmp处返回。这里存在一个问题,从setjmp函数返回的,可能是setjmp函数本身,也可能是longjmp跳转到此的返回。怎么区分是哪种情况呢?约定:setjmp返回值是0,表示setjmp函数本身返回,返回值非0,表示longjmp跳转到此的返回
  • qemu中既使用了ucontext api,又使用了setjmp api,其实单独使用ucontext api就可以实现协程,但是ucontext api保存了所有进程的信号掩码,增加了系统调用负担。setjmp api中的sigsetjmp/siglongjmp只保存了当前进程栈的信号掩码,setjmp/longjmp甚至没有保存信号掩码,相对来说性能更好,但仅仅使用setjmp api又无法完成协程的切换,所以qemu协程实现的策略是通过ucontext api实现协程创建和切换,其它地方都使用setjmp api以提高性能

qemu协程基本接口

创建协程

  • 创建协程就是初始化CoroutineCoroutineUContext数据结构,最后返回Coroutine
/* 调度器主函数,每个协程在第一次被创建时都必须进入,只做一件事:保存当前上下文到self->env结构体中 */
static void coroutine_trampoline(int i0, int i1)	
{
    union cc_arg arg;
    CoroutineUContext *self;
    Coroutine *co;

    arg.i[0] = i0;
    arg.i[1] = i1;
    self = arg.p;
    co = &self->base;

    /* Initialize longjmp environment and switch back the caller */
    /* 保存当前上下文到CoroutineUContext的env成员中,下次如果有longjmp使用该env作为参数,就会回到这里*/
    if (!sigsetjmp(self->env, 0)) {
    	/* 取出之前保存的env,跳回到调用者,之前在qemu_coroutine_new函数的sigsetjmp(old_env, 0)中保存了env的上下文
    	 * 所以这里使用siglongjmp会跳回到qemu_coroutine_new
    	 * /
        siglongjmp(*(sigjmp_buf *)co->entry_arg, 1); 
    }
	/* 上面sigsetjmp保存上下文到self->env中,在qemu_aio_coroutine_enter或者qemu_coroutine_yield的时候
	 * 会调用qemu_coroutine_switch,里面会调用siglongjmp(to->env, action)跳转到env保存的上下文
	 * 如果env保存的时上面的上下文,程序就会跳转到这里进入while循环
	 * while循环没有终止条件,当它想要跳出循环时,直接跳转到caller->env保存的上下文,然后再页不回来
	 * 所以,从coroutine_trampoline函数返回的只有第一次创建协程的流程,之后,中途进入coroutine_trampoline的流程
	 * 不会从coroutine_trampoline返回,而是通过qemu_coroutine_switch跳转
	 */
    while (1) {	
        co->entry(co->entry_arg);
        qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE);
    }
}
/*新分配一个协程CoroutineUContext,最后返回它的父对象Coroutine */
Coroutine *qemu_coroutine_new(void)
{
    CoroutineUContext *co;
    ucontext_t old_uc, uc;
    sigjmp_buf old_env;
    union cc_arg arg = {0};

    /* The ucontext functions preserve signal masks which incurs a
     * system call overhead.  sigsetjmp(buf, 0)/siglongjmp() does not
     * preserve signal masks but only works on the current stack.
     * Since we need a way to create and switch to a new stack, use
     * the ucontext functions for that but sigsetjmp()/siglongjmp() for
     * everything else.
     */
    if (getcontext(&uc) == -1) {	//获取当前进程上下文,为makcontext替换其中的关键信息,构造ucontext上下文做准备
        abort();
    }
	/* 初始化Coroutine相关信息*/
    co = g_malloc0(sizeof(*co));	//分配协程空间
    co->stack_size = COROUTINE_STACK_SIZE;			//设置协程栈空间大小
    co->stack = qemu_alloc_stack(&co->stack_size);	//分配协程栈空间
    co->base.entry_arg = &old_env; /* stash away our jmp_buf */	  //将协程任务函数的参数指向old_env,后面会将上下文保存到old_env中
	/* 初始化ucontext_t 结构体 */
    uc.uc_link = &old_uc;			//初始化协程任务执行完成后要切换的上下文,后面会将协程切换前的上下文保存到此处
    uc.uc_stack.ss_sp = co->stack;	//堆栈指向之前设置的堆栈
    uc.uc_stack.ss_size = co->stack_size;
    uc.uc_stack.ss_flags = 0;

    arg.p = co;
	/* 构造ucontext上下文,设置所有协程的入口函数coroutine_trampoline */
    makecontext(&uc, (void (*)(void))coroutine_trampoline, 2, arg.i[0], arg.i[1]);
    
    /* swapcontext() in, siglongjmp() back out */
    /* 保存当前上下文到old_env中,因此co->base.entry_arg指向了old_env,后面如果有longjmp(old_env)的类似调用,就会回到这里 
	 * sigsetjmp函数执行完成后会返回0,满足条件进入swapcontext,如果是longjmp跳转到此处,sigsetjmp返回的非0,不会进入
	 * swapcontext,保证了swapcontext执行1次,不会重复进入。
	 * coroutine_trampoline函数中会调用siglongjmp(self->env, 0)其实就是调用siglongjmp(old_env, 0)
	 * 所以,coroutine_trampoline函数会回到这里
	 **/
    if (!sigsetjmp(old_env, 0)) {	
        swapcontext(&old_uc, &uc);
    }

    return &co->base;
}

协程创建实现了三个目标,一是分配协程结构体,二是将任务装进协程结构体里面,三是把协程主入口上下文环境放到协程的env中。后面enter协程时,就可以直接调用switch切换到之前保存的主入口上下文,紧接着就可以执行任务函数

  • 流程图
    在这里插入图片描述

进入协程

  • 协程创建完成后,CoroutineUContext结构体的env保存了主入口上下文,通过switch可以回到主入口
/* 
 * 协程切换,跳转到to保存的上下文并将跳转前的上下文保存到from中,更新current执行即将执行的协程to
 * to中的caller还保存了指向from的指针,方便在to执行yield时,切换回调用者的上下文
 */
CoroutineAction __attribute__((noinline))
qemu_coroutine_switch(Coroutine *from_, Coroutine *to_,
                      CoroutineAction action)
{
    CoroutineUContext *from = DO_UPCAST(CoroutineUContext, base, from_);
    CoroutineUContext *to = DO_UPCAST(CoroutineUContext, base, to_);
    int ret;
        
    current = to_; //设置current指向即将执行的协程
        
    ret = sigsetjmp(from->env, 0);	//保存当前上下文到caller的env,如果是第一次调用,caller就是leader
    if (ret == 0) {
        siglongjmp(to->env, action);//切换到qemu_coroutine_new中保存的coroutine_trampoline上下文,进入while循环
    }   

    return ret;
}

void qemu_aio_coroutine_enter(Coroutine *co)
{   
    QSIMPLEQ_HEAD(, Coroutine) pending = QSIMPLEQ_HEAD_INITIALIZER(pending); //初始化维护等待协程的队列
    Coroutine *from = qemu_coroutine_self(); //取出当前运行的协程
    
    QSIMPLEQ_INSERT_TAIL(&pending, co, co_queue_next); //将协程通过其成员co_queue_next放到pending等待队列中

    /* Run co and any queued coroutines */
    /*
     * 遍历等待队列,依次切换到其上每个协程保存的上下文中
     */
    while (!QSIMPLEQ_EMPTY(&pending)) {
        Coroutine *to = QSIMPLEQ_FIRST(&pending); //取出等待队列中的第一次协程
        CoroutineAction ret;

        /* Cannot rely on the read barrier for to in aio_co_wake(), as there are
         * callers outside of aio_co_wake() */
        const char *scheduled = to->scheduled;
    
        QSIMPLEQ_REMOVE_HEAD(&pending, co_queue_next); //等待队列中除当前要执行的协程以外,如没有其它协程了,将等待队列清空
    
        /* if the Coroutine has already been scheduled, entering it again will
         * cause us to enter it twice, potentially even after the coroutine has
         * been deleted */
        if (scheduled) {
            fprintf(stderr,
                    "%s: Co-routine was already scheduled in '%s'\n",
                    __func__, scheduled);
            abort();
        } 
		/* to中的caller指向调用此协程的协程,如果有,表明该协程已经被运行了,不应该再次运行
		 * 有两种情况caller为空,一是协程还没有开始执行,只是被创建好;二是协程执行过程中主动yield
		 * 除此之外,不允许协程再次进入
		 */
        if (to->caller) {
            fprintf(stderr, "Co-routine re-entered recursively\n");
            abort();
        }
		/* 设置协程当前协程的调用协程 */
        to->caller = from;
		/* 协程切换 */
        ret = qemu_coroutine_switch(from, to, COROUTINE_ENTER);
		/* 协程返回,分两种情况:一是协程协程运行完了,二是协程主动让出 */
        /* Queued coroutines are run depth-first; previously pending coroutines
         * run after those queued more recently.
         * 如果协程执行期间有别的协程请求调度,会将自身加到wakeup队列上。
         * 协程返回后需要先检查wakeup队列中是否有元素,如果有,将其加入到pending中,继续执行协程
         */
        QSIMPLEQ_PREPEND(&pending, &to->co_queue_wakeup);

        switch (ret) {
        case COROUTINE_YIELD:
            break;
        case COROUTINE_TERMINATE:
            coroutine_delete(to);
            break;
        default:
            abort();
        }
    }
}
  • 流程图
    在这里插入图片描述

协程生命周期

  • 协程生命周期包括协程进入,执行,挂起,和终止,挂起操作是可选的,取决于任务的需求
1)协程进入
qemu_aio_coroutine_enter
	qemu_coroutine_switch(from, to, COROUTINE_ENTER)
		sigsetjmp(from->env, 0) //保存当前上下文到leader的env中
			siglongjmp(to->env, COROUTINE_ENTER) //跳转到协程创建时保存的上下文中
2)协程执行
coroutine_trampoline()
	sigsetjmp(self->env, 0) //从longjmp返回,返回值为COROUTINE_ENTER,不为0,因此进入循环
		while (1) {
        	co->entry(co->entry_arg); //执行任务函数
        	qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE)
    	}
3)协程挂起
co->entry(co->entry_arg)
	qemu_coroutine_yield() //任务函数执行过程中挂起协程
		qemu_coroutine_switch(self, to, COROUTINE_YIELD) //保存上下文到自身co的env中,切换到调用者即leader的上下文
4)协程终止
qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE)
  • 协程执行过程中,有两个全局变量用于辅助查询当前协程的运行状况
static __thread CoroutineUContext leader; 	//所有协程的起始调用者
static __thread Coroutine *current; 		//当前正在执行的协程

/* 
 * 取出当前正在执行的协程co
 * 如果是第一次执行,没有协程正在执行,leader就被设置成当前的协程,用于保存执行协程时,调用者的上下文
 * 协程切换时,会调整current的值,指向当前执行的协程
 * 如果是其它情况,current指向正在执行的协程
 */
Coroutine *qemu_coroutine_self(void)
{
    if (!current) {
        current = &leader.base;
    }
    return current;
}
/* 设置current指向即将执行的协程 */
qemu_coroutine_switch
 	current = to_; 
/*
 * 判断协程是否正在执行,可以由其它线程判断
 * current不为空并且caller存在
 * caller为空只有两种情况:
 * 1 协程执行终止了,被删除了,caller为空,表示当前没有协程运行
 * 2 协程主动让出了,设置caller为空
 */
int qemu_in_coroutine(void)
{
    return current && current->caller;
}

qemu_coroutine_yield
	self->caller = NULL
	
coroutine_delete
	self->caller = NULL
  • 协程状态图
    在这里插入图片描述

qemu 协程demo

  • 代码见这里
  • 协程yield demo
    协程在执行过程中yield主动让出cpu之后,qemu_coroutine_enter就会返回,之后如果要重新执行挂起的协程,需要再次调用qemu_coroutine_enter继续执行挂起之前的任务。协程yield和terminal的相同点是的都会从qemu_coroutine_enter返回,不同点是yield返回时保存了上下文到co中,下一次还可以进入,terminal返回已经将co销毁了
static void
coroutine_fn yield_5_times(void *opaque)
{
    int *done = opaque;
    int i;

    for (i = 0; i < 5; i++) {
        fprintf(stdout, "yield_5_times: ready to yield to test_yield, count %d\n", i);
        qemu_coroutine_yield();
        fprintf(stdout, "yield_5_times: return frome yield, count %d\n", i);
    }
    *done = 1;
}

static void
test_yield(void)
{
    Coroutine *coroutine;
    int done = 0;
    int i = -1; /* one extra time to return from coroutine */

    coroutine = qemu_coroutine_create(yield_5_times, &done);
    while (!done) {
        fprintf(stdout, "test_yield: ready to enter yield_5_times, count %d\n", i);
        qemu_coroutine_enter(coroutine);
        fprintf(stdout, "test_yield: yield_5_times has yield, count %d\n", i);
        i++;
    }
}

qemu IO流程

qemu io流程涉及到协程的使用,这一节梳理整个io流程,假设使用qemu-img工具执行dd命令

打开文件

img_dd
	img_open	// 获得一个BlockBackend
		img_open_opts
			blk_new_open
				blk_new
				bdrv_open
  • 打开文件做了两个事情,一是创建BlockBackend结构体,用来抽象磁盘文件,另一个是打开文件,创建BlockDriverState结构,然后将两个通过bdrv_root_attach_child函数联系起来。
  • BlockBackend结构体从后端qemu角度看,是一个磁盘文件的操作句柄,其它地方的代码只要拿到这个东西就能操作一个逻辑磁盘,BlockBackend代表一个逻辑磁盘,它背后的root->bs真正代表一个真正的虚拟机磁盘。
  • 所以blk_new没有真正打开文件,bdrv_open才会打开文件,也就是打开文件描述符。BlockBackend指向的root下面有多个bs,形成一个树状链表并且有父子关系,每个bs代表一个从虚拟机角度看到的逻辑磁盘,它会有多个backing,因此一个bs可能实际由多个文件组成。关于为什么要抽象一个BdrvChild结构出来,本人不是很理解,在多数场景下这个结构里面除了bs以外,其它结构体都没有用到。猜测这个结构体是设计用来做权限控制的,具体怎么用就不知道了。

读写文件

通用接口到驱动接口

  • 文件读写的输入都是BlockBackend,形式类似,blk_pread/blk_pwrite,我们以读文件举例
blk_pread
	blk_prw(blk, offset, buf, count, blk_read_entry, 0)	// 从这里开始,就要用协程执行函数了,blk_read_entry就是需要协程执行的函数
	
static int blk_prw(BlockBackend *blk, int64_t offset, uint8_t *buf,
                   int64_t bytes, CoroutineEntry co_entry,
                   BdrvRequestFlags flags)
{   
    QEMUIOVector qiov = QEMU_IOVEC_INIT_BUF(qiov, buf, bytes);
    BlkRwCo rwco = {	// 封装数据作为参数传递给协程	
        .blk    = blk,   
        .offset = offset,
        .iobuf  = &qiov,
        .flags  = flags,
        .ret    = NOT_DONE,
    };
    
    if (qemu_in_coroutine()) {	// 判断当前上下文是不是协程调用进来的,如果是,直接执行io读函数
        /* Fast-path if already in coroutine context */
        co_entry(&rwco);
    } else {             		// 如果当前不在协程环境,创建一个协程,并发起调用,创建协程参见上面的小节
        Coroutine *co = qemu_coroutine_create(co_entry, &rwco);
        bdrv_coroutine_enter(blk_bs(blk), co);
        BDRV_POLL_WHILE(blk_bs(blk), rwco.ret == NOT_DONE);
    }
    return rwco.ret;
}
  • bdrv_coroutine_enter会跳转到协程创建时注册的上下文,然后执行协程携带的用户函数
bdrv_coroutine_enter
	aio_co_enter
		qemu_aio_coroutine_enter
			qemu_coroutine_switch(from, to, COROUTINE_ENTER)	// 跳转
				sigsetjmp(from->env, 0)	// 先保存当前上下文,相当于记录返回地址
				siglongjmp(to->env, action)	// 再跳转到协程创建时保存的上下文
  • 协程创建时保存上下文过程如下
qemu_coroutine_create
	qemu_coroutine_new
		makecontext(&uc, (void (*)(void))coroutine_trampoline, 2, arg.i[0], arg.i[1])
			coroutine_trampoline
				sigsetjmp(self->env, 0) // 第创建协程之后,首次进入协程就从这里返回。由于时从协程跳转,返回值非0,因此执行下面的while循环
				
    while (true) {
        co->entry(co->entry_arg);	// 终于,这个函数对应的就是前文的blk_read_entry,协程里面开始执行
        qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE);
    }                
  • 读磁盘文件
blk_read_entry
	blk_co_preadv
		bdrv_co_preadv	// 位于block/io.c中
			bdrv_co_preadv
				bdrv_aligned_preadv
					bdrv_driver_preadv
  • 流程走到bdrv_driver_preadv这一步,就要区分具体的qemu磁盘驱动了,驱动的读接口有三种:bdrv_co_preadv,bdrv_aio_preadv,bdrv_co_readv,优先级依次降低,首选第一个bdrv_co_preadv,每个类型的磁盘至少实现其中一个接口。对于RBD磁盘文件,它实现了bdrv_aio_preadv接口,对于普通文件系统,实现了bdrv_co_readv。

驱动接口到落盘

  • 对于RBD,磁盘读写流程如下
    if (drv->bdrv_aio_preadv) {
        BlockAIOCB *acb;
        CoroutineIOCompletion co = {
            .coroutine = qemu_coroutine_self(),
        };
		/* 调用rbd驱动实现的接口qemu_rbd_aio_preadv */
        acb = drv->bdrv_aio_preadv(bs, offset, bytes, qiov, flags, bdrv_co_io_em_complete, &co);
        /* RBD接口时异步io接口,需要注册io完成时的回调 bdrv_co_io_em_complete 函数*/                 		
        if (acb == NULL) {	// 执行出错,返回EIO错误
            return -EIO;
        } else {			
        	/* 正常执行,协程跳转到进入协程bdrv_coroutine_enter时的上下文,相当于直接返回了 
        	 * qemu_coroutine_yield还保存了当前上下文,相当于返回地址,这样在异步io完成之后,回调函数可以跳转到这里
        	 * /
            qemu_coroutine_yield();
            /* io完成,由bdrv_co_io_em_complete跳转到这里 */
            return co.ret;
        }
    }

static void bdrv_co_io_em_complete(void *opaque, int ret)
{
    CoroutineIOCompletion *co = opaque;
        
    co->ret = ret;	// 将异步io接口的返回值给协程
    aio_co_wake(co->coroutine);	// 跳转到协程yield时候的上下文
}

aio_co_wake
	aio_co_enter
		qemu_aio_coroutine_enter
			qemu_coroutine_switch(from, to, COROUTINE_ENTER)
				跳转到bdrv_aio_preadv函数的return co.ret一行,逐级返回,完成整个io流程
  • 对于文件系统,磁盘读写调用内核的libaio接口,虽然这个也是异步IO接口,但需要我们自己监听fd,因此文件系统的IO还有一个监听fd的过程,具体流程如下
bdrv_aio_preadv
	/* 调用file文件的 raw_co_preadv 接口*/
	drv->bdrv_co_readv(bs, sector_num, nb_sectors, qiov)
	raw_co_preadv
		raw_co_prw(bs, offset, bytes, qiov, QEMU_AIO_READ)
			/* 获取磁盘文件的事件循环上下文,直接利用QEMU的事件循环机制poll异步IO * /
			LinuxAioState *aio = aio_get_linux_aio(bdrv_get_aio_context(bs));	
			laio_co_submit(bs, aio, s->fd, offset, qiov, type)	// 提交IO流程
			
int coroutine_fn laio_co_submit(BlockDriverState *bs, LinuxAioState *s, int fd,
                                uint64_t offset, QEMUIOVector *qiov, int type)
{   
    int ret;
    struct qemu_laiocb laiocb = {
        .co         = qemu_coroutine_self(),
        .nbytes     = qiov->size,
        .ctx        = s,
        .ret        = -EINPROGRESS,
        .is_read    = (type == QEMU_AIO_READ),
        .qiov       = qiov,
    };

    ret = laio_do_submit(fd, &laiocb, offset, type);		// 提交io流程
    if (ret < 0) {
        return ret;    
    }
    
    if (laiocb.ret == -EINPROGRESS) {
        qemu_coroutine_yield();	// 跳转到enter协程的地方
    }
    return laiocb.ret;
}   	
  • 内核异步io库libaio使用的接口有io_setup,io_set_eventfd,io_submit,io_getevents,io_destroy
  1. int io_setup (int maxevents, io_context_t *ctxp)
    io_context_t对应内核中一个结构,为异步IO请求提供上下文环境,初始化这个数据结构这个数据
    这步在laio_init中完成
  2. void io_set_eventfd(struct iocb *iocb, int eventfd)
    struct iocb时aio接口定义的io回调数据结构,这个函数的意思是将iocb这个IO的poll fd设置成eventfd,这样内核在aio调用完成后就会设置eventfd,所有poll这个fd的应用程序都会变成可读。
    这一步在laio_do_submit中完成
  3. long io_submit (aio_context_t ctx_id, long nr, struct iocb **iocbpp)
    提交io任务,这个ioq_submit中完成
  • 分析上面的流程,可以知道对文件系统的异步io,QEMU直接使用的事件循环来监听异步IO是否完成,而事件循环监听的注册在哪里,这个在raw文件打开的时候就设置好了
raw_open
	raw_open_common
		aio_setup_linux_aio(bdrv_get_aio_context(bs), errp)) 
LinuxAioState *aio_setup_linux_aio(AioContext *ctx, Error **errp)
{
    if (!ctx->linux_aio) {						// LinuxAioState每个磁盘AioContext中包含一个,如果没有就创建
        ctx->linux_aio = laio_init(errp);		// 初始化LinuxAioState
        if (ctx->linux_aio) {
            laio_attach_aio_context(ctx->linux_aio, ctx);	// 将其关联到AioContext上,下一次提交IO前直接取该磁盘对应的LinuxAioState
        }
    }
    return ctx->linux_aio;
} 
		laio_init
			event_notifier_init(&s->e, false)	//创建eventfd,用作异步IO Poll
			/* 设置其最大可监听数量,注意,MAX_EVENTS值决定了一次性可并发提交IO的数量 */
			io_setup(MAX_EVENTS, &s->ctx)
			/* 初始化IO提交队列 */
			ioq_init(&s->io_q)
	/* 回到raw_open_common 添加libaio的fd到事件循环 */
	s->aio_context = new_context;
    s->completion_bh = aio_bh_new(new_context, qemu_laio_completion_bh, s);
    /* 事件循环监听LinuxAioState的EventNotifier,当其rfd准备好之后,调用注册的回调  qemu_laio_completion_cb */
    aio_set_event_notifier(new_context, &s->e, false,
                           qemu_laio_completion_cb,
                           qemu_laio_poll_cb);

qemu_laio_completion_cb
	qemu_laio_process_completions_and_submit
		qemu_laio_process_completions
			qemu_laio_process_completion(laiocb)
				aio_co_wake(laiocb->co)
					aio_co_enter(ctx, co)
						qemu_aio_coroutine_enter(ctx, co)
							从laio_co_submit的qemu_coroutine_yield()返回,完成整个io流程
  • laio_do_submit排队提交IO请求
laio_do_submit
	/* libaio接口,设置内核IO完成后通知用户空间程序的fd */
	io_set_eventfd(&laiocb->iocb, event_notifier_get_fd(&s->e))
		/* 排队,将libaiocb作为队列一员,提交给磁盘的IO提交队列LinuxAioState->io_q 
		 * 如果IO提交阻塞并且队列长度小于MAX_EVENTS,不要提交IO,累计到MAX_EVENTS在一次性提交
		 */
		QSIMPLEQ_INSERT_TAIL(&s->io_q.pending, laiocb, next);
		    s->io_q.in_queue++; 
    if (!s->io_q.blocked &&
        (!s->io_q.plugged ||
         s->io_q.in_flight + s->io_q.in_queue >= MAX_EVENTS)) {
        ioq_submit(s);
        	/* 调用libaio接口提交IO */
        	io_submit(s->ctx, len, iocbs)
    }

协程调试

  • gdb是基于CPU进程控制寄存器开发的用户态调试工具,对于协程通常无法调试,但QEMU提供的调试协程堆栈的脚本,方便开发调试协程接口,路径在qemu源码的scripts/qemu-gdb.py,用法如下:
# gdb -p {qemu_pid}
# source /path/to/qemu-gdb.py
  • 之后可以像普通线程一样追踪协程的断点了。
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

享乐主

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值