程序间的跳跃与协作——谈谈协程的运用
goto与setjmp
C++的初学者在遇到一些复杂的程序结构问题的时候,往往会求助于这样一个关键字:goto。它的用法是这样的:
int main()
{
static int count = 0;
if (false)
{
here:
cout << "come by goto:" << endl;
}
cout << "check count " << count++ << endl;
if (count == 1)
goto here;
return 0;
}
goto的作用
不得不承认,初次看到这个用法确实能给人一种惊艳的感觉。它的作用正如它的名字——goto,程序段仿佛成了时空隧道,goto则成为随意穿越其中的魔术师。
然而所有正派的编程书籍都会告诉你:避免使用goto。这不仅会让程序变得更加晦涩,同时goto的不当使用会引发令人费解的问题——混乱的循环,被修改的变量,不符合预期的输出。
实际上,除开这些问题,goto的另一个不足在于其语义是局部的,不能从一个函数goto到另一个函数中,这限制了goto的使用空间。无独有偶,c语言提供了同样能够随意跳跃的setjmp,如下:
int setjmp(jmp_buf envbuf);
void longjmp(jmp_buf envbuf, int val);
setjmp“注册”一个“标签“到envbuf,而longjmp则前往envbuf所在的标签程序处,并将val的值作为当次setjmp的返回值。同时注册标签setjmp返回为0,因此通过返回值便可判定程序从何处来(或者是否为注册)。
无疑,setjmp比goto更加强大,然而它的优点同样成了它的问题,让我们看看另一个例子:
jmp_buf buf;
void something()
{
int a = 20;
int res = setjmp(buf);
cout << "res = " << res << " a = " << a << endl;
}
int main()
{
something();
longjmp(buf, 1);
return 0;
}
setjmp的问题
这里的情况有点令人费解,第一次的输出res为0,显然是注册标签。而第二次res为1说明这一次返回来自后文的longjmp,然而为什么a的值变成0了?
原因在于setjmp之后这个函数继续运行,在其结束后函数返回,相关的堆栈被删除,因此后文的longjmp跳入了一个”已经不存在的堆栈“中,此时的行为是无法预料的。setjmp并不会检查要去的地方是否合理(goto也是如此),只是检查envbuf的位置,然后”跳过去“。
如此看来,时空穿梭也不是一个简单的技术——不论现实或代码均是如此。goto和setjmp与其说是魔术师,倒不如说是破坏者,它们肆意游走于语句间,却完全无视整个程序的安全。
真正的魔术师头衔应当被授予一个有些冷门的物件——协程。一言概括之:协程是一种可以自我切换运行上下文的轻量级线程。同时保证了所有局部量被妥善保存。
协程类似于线程,同时能有效完成类似goto语句的任务,同时,协程能够确保运行安全的一个主要原因在于它额外增加了一个变量用于表示其相关的函数是否退出,这保证了它不会跳入一个已经被删除的堆栈之中。
线程的协作任务
在探讨协程之前,让我们先来看一个和线程有关的问题:顺序循环输出ABC。
这个问题实在不能算一个问题,甚至解决的方法也可以五花八门:
如果是单个线程,那这完全不能算一个问题。问题是多个线程下如何协作完成。众所周知,线程的主要问题是无法确保时序,因此需要引入额外的同步策略。
一个想当然的想法是加锁,我们可以用3把锁,每个线程各加锁一次解锁一次。三把锁循环解锁:
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexC = PTHREAD_MUTEX_INITIALIZER;
void *funcA(void*)
{
while (true)
{
pthread_mutex_lock(&mutexA);
cout << "A";
pthread_mutex_unlock(&mutexB);
}
}
//另外两个类似,省略之
这个办法看起来能解决问题,但它却违背了多线程的一个重要原则:不要在多个线程进行成对的锁操作:线程在解锁前如果出现意外可能会导致死锁,而通过raii机制可以应对这个问题,然而如果加锁与解锁在两个线程出现,raii便无法生效,如果其中有一个线程崩溃了,其他线程将陷入一个极其病态的死锁之中。
此外,由于三次锁操作本身具有一个顺序,因此这个解法实际上上将一个同步问题推到了另一个,退一步讲,如果将这个问题扩展到更多线程,编码时更多锁的顺序问题足以让人疯狂。
当然正常的思路是用一把锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
char c = 'A';
void *funcA(void*)
{
while (true)
{
while (c != 'A')
pthread_cond_wait(&cond, &mutex);
cout << c;
c = 'B';
pthread_cond_broadcast(&cond);
}
}
//另外两个类似,省略之
int main()
{
pthread_t a, b, c;
pthread_create(&a, NULL, &funcA, NULL);
pthread_create(&b, NULL, &funcB, NULL);
pthread_create(&c, NULL, &funcC, NULL);
pthread_join(a, NULL);
pthread_join(b, NULL);
pthread_join(c, NULL);
return 0;
}
用全局变量来存储下一个要输出的值,用条件变量通知每个线程,这样即使是多个线程也只需要修改线程函数的判定条件即可。将打印值抽象为一个回调函数,则这个机制可以视为一个简单的事件驱动。
当然,也有不使用常规同步策略的方法——信号:通过线程信号掩码来控制信号的接收者,同时按顺序编写信号和其处理器,这在机制上和三把锁异曲同工,显然不值得提倡,代码也不再赘述。
如果纯粹从线程协作的角度(而不依赖其他的同步)来思考,一个容易被忽略的方法是这样的:
void *oneA(void*)
{
cout << "A";
}
void *oneB(void*)
{
cout << "B";
}
void *oneC(void*)
{
cout << "C";
}
int main()
{
pthread_t a, b, c;
while (true)
{
pthread_create(&a, NULL, &oneA, NULL);
pthread_join(a, NULL);
pthread_create(&b, NULL, &oneB, NULL);
pthread_join(b, NULL);
pthread_create(&c, NULL, &oneC, NULL);
pthread_join(c, NULL);
}
}
对比之前的例子,很容易觉得这个方案最具有观赏性。然而如果仔细分析,会意识到这里根本不存在多线程,这样大行为与直接在循环中输出ABC并无区别(肯定会更慢)。这里的关键在于join,我们在这里不单纯是利用join来释放资源,join同时扮演了我们保证顺序所需要的锁的角色。join确保了上一个线程完成了工作,这里我们想要的是一个类似join的存在,它既能充当我们想要的那把锁,又能不像join一般直接销毁线程(而是暂停它)。
不难意识到,我们需要的恰好是如同setjmp或goto一样的方式,在做一件事时去做另一件,并能准确地返回。goto无法适应多函数的需求,sejmp有着不安全的问题。这里我们回到之前的讨论——用协程来解决问题。
void coA()
{
while (true)
{
cout << "A";
co_yield();
}
}
//另外两个类似,省略之
int main()
{
Coroutine a(coA);
Coroutine b(coB);
Coroutine c(coC);
while (true)
{
a.resume();
b.resume();
c.resume();
}
return 0;
}
当然结果并不会有什么不同
协程的协作输出关于协程与线程,一个小型协程库
正如我之前所述,协程能自我切换上下文。不难发现,这个过程在感觉上是很直观的。yield和resume是协程的两个关键函数——暂停(切出)与继续(切入,第一次的resume应当视为开始)。显而易见,这里的线程(协程)完全依照我们的需要暂停与重开了,这简化了整个流程,也保留了扩展的空间。
不得不说,协程这个词似乎是有些冷门了,它被视为一种轻量级的线程,与线程在行为上有着极大的相似。其于线程本质上的差别在于它可以决定自己被切换的时机——主动交出运行机会。
造成差异的一个重要的因素是线程的调度依赖内核的介入。也正因此,线程的执行不能依赖于特定的顺序(除非加上其他的同步手段)。一个有趣之处在于Java的线程提供了一个几乎没有作用的函数yield(这里只是名字一样),当它被执行后,对应的线程对象会有策略地让出运行权,转而允许其他线程运行。认为这个函数没有用的原因就在于其让出的策略。首先,Java的线程具有优先级这一概念,这决定了yield的工作模式:不同优先级线程的工作权重已经被纳入了线程的调度策略中,就是说yield执行时,不应该去考虑那些不同优先级的线程,它们已经具有了不同的调度等级,高优先级线程即使没有yield也会更多获得运行权。于是剩下了唯一的候选受众:同优先级的其他线程。而这里的有趣之处在于Java不会确保运行权会交给另一个线程,换言之,有可能一个线程在yield后又立即执行了。同时,Java的线程没有resume这个函数,显然这里的yield与协程不是同一物品。
Java的yield代表了线程的一般问题:由于内核的过多介入,任何试图仅依靠线程本身来控制运行权的行为都会出现问题(当然,也可以引入同步手段,然而这只会加大内核的介入量)。这便是协程试图解决的问题——自主地交出运行权,使得线程之间能够以更优雅的形式进行协作。其实换一个角度,协程应当被视为为用户态(对应内核态)的线程,也正因此,一个内核线程中,即使存在多个协程,也会有一个真正在运行。
遗憾之处在于,c++并没有直接提供协程这一封装(c++在这方面似乎始终是滞后的,c++的线程也是c++11之后的事情),我在上文中使用的协程类是基于c中的ucontext完成的(我的封装参考了云风的c版协程库实现,他的版本请戳:cloudwu的协程)。
主要的数据结构是ucontext_t如下:
typedef struct ucontext {
struct ucontext *uc_link;
sigset_t uc_sigmask;
stack_t uc_stack;
mcontext_t uc_mcontext;
...
} ucontext_t;
uc_stack和uc_link分别指明了其相应堆栈以及函数完成后需要转往的上下文段落。
关键函数为如下的四个:
int getcontext(ucontext_t *ucp); //获取当前上下文
int setcontext(const ucontext_t *ucp); //前往ucp所指上下文
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); //将函数与参数写入ucp
int swapcontext(ucontext_t *oucp, ucontext_t *ucp); //保存当前上下文到oucp,前往ucp所指上下文
这里的叙述比较简略,想要了解这几个函数的详情请戳ucontext-人人都可以实现的简单协程库
我的封装主要涉及了一些接口的简化与裸指针的隐藏,我保留了采用id来使用协程对象的风格,一方面留存了C与linux的风格,另一方面C++的类型限制确实没有给人太多发挥空间。
相关的代码我放到了github上,请戳线程组件库,Coroutine即为协程部分。
协程的上下文保存
最后,我在封装中遇到的一个主要问题是如何保留每个协程的上下文,这里存在两种选择:
传统的手法是参考内核线程的行为,保留一个主堆栈,每次切出时复制出当前的上下文进一个副本,而每次在切入协程前将副本复制进主堆栈。这样的优势在于由于每次切换上下文时知道堆栈的使用情况,因此每个副本都不会浪费空间,同时,由于当前运行的永远是主堆栈,因此可以在协程终止后立刻删除副本堆栈而不会引发问题。其缺点则在于每次的复制会消耗一定的时间。
一个替代方案是在运行协程时直接切入对应的堆栈,这样则免去了复制的时间,然而为了确保运行安全,每个堆栈都必须保留至线程结束(如果试图删除当前协程对应的堆栈,删除的不是副本,而会是当前正在使用的堆栈本身,删除会立刻引发一个段错误,即使协程终止也是如此)。两种方案分别在空间和时间上各有优势,应当依照需求予以考虑。