多道程序设计〕协程构造

转载:http://tieba.baidu.com/p/1394788792

setjmp衍生物。

尽管对称多处理器的应用已经很广泛,但多道程序设计的技术仍然沿用了下来:存储器中存储了多个程序,处理器则根据调度要求交替执行各程序。现代的多用户操作系统提供了一系列基于多道程序设计思想的主动成分——无论它们叫做进程、任务、(内核)线程、纤程或是轻进程,而澄清主动成分的确切含义也是在设计操作系统时的重要过程。

当然之前已经有过构造内核级别主动成分的文章了(tieba.baidu.com/p/1273477757),这次需要解决的是协程——一个介乎主动和被动之间的机制。协程是一种更宽泛的子过程,允许有多个入口点,也可以在某一入口点继续之前的执行流程。这样的结构可以用来实现状态机,在某些主存资源严重受限的情况下也可以用于实现一部分主动成分(比如需要相互协作的多个任务)。Lua协程似乎是一个比较成熟而流行的实现,但我没有用过Lua所以也没法再多说什么了。另一个极致轻量的c协程实现来自Protothreads, 全部的机制都由百余行宏提供,独立于任意的操作系统、体系结构和编译器,可以说是在苛刻环境下能作出的很精巧的协程库了,虽然也因为限制不得在协程中使用局部变量以及不能在协程入口例程之外的地方yield而在使用上受到了很大的限制,但仅仅作出这样的妥协就能提供完全支持不同平台/架构/工具的库确实值得赞叹。

不过这里的实现退后一步。这里为了实现尽可能完整而自由的协程功能,限制了平台和工具:下面的讨论和实现,都基于IA-32体系,使用GCC编译。
不太了解c语言运行时存储结构的读者,建议先阅读tieba.baidu.com/p/1393753521这篇文章,文中实现了一对简单的setjmp-longjmp操作,并简单介绍了实现过程中所涉及的栈桢结构。另外,本文的实现中使用了链表来存储协程控制块,具体的实现来自tieba.baidu.com/p/1393147869这篇文章介绍的Linux内核链表实现。另外,本文中提及的协程均是对称式协程,也即协程切换的操作应当只是将控制权交给另一协程(下面给出的实现加入了一点不确定控制权交付目标时使用的调度因素)。在后文中,假设读者对c栈桢有了初步的了解,并能够读懂GCC内联汇编。

实际上,决定了各具体协程执行流程差异的因素仍然是老调:栈桢和指令指针。基于这个想法,可以给出下面的协程控制块:

struct crtn_struct {
    int id;
    void *esp, *ebp;
    void *arg;
    list_ctl_t lctl;
    void *crstack[];
};
typedef struct crtn_struct crtn_t;

#define     STACKSIZE       512
为了方便地址类型的数据赋值,这里并没有用32位无符号整型,而是使用了void *. 对于32位机来说两者的尺寸和符号是一致的。id和arg给出了协程的标识和入口参数,lctl则是链表控制结构,crstack给出了独立的协程栈,esp和ebp则用于存储协程断点处的esp和ebp值。唯一特别的地方在于这里我们并没有给出eip, 较之内核主动成分或是setjmp的处理方法看起来稍微有点不安心。实际上,这是由于对称式协程放弃流程的时候基本会调用协程切换例程(除了协程退出的时候),所以断点eip实际上可以通过协程切换例程选择适当的栈桢后,直接执行返回代码自动恢复;相对地,内核主动成分的断点并不能由栈桢确定,重调度所需的断点可能出现在一个函数体内的任意位置(从高级语言的角度看来),setjmp-longjmp则是因为调用longjmp时,setjmp的栈桢已经不复存在,只能使用setjmp调用者的栈桢和setjmp的返回地址构造出一个拦腰斩断的废止性返回。

基于上面的考虑,继续构造协程系统。为了支持协程的交替运行,需要知道当前的协程;另外需要注意到,各协程都具有独立的栈,但主协程的栈可以直接使用当前任务(或者其他定义了内核调度单元的术语,比如内核线程)的栈。因此需要给出协程系统的初始化:

crtn_t *curr_crtn = NULL;
crtn_t *main_crtn = NULL;

list_ctl_t *h = NULL;
int crtnid = 0;

