协程库st(state threads library)原理解析
协程库state threads library(以下简称st)是一个基于setjmp/longjmp实现的C语言版用户线程库或协程库(user level thread)。
这里有一个基本的协程例子 http://www.csl.mtu.edu/cs4411.ck/www/NOTES/non-local-goto/coroutine.html, 可以了解setjmp和longjmp的基本用法。如还有不懂,请自行查阅其他资料。本文主要关注st基于setjmp和longjmp的实现原理及其程序结构。
st基本介绍 http://state-threads.sourceforge.net/docs/st.html。
从中可以看出,IA(Internet Application)架构演化历史:
1.多进程MP
以Apache为代表的web server。创建进程服务新用户,开销过高。调度单位是进程。
2.多线程MT
创建新线程服务新用户,线程间上下文切换,锁竞争等等,增加了额外开销。调度单位是线程。
3.事件驱动状态机EDSM
基于IO复用机制,实现大量并发请求的处理。但程序是一体的,并不是基于线程,新程序需要从头开始。该模式下主要使用回调和状态参数来进行上下文切换,实际上是以一种非常艰难和痛苦的方式实现了类似线程和栈的思想。ESDM最大的问题是其“将线性思路分解成大量的回调所固有的复杂性”,导致程序难以实现,扩展和维护。
传统EDSM程序架构:
4.协程
调度单位减小到函数,上下文切换不需要内核参与,不存在系统调用。上下文切换开销降到最低,系统调用降到最低,没有锁竞争,没有信号处理。保留了程序对请求的线性处理逻辑,提高了程序的开发效率,可扩展性和可维护性。
基于st的ESDM程序模型:
基于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。
st基于setjmp和longjmp的具体实现:
线程初始化:
#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数据存储。
线程切换:
#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恢复运行。
线程恢复:
#define _ST_RESTORE_CONTEXT(_thread) \ ST_BEGIN_MACRO \ _ST_SET_CURRENT_THREAD(_thread); \ MD_LONGJMP((_thread)->context, 1); \ ST_END_MACRO
起始线程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中被调用。
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中。