c++实现简单的协程库

为啥需要协程?

出于什么样的原因,诞生了「协程」这一概念? - Ivony的回答 - 知乎 https://www.zhihu.com/question/50185085/answer/1342613525

可以看看知乎大佬的回答,简而言之,

但是在线程不是相互独立,经常因为争抢而阻塞的情况下,抢断式的线程调度器并不是每个时候都是合适的,协程可以看成是一种可以用户自己写策略调度,可以主动让出(yield)的线程。

实现

游戏开发大佬云风写了一个简易的协程序库,用的是ucontext的组件,代码200多行,很适合学习,源码分析详见回答:

云风coroutine协程库源码分析 - cyhone的文章 - 知乎 https://zhuanlan.zhihu.com/p/84935949

在参考了云风老师的实现之后,基于ucontext组件,做出自己的实现。我要实现的是有栈的协程。

有栈协程的实现原理

一个程序要真正运行起来,需要两个因素:可执行代码段、数据。体现在CPU中,主要包含以下几个方面:

1. EIP寄存器,用来存储CPU要读取指令的地址

2. ESP寄存器:指向当前线程栈的栈顶位置

3. 其他通用寄存器的内容:包括代表函数参数的rdi、rsi等等。

4. 线程栈中的内存内容。

这些数据内容,我们一般将其称为"上下文"或者"现场"。

有栈协程的原理,就是从线程的上下文下手,如果把线程的上下文完全改变。即:改变EIP寄存的内容,指向其他指令地址;改变线程栈的内存内容等等。 这样的话,当前线程运行的程序也就完全改变了,是一个全新的程序。

Linux下提供了一套函数,叫做ucontext簇函数,可以用来获取和设置当前线程的上下文内容。

ucontext簇函数初体验

利用ucontext提供的四个函数getcontext(),setcontext(),makecontext(),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;
}

结果:

Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
^C

我们可以看到,程序在输出第一个“Hello world"后并没有退出程序,而是持续不断的输出”Hello world“。其实是程序通过getcontext先保存了一个上下文,然后输出"Hello world",在通过setcontext恢复到getcontext的地方,重新执行代码,所以导致程序不断的输出”Hello world“。


ucontext组件介绍

 在类System V环境中,在头文件< ucontext.h > 中定义了两个结构类型,mcontext_t和ucontext_t和四个函数getcontext(),setcontext(),makecontext(),swapcontext().利用它们可以在一个进程中实现用户级的线程切换。

 mcontext_t类型与机器相关,并且不透明.ucontext_t结构体则至少拥有以下几个域:

           typedef struct ucontext {
               struct ucontext *uc_link;
               sigset_t         uc_sigmask;
               stack_t          uc_stack;
               mcontext_t       uc_mcontext;
               ...
           } ucontext_t;

当当前上下文(如使用makecontext创建的上下文)运行终止时系统会恢复uc_link指向的上下文;uc_sigmask为该上下文中的阻塞信号集合;uc_stack为该上下文中使用的栈;uc_mcontext保存的上下文的特定机器表示,包括调用线程的特定寄存器等。 

下面介绍四个函数:

int getcontext(ucontext_t *ucp);

 getcontext(2) gets the current context of the calling process, storing it in the ucontext struct pointed to by ucp.//或者调用方的当前上下文,并且存到ucp中。当前就是在某一行使用了getconext(),就在那个调用行保持当前上下文

int setcontext(const ucontext_t *ucp);

设置当前的上下文为ucp,setcontext的上下文ucp应该通过getcontext或者makecontext取得,如果调用成功则不返回。如果上下文是通过调用getcontext()取得,程序会继续执行这个调用。如果上下文是通过调用makecontext取得,程序会调用makecontext函数的第二个参数指向的函数,如果func函数返回,则恢复makecontext第一个参数指向的上下文第一个参数指向的上下文context_t中指向的uc_link.如果uc_link为NULL,则线程退出。

void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