void init_main_crtn() {
    main_crtn = (crtn_t *) malloc(sizeof(crtn_t));
    curr_crtn = main_crtn;
    curr_crtn->arg = NULL;
    curr_crtn->id = crtnid++;

    h = (list_ctl_t *) malloc(sizeof(list_ctl_t));
    init_list_head(h);
    list_add_tail(&(main_crtn->lctl), h);
}

我们并不为主协程main_crtn申请栈空间,否则协程之外程序的执行流程会被毁掉。h是用于存放协程控制块的线性表表头,同时也是表尾的哨卫节点。在使用协程系统之前,必须使用init_main_crtn初始化主协程、当前协程和协程控制块列表,否则会引起段违例。
同时我们给出协程的调度例程。这里只是简单的轮转调度:

crtn_t *crtn_sched() {
    list_ctl_t *next = 
        (curr_crtn->lctl.next == h) ? (h->next) : (curr_crtn->lctl.next);

    return container_of(next, crtn_t, lctl);
}

这样我们可以取出curr_crtn的下一个协程供调度。这样的机制是危险的,用户需要保证协程在运行期间需要检查在被调度之后,自身可以继续运行的关键条件是否都已经满足,否则会因为同步失败产生错误。当然读者也许会联想到可能出现的死锁,不过死锁并不是用户态下的协程库所能解决的问题,即使在内核态也没有可承受的死锁解决方法,所以常用的非分布式系统实现索性采取了鸵鸟态度,让管理员和软件开发者自行判断。

下面是协程系统的关键操作之一:协程切换。

void crtn_switch(crtn_t *c) {
    if (c == NULL) {
        c = crtn_sched();
    }
    if (curr_crtn != NULL) {
        // WARNING: some register may be trashed by inline asm
        asm volatile ("mov %%esp, %0" : "=r" (curr_crtn->esp));
        asm volatile ("mov %%ebp, %0" : "=r" (curr_crtn->ebp));
    }
    curr_crtn = c;

    asm volatile ("mov %0, %%esp" : : "r" (curr_crtn->esp));
    asm volatile ("mov %0, %%ebp" : : "r" (curr_crtn->ebp));
}

熟悉内核上下文切换的读者应该了解在上下文突变区需要小心的一个问题,也即切换栈桢之后的局部量不可使用。所以这里仅仅使用了一个参数。
其实crtn_switch并没有做太多工作,只是保存+切换了栈指针和栈基址。如前所述,返回地址是存储在栈桢上的,我们没必要也不该在意。假如我们能够适当地构建栈桢,就可以平滑地完成各个协程入口点之间的切换。假设现有若干个运行过的协程和一个运行中的协程C, 当C放弃运行指定某个C'运行的时候,我们容易知道C'一定也曾经调用过crtn_switch放弃流程,也即C'的最顶层栈桢是crtn_switch. 在内核上下文的切换当中我们也使用过相同的做法,即指定一个函数,在它的函数体内构造上下文的突变区,以提供统一的断点形式。于是伴随着这个绝大部分时候都好用的机制,一个在内核上下文中出现过的问题又出现了:刚刚创建的协程并不具有crtn_switch栈桢,如何进入协程入口函数〔自然〕的入口点(也即,像一个函数调用一样从函数体内的首条指令开始执行)?回忆起曾经在创建内核线程时使用的方法,实际上也足以解决这个问题:我们记录下入口点的地址,在控制块中填写好初始栈桢的范围,然后确认上下文切换可以在不作改动的情况下〔恢复〕栈桢并〔返回〕到入口点。

方便起见,我们要求协程的入口例程不应该接受任何参数,安全期间也要从类型上阻止接受参数的例程作为入口例程:

typedef void (*crtn_entrance)(void);

并提供一个例程用来从协程控制块中取得预置的入口参数,算是一种妥协:

void *crtn_get_arg(void) { return curr_crtn->arg; }

