State Threads 回调终结者

State Threads 回调终结者

(感谢网友 @我的上铺叫路遥 投稿)

上回写了篇《一个“蝇量级”C语言协程库》,推荐了一下Protothreads,通过coroutine模拟了用户级别的multi-threading模型,虽然本身足够“轻”,杜绝了系统开销,但这个库本身应用场合主要是内存限制的嵌入式领域,提供原生态组件太少,使用限制太多,比如依赖其它调用产生阻塞等。

这回又替大家在开源界淘了个宝,推荐一个轻量级网络应用框架State Threads(以下简称ST),总共也就3000行C代码,跟Protothreads不同在于ST针对的就是高性能可扩展服务器领域(值得一提的是Protothreads官网参考链接上第一条就是ST的官网)。在其FAQ页面上一句引用”Perfection is achieved not when there is nothing more to add, but rather when there is nothing more to take away.”可以视为开发人员对ST源码质量的自信。

历史渊源

首先介绍一下这个库的历史渊源,从代码贡献者来看,ST不是个人作品,而是有着雄厚的商业支持和应用背景,比如服务器领域,在这里你可以看到ST曾作为Apache的多核应用模块发布。其诞生最初是由网景(Netscape)公司的MSPR(Netscape Portable Runtime library)项目中剥离出来,后由SGI(Silicon Graphic Inc)还有Yahoo!公司(前者是主力)开发维护的独立线程库。历史版本方面,作为SourceForge上开源项目,由2001年发布v1.0以来一直到2009年v1.9稳定版后未再变动。在平台移植方面,从Makefile的配置选项中可知ST支持多种Unix-like平台,还有专门针对Win32的源码改写。源码例子中,提供了web server、proxy以及dns三种编程实例供参考。可以说代码质量应该是相当的稳定和可靠的。

至于许可证方面,有必要略作说明。出于历史原因,网景最初发布时选择了MPL1.1许可证,而后SGI在维护中又混进了GPLv2许可证,照理说这两种许可证是互不兼容的(MPL1.1后续版本是GPL兼容的),也就是说用双许可证打包发布理论上是非法无效的,见GNU官网上MPL兼容性一节。但这里有值得商榷的地方,因为文中又提及,根据MPL1.1中某条款第13节,如果整段或部分代码允许采用另一许可证作为备用(alternate)选择,比如GPL及其兼容,那么整个库的许可证就可视为GPL兼容的。如此一来所谓GPL兼容性一般解释为你不能在GPLv2的代码中混入MPL1.1,而不是说你不能在MPL1.1代码中混入GPLv2,也就是说GPLv2在MPL1.1之后是可以接受的,事实上SGI就采用了后面的做法,尚未引起版权上的纠纷。为此我还考证了一下FAQ上license一节的说法,说ST既可以在MPL和GPL之间选择一种,也可以继续用双许可证,还补了一句在non-free项目使用上也没有限制,但对ST源码所做改动必须对用户可见。在源码文件中的SGI的附加声明还解释了将ST转为GPL代码的做法,就是可以删除前面MPL的声明,否则后续用户仍可以在两者之间二选一。个人觉得既然SGI都这样发话了,那么可解释为反之删除GPL的声明继续采用MPL也是可以接受的,如果你对双许可证承诺仍不放心的话。

基于事件驱动状态机(EDSM)

好了,下面该进入技术性话题了。前面说了ST的目标是高性能可扩展,其技术特征一言以蔽之就是

“It combines the simplicity of the multi-threaded programming paradigm, in which one thread supports each simultaneous connection, with the performance and scalability of an event-driven state machine (EDSM) architecture.”

我们先来纵向比较ST与传统的EDSM区别,再来横向比较与其它线程库(比如Pthread)的区别(注:以下图片全部来自State Threads Library FAQ)。

传统EDSM最常见的方式就是I/O事件的异步回调。基本上都会有一个叫做dispatcher的单线程主循环(又叫event loop),用户通过向dispatcher注册回调函数(又叫event handler)来实现异步通知,从而不必在原地空耗资源干等,在dispatcher主循环中通过select()/poll()系统调用来等待各种I/O事件的发生,当内核检测到事件触发并且数据可达或可用时,select()/poll()会返回从而使dispatcher调用相应的回调函数来对处理用户的请求。所以异步回调与其说是通知,不如说用委托更恰当。