makecontext() 函数修改 ucp 指向的上下文(可以通过调用 getcontext(3) 获得),用于指定上下文执行时的入口点函数。 在调用 makecontext() 之前,调用者必须为此上下文分配一个新堆栈并将其地址分配给 ucp->uc_stack,并定义一个后继上下文并将其地址分配给 ucp->uc_link。argc是函数的入参。

int swapcontext(ucontext_t *oucp, ucontext_t *ucp);

 保存当前上下文到oucp结构体中,然后激活upc上下文。 

 如果执行成功,getcontext返回0,setcontext和swapcontext不返回;如果执行失败,getcontext,setcontext,swapcontext返回-1,并设置对于的errno.

 简单说来,  getcontext获取当前上下文,setcontext设置当前上下文,swapcontext切换上下文,makecontext创建一个新的上下文。

使用ucontext实现线程切换

虽然我们称协程是一个用户态的轻量级线程,但实际上多个协程同属一个线程。任意一个时刻,同一个线程不可能同时运行两个协程。如果我们将协程的调度简化为:主函数调用协程1,运行协程1直到协程1返回主函数,主函数在调用协程2,运行协程2直到协程2返回主函数。示意步骤如下:

    执行主函数
    切换:主函数 --> 协程1
    执行协程1
    切换:协程1  --> 主函数
    执行主函数
    切换:主函数 --> 协程2
    执行协程2
    切换协程2  --> 主函数
    执行主函数

我们实现的协程并不是完备的,在工业生产中,协程1-->协程2的转换是允许的,而我们实现的协程库,包括云风老师实现的协程库都不允许这样的嵌套操作,必须先切换到主函数,再由主函数切换到其他协程。

这种设计的关键在于实现主函数到一个协程的切换,然后从协程返回主函数。这样无论是一个协程还是多个协程都能够完成与主函数的切换,从而实现协程的调度。

 实现用户线程的过程是:

      1、我们首先调用getcontext获得当前上下文
      2、修改当前上下文ucontext_t来指定新的上下文,如指定栈空间极其大小,设置用户线程执行        完后返回的后继上下文(即主函数的上下文)等
      3、调用makecontext创建上下文,并指定用户线程中要执行的函数
      切换到用户线程上下文去执行用户线程(如果设置的后继上下文为主函数,则用户线程执行完        后会自动返回主函数)。

下面代码context_test函数完成了上面的要求。


#include <ucontext.h>
#include <stdio.h>
 
void func1(void * arg)
{
    puts("1");
    puts("11");
    puts("111");
    puts("1111");
 
}
void context_test()
{
    char stack[1024*128];
    ucontext_t child,main;
 
    getcontext(&child); //获取当前上下文
    child.uc_stack.ss_sp = stack;//指定栈空间
    child.uc_stack.ss_size = sizeof(stack);//指定栈空间大小
    child.uc_stack.ss_flags = 0;
    child.uc_link = &main;//设置后继上下文
 
    makecontext(&child,(void (*)(void))func1,0);//修改上下文指向func1函数
 
    swapcontext(&main,&child);//切换到child上下文,保存当前上下文到main
    puts("main");//如果设置了后继上下文,func1函数指向完后会返回此处
}
 
int main()
{
    context_test();
 
    return 0;

}

运行结果:


1
11
111
1111
main

来分析一下代码:

开始我们用getcontext初始化了上下文child,指定了栈空间的大小,并且指定了后续要执行的上下文是main,但是此时main并未初始化。 接着我们利用makecontext把child这个上下文和函数func1绑定在一起,我个人理解就是func1所运行需要的资源由child来提供。swapcontext十分关键,激活上下文child,并且把当前上下文保存到main中,此时,上下文main就被初始化了。激活上下文child会使得func1先执行,执行完毕后,执行child的后续上下文main。比如main这个上下文是在第10行被保存的,执行main这个上下文就会到程序的11行执行。所以结果是显而易见的,先执行func1,然后跳到puts("main")执行。这也是协程切换的基本原理。

实现自己的协程

定义一个协程的结构体为:


