协程&ucontext协程库

——学习笔记

Posted by Samuel on August 4, 2017

目录

协程

Coroutine是编译器的魔术,通过插入相关的代码使得代码段能够实现分段式的执行,重新开始的地方是yield关键字指定的,一次一定会跑到一个yield对应的地方。

从硬件发展来看,从最初的单核单CPU,到单核多CPU,多核多CPU,似乎已经到了极限了,但是单核CPU性能却还在不断提升。server端也在不断的发展变化。如果将程序分为IO密集型应用和CPU密集型应用,二者的server的发展如下:

  1. IO密集型应用: 多进程->多线程->事件驱动->协程

  2. CPU密集型应用: 多进程–>多线程

如果说多进程对于多CPU,多线程对应多核CPU,那么事件驱动和协程则是在充分挖掘不断提高性能的单核CPU的潜力。

同步VS异步

无论是线程还是进程,使用的都是同步机制,当发生阻塞时,性能会大幅度降低,无法充分利用CPU潜力,浪费硬件投资,更重要造成软件模块的铁板化,紧耦合,无法切割,不利于日后扩展和变化。不管是进程还是线程,每次阻塞、切换都需要陷入系统调用(system call),先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。多个线程之间在一些访问互斥的代码时还需要加上锁,这也是导致多线程编程难的原因之一。

现下流行的异步server都是基于事件驱动的(如nginx)。事件驱动简化了编程模型,很好地解决了多线程难于编程,难于调试的问题。

异步事件驱动模型中,把会导致阻塞的操作转化为一个异步操作,主线程负责发起这个异步操作,并处理这个异步操作的结果。由于所有阻塞的操作都转化为异步操作,理论上主线程的大部分时间都是在处理实际的计算任务,少了多线程的调度时间,所以这种模型的性能通常会比较好。

总的说来,当单核cpu性能提升,cpu不在成为性能瓶颈时,采用异步server能够简化编程模型,也能提高IO密集型应用的性能。

协程VS线程

协程是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置

在并发编程中,协程与线程类似,每个协程表示一个执行单元,有自己的本地数据,与其它协程共享全局数据和其它资源。目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式多任务

不管是进程还是线程,每次阻塞、切换都需要陷入系统调用(system call),先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。

而且由于抢占式调度执行顺序无法确定的特点,使用线程时需要非常小心地处理同步问题,而协程完全不存在这个问题(事件驱动和异步程序也有同样的优点)。

我们在自己在进程里面完成逻辑流调度,碰着I/O我就用非阻塞式的。那么我们即可以利用到异步优势,又可以避免反复系统调用,还有进程切换造成的开销,分分钟给你上几千个逻辑流不费力。这就是协程。

协程VS事件驱动

以nginx为代表的事件驱动的异步server正在横扫天下,那么事件驱动模型会是server端模型的终点吗?

我们可以深入了解下,事件驱动编程的模型。

事件驱动编程的架构是预先设计一个事件循环,这个事件循环程序不断地检查目前要处理的信息,根据要处理的信息运行一个触发函数。其中这个外部信息可能来自一个目录夹中的文件,可能来自键盘或鼠标的动作,或者是一个时间事件。这个触发函数,可以是系统默认的也可以是用户注册的回调函数。

事件驱动程序设计着重于弹性以及异步化上面。许多GUI框架(如windows的MFC,Android的GUI框架),Zookeeper的Watcher等都使用了事件驱动机制。未来还会有其他的基于事件驱动的作品出现。

基于事件驱动的编程是单线程思维,其特点是异步+回调。

协程也是单线程,但是它能让原来要使用异步+回调方式写的非人类代码,可以用看似同步的方式写出来。它是实现推拉互动的所谓非抢占式协作的关键。

生产者-消费者模型

  1. 下面是生产者消费者模型的基于抢占式多线程编程实现(伪代码):
// 队列容器
var q := new queue
// 消费者线程
loop
lock(q)
get item from q
unlock(q)
if item
use this item
else
sleep 
// 生产者线程
loop
create some new items
lock(q)
add the items to q
unlock(q)
  1. 基于协程的生产者消费者模型实现(伪代码):
// 队列容器
var q := new queue
// 生产者协程
loop
while q is not full
create some new items
add the items to q
yield to consume
// 消费者协程
loop
while q is not empty
remove some items from q
use the items
yield to produce

ucontext协程库

一个小例子

#include <stdio.h>  
#include <ucontext.h>  
#include <unistd.h>  
  
int main(int argc, const char *argv[]){  
    ucontext_t context;  
  
    getcontext(&context);  
    puts("Hello world");  
    sleep(1);  
    setcontext(&context);  
    return 0;  
}  

gcc main.c -o main

运行结果:

[ltf@syalrdev-b6rpd testUntext]$ ./main 
Hello world
Hello world
Hello world
Hello world
Hello world
^Z
[2]+  Stopped                 ./main

我们可以看到,程序在输出第一个“Hello world”后并没有退出程序,而是持续不断的输出”Hello world“。其实是程序通过getcontext先保存了一个上下文,然后输出”Hello world”,在通过setcontext恢复到getcontext的地方,重新执行代码,所以导致程序不断的输出”Hello world“

ucontext组件

在头文件<ucontext.h> 中定义了两个结构类型,mcontext_tucontext_t和四个函数getcontext(),setcontext(),makecontext(),swapcontext()。利用它们可以在一个进程中实现用户级的线程切换。