当然这也可以是一个宏或者内联函数。下面我们试着新建一个协程:
crtn_t *crtn_new(crtn_entrance entrance, void *arg) {
    crtn_t *c = (crtn_t *) 
        malloc(sizeof(crtn_t) + STACKSIZE * sizeof(void *));
    c->crstack[STACKSIZE - 1] = crtn_end;
    c->crstack[STACKSIZE - 2] = entrance;
    c->crstack[STACKSIZE - 3] = &c->crstack[STACKSIZE - 3];
    c->arg = arg;
    c->id = crtnid++;
    c->esp = c->crstack + STACKSIZE - 3;
    c->ebp = c->esp;
    list_add_tail(&(c->lctl), h);

    return c;
}

首先我们为协程控制块(包括协程栈)申请空间。空间在哪里并不重要,只要从某个内存分配器里获得一块充分大而且难以被正常流程覆盖的内存块就可以。此后,我们手工制造两个返回地址crtn_end和entrance, 分别指向协程的销毁例程和入口例程,并将栈指针和栈基址指向虚拟的栈顶,此时entrance位于次栈顶——这样的情景,相当于构造了一个从无参、无局部变量、无临时变量的函数返回entrance的假象,而entrance返回时又会使用crtn_end的入口地址作为返回地址。于是我们成功实现了协程的新建操作,它可以适应一般情况下的协程上下文切换。

顺理成章地,给出协程的销毁操作crtn_end:

void crtn_end() {
    crtn_t *next = crtn_sched();
    list_del(&(curr_crtn->lctl));
    free(curr_crtn);
    curr_crtn = NULL;
    crtn_switch(next);
}

和crtn_sched一样,这个函数不应当被用户调用。协程的入口函数只要返回就好了。销毁操作取得队列中的下一个协程控制块,销毁当前协程控制块并进行一次上下文切换。注意到crtn_switch中curr_crtn == NULL的判断就是针对这里。
 
至此主要的工作已经完成了,我们可以进行一点测试:

crtn_t *c1, *c2, *c3;
int flag = 0;

void func() {
    printf("world ");
    crtn_switch(c2);
    printf("hehe ");
}

void crtn1(void) {
    char *c = (char *) crtn_get_arg();
    int i;
    for (i = 0; i < 5; i++)
        putchar(*c);
    putchar(' ');
    func();
    //crtn_switch(c2);
    for (i = 0; i < 5; i++)
        putchar(*c);
    putchar(' ');
    flag++;
}

void crtn2(void) {
    char *c = (char *) crtn_get_arg();
    int i;
    for (i = 0; i < 5; i++)
        putchar(*c);
    putchar(' ');
    crtn_switch(c1);
    for (i = 0; i < 5; i++)
        putchar(*c);
    putchar(' ');
    flag++;
}

void crtn3(void) {
    char *c = (char *) crtn_get_arg();
    int i;
    for (i = 0; i < 5; i++)
        putchar(*c);
    putchar(' ');
    flag++;
}

int main(void) {
    char ch1, ch2;

    ch1 = 'a';
    ch2 = 'b';

    init_main_crtn();
    c1 = crtn_new(crtn1, &ch1);
    c2 = crtn_new(crtn2, &ch2);
    crtn_switch(c1);
    c3 = crtn_new(crtn3, &ch1);
    crtn_switch(c3);
    while (flag != 3);
    printf("end ");
    return 0;
}

编译之后的运行结果:

aaaaa
world
bbbbb
hehe
aaaaa
bbbbb
aaaaa
end

程序正常终止,协程的工作也是正常的。如果读者有兴趣,可以考虑构造一种基本的IPC机制以简化协程间同步的设计复杂度。


最后的总结,是关于处理上下文的问题。虽然这个话题在用户态程序当中不会很常见。
或许在这里展示的处理上下文切换的方法更多是归纳法一样的设计:假设对一切k<n我们有办法处理P(k),而且存在合理的初值P(0),那么一切P(n)是可以处理的。我们首先给出了一般的P(k)实现,然后把P(0)塑造成适合P(k)的形态,于是整套操作就可以正常工作了。协程的例子同样也说明了一点,即操作系统本身对主动成分和被动成分的管理,对编写一般应用程序都有借鉴意义。对资源的管理,无论语言提供的机制方便到何种程度,都是需要考量的话题。
 
fix:应该是〔对一切k<n我们有办法处理P(k),且也能验证P(n)可以用相同的办法处理〕
原文地址:https://www.cnblogs.com/foohack/p/3813169.html