探索Coroutine

关于为什么突然就想学习一下Coroutine,其实历程有点曲折。一开始是在看APUE的时候看到了setjmplongjmp两个神奇的东西。然后查了一下,发现竟然可以用来实现协程。。。

setjmplongjmp

下面是一段longjmpsetjmp的示例代码。该程序做的是不停地每隔一秒(不准确)打印一次”你好”。该程序的执行流程是这样,第一次先调用setjmp,此时setjmp返回结果为0,与此同时,根据mansetjmp把栈指针、PC及一些其他寄存器的值保存在jmp_buf中。然后if语句为假,直接执行下面的longjmp。由于调用longjmp时候,传入了1,且longjmp会利用jmp_buf里面保存的信息,跳回setjmp处。此时根据longjmp传入的1,setjmp返回1,则进入if语句块,执行printf。由于setjmp又一次保存了信息,那么下一次longjmp又可以跳回。因此循环往复,程序将不停地打印”你好”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <setjmp.h>
#include <unistd.h>
#include <string.h>
jmp_buf jbuf;
int main()
{
char c[] = "你好\n";
if (setjmp(jbuf) == 1)
{
sleep(1);
printf("%s", c);
}
longjmp(jbuf, 1);
}

协程

协程,按字面意思,应该是相互协作的程序。在执行过程中,协程可以主动放弃CPU,让另外一个协程运行,以此实现协同工作。咋一看,协程和线程也差不多嘛,都是一个先执行一下,再切换到另一个执行,循环往复,直至执行结束。其实还是有区别的。多线程可以跑在多个核上,并由OS进行调度。而协程是非抢占式的,由用户态进行调度,由协程来决定何时交出CPU,多个协程共用CPU,实际上可以看成是单线程的性能。因此在不同的任务情景下,协程和线程可以体现出它们间的性能差异。

CPU密集型

需要大量计算的程序,可以看出,像非抢占式的协程,很有可能导致时间片分配不均匀的问题。而且单核上跑,性能显然不能和多线程在多核上跑比。

IO密集型

CPU速度不知道比IO速度高到哪里去了。因此,对于IO密集型程序,即使你多线程,由于瓶颈在IOCPU的优势也没法发挥出来,反倒是如果协程一遇到IO,就主动交出CPU,这样由于协程较线程更为轻量,不仅切换开销低,在相同并发度下,负载也相对更低。不仅如此,由于协程相比多线程而言,还不需要加锁。此时看起来协程更优。

协程示例

下面这个Python程序模拟了基于协程的生产者消费者问题。首先是producer.send(None)启动generator。然后consumer通过send切换到producer,让producer生产,然后producer通过yield切换回consumer,让consumer消费。这样就可以协作以实现生产消费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3
def producer():
while True:
print('Producing {0}...'.format((yield)))
def consumer(producer):
producer.send(None)
n = 1
while n <= 5:
producer.send(n)
print('Consuming {0}...'.format(n))
n += 1
producer.close()
consumer(producer())

回到setjmplongjmp

看下面一个程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>
#include <setjmp.h>
#include <unistd.h>
#include <string.h>
jmp_buf jbuf;
void test()
{
longjmp(jbuf, 1);
}
void crash()
{
char c[] = "你好\n";
if (setjmp(jbuf) == 1)
{
sleep(1);
printf("%s", c);
}
}
int main()
{
crash();
longjmp(jbuf, 1);
}

该程序看起来和上面的那个没啥区别,实际上由于setjmp保存状态和longjmp恢复状态不在同一个作用域,会出现奇怪的问题,像crash退出后,其实它调用setjmp保存的那一个状态已经不能用了。虽然此处没有出现错误,但是调试可以发现像数组c的值在longjmp回去后已经变为了”\0”串,打印时是没有输出的。这样一来,如果协程用这个来实现貌似很不靠谱,连栈帧状态都不能保存,怎么保证协程切换之后原来的上下文有效呢?查了一下其实是有ucontext.h这种东西的。这是一个*nix系统才有的东西,可以用来保存上下文。这样看来就比较完美了。但是还有一个问题,怎么对付IO处理的那些函数呢?像下面这个函数,就真正提高了性能吗?

1
2
3
4
5
6
7
void not_real_asyncio()
{
// do something
send(...)
yield(...)
// do something
}

即使用了协程,也yield主动放弃了CPU,但是,如果send之类的I/O函数没有实现非阻塞I/O,这有意义吗?所以一个可能的实现,把send之流搞成非阻塞的,调完之后就把CPU让出去,然后当网络事件到达就再考虑切回来。这样看来,似乎可以简化程序的编写。如果直接上非阻塞的I/O多路复用而不是协程,想必要实现多阶段功能,就要通过传送的包里面的状态码之类的东西进行状态标识。而用协程来搞的好处就在于,直接不用管,一个函数的逻辑就从头写到尾。遇到I/O就及时切出CPU,让主调度线程来负责调度。下面说到的Go用的就是类似这种方案。

Goroutine

关于Go语言,一直以来的印象就只有那个萌萌的吉祥物和高并发、易开发,直到前几天第一次接触Go入门教程。。。由于不懂golang,查了一下发现有一个人总结的很好。golang的goroutine是如何实现的?(Yi Wang翻译的答案)

可以看出,Goroutine对应内核线程为M:N的关系。多个GoroutineScheduler调度,被放到内核线程上跑,由于保存了上下文,就可以由Scheduler来实现切换等操作。然后Go又把I/O封装成非阻塞,底层是用的I/O多路复用,写的时候就不用管了,可以当成是同步的写法,调I/O函数时候注册事件,让出CPU,由Scheduler把其它协程切入,当epoll/kqueue监听到I/O事件到达,Scheduler就可以调度,把CPU还给该进程。相当于是不需要手动写epoll代码,而是直接通过go/channel就可以实现提高性能的目的,简化了编程。

参考资料

  1. golang的goroutine是如何实现的?(Yi Wang翻译的答案)
  2. The Go netpoller
  3. APUE(第3版 中文翻译版)
  4. 《学习GO语言》中文版 邢星译