组件中的结构类型

mcontext_t类型与机器相关,并且不透明。ucontext_t结构体则至少拥有以下几个域:

typedef struct ucontext{  
    struct ucontext *uc_link;       //当前上下文运行结束后指向的下一上下文
    sigset_t         uc_sigmask;    //当前上下文中阻塞信号集合
    stack_t          uc_stack;      //当前上下文中使用的栈
    mcontext_t       uc_mcontext;   //保存上下文中特定的机器表示
    ...  
} ucontext_t;

当前上下文(如使用makecontext创建的上下文)运行终止时系统会恢复uc_link指向的上下文;uc_sigmask为该上下文中的阻塞信号集合;uc_stack为该上下文中使用的栈;uc_mcontext保存的上下文的特定机器表示,包括调用线程的特定寄存器等。

组件中的四个常用函数

  1. int getcontext(ucontext_t *ucp);

初始化ucp结构体,将当前的上下文保存到ucp中

  1. int setcontext(const ucontext_t *ucp);

设置当前的上下文为ucp,setcontext的上下文ucp应该通过getcontext或者makecontext取得,如果调用成功则不返回。如果上下文是通过调用getcontext()取得,程序会继续执行这个调用。如果上下文是通过调用makecontext取得,程序会调用makecontext函数的第二个参数指向的函数,如果func函数返回,则恢复makecontext第一个参数指向的上下文第一个参数指向的上下文context_t中指向的uc_link.如果uc_linkNULL,则线程退出。

  1. void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

makecontext修改通过getcontext取得的上下文ucp(这意味着调用makecontext前必须先调用getcontext)。然后给该上下文指定一个栈空间ucp->stack,设置后继的上下文ucp->uc_link.

当上下文通过setcontext或者swapcontext激活后,执行func函数,argc为func的参数个数,后面是func的参数序列。当func执行返回后,继承的上下文被激活,如果继承上下文为NULL时,线程退出。

  1. int swapcontext(ucontext_t *oucp, ucontext_t *ucp);

保存当前上下文到oucp结构体中,然后激活upc上下文。

如果执行成功,getcontext返回0,setcontextswapcontext不返回;如果执行失败,getcontext,setcontext,swapcontext返回-1,并设置对应的errno.

简单说来,getcontext获取当前上下文,setcontext设置当前上下文,swapcontext切换上下文,makecontext创建一个新的上下文。

小试牛刀-使用ucontext组件实现线程切换

虽然我们称协程是一个用户态的轻量级线程,但实际上多个协程同属一个线程。任意一个时刻,同一个线程不可能同时运行两个协程。如果我们将协程的调度简化为:主函数调用协程1,运行协程1直到协程1返回主函数,主函数在调用协程2,运行协程2直到协程2返回主函数。示意步骤如下:

执行主函数  
切换:主函数 --> 协程1  
执行协程1  
切换:协程1  --> 主函数  
执行主函数  
切换:主函数 --> 协程2  
执行协程2  
切换协程2  --> 主函数  
执行主函数  
...  

这种设计的关键在于实现主函数到一个协程的切换,然后从协程返回主函数。这样无论是一个协程还是多个协程都能够完成与主函数的切换,从而实现协程的调度。

实现用户协程的过程

  1. 我们首先调用getcontext获得当前上下文
  2. 修改当前上下文ucontext_t来指定新的上下文,如指定栈空间极其大小,设置用户线程执行完后返回的后继上下文(即主函数的上下文)等
  3. 调用makecontext创建上下文,并指定用户线程中要执行的函数
  4. 切换到用户线程上下文去执行用户线程(如果设置的后继上下文为主函数,则用户线程执行完后会自动返回主函数)。

下面代码context_test()函数完成了上面的要求。

#include <ucontext.h>  
#include <stdio.h>  
  
void func1(void * arg) {  
    puts("1");  
    puts("11");  
    puts("111");  
    puts("1111");  
}  
void context_test() {  
    char stack[1024*128];  
    ucontext_t child,main;  
  
    getcontext(&child);                         //获取当前上下文  
    child.uc_stack.ss_sp = stack;               //指定栈空间  
    child.uc_stack.ss_size = sizeof(stack);     //指定栈空间大小  
    child.uc_stack.ss_flags = 0;  
    child.uc_link = &main;                      //设置后继上下文  
  
    makecontext(&child, (void (*)(void))func1, 0);//修改上下文指向func1函数  
  
    swapcontext(&main,&child);                  //切换到child上下文,保存当前上下文到main  
    puts("main");                               //如果设置了后继上下文,func1函数指向完后会返回此处  
}  
  
int main() {  
    context_test();  
    return 0;  
}  

在swap处切换上下文到child,保存当前上下文到main。

[ltf@syalrdev-b6rpd testUntext]$ ./contextTest 
1
11
111
1111
11111
main

你也可以通过修改后继上下文的设置,来观察程序的行为。如修改代码 child.uc_link = &main;

child.uc_link = NULL;

再重新编译执行,其执行结果为:

[ltf@syalrdev-b6rpd testUntext]$ ./contextTest
1  
11  
111  
1111  
11111

可以发现程序没有打印”main”,执行为func1后直接退出,而没有返回主函数。可见,如果要实现主函数到线程的切换并返回,指定后继上下文是非常重要的。

参考:ucontext-人人都可以实现的简单协程库