回上级页面

天地一指

03 月 30 日


前言

在超弦理论致力于统一广义相对论与量子力学过程中,令超弦理论学家尴尬的是,他们创造了五种不同的超弦理论,这不是统一,而是更严重的分裂。好在爱德华·威滕出手,在超弦理论的十维空间设定上增加了一个新的维度,并提出了膜的概念,认为以振动的方式形成粒子的弦是 1-维膜,亦即弦是膜的特例。

实际上,操作系统对线程的调度,也有多种策略,足够凑出 5 种了。我将每种策略对应每个超弦理论,这就意味着,若追求一种大一统的线程调度理论,也需要在线程上增加一个维度,同时构造出与膜在哲学上有共性的结构。

这个结构是什么呢?协程!它的历史据说比进程和线程都要悠久,只是长期未受学术界和工程界的重视而沉睡数十年,直至最近这些年,因对回调地狱这一顽疾有神奇疗效而大行其道。现今,若是何种编程语言未提供与构造协程相关的语法,应该会招来众多批评。

C 语言没有协程的概念,不过,我们可以尝试给出一些近似的模拟,也许能让你更为深刻地理解,何为协程。

新的维度

实际上这个新的维度,我们在「弦动」中基于多线程、互斥以及条件语句所实现的生产者/消费者程序中已经感受到了,只不过是以线程「通信」的方式。若不用线程,基于多进程以及进程间通信(例如网络通信)机制,也能实现生产者/消费者程序。

现在,我们考虑的是,普通的两个 C 函数,它能否实现生产者/消费者模型呢?若答案是能,这意味着普通的函数不仅可并发运行且能彼此节制,那么无论是多线程机制,还是多进程机制,在理论上它们就毫无神秘之处可言了;它们之所以神秘,是因操作系统实现的调度和通信机制对我们而言是透明的,而现在我们要打破这种透明。

在 C 语言中,两个普通的函数 foobar,它们之间若存在关系——在物理学里,你可称其为引力,只能是 foo 调用 bar,或 bar 调用 foo。不过,若再引入一个函数 foobar,我们希望在其中能够建立 foobar 的关系,前提是无需二者存在调用关系,就像在生产者/消费者模型中,一个只考虑生产,一个只考虑消费,但二者却存在联系。

为了更好地表达我们的意图,我需要先写一个普通的 foo 函数,它用于生产数据:

#define TOTAL 7
int data = 0;

void foo(void) {
        for (int i = 0; i < TOTAL; i++) {
             data = i + 1;
             printf("生产者:生成数据 %d\n", data);
        }
}

再写一个普通的 bar 函数,用于消费数据:

void bar(void) {
        printf("消费者:消费数据 %d\n", data);
}

现在,我们期望的是,在 foo 每产出 1 条数据,bar 随即消费它,但二者不存在调用关系。实现这一目的的第一步是,需要让 foo 在每产出 1 条数据后,便暂停,但是 C 语言没有给普通函数提供这样的暂停支持,操作系统也只能暂停线程或进程,但是仔细想一下,我们用 return 能够让 foo 在产出 1 条数据后就返回:

void foo(void) {
        for (int i = 0; i < TOTAL; i++) {
             data = i + 1;
             printf("生产者:生成数据 %d\n", data);
             return;
        }
}

现在若在 foobar 函数里运行 foo 函数和 bar 函数:

void foobar(void) {
        foo();
        bar();
}

可以完成数据的第一次产出和消费。

若我们打算在 foobar 中再次运行 foo 时,若 foo 能从上一次它的返回之处继续运行,便可产出下 1 条数据了,但前提是,需要知道上一次循环变量的值。这个前提,可借助 static 变量实现:

void foo(void) {
        static int i;
        for (i = 0; i < TOTAL; i++) {
             data = i + 1;
             printf("生产者:生成数据 %d\n", data);
             return;
             /* 我们希望再次运行 foo 时,从此处运行 */
        }
}

在函数内的 static 变量 i,它拥有全局变量的特性,在 foo 函数再次运行时,i 依然是上一次 foo 函数返回时的状态。通过 static 变量,能够让 foo 函数拥有记忆……或者历史。

实现再次运行 foo 时从它上次的返回处继续运行,需借助一个状态机和长期被视为恶魔的 goto 语句:

void foo(void) {
        static int i;
        static int state = 0;
        switch (state) {
                case 0: goto BEGIN;
                case 1: goto NEXT;
        }
        BEGIN:
        for (i = 0; i < TOTAL; i++) {
                 data = i + 1;
                 printf("生产者:生成数据 %d\n", data);
                 state = 1;
                 return;
                 NEXT:
        }
        state = 0; /* 状态机重置 */
}

上述代码中,switch/case 语句实现了一个很简单的状态机,其状态变量也是 foo 的记忆。可能长期以来,大家对 goto 的痛恨,已经让你淡忘了 goto 的用法,现在可趁机温习。goto 可将程序的运行流导向到代码中用标签标定的位置。

现在,在 foobar 里两次调用 foobar

void foobar(void) {
        foo();
        bar();
        foo();
        bar();
}

foobar 的输出应当是

生产者:生成数据 1
消费者:消费数据 1
生产者:生成数据 2
消费者:消费数据 2

现在,将 foobar 写为

void foobar(void) {
        for (int i = 0; i < TOTAL; i++) {
                foo();
                bar();
        }
}

便可将 foo 所有产出的数据逐一交于 bar 消费掉。

但愿你会因此觉得惊奇。这种惊奇不仅仅是我们在 foobar 无需借助调用关系的前提下实现了二者的协作,它更是一切竟如此简单。可惜的是,物理学界的超弦和 M 理论专家,他们似乎理解不了这种简单,反而制造出一些精妙但复杂的机制,犹如尽浓妆艳抹之能事,打扮一位清水芙蓉般的少女。

梳子

如果你的确因 goto 的存在而不安,在 C 语言中的确是有方法完全基于你信任的 switch/case 语句而消除 goto 语句以及它所需要的所有标签,只是我又担心,这种方法又会让你对 switch/case 觉得不安。这种方法就是,switch/case 语句能像梳子一样刺入松软的代码块中:

void foo(void) {
        static int i;
        static int state = 0;
        switch (state) {
        case 0: for (i = 0; i < TOTAL; i++) {
                         data = i + 1;
                         printf("生产者:生成数据 %d\n", data);
                         state = 1;
                         return;
        case 1: ;
                } /* for 循环结束 */
        } /* switch 结束 */
        state = 0;
}

上述代码所用的方法,就是历史上有名的「达夫设备」,它是合乎 C 语法的,原因是 case 语句与配合 goto 的标签是等价的,它们的作用只是用于标定程序流的位置,但对程序流毫不影响。改变程序流的是 switchgoto

不妨勇敢一些,在新的 foo 函数中,代码左侧的 switch / case 语句是卡拉比-丘空间,右侧的 for 循环结构是我们的时空。在超弦理论里,这个卡拉比-丘空间,还是像超弦理论那样畏畏缩缩地蜷缩于时空之中,而在新的 foo 函数里,它完全融入了时空。

牛顿

物理学的本质是什么?是创造一套尽量简单的协议,这份协议像是与天地订下的契约。既然我们已经发现了一种可以让函数之间平等相处的方法,而不是传统的谁调用谁,谁被谁调用这种类似物理学中引力机制的方法,我们也可以将其协议化,这就是计算机软件世界里我们的「物理」学。

我们创造的第 1 条协议是