typedef void (*Fun)(void *arg);
 
typedef struct uthread_t
{
    ucontext_t ctx;
    Fun func;
    void *arg;
    enum ThreadState state;
    char stack[DEFAULT_STACK_SZIE];
}uthread_t;

 ctx保存协程的上下文,stack为协程的栈,栈大小默认为DEFAULT_STACK_SZIE=128Kb.你可以根据自己的需求更改栈的大小。func为协程执行的用户函数,arg为func的参数,state表示协程的运行状态,包括FREE,RUNNABLE,RUNING,SUSPEND,分别表示空闲,就绪,正在执行和挂起四种状态。

调度器包括主函数的上下文main,包含当前调度器拥有的所有协程的vector类型的threads,以及指向当前正在执行的协程的编号running_thread.如果当前没有正在执行的协程时,running_thread=-1.
 接下来,在定义几个使用函数uthread_create,uthread_yield,uthread_resume函数已经辅助函数schedule_finished.就可以了。

int  uthread_create(schedule_t &schedule,Fun func,void *arg);


 创建一个协程,该协程的会加入到schedule的协程序列中,func为其执行的函数,arg为func的执行函数。返回创建的线程在schedule中的编号。

void uthread_yield(schedule_t &schedule);


 挂起调度器schedule中当前正在执行的协程,切换到主函数。

void uthread_resume(schedule_t &schedule,int id);


 恢复运行调度器schedule中编号为id的协程

int  schedule_finished(const schedule_t &schedule);


 判断schedule中所有的协程是否都执行完毕,是返回1,否则返回0.注意:如果有协程处于挂起状态时算作未全部执行完毕,返回0.

以下是代码:

uthread.h

#ifndef MY_UTHREAD_H
#define MY_UTHREAD_H

#ifdef __APPLE__
#define _XOPEN_SOURCE
#endif 

#include <ucontext.h>
#include <vector>

#define DEFAULT_STACK_SZIE (1024*128)//stack大小
#define MAX_UTHREAD_SIZE   1024//最多有几个协程

enum ThreadState{FREE,RUNNABLE,RUNNING,SUSPEND};//协程的四态模型

struct schedule_t;

typedef void (*Fun)(void *arg);

typedef struct uthread_t
{
    ucontext_t ctx;
    Fun func;
    void *arg;
    enum ThreadState state;
    char stack[DEFAULT_STACK_SZIE];
}uthread_t;//协程的定义

typedef struct schedule_t//协程调度器
{
    ucontext_t main;
    int running_thread;//那个协程在运行
    uthread_t *threads;
    int max_index; // 曾经使用到的最大的index + 1

    schedule_t():running_thread(-1), max_index(0) {//一开始设置为-1
        threads = new uthread_t[MAX_UTHREAD_SIZE];
        for (int i = 0; i < MAX_UTHREAD_SIZE; i++) {
            threads[i].state = FREE;
        }
    }
    
    ~schedule_t() {
        delete [] threads;//释放内存
    }
}schedule_t;

/*help the thread running in the schedule*/
static void uthread_body(schedule_t *ps);

/*Create a user's thread
*    @param[in]:
*        schedule_t &schedule 
*        Fun func: user's function
*        void *arg: the arg of user's function
*    @param[out]:
*    @return:
*        return the index of the created thread in schedule
*/
int  uthread_create(schedule_t &schedule,Fun func,void *arg);

/* Hang the currently running thread, switch to main thread */
void uthread_yield(schedule_t &schedule);

/* resume the thread which index equal id*/
void uthread_resume(schedule_t &schedule,int id);

/*test whether all the threads in schedule run over
* @param[in]:
*    const schedule_t & schedule 
* @param[out]:
* @return:
*    return 1 if all threads run over,otherwise return 0
*/
int  schedule_finished(const schedule_t &schedule);

#endif

#ifndef MY_UTHREAD_CPP
#define MY_UTHREAD_CPP


#include "uthread.h"
//#include <stdio.h>

