一、协程简介
1、简介
协程(coroutine)不是进程或线程,其执行过程更类似于子例程,或者说不带返回值的函数调用。一个程序可以包含多个协程,可以对比与一个进程包含多个线程。我们知道多个线程相对独立,有自己的上下文,切换受系统控制。而协程也相对独立,有自己的上下文,但是其切换由自己控制,当前协程切换到其他协程由当前协程来控制。
协程调度单位减小到函数,上下文切换不需要内核参与,不存在系统调用。上下文切换开销降到最低,系统调用降到最低,没有锁竞争,没有信号处理。保留了程序对请求的线性处理逻辑,提高了程序的开发效率,可扩展性和可维护性。协程是一种程序组件。通常我们把协程理解为是一种程序自己实现调度、用于提高运行效率、降低开发复杂度的东西。而State-Thread(以下简称st),就是一个由C语言编写的小巧、简洁却高效的开源协程库。这个库基于单线程运作、不强制占用用户线程,给予了开发者最大程度的轻量级和较低的侵入性。
很多语言都拥有协程,例如python或者golang。而对于c/c++而言,通常实现协程的常见方式,通常是依赖于glibc提供的setjump&longjump或者基于汇编语言,当然还有基于语义实现(protothread)。linux上使用协程库的方式,通常也会分为替换函数和更为暴力的替换so来实现。当然而各种方式有各自的优劣。而st选用的汇编语言实现setjump&longjump和要求用户调用st_打头的函数来嵌入程序。所以st具备了跨平台的能力,以及让开发者们更开心的“与允许调用者自行选择切换时机”的能力。随着coroutine ts正式进入c++20,c++已经进入协程时代了。但是不幸的是c++20的协程标准只包含编译器需要实现的底层功能,并没有包含简单方便地使用协程的高级库,相关的类和函数进入std标准库估计要等到c++23。本文先以协程库(State Threads)来讲解c++中的协程。
2、state threads优缺点
优点:
- 允许设计快速且高效可扩展的应用程序。应用程序将在CPU的负载和数量上都能很好的扩展。
- 简化了应用程序的编程和调试,因为通常不需要互斥锁定,并且整个应用程序都可以自由适应静态变量和不可重入的库函数。
缺点:
- 套接字上的所有I/O操作都必须适应st库的I/O函数,因为只有那些函数才能执行线程调度并阻止应用程序的进程被阻塞。
3、state threads工作流程
在宏观的来看,ST的结构主要分成:
- vp_schedule。主要是负责了一个调度的能力。有点类似于linux内核当中的schedule()函数。每次当这个函数被调用的时候,都会完成一次线程的切换。
- 各种Queue。用于保存各种状态下等待被调度协程(st_thread)
- Timer。用于记录各种超时和sleep。
- poll。用于监听各种io事件,会根据系统能力不同而进行切换(kqueue、epoll、poll、select)。
- st_thread。用于保存各种协程的信息。
其中比较重要的是schedule模块和thread模块两者。这两者实现了一个完整的协程切换和调度。属于st的核心。而schedule部分通常是开发者们最需要关心的部分。
二、具体实现
0、库的下载
https://sourceforge.net/projects/state-threads/
解压:tar zxvf st-1.9.tar.gz
编译:make linux-debug
解压并编译后的结果如下:
(st-1.9)解压后文件简介:
(1)doc目录
- st.html——ST库概论
- timeout_heap.txt——超时heap实现
- notes.html——给出了编程注意点,包括移植,信号,进程内同步,进程间同步,非网络IO,超时处理,特别谈到进程内同步非常简单,不需要同步资源;非网络IO中谈到drawback和设计时需要避免的方法
- reference.html——一个API接口文档介绍
(2)example目录
- server:包含server.c和error.c。server接受一个客户端连接,接收客户端数据并返给客户端一个简单的HTML网页
- lookupdns:包含lookupdns.c和res.c。
- proxy:包含proxy.c。
st程序结构:
st底层基于event-driven select/poll/kqueue/epoll等IO复用机制。下面以epoll为例说明st底层事件管理机制。
st中有IOQ,ZOMBIEQ,RUNQ,SLEEPQ等几个队列,用来存储处于对应状态的threads。
- RUNQ中存储的是可以被调度运行的threads,每次调用_st_vp_schedule即从该队列取出一个thread去运行。
- IOQ存储处于IO等待状态的threads,当上层调用st_poll时,将该thread放入IOQ中;当底层epoll有IO事件到达时,将该thread从IOQ中移除,并放入RUNQ中。
- 当thread退出时,放入ZOMBIEQ中。
- 当st_poll传入超时参数>0或调用st_usleep和st_cond_timewait时,将thread加入SLEEPQ中。
setjmp和longjmp:
(1)setjmp
#include <setjmp.h>
int setjmp(jmp_buf env);
该函数将栈的上下/环境保存在env中,供longjmp使用。
返回值:如果直接返回,则返回0;如果使用保存的上下文从longjmp(3)返回,则返回非零。
(2)longjmp
#include <setjmp.h>
void longjmp(jmp_buf env, int val);
使用相应的env参数还原最后一次调用setjmp(3)保存的env。由此可知:
setjmp第一次返回0,第二次返回longjmp中的val参数。
调用longjmp后,返回setjmp函数的返回地址处。
基于setjmp和longjmp实现协程库基本步骤(下述线程指用户线程):
- (1)需要用jmpbuf变量保存每一个线程的运行时环境,称为线程上下文context。
- (2)为每个线程分配(malloc/mmap)一个stack,用于该线程运行时栈,该stack完全等效于普通系统线程的函数调用栈。该stack地址是在线程初始化时设置,所以不需要考虑setjmp时保存线程的栈上frames数据的问题。
- (3)通过调用setjmp初始化线程运行时上下文,将context数据存放到jmpbuf结构中。然后修改其中的栈指针sp指向上一步分配的stack。根据当前系统栈的增长方向,将sp设置为stack的最低或最高地址。
- (4)线程退出时,需要返回到一个安全的系统位置。即,需要有一个主线程main thread或idle thread来作为其他线程最终的退出跳转地址。需要为主线程保存一个jmpbuf。
- (5)设置过main thread的jmpbuf后,需要跳转到其他线程开始执行业务线程。
- (6)实现一个context交换函数,在多个线程之间进行跳转:保存自己的jmpbuf,longjmp到另一个线程的jmpbuf。
例:
#include <stdio.h>
#include <setjmp.h>
jmp_buf buf;
int times = 0;
void second(int *k)
{
printf("second times = %d\n", ++(*k));
longjmp(buf, 65536);
}
void first(int *k)
{
printf("first times = %d\n", ++(*k));
second(k);
}
int main()
{
int ret = setjmp(buf);
if (0 == ret){
printf("1. ret is %d\n", ret);
first(×);
} else{
printf("2. ret is %d\n", ret);
}
return 0;
}
输出:
2、线程初始化
#define MD_INIT_CONTEXT(_thread, _sp, _main) \
ST_BEGIN_MACRO \
if (MD_SETJMP((_thread)->context)) \
_main(); \
MD_GET_SP(_thread) = (long) (_sp); \
ST_END_MACRO
setjmp(将jmpbuf存放到thread->context)之后,同时修改它的栈指针sp指向新分配的线程stack->sp地址。该sp指针用于该thread以后的栈frames数据存储。
3、线程切换
#define _ST_SWITCH_CONTEXT(_thread) \
ST_BEGIN_MACRO \
ST_SWITCH_OUT_CB(_thread); \
if (!MD_SETJMP((_thread)->context)) { \
_st_vp_schedule(); \
} \
ST_DEBUG_ITERATE_THREADS(); \
ST_SWITCH_IN_CB(_thread); \
ST_END_MACRO
其中主要时MD_SETJMP保存当前context,然后调用_st_vp_schedule()从_ST_RUNQ上取第一个可运行的thread,并调用_ST_RESTORE_CONTEXT将该thread恢复运行。
4、线程恢复
#define _ST_RESTORE_CONTEXT(_thread) \
ST_BEGIN_MACRO \
_ST_SET_CURRENT_THREAD(_thread); \
MD_LONGJMP((_thread)->context, 1); \
ST_END_MACRO
5、起始线程primordial thread和休眠线程idle thread
/*
* Initialize this Virtual Processor
*/
int st_init(void)
{
_st_thread_t *thread;
if (_st_active_count) {
/* Already initialized */
return 0;
}
/* We can ignore return value here */
st_set_eventsys(ST_EVENTSYS_DEFAULT);
if (_st_io_init() < 0)
return -1;
memset(&_st_this_vp, 0, sizeof(_st_vp_t));
ST_INIT_CLIST(&_ST_RUNQ);
ST_INIT_CLIST(&_ST_IOQ);
ST_INIT_CLIST(&_ST_ZOMBIEQ);
#ifdef DEBUG
ST_INIT_CLIST(&_ST_THREADQ);
#endif
if ((*_st_eventsys->init)() < 0)
return -1;
_st_this_vp.pagesize = getpagesize();
_st_this_vp.last_clock = st_utime();
/*
* Create idle thread
*/
_st_this_vp.idle_thread = st_thread_create(_st_idle_thread_start,
NULL, 0, 0);
if (!_st_this_vp.idle_thread)
return -1;
_st_this_vp.idle_thread->flags = _ST_FL_IDLE_THREAD;
_st_active_count--;
_ST_DEL_RUNQ(_st_this_vp.idle_thread);
/*
* Initialize primordial thread
*/
thread = (_st_thread_t *) calloc(1, sizeof(_st_thread_t) +
(ST_KEYS_MAX * sizeof(void *)));
if (!thread)
return -1;
thread->private_data = (void **) (thread + 1);
thread->state = _ST_ST_RUNNING;
thread->flags = _ST_FL_PRIMORDIAL;
_ST_SET_CURRENT_THREAD(thread);
_st_active_count++;
#ifdef DEBUG
_ST_ADD_THREADQ(thread);
#endif
return 0;
}
在st_init里面,创建primordial thread和idle thread。primordial thread作为起始线程当有其他线程加入运行队列后从该线程切出,idle thread作为背景线程在没有可运行线程的时候执行io调度函数分发事件。
st_init里面调用st_thread_create并不会开始执行idle线程,创建其他线程也一样,只有在直接或间接调用_st_vp_schedule之后才会开始执行RUNQ上面的线程。_st_vp_schedule函数在_ST_SWITCH_CONTEXT中被调用。
6、事件系统(_st_eventsys_t)
常见的方式:poll/select/epoll/kqueue等等(默认采用select)。
typedef struct _st_eventsys_ops {
const char * name; /* Name of this event system */
int val; /* Type of this event system */
int (*init)(void); /* Initialization */
void (*dispatch)(void); /* Dispatch function */
int (*pollset_add)(struct pollfd *, int); /* Add descriptor set */
void (*pollset_del)(struct pollfd *, int); /* Delete descriptor set */
int (*fd_new)(int); /* New descriptor allocated */
int (*fd_close)(int); /* Descriptor closed */
int (*fd_getlimit)(void); /* Descriptor hard limit */
} _st_eventsys_t;
7、协程态(_st_vp_t)
每个vp对应一个idle协程,4个队列,切换回调等。
typedef struct _st_vp {
_st_thread_t *idle_thread; /* Idle thread for this vp */
st_utime_t last_clock; /* The last time we went into vp_check_clock() */
_st_clist_t run_q; /* run queue for this vp */
_st_clist_t io_q; /* io queue for this vp */
_st_clist_t zombie_q; /* zombie queue for this vp */
#ifdef DEBUG
_st_clist_t thread_q; /* all threads of this vp */
#endif
int pagesize;
_st_thread_t *sleep_q; /* sleep queue for this vp */
int sleepq_size; /* number of threads on sleep queue */
#ifdef ST_SWITCH_CB
st_switch_cb_t switch_out_cb; /* called when a thread is switched out */
st_switch_cb_t switch_in_cb; /* called when a thread is switched in */
#endif
} _st_vp_t;
8、队列(run/io/zombie/sleep queue)
st使用了4个队列, 这个在上面的结构中就已经有,为了访问的方便,定义了4组宏(debug模式下,还有另一个),如下。这4个队列分别对应了4种状态,由此形成自己的状态机体系。
每当创建一个微线程时,都会将其加入到RUNQ中。
#define _ST_RUNQ (_st_this_vp.run_q)
#define _ST_IOQ (_st_this_vp.io_q)
#define _ST_ZOMBIEQ (_st_this_vp.zombie_q)
#ifdef DEBUG
#define _ST_THREADQ (_st_this_vp.thread_q)
#endif
#define _ST_SLEEPQ (_st_this_vp.sleep_q)
9、微线程(_st_thread_t)
在vp中,我们看到有一个成员:idle_thread. 自然会有疑问:这货是干嘛的?这货长的像个线程,但其实当然不是线程,就以“微线程”来称吧:即用户态下实现的线程。
主要关注点:
- 微线程的状态机
- 每个微线程维护的栈空间、私有数据
- 每个微线程的入口点
- 我们熟悉的jmp_buf上下文:context
typedef struct _st_thread _st_thread_t;
struct _st_thread {
int state; /* Thread's state */
int flags; /* Thread's flags */
void * (*start)(void *arg); /* The start function of the thread */
void * arg; /* Argument of the start function */
void * retval; /* Return value of the start function */
_st_stack_t * stack; /* Info about thread's stack */
_st_clist_t links; /* For putting on run/sleep/zombie queue */
_st_clist_t wait_links; /* For putting on mutex/condvar wait queue */
#ifdef DEBUG
_st_clist_t tlink; /* For putting on thread queue */
#endif
st_utime_t due; /* Wakeup time when thread is sleeping */
_st_thread_t * left; /* For putting in timeout heap */
_st_thread_t * right; /* -- see docs/timeout_heap.txt for details */
int heap_index;
void ** private_data; /* Per thread private data */
_st_cond_t * term; /* Termination condition variable for join */
jmp_buf context; /* Thread's context */
};
10、栈(_st_stack_t)
每个微线程自身单独都会维护自己的一个“栈空间”。栈有自己独立的内存,大小,栈底,栈顶,栈指针(程序级别、非系统内存级别),恢复点。这里的“栈”只是一个概念(实际是堆,通过malloc或mmap来实现),并非我们通常指的栈。
typedef struct _st_stack {
_st_clist_t links;
char * vaddr; /* Base of stack's allocated memory */
int vaddr_size; /* Size of stack's allocated memory */
int stk_size; /* Size of usable portion of the stack */
char * stk_bottom; /* Lowest address of stack's usable portion */
char * stk_top; /* Highest address of stack's usable portion */
void * sp; /* Stack pointer from C's point of view */
#ifdef __ia64__
void * bsp; /* Register stack backing store pointer */
#endif
} _st_stack_t;
_st_stack的可用空间大小缺省为:128k,即刚开始分配时的大小。当不够用时,会增加一个内存页的大小(getpagesize),当然的大小必须是内存页的整数倍。(详见st_thread_create)。这部分是可用栈空间的大小,实际大小还会加上:2*REDZONE+extra。 在stack的每一端(栈顶、栈底)都有一个REDZONE,其大小也是一个分页;而extra则看是否需要(0或者一个分页大小)
在st_stack中,入栈的方式,有两种:向下增长,向上增加。以向上增长为例(向下增长的话,类似,反之即可)
11、jmp_buf上下文
即然栈是自己实现的,那边对应的jmp_buf里头的sp、bsp也需要跟着变。
...
_ST_INIT_CONTEXT(thread, stack->sp, stack->bsp, _st_thread_main);
...
#define MD_SETJMP(env) _setjmp(env)
#define MD_LONGJMP(env, val) _longjmp(env, val)
...
#define MD_INIT_CONTEXT(_thread, _sp, _bsp, _main) \
ST_BEGIN_MACRO \
if (MD_SETJMP((_thread)->context)) \
_main(); \
memcpy((char *)(_bsp) - MD_STACK_PAD_SIZE, \
(char *)(_thread)->context[0].__jmpbuf[17] - MD_STACK_PAD_SIZE, \
MD_STACK_PAD_SIZE); \
(_thread)->context[0].__jmpbuf[0] = (long) (_sp); \
(_thread)->context[0].__jmpbuf[17] = (long) (_bsp); \
ST_END_MACRO
三、微线程的创建
1、整体流程
微线程的创立,通过_st_thread_create()来完成。
主要过程如下:
- (1)创建栈,分配空间。
- (2)初始化sp/bsp、私有数据(private_data)以及微线程自身所需空间(thread),入口函数(start)及参数。
- (3)调用setjmp,若成功,则执行_st_thread_main();
- (4)将之前的sp恢复.
其中的第三步,_st_thread_main,也就是微线程自身的执行点,过程也很简单:
- (1)获取当前微线程
- (2)将微线程对应的栈空间位置状态变量置为0(state和flags,转型为valatile,防止longjmp切换时更改值。ps: 这里有个关于二级指针数组[1]的tricks)
- (3)执行微线程的入口函数(start)
- (4)退出微线程(_st_thread_exit)
退出微线程时,会发生一些很奇妙的事情:
- (1)清理工作
- (2)对term做检查,是否已退出。若是,添加thread到僵尸队列:zombieq中, 通知term信号,并切换context(_ST_SWITCH_CONTEXT,清理term
- (3)清理stack(_st_stack_free)
- (4)切换至另一个thread执行(_ST_SWITCH_CONTEXT)
上面就是一个整体的流程。而其中有两个地方,需要特别再关注一下。
2、上下文切换
因为涉及到微线程的切换,即然是“切换”,那么这里肯定至少涉及到两个微线程。由于微线程本身处在RUNQ中,切换的话,自然会将next。
- (1)如果runq队列有在运行,切换至next; 如果没有,切换至idle_thread
- (2)将切换后的当前微线程置为run状态
- (3)longjmp置相应微线程的jmp_buf处,恢复栈执行
四、实例
#include <stdio.h>
#include <stdlib.h>
#include "st.h"
using namespace std;
void* do_calc(void* arg) {
int sleep_ms = (int)(long int)(char*)arg * 10;
for (;;) {
printf("in sthread #%dms\n", sleep_ms);
st_usleep(sleep_ms * 1000);
}
return NULL;
}
int main(int argc, char** argv) {
if (argc <= 1) {
printf("Test the concurrence of state-threads!\n"
"Usage: %s <sthread_count>\n"
"eg. %s 10000\n", argv[0], argv[0]);
return -1;
}
if (st_init() < 0) {
printf("error!");
return -1;
}
int i;
int count = atoi(argv[1]);
for (i = 1; i <= count; i++) {
if (st_thread_create(do_calc, (void*)i, 0, 0) == NULL) {
printf("error!");
return -1;
}
}
st_thread_exit(NULL);
return 0;
}
输出参数5时,输出如下:
参考:
http://state-threads.sourceforge.net/ :官网
http://state-threads.sourceforge.net/docs/st.html :state thread官网介绍
https://blog.csdn.net/win_lin/article/details/8242653 :译文
http://ossrs.github.io/state-threads/docs/reference.html#set_eventsys :api文档
https://blog.csdn.net/tao_627/article/details/45788013:state thread库目录介绍