#define cr_begin static int state = 0; \
                 switch (state) { \
                 case 0:

上述代码定义了一个宏 cr_begin,若像下面这样调用它:

cr_begin;

可展开为

static int state = 0; switch (state) { case 0:;

case 0: 后面虽然多处了 ;,但是无妨,它是空代码。

类似地,创造第 2 条协议:

#define cr_yield do {state = 1; \
                     return; \
                     case 1: ; } while (0)

do { ... } while (0) 是 C 语言宏定义时常用的技法,它用一个只能运行一次的 do ... while 循环结构将一些代码包含起来,使得宏的展开结果不与周边代码产生错误联系。不过,将 case 1: 这样的语句直接放在一个循环结构中,也许你心里难免会有些恐慌。不要怕,case 1: 只是个标签,它无论放在何处,也只是标定当前程序流的位置而已,何况其后只是一个空语句。

第 3 条协议是:

#define cr_end do {state = 0;} while (0); }

基于上述 3 条协议,便可重写 foo 函数:

void foo(void) {
        static int i;
        cr_begin;
        for (i = 0; i < TOTAL; i++) {
               data = i + 1;
               printf("生产者:生成数据 %d\n", data);
               cr_yield;
        }
        cr_end;
}

foo 的代码看上去已颇为舒爽,不过存在隐患。例如将 foo 改写为

void foo(void) {
        static int i;
        cr_begin;
        for (i = 0; i < TOTAL; i++) {
               data = i + 1;
               printf("生产者:生成数据 %d\n", data);
               cr_yield;
               printf("生产者:已产出数据共 %d\n", i);
               cr_yield;
        }
        cr_end;
}

上述代码等价于

void foo(void) {
        static int i;
        static int state = 0;
        switch (state) {
        case 0: for (i = 0; i < TOTAL; i++) {
                         data = i + 1;
                         printf("生产者:生成数据 %d\n", data);
                         state = 1;
                         return;
        case 1: ;
                         printf("生产者:已产出数据共 %d\n", i);
                         state = 1;
                         return;
        case 1: ;
                } /* for 循环结束 */
        } /* switch 结束 */
        state = 0;
}

假设上述的 foo 函数已运行了两次,它在第 3 次运行时,switch 会将程序流切换到第一个 case 1 处,但实际上应该切换到第 2 个 case 1,亦即当 cr_yield 多次出现时,状态变量 state 需要有变化,它不能只是 1。为了解决这个问题,需要重新定义 cr_yield

#define cr_yield(n) do {state = n; \
                     return; \
                     case n: ; } while (0)

新的 cr_yield 需要 1 个参数,用作状态变量 state 的值。

修改上述 foo 函数:

void foo(void) {
        static int i;
        cr_begin;
        for (i = 0; i < TOTAL; i++) {
               data = i + 1;
               printf("生产者:生成数据 %d\n", data);
               cr_yield(1);
               printf("生产者:已产出数据共 %d\n", i);
               cr_yield(2);
        }
        cr_end;
}

宏展开后的代码等价于以下代码:

void foo(void) {
        static int i;
        static int state = 0;
        switch (state) {
        case 0: for (i = 0; i < TOTAL; i++) {
                         data = i + 1;
                         printf("生产者:生成数据 %d\n", data);
                         state = 1;
                         return;
        case 1: ;
                         printf("生产者:已产出数据共 %d\n", i);
                         state = 2;
                         return;
        case 2: ;
                } /* for 循环结束 */
        } /* switch 结束 */
        state = 0;
}

不过,我们有更好的解决方案。C 语言标准中有一个宏 __LINE__,调用它时,展开结果是当前代码的行号,我们可以用它作为状态变量的值,便可保证多次调用 cr_yield 时,不会出现重复状态。重新定义 cr_yield

#define cr_yield do {state = __LINE__; \
                     return; \
                     case __LINE__: ; } while (0)

现在 cr_yield 又变成无参数宏了。只是要记住,永远不要在同一行内多次调用 cr_yield,不然状态变量 state 的值又要出现重复了。

返回值

上一节实现的 cr_yield 只适合无返回值的协程。实际上大多数函数需要有返回值,故而还需改进 cr_yield,使之能支持更具一般性的函数。

C 语言从 C99 标准开始提供了可变参数宏的语法。我们可以基于可变参数宏定义 cy_yield

#define cr_yield(...) do {state = __LINE__; \
                      return __VA_ARGS__; \
                      case __LINE__: ; } while (0)

对于无返回值的函数,只需像下面这样使用 cr_yield

cr_yield();

对于有返回值的函数,假设返回值为 foo,只需

cr_yield(foo);

拉链

我想出来一个例子,它更能表达协程的用途。这个例子很像生活中的拉链。

假设有一个数组 numbers,其中存储了一些整型数,有正数,有负数。另外有两个函数,foobar,它们都要遍历 numbersfoo 需要处理 numbers 里的正数,例如将其增 1。我们希望当 foonumbers 里的每个正数增 1 的时候,bar 能随即将该正数减 1。

我们先用协程实现 foo 函数:

void foo(int *numbers, int n) {
        static int i;
        cr_begin;
        for (i = 0; i < n; i++) {
                int a = numbers[i];
                if (a > 0) printf("(%d, ", a + 1);
                cr_yield;
        }
        cr_end;
}

bar 函数与 foo 类似:

void bar(int *numbers, int n) {
        static int i;
        cr_begin;
        for (i = 0; i < n; i++) {
                int a = numbers[i];
                if (a > 0) printf("%d)\n", a - 1);
                cr_yield();
        }
        cr_end;
}

foobar 就像拉链两边的链带,或者像两条铁轨,或者像 DNA 的双链……下面的 foobar 函数则像拉锁头、火车、读取 DNA 的蛋白质……

void foobar(void) {
        int numbers[] = {1, 3, -2, -3, 5};
        int n = sizeof(numbers) / sizeof(int);
        for (int i = 0; i < n; i++) {
                foo(numbers, n);
                bar(numbers, n);
        }
}

foobar 的输出应当是

(2, 0)
(4, 2)
(6, 4)

协程能够将两个独立的过程交相运行。在操作系统中,不同的线程或不同的进程实际上也是用着相似的原理运行的。对于上述的 foobar 而言,foobar 是它们的操作系统。

可重入性

我们的协程物理学,对于简单的情况已经是足够用的了,但是在多线程机制里,它会很容易错乱起来。例如,如果有两个线程皆以上一节的 foo 函数作为例程,由于循环变量 i 和状态变量 state 皆为 static,这意味着它们是两个线程共享的变量,当一个线程修改它,它便可能会导致另一个线程的行为异常,通过互斥加以保护也无用,因为这两个变量本不应该被多个线程共享。还有一处,foo 函数产出的数据,也是保存在全局变量 data 里的,它也不该被多个线程共享。

foo 这样的函数,在多线程环境中,堪称以一女事二夫——仅作比喻,不歧视性别和伦理,通常称它们为不具备可重入性。为了程序的安全,让函数具备可重入性通常是必要的。

若一个函数,它原本依赖静态变量或全局变量,但是为了可重入性,必须放弃这种依赖,最终它必须以闭包(Closure)的形式存在。倘若你之前经常听到闭包而不知其意,那么至少在 C 语言的语境里,我已很清晰地给你刻画出了它该有的样子,倘若你依然未能想象出来,不妨看下面重新定义的 foo 函数:

void foo(int *state, int *i, int *data) {
        switch (*state) {
        case 0: for (*i = 0; *i < TOTAL; (*i)++) {
                         *data = *i + 1;
                         printf("生产者:生成数据 %d\n", *data);
                         *state = 1;
                         return;
        case 1: ;
                }
        }
        *state = 0;
}

我们只需将 foo 原本依赖的静态变量和全局变量,统统变成指针形式的参数。至于这些指针指向的变量,它们可以是某个函数的局部变量,例如,

void foobar(void) {
        int state = 0, i = 0; data = 0;
        foo(&state, &i, &data);
}

于是,foo 函数便是可重入的函数,而且我们也可以称它是一个闭包。若对闭包给出一个明确的定义,就是具备捕捉外部变量能力的函数。就像一个茶杯,它虽然在宇宙之中,但是它不也装着整个宇宙么?在 C 程序里,将指针作为函数参数,是一种非常普遍的现象,只是大家很少去关心这样的函数是闭包,原因是 C 语言并不觉得有什么必要开设这个概念。

同理,我们也能将消费数据的函数 bar 改成闭包,让它无需再依赖全局变量 data

void bar(int *data) {
        printf("消费者:消费数据 %d\n", *data);
}

对于变成了闭包的 foo,之前我们设计的协程物理学,也需要相应地予以改进,但这并非难事。你可以作为一道练习题去完成它。

若我此刻我说,在超弦理论中,以振动的方式形成各种基本粒子的开弦,必须附着在一种叫作 D 膜的结构上,而在 M 理论中,基本粒子是由一种 2 维的 M 膜的振动形成的,而这种膜不需要附着在其它结构上。可能你不懂这句话的意思,请放心,我也不懂我在说什么。我只是大致明白了,在多线程机制中,若想用多个线程做一些有用处的事情,这些线程之间必须用互斥和条件变量予以约束,而在协程中,这种约束原本是通过全局变量或函数内的静态变量实现的,但是当我们将协程升级为闭包,这种约束也就不存在了。闭包可以不依附其它结构而自由存在。

总结

天地一协程,万物一闭包。

C 语言没有协程,也没有闭包,却也能无中生有。

我们好像已经走得太远了,甚至失去了踪迹。

协程,与网络编程有什么关系呢?