整个过程都是单线程的。这种处理本质上就是将一堆互不相交(disjoint)的回调实现同步控制,就像串联在一个顺序链表上。见图1,黑色的双箭头表示I/O事件复用,回调是个筐,里面装着对各种请求的处理(当然不是每个请求都有回调,一个请求也可以对应不同的回调),每个回调被串联起来由dispatcher激活。这里请求等价于thread的概念(不是操作系统的线程),只不过“上下文切换”(context switch)发生在每个回调结束之时(假设不同请求对应不同回调),注册下一个回调以待事件触发时恢复其它请求的处理。至于dispatcher的执行状态(execute state)可作为回调函数的参数保存和传递。

EDSM

异步回调的缺陷在于难以实现和扩展,虽然已经有libevent这样的通用库,以及其它actor/reacotor的设计模式及其框架,但正如Dean Gaudet(Apache开发者)所说:“其内在的复杂性——将线性思维分解成一堆回调的负担(breaking up linear thought into a bucketload of callbacks)——仍然存在”。从上图可见,回调之间请求例程不是连续的,比如回调之间的切换会打断部分请求,又比如有新的请求需要重新注册。

ST本质上仍然是基于EDSM模型,但旨在取代传统的异步回调方式。ST将请求抽象为thread概念以更接近自然编程模式(所谓的linear thought吧,就像操作系统的线程之间切换那样自然)。ST的调度器(scheduler)对于用户来说是透明的,不像dispatcher那种将执行状态(execute state)暴露给回调方式。每个thread的现场环境可以保存在栈上(一段连续的大小确定的内存空间),由C的运行环境管理。从图2看到,ST的threads可以并发地线性地处理I/O事件,模型比异步回调简单得多。

State Threads

这里稍微解释一下ST调度工作原理,ST运行环境维护了四种队列,分别是IOQ、RUNQ、SLEEPQ以及ZOMBIEQ,当每个thread处于不同队列中对应不同的状态(ST顾名思义所谓thread状态机)。比如polling请求的时候,当前thread就加入IOQ表示等待事件(如果有timeout同时会被放到SLEEPQ中),当事件触发时,thread就从IOQ(如果有timeout同时会从SLEEPQ)移除并转移到RUNQ等待被调度,成为当前的running thread,相当于操作系统的就绪队列,跟传统EDSM对应起来就是注册回调以及激活回调。再比如模拟同步控制wait/sleep/lock的时候,当前thread会被放入SLEEPQ,直到被唤醒或者超时再次进入RUNQ以待调度。

ST的调度具备性能与内存双重优点:在性能上,ST实现自己的setjmp/longjmp来模拟调度,无任何系统开销,并且context(就是jmp_buf)针对不同平台和架构用底层语言实现的,可移植性媲美libc。下面放一段代码解释一下调度实现:

/*
 * Switch away from the current thread context by saving its state 
 * and calling the thread scheduler
 */
#define _ST_SWITCH_CONTEXT(_thread)       \
    ST_BEGIN_MACRO                        \
    if (!MD_SETJMP((_thread)->context)) { \
      _st_vp_schedule();                  \
    }                                     \
    ST_END_MACRO

/*
 * Restore a thread context that was saved by _ST_SWITCH_CONTEXT 
 * or initialized by _ST_INIT_CONTEXT
 */
#define _ST_RESTORE_CONTEXT(_thread)   \
    ST_BEGIN_MACRO                     \
    _ST_SET_CURRENT_THREAD(_thread);   \
    MD_LONGJMP((_thread)->context, 1); \
    ST_END_MACRO

void _st_vp_schedule(void)
{
    _st_thread_t *thread;

    if (_ST_RUNQ.next != &_ST_RUNQ) {
        /* Pull thread off of the run queue */
        thread = _ST_THREAD_PTR(_ST_RUNQ.next);
        _ST_DEL_RUNQ(thread);
    } else {
        /* If there are no threads to run, switch to the idle thread */
        thread = _st_this_vp.idle_thread;
    }
    ST_ASSERT(thread->state == _ST_ST_RUNNABLE);

    /* Resume the thread */
    thread->state = _ST_ST_RUNNING;
    _ST_RESTORE_CONTEXT(thread);
}

如果你熟悉setjmp/longjmp的用法,你就知道当前thread在调用MD_SETJMP将现场上下文保存在jmp_buf中并返回返回0,然后自己调用_st_vp_schedule()将自己调度出去。调度器先从RUNQ上找,如果队列为空就找idle thread,这是在整个ST初始化时创建的一个特殊thread,然后将当前线程设为自己,再调用MD_LONGJMP切换到其上次调用MD_SETJMP的地方,从thread->context恢复现场并返回1,该thread就接着往下执行了。整个过程就同EDSM一样发生在操作系统单线程下,所以没有任何系统开销与阻塞。

其实真正的阻塞是发生在等待I/O事件复用上,也就是select()/poll(),这是整个ST唯一的系统调用。ST当前的状态是,整个环境处于空闲状态,所有threads的请求处理都已经完成,也就是RUNQ为空。这时在_st_idle_thread_start维护了一个主循环(类似于event loop),主要负责三种任务:1.对IOQ所有thread进行I/O复用检测;2.对SLEEPQ进行超时检查;3.将idle thread调度出去,代码如下:

void *_st_idle_thread_start(void *arg)
{
    _st_thread_t *me = _ST_CURRENT_THREAD();

    while (_st_active_count > 0) {
        /* Idle vp till I/O is ready or the smallest timeout expired */
        _ST_VP_IDLE();

        /* Check sleep queue for expired threads */
        _st_vp_check_clock();

        me->state = _ST_ST_RUNNABLE;
        _ST_SWITCH_CONTEXT(me);
    }

    /* No more threads */
    exit(0);

    /* NOTREACHED */
    return NULL;
}

这里的me就是idle thread,因为_st_idle_thread_start就是创建idle thread的启动点,每从上次_ST_SWITCH_CONTEXT()切换回来的时候,接着在_ST_VP_IDLE()里轮询I/O事件的发生,一旦检测到发生了别的thread事件或者SLEEPQ里面发生超时,再用_ST_SWITCH_CONTEXT()把自己切换出去,如果此时RUNQ中非空的话就切换到队列第一个thread。这里主循环是不会退出的。

在内存方面,ST的执行状态作为局部变量保存在栈上,而不是像回调需要动态分配,用户可能分别这样使用thread模式和callback模式:

/* thread land */
int foo()
{
    int local1;
    int local2;
    do_some_io();
}

/* callback land */
struct foo_data {
    int local1;
    int local2;
};

void foo_cb(void *arg)
{
    struct foo_data *locals = arg;
    ...
}

void foo()
{
    struct foo_data *locals = malloc(sizeof(struct foo_data));
    register(foo_cb, locals);
}

基于Mult-Threading范式

同样基于multi-threading编程范式,ST同其它线程库又有和有点呢?比如Posix Thread(以下简称PThread)是个通用的线程库,它是将用户级线程(thread)同内核执行对象(kernel execution entity,有些书又叫lightweight processes)做了1:1或m:n映射,从而实现multi-threading模式。而ST是单线程(n:1映射),它的thread实际上就是协程(coroutine)。通常的网络应用上,多线程范式绕不开操作系统,但在某些特定的服务器领域,线程间的共享资源会带来额外复杂度,锁、竞态、并发、文件句柄、全局变量、管道、信号等,面对这些Pthread的灵活性会大打折扣。而ST的调度是精确的,它只会在明确的I/O和同步函数调用点上发生上下文切换,这正是协程的特性,如此一来ST就不需要互斥保护了,进而也可以放心使用任何静态变量和不可重入库函数了(这在同样作为协程的Protothreads里是不允许的,因为那是stack-less的,无法保存上下文),极大的简化了编程和调试同时增加了性能。

对于同样用户级线程如GNU Pth和MIT Phread比起来呢?有两点,一是ST的thread是无优先级的非抢占式调度,也就是说ST基于EDSM的,每个thread都是事件或数据驱动,迟早会把自己调度出去,而且调度点是明确的,并非按时间片来的,从而简化了thread管理;二是ST会忽略所有信号处理,在_st_io_init中会把sigact.sa_handler设为SIG_IGN,这样做是因为将thread资源最小化,避免了signal mask及其系统调用(在ucontext上是避免不了的)。但这并不意味着ST就不能处理信号,实际上ST建议将信号写入pipe的方式转化为普通I/O事件处理,示例详见这里

这里顺便说一句,C语言实现的协程据我所知只有三种方式:Protothread为代表利用switch-case语义跳转,以ST为代表不依赖libc的setjmp/longjmp上下文切换,以及依赖glibc的ucontext接口(云风的coroutine)。第一种最轻,但受限最大,第三种耗资源性能慢(宝酷注:glibc的ucontext接口的实现中有一个和信号有关的系统调用,所以会慢,估计在一些情况下会比pthread还慢),目前看来ST是最好使的。

基于多核环境

下面来聊聊ST在多核环境下的应用。服务器领域多核的优势在于实现了物理上真正的并发,所以如何充分利用系统优势也是线程库的一大难点。这对ST来说也许正是它的拿手好戏,前面提及ST曾作为Apache的多核引擎模块发布。这里要补充一下前面漏掉的ST的一个重要概念——虚拟处理器(virtual processor,简称vp),见图3,多个cpu通过内核的SMP模拟出多个“核”(core),一个core对应一个内核任务(kernel task),同时对应一个用户进程(process),一个process对应ST的一个vp,每个vp下就是ST的thread(是协程不是线程),结合前面所述,vp初始化先创建idle thread,然后根据I/O事件驱动其它threads,这就是ST的多核架构。

multi-core

这里要指出的是,ST只负责自身thread调度,进程管理是应用程序的事情,也就是说由用户来决定fork多少进程,每个进程分配多少资源,如何进行IPC等。这种架构的好处就是每个vp有自己独立的空间,避免了资源同步竞态(比如杜绝了多进程里的多线程这样混乱的模型)。我们知道这种基于进程的架构是非常健壮的,一个进程奔溃不会影响到其它进程,同时充分利用多核硬件的高并发。同时对于具体逻辑业务使用vp里的thread处理,这是基于EDSM的,如此一来做到了逻辑业务与内核执行对象之间的解耦,没必要因为1K个连接去创建1K的进程。这就是ST的扩展性和灵活性。

使用限制

ST的主要限制在于,应用程序所有I/O操作必须使用ST提供的API,因为只有这样thread才能被调度器管理,并且避免阻塞。

另一个限制在于thread调试,这本身不容易,好在v1.9的ST提供了DEBUG参数,使用TREADQ以及_st_iterate_threads接口检测thread调度情况,用户还可自定义_st_show_thread_stack接口dump每个thread的栈,在GDB使能_st_iterate_threads_flag变量,这些都在Readme中对调试方法有具体说明。按下不表。

总结

这篇文章写得有点短了,主要是通过对比来介绍ST的,其实还有大段原理可以讲,大段源码以及实战用例可以贴,但这一下子又写不过来,ST还是有点技术含量的。说白了,ST的核心思想就是利用multi-threading的简单优雅范式胜过传统异步回调的复杂晦涩实现,又利用EDSM的性能和解耦架构避免了multi-threading在系统上的开销和暗礁。学习ST告诉我们一个道理:未来技术的趋势永远都是融合的。

参考

  • SourceForge以及github上的源码:前者有历史版本及win32版本,后者只有v1.9。
  • Programing Notes:编程注意事项,包括信号处理,IPC,非网络I/O事件等。

(全文完)

(转载本站文章请注明作者和出处 宝酷 – sou-ip ,请勿用于任何商业用途)

好烂啊有点差凑合看看还不错很精彩 (32 人打了分,平均分: 3.91 )
Loading...

State Threads 回调终结者》的相关评论

  1. 赞.
    相比于协程而言,还是充分利用多核的thread好用。
    此外,多进程间的通讯貌似没看到,不知道是不是我太粗心了

  2. 测试中发现每个thread默认栈大小为65536字节,且用mmap匿名分配,这样大约创建32800个thread就返回失败,用malloc还行。除非把栈改小些。

  3. 这个思路真是不错,从最底层的跳转方式入手改变,对于多thread的方式确实是很好的补充,用起来会方便不少。

  4. 确实不错,读了st的代码,以及其代码里一个server的example,又来重读了一遍这篇blog,
    受益匪浅。感谢作者 && 博主。
    —-
    另:很多后台服务的性能点,其实不在异步IO上,这类服务的client数量往往是固定或可预期的,比如10个等。而这类服务的性能点往往在于cpu计算,一个socket请求过来,大量密集的cpu计算,才能response这个socket。
    因此,未来非常希望博主写一些: 如何将一个特定计算任务进行巧妙的粒度拆分,然后并行执行这些unit以提高性能的文章。

  5. Leo :
    @zhouzhenghui
    ST针对事件和数据驱动场合,没说能处理所有I/O
    @starshine
    我觉得性能主要还是在I/O上吧,CPU一般来说不太会是瓶颈。

    和你的看法相反,我没有看到ST中泛化的事件概念,才是我质疑的地方。I/O虽然是事件的主要发起者,但并不涵盖所有,甚至不应该是程序直接面对的。我相信ST内部是有这个抽象的,但却没有暴露出来,体现了设计的局限,我看项目自身也定位于网络服务器的概念。

    我是认同starshine的的观点,其中和上面说的有部分重叠。如果有兴趣不妨关注一下我的项目github.com/zhouzhenghui。

  6. 看完以后说说自己的理解,看看是不是理解对了,有问题请指正一下,谢鞋
    就是通过多线程的方式取代传统模式下state maching的方式
    传统方式就是注册回调,通过状态转化,激活当前状态需要对应的回调。
    当前线程的状态由四种不同类型的线程队列的状态(是否为空)取而代之
    然后通过一个event loop 对4个队列进行轮询
    减小系统调用select/poll次数提高性能,从而线程切换使用MD_JMP实现,弥补多线程切换造成的性能损耗

  7. 在总结一下,就是使用单线程优势以及编程便利性来实现多线程的性能,可以这么理解么?

  8. @Leo
    和你的看法相反,我没有看到ST中泛化的事件概念,才是我质疑的地方。I/O虽然是事件的主要发起者,但并不涵盖所有,甚至不应该是程序直接面对的。我相信ST内部是有这个抽象的,但却没有暴露出来,体现了设计的局限,我看项目自身也定位于网络服务器的概念。

    我是认同starshine的的观点,其中和上面说的有部分重叠。

  9. @zhouzhenghui
    ST的确无法处理中断、信号等内核层面的事件(信号是可以写pipe方式转化为I/O),可能也没有向你所说泛华事件概念。文档中也说明ST定位的是网络服务器,这样一来性能要比概念泛化更重要,用closure模拟回调的做法不熟悉,性能不知能否满足,毕竟跟setjmp/longjmp跳转比较,函数调用在C中本身是一笔开销。

  10. State Threads虽然名义上叫thread,但它不是常规意义上的thread/coroutine,因为它不能并发,所有的ST threads连同它们的调度器都是在同一个系统本地线程内执行的。ST完全回避了多核环境,大大限制了自身应用范围。(libuv虽然也是单线程的IO调度(不同的是callback),但还额外提供了让任务在其他线程执行的便利途径。)

  11. @Liigo
    回复晚了,我发现还是有很多人不了解ST应用场景。这里再强调一次,ST只负责一个用户空间进程(在ST里面叫virtual process)内部的thread协作,不负责进程之间的调度。而并发模式采用的是多核,其映射到用户空间就是vp,所以并发应用就是多个vp的并行。而ST的vp之间是隔离的,它不处理操作系统进程这些事情,是全权交给用户来处理,参考资料中ST的文档已经很详细的例子介绍如何玩并发。

    一个vp对应一个核好处是什么,我也说过,比如不存在cpu之间的竟态,不需要无谓的上下文切换,还有缓存失效等等,实际上ngnix也是类似的做法。

    总之,do one thing and do it well,ST只是一个库,不是一个框架,你需要自己(自由地)利用它搭建你的应用程序,明白吗?

  12. @Liigo
    回复晚了,我发现还是有很多人不了解ST应用场景。这里再强调一次,ST只负责一个用户空间进程(在ST里面叫virtual process)内部的thread协作,不负责进程之间的调度。而并发模式采用的是多核,其映射到用户空间就是vp,所谓并发是指多个vp并发而不是多thread并发。而ST的vp之间是隔离的,它不处理操作系统进程这些事情,是全权交给用户来处理,参考资料中ST的文档已经很详细的例子介绍如何玩并发。

    一个vp对应一个核好处是什么,我也说过,比如不存在cpu之间的竟态,不需要无谓的上下文切换,还有缓存失效等等,实际上ngnix也是类似的做法。

    总之,do one thing and do it well,ST只是一个库,不是一个框架,你需要自己(自由地)利用它搭建你的应用程序,明白吗?

  13. @Leo
    对, Pth更加侧重可移植/可扩展, ST也许是更加注重性能吧.
    另外举个例子, ST的recv()/send()的额外参数只有一个timeout,而Pth的recv/send等参数是一个pth_event_t,这个event可以是某个时间点,或是某个fd可读, 或是某个thread进入某种状态. 而且event可以串起来, 只要其中event触发, 就算触发.

    这个event机制在我写某些程序时还是很有用的.

  14. ST尽管封装了很多阻塞API,但是并没有封装全部的,比如posix消息队列。其实协程机制还是由操作系统提供最合理。比如操作系统提供 一种 线程组间 非抢占调度策略接口,整个线程组之间不能互相抢占。而且我认为这在内核实现起来也并不麻烦。

  15. 这个库1.0版本完全用的c标准库的setjmp longjmp,2.0 是自己用汇编写的寄存器和栈 的保存切换,感觉只保存了几个寄存器,好多寄存器都没保存!

    1. 深入研究winlin的SRS,发现lz在2014年已经开始介绍协程,不得不说佩服!winlin和sou-ip 牛!

  16. 这篇文章引用了srs,而srs知道这个是通过一个50多岁的国外架构师引荐的。不得不说国外程序员很牛逼。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注