协程的C实现

今天正好跟ownwaterloo聊到协程,于是查了查资料,顺便写个博客记录一下吧。

我主要参考的是这篇资料http://www.chiark.greenend.org.uk/~sgtatham/coroutines.html,是Simon Tatham提出的一个协程的C实现,非常有意思。

协程的思想主要是为了解决多个任务如何分享CPU这个问题的。线程在很多协作问题上处理的不好,而且需要锁机制,导致运行缓慢,不易理解,容易出错。协程的思想是,一系列互相依赖的协程间依次使用CPU,每次只有一个协程工作,而其他协程处于休眠状态。与线程不同,协程是自己主动让出CPU,并交付他期望的下一个协程运行,而不是在任何时候都有可能被系统调度打断。因此协程的使用更加清晰易懂,并且多数情况下不需要锁机制(或者说他本身就是一个全局锁)。

Simon的举例是一个生产者消费者例子。传统的程序可能是生产者一个循环不断产生字符,之后退出。而消费者一个循环不断读取字符,并处理之。使用时或许会用一个管道,将生产者的输出重定向到消费者的输入。从而使两者协作。

Simon提出,如何使这样的简单任务无需通过管道这类操作系统相关的重型机制,就能完成。他提出,生产者或者消费者之间,有一方需要改变自己的工作模式。不再是在循环中不断处理任务,而是一次只处理一个字符然后立刻返回。而另一方依旧保持原状,只需在原本输出到或读取自标准IO的地方修改为调用对方的那个函数就可以了。

但这种写法难以维护。他提出了协程的概念,并期待写出类似这样的代码:

int function(void) {
    int i;
    for (i = 0; i < 10; i++)
        return i;   /* won't work, but wouldn't it be nice */
}

在这里return语句并不是终止函数,而是将函数转入睡眠的状态,并将CPU交付给另一个函数执行。例如生产者写入一个字符后,调用类似的return语句,交付消费者处理这个字符。稍后当消费者处理完并调用return语句后,能重新激活生产者协程,并继续这个for循环。

如何在C语言中实现这样的功能而不需使用汇编代码去hack堆栈和寄存器呢?他给出的最初的实现是使用goto语句。

int function(void) {
    static int i, state = 0;
    switch (state) {
        case 0: goto LABEL0;
        case 1: goto LABEL1;
    }
    LABEL0: /* start of function */
    for (i = 0; i < 10; i++) {
        state = 1; /* so we will come back to LABEL1 */
        return i;
        LABEL1:; /* resume control straight after the return */
    }
}

巧妙的使用静态变量存储函数状态,并使用goto语句来继续for循环。但这种写法不够优美,于是又引入了Duff’s device。

switch (count % 8) {
        case 0:        do {  *to = *from++;
        case 7:              *to = *from++;
        case 6:              *to = *from++;
        case 5:              *to = *from++;
        case 4:              *to = *from++;
        case 3:              *to = *from++;
        case 2:              *to = *from++;
        case 1:              *to = *from++;
                       } while ((count -= 8) > 0);
    }

使用这种trick来将switch-case语句作为循环中的跳转。这就避免了goto-label语句需要同时维护goto和label两处代码的麻烦。于是前面的代码可以变成这个样子。

int function(void) {
    static int i, state = 0;
    switch (state) {
        case 0: /* start of function */
        for (i = 0; i < 10; i++) {
            state = 1; /* so we will come back to "case 1" */
            return i;
            case 1:; /* resume control straight after the return */
        }
    }
}

他的文章后面还有一些内容,例如如何使用宏包装这套机制。如何使用__LINE__宏避免state被赋予相同的数值。如何避免多线程调用下干扰静态变量等等。我这里就不赘述了,大家有兴趣可参考原文。
总之读此文的两个收获一个是认识协程,一个是学习到了一种诡谲的C语言用法。非常开心。

10 comments on “协程的C实现

    • 他原文有说嘛。。就是用个结构体,对每个线程新malloc一个。那些静态变量放进结构体,用指针访问。

    • 看了你的评论了,关于静态变量的问题,用他说的结构体的方式,或者其他的方式,总有办法可以解决。关于返回值的问题,完全可在循环结束后给一个另外的返回值。
      他的这套设计,用Simon本人的话讲,并不是Knuth定义的原始的coroutine。因为为了能在C语言里不加汇编实现这套机制,一定需要一个调用者和被调用者的关系,而无法完全实现两方面平衡对等的局面。也许这点是使你认为它有缺陷的地方。但这种方式实现的协程不需要锁机制,能大大提高效率。在C语言中我能想到的其他的实现方式,要不然就是线程+锁,要不然就是嵌入汇编操作寄存器和堆栈。都没有这种方式简洁。
      当然wikipedia上对这种方式的评论,是说这种写法,或者在C中使用setjmp/longjmp,都会带来bug和维护上的麻烦。所以协程这个概念一直没有推广。

      • OwnWaterloo says:

        设计一个context结构体, 当作参数传入, 这不是重点。
        重点是这个context结构体到底包含什么? 是人去分析? 还是编译器去分析?
        人肉分析还算什么coroutine?

        另外就是coroutine还有很多附属设施: 可以指代”函数”的类型, 多返回值或者异常等。
        这些都是C语言缺乏的。

        关于效率, 如果协程真要广泛使用就肯定不会用线程去实现, 太浪费了。

        • 如果不是人肉去分析的话就不是C语言编译器了。我觉得不应该过于纠结他的语法意义。我只是觉得他提出的这种用法至少在C语言现有的前提下,能解决问题。我们可以认为它不是一种语言机制,而仅仅是一种“设计模式”,可以一定程度的实现coroutine的需求。Simon本人也说这种方式实现的并不是原本的coroutine。

  • OwnWaterloo says:

    就拿OO做比较吧, C语言可以实现OO思想, 但可以说C语言支持OO么?
    把C++编译器能自动实现的东西, 去手工实现一遍, 很有快感么?

    • 这不是快感不快感的问题。而是使用一种折中的设计模式,去解决一个问题。我不想争吵语言的完备性的问题。说到OO,Linux内核代码里面,大量使用包含函数指针的结构体模拟对象结构。还使用包含结构体的结构体模拟类的继承关系。也许不优美不利索。但至少实用。

      • =。=|| 看了两位的评论,我来说一句,协程在后台服务的框架上的作用还是比较明显的,它的并发处理能力确实提高了不少,不过相对的,它的可维护性和使用的灵活性也受到了严格的限制。
        对于服务框架来说,协程的使用还是比较多的,但对开发人员的要求很高,要是碰到一个不靠谱的兄弟写得框架,就只能跟着倒霉。
        而对于客户端或者有UI的这种程序来说,为了更好的用户体验,协程就是个渣。

Comments are closed.