void uthread_resume(schedule_t &schedule , int id)
{
    if(id < 0 || id >= schedule.max_index){//id不合法的情况
        return;
    }

    uthread_t *t = &(schedule.threads[id]);

    if (t->state == SUSPEND) {//激活协程t的上下文,执行t绑定的函数,执行完了执行t的后继上下文
        //main,因为在creat的时候就用makecontext把t的后继上下文指定为main了,所以所以程序在
        //执行完t对应的函数后会顺延执行到标号1处。
        swapcontext(&(schedule.main),&(t->ctx));
    }//1
}

void uthread_yield(schedule_t &schedule)//主动让出
{
    if(schedule.running_thread != -1 ){
        uthread_t *t = &(schedule.threads[schedule.running_thread]);//找到该协程
        t->state = SUSPEND;//挂起
        schedule.running_thread = -1;//-1的意思是要回到主函数

        swapcontext(&(t->ctx),&(schedule.main));//跳到上下文main的点,然后保存当前上下文到 
        //t->ctx
    }//1
}

void uthread_body(schedule_t *ps)//使得调度器ps调度一个协程。
{
    int id = ps->running_thread;

    if(id != -1){
        uthread_t *t = &(ps->threads[id]);

        t->func(t->arg);

        t->state = FREE;
        
        ps->running_thread = -1;
    }
}

int uthread_create(schedule_t &schedule,Fun func,void *arg)
{
    int id = 0;
    
    for(id = 0; id < schedule.max_index; ++id ){
        if(schedule.threads[id].state == FREE){
            break;
        }
    }//找一个空位,
    
    if (id == schedule.max_index) {
        schedule.max_index++;//扩容
    }

    uthread_t *t = &(schedule.threads[id]);//拿到此协程

    t->state = RUNNABLE;//变换状态
    t->func = func;//绑定函数
    t->arg = arg;//绑定参数

    getcontext(&(t->ctx));//这个操作就不说了,和之前举过的例子是完全一样的操作,直接运行。
    
    t->ctx.uc_stack.ss_sp = t->stack;
    t->ctx.uc_stack.ss_size = DEFAULT_STACK_SZIE;
    t->ctx.uc_stack.ss_flags = 0;
    t->ctx.uc_link = &(schedule.main);
    schedule.running_thread = id;
    
    makecontext(&(t->ctx),(void (*)(void))(uthread_body),1,&schedule);//t->ctx绑定的是函数
    //uthread_body(),很重要!。
    swapcontext(&(schedule.main), &(t->ctx));
    
    return id;//返回运行的协程的id
}

int schedule_finished(const schedule_t &schedule)//如果所有协程都运行完毕返回1,否则返回0
{
    if (schedule.running_thread != -1){
        return 0;
    }else{
        for(int i = 0; i < schedule.max_index; ++i){
            if(schedule.threads[i].state != FREE){
                return 0;
            }
        }
    }

    return 1;
}

#endif

测试代码和结果:

#include "uthread.h"
#include <stdio.h>
 
void func2(void * arg)
{
    puts("22");
    puts("22");
    uthread_yield(*(schedule_t *)arg);
    puts("22");
    puts("22");
}
 
void func3(void *arg)
{
    puts("3333");
    puts("3333");
    uthread_yield(*(schedule_t *)arg);
    puts("3333");
    puts("3333");
 
}
 
void schedule_test()
{
    schedule_t s;
 
    int id1 = uthread_create(s,func3,&s);
    int id2 = uthread_create(s,func2,&s);
 
    while(!schedule_finished(s)){
        uthread_resume(s,id2);
        uthread_resume(s,id1);
    }
    puts("main over");
 
}
int main()
{
    schedule_test();
 
    return 0;
}

结果: 

22
22
3333
3333
22
22
3333
3333
main over

总结:

我们可以看到,resume的作用是激活一个协程,使得该协程对应的函数运行起来,运行的起点是该协程上次保存的上下文,yield保存当前上下文,切换到主函数。resume->yield是一般使用方法。


 

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值