03 月 30 日
在超弦理论致力于统一广义相对论与量子力学过程中,令超弦理论学家尴尬的是,他们创造了五种不同的超弦理论,这不是统一,而是更严重的分裂。好在爱德华·威滕出手,在超弦理论的十维空间设定上增加了一个新的维度,并提出了膜的概念,认为以振动的方式形成粒子的弦是 1-维膜,亦即弦是膜的特例。
实际上,操作系统对线程的调度,也有多种策略,足够凑出 5 种了。我将每种策略对应每个超弦理论,这就意味着,若追求一种大一统的线程调度理论,也需要在线程上增加一个维度,同时构造出与膜在哲学上有共性的结构。
这个结构是什么呢?协程!它的历史据说比进程和线程都要悠久,只是长期未受学术界和工程界的重视而沉睡数十年,直至最近这些年,因对回调地狱这一顽疾有神奇疗效而大行其道。现今,若是何种编程语言未提供与构造协程相关的语法,应该会招来众多批评。
C 语言没有协程的概念,不过,我们可以尝试给出一些近似的模拟,也许能让你更为深刻地理解,何为协程。
实际上这个新的维度,我们在「弦动」中基于多线程、互斥以及条件语句所实现的生产者/消费者程序中已经感受到了,只不过是以线程「通信」的方式。若不用线程,基于多进程以及进程间通信(例如网络通信)机制,也能实现生产者/消费者程序。
现在,我们考虑的是,普通的两个 C 函数,它能否实现生产者/消费者模型呢?若答案是能,这意味着普通的函数不仅可并发运行且能彼此节制,那么无论是多线程机制,还是多进程机制,在理论上它们就毫无神秘之处可言了;它们之所以神秘,是因操作系统实现的调度和通信机制对我们而言是透明的,而现在我们要打破这种透明。
在 C 语言中,两个普通的函数 foo
和
bar
,它们之间若存在关系——在物理学里,你可称其为引力,只能是
foo
调用 bar
,或 bar
调用
foo
。不过,若再引入一个函数
foobar
,我们希望在其中能够建立 foo
和
bar
的关系,前提是无需二者存在调用关系,就像在生产者/消费者模型中,一个只考虑生产,一个只考虑消费,但二者却存在联系。
为了更好地表达我们的意图,我需要先写一个普通的 foo
函数,它用于生产数据:
#define TOTAL 7
int data = 0;
void foo(void) {
for (int i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf}
}
再写一个普通的 bar
函数,用于消费数据:
void bar(void) {
("消费者:消费数据 %d\n", data);
printf}
现在,我们期望的是,在 foo
每产出 1
条数据,bar
随即消费它,但二者不存在调用关系。实现这一目的的第一步是,需要让
foo
在每产出 1 条数据后,便暂停,但是 C
语言没有给普通函数提供这样的暂停支持,操作系统也只能暂停线程或进程,但是仔细想一下,我们用
return
能够让 foo
在产出 1
条数据后就返回:
void foo(void) {
for (int i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printfreturn;
}
}
现在若在 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++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printfreturn;
/* 我们希望再次运行 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;
}
:
BEGINfor (i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf= 1;
state return;
:
NEXT}
= 0; /* 状态机重置 */
state }
上述代码中,switch/case
语句实现了一个很简单的状态机,其状态变量也是 foo
的记忆。可能长期以来,大家对 goto
的痛恨,已经让你淡忘了
goto
的用法,现在可趁机温习。goto
可将程序的运行流导向到代码中用标签标定的位置。
现在,在 foobar
里两次调用 foo
和
bar
:
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
消费掉。
但愿你会因此觉得惊奇。这种惊奇不仅仅是我们在 foo
和
bar
无需借助调用关系的前提下实现了二者的协作,它更是一切竟如此简单。可惜的是,物理学界的超弦和
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++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf= 1;
state return;
case 1: ;
} /* for 循环结束 */
} /* switch 结束 */
= 0;
state }
上述代码所用的方法,就是历史上有名的「达夫设备」,它是合乎 C
语法的,原因是 case
语句与配合 goto
的标签是等价的,它们的作用只是用于标定程序流的位置,但对程序流毫不影响。改变程序流的是
switch
或 goto
。
不妨勇敢一些,在新的 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_beginfor (i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf;
cr_yield}
;
cr_end}
foo
的代码看上去已颇为舒爽,不过存在隐患。例如将
foo
改写为
void foo(void) {
static int i;
;
cr_beginfor (i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf;
cr_yield("生产者:已产出数据共 %d 次\n", i);
printf;
cr_yield}
;
cr_end}
上述代码等价于
void foo(void) {
static int i;
static int state = 0;
switch (state) {
case 0: for (i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf= 1;
state return;
case 1: ;
("生产者:已产出数据共 %d 次\n", i);
printf= 1;
state return;
case 1: ;
} /* for 循环结束 */
} /* switch 结束 */
= 0;
state }
假设上述的 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_beginfor (i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf(1);
cr_yield("生产者:已产出数据共 %d 次\n", i);
printf(2);
cr_yield}
;
cr_end}
宏展开后的代码等价于以下代码:
void foo(void) {
static int i;
static int state = 0;
switch (state) {
case 0: for (i = 0; i < TOTAL; i++) {
= i + 1;
data ("生产者:生成数据 %d\n", data);
printf= 1;
state return;
case 1: ;
("生产者:已产出数据共 %d 次\n", i);
printf= 2;
state return;
case 2: ;
} /* for 循环结束 */
} /* switch 结束 */
= 0;
state }
不过,我们有更好的解决方案。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
,只需
(foo); cr_yield
我想出来一个例子,它更能表达协程的用途。这个例子很像生活中的拉链。
假设有一个数组
numbers
,其中存储了一些整型数,有正数,有负数。另外有两个函数,foo
和 bar
,它们都要遍历 numbers
。foo
需要处理 numbers
里的正数,例如将其增 1。我们希望当
foo
对 numbers
里的每个正数增 1
的时候,bar
能随即将该正数减 1。
我们先用协程实现 foo
函数:
void foo(int *numbers, int n) {
static int i;
;
cr_beginfor (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_beginfor (i = 0; i < n; i++) {
int a = numbers[i];
if (a > 0) printf("%d)\n", a - 1);
();
cr_yield}
;
cr_end}
foo
和 bar
就像拉链两边的链带,或者像两条铁轨,或者像 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++) {
(numbers, n);
foo(numbers, n);
bar}
}
foobar
的输出应当是
(2, 0)
(4, 2)
(6, 4)
协程能够将两个独立的过程交相运行。在操作系统中,不同的线程或不同的进程实际上也是用着相似的原理运行的。对于上述的
foo
和 bar
而言,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;
("生产者:生成数据 %d\n", *data);
printf*state = 1;
return;
case 1: ;
}
}
*state = 0;
}
我们只需将 foo
原本依赖的静态变量和全局变量,统统变成指针形式的参数。至于这些指针指向的变量,它们可以是某个函数的局部变量,例如,
void foobar(void) {
int state = 0, i = 0; data = 0;
(&state, &i, &data);
foo}
于是,foo
函数便是可重入的函数,而且我们也可以称它是一个闭包。若对闭包给出一个明确的定义,就是具备捕捉外部变量能力的函数。就像一个茶杯,它虽然在宇宙之中,但是它不也装着整个宇宙么?在
C
程序里,将指针作为函数参数,是一种非常普遍的现象,只是大家很少去关心这样的函数是闭包,原因是
C 语言并不觉得有什么必要开设这个概念。
同理,我们也能将消费数据的函数 bar
改成闭包,让它无需再依赖全局变量 data
:
void bar(int *data) {
("消费者:消费数据 %d\n", *data);
printf}
对于变成了闭包的
foo
,之前我们设计的协程物理学,也需要相应地予以改进,但这并非难事。你可以作为一道练习题去完成它。
若我此刻我说,在超弦理论中,以振动的方式形成各种基本粒子的开弦,必须附着在一种叫作 D 膜的结构上,而在 M 理论中,基本粒子是由一种 2 维的 M 膜的振动形成的,而这种膜不需要附着在其它结构上。可能你不懂这句话的意思,请放心,我也不懂我在说什么。我只是大致明白了,在多线程机制中,若想用多个线程做一些有用处的事情,这些线程之间必须用互斥和条件变量予以约束,而在协程中,这种约束原本是通过全局变量或函数内的静态变量实现的,但是当我们将协程升级为闭包,这种约束也就不存在了。闭包可以不依附其它结构而自由存在。
天地一协程,万物一闭包。
C 语言没有协程,也没有闭包,却也能无中生有。
我们好像已经走得太远了,甚至失去了踪迹。
协程,与网络编程有什么关系呢?