为啥需要协程?
出于什么样的原因,诞生了「协程」这一概念? - 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是一般使用方法。