linux c 编程 --- 协程
什么是协程
协程(Coroutines)是一种比线程更加轻量级的存在,协程可以理解为一个特殊的函数,这个函数可以在某个地方挂起去执行别的函数,并且可以返回挂起处继续执行。一个线程内可以由多个协程来交互运行,但是多个协程的运行是绝对串行的,也就是说同一时刻只有一个协程在运行,当一个协程运行时,其它的协程必须被挂起。
协程不是被操作系管理的,而是是在用户态执行,完全由程序所控制的,根据程序员所写的调度策略,通过协作(而不是抢占)来进行切换的。协程的本质思想就是控制函数运行时的主动让出(yield)和恢复(resume)。每个协程有自己的上下文,其切换由自己控制,当前协程切换到其它协程是由当前协程自己控制的。
协程函数与普通函数的区别:
- 普通函数执行完后会退出,并释放栈帧。
- 协程函数可以在运行过程中保存上下文(栈帧),并主动切换到其它线程执行,还可以通过其它协程协作返回本函数继续执行。
协程的特点总结如下:
- 用户态:协程是在用户态实现调度。
- 轻量级:协程不在内核调度,不需要内核态和用户态之间切换,使用开销比较小。
- 非抢占:协程是由用户自己实现调度,并且同一时间只能有一个协程在执行,协程主动交出CPU资源。
进程、线程、协程的对比
进程、线程都是由操作系统所管理的,存在用户态和内核态;而协程完全在用户态运行,自己实现调度。
- 一个进程可以包含多个线程,一个线程可以包含多个协程。
- 一个进程最少包含一个线程;但线程内可以不存在协程。
- 当CPU存在多个内核时,一个进程的多个线程可以并行执行;但是一个线程中的多个协程一定是串行执行的。
- 进程、线程、协程的切换都是上下文切换,区别如下:
- 进程的切换上下文:切换虚拟地址空间,切换内核栈和硬件上下文,切换内容保存在内存中。
- 线程的切换上下文:切换内核栈和硬件上下文,切换内容保存在内核栈中。
- 协程的切换上下文:切换硬件上下文,切换内容保存在用户态的变量(用户栈或堆)中。
- 进程、线程、协程的调度开销程度: 进程 > 线程 >> 协程
进程和线程在 Linux 中没有本质区别,他们最大的不同就是进程有自己独立的内存空间,而线程(同进程中)是共享内存空间。
在进程切换时需要转换内存地址空间,而线程切换没有这个动作,所以线程切换比进程切换代价更小。
为什么内存地址空间转换这么慢?Linux 实现中,每个进程的地址空间都是虚拟的,虚拟地址空间转换到物理地址空间需要查页表,这个查询是很慢的过程,因此会用一种叫做 TLB 的 cache 来加速,当进程切换后,TLB 也随之失效了,所以会变慢。
协程适用场景
IO密集型 (IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。)。
在IO密集型场景下,协程的调度开销比线程小,能够快速实现调度,尽可能的提升CPU利用率。
协程不适用于计算密集型场景,因为协程不能很好的利用多核CPU。
协程库 - ucontext组件介绍
ucontext使得linux程序可以在用户态执行上下文切换,从而避免了进程或者线程切换导致的切换用户空间、切换堆栈,因此,效率相对更高。
ucontext属于glibc中的组件,ucontext提供4个函数 getcontext() setcontext() makecontext() swapcontext(),及 2个结构体ucontext_t mcontext_t,使用4个函数可以在线程中实现用户级的协程切换。这四个函数的作用可以通过linux man手册(举例:man getcontext)进行查看。
ucontext组件的头文件为<ucontext.h> ,该文件中主要关注结构体struct ucontext_t,内容如下:
/* Userlevel context. */ typedef struct ucontext_t { unsigned long int __ctx(uc_flags); struct ucontext_t *uc_link; stack_t uc_stack; mcontext_t uc_mcontext; sigset_t uc_sigmask; struct _libc_fpstate __fpregs_mem; } ucontext_t;
其中:
uc_link 指向后继上下文(即,当前协程运行结束后,接着要被恢复的下一个协程的上下文);
uc_stack 为该上下文中使用的栈;
uc_sigmask 为该上下文中的阻塞信号集合(可通过man sigprocmask 命令查看相关信息);
uc_mcontext 这个结构体依赖于机器且不透明,作用是保存硬件上下文,包括硬件寄存器的值 。
uc_stack协程栈对应的stack_t 结构体的内容如下:
/* Structure describing a signal stack. */ typedef struct { void *ss_sp; /* Base address of stack */ int ss_flags; /* Flags */ size_t ss_size; /* Number of bytes in stack */ } stack_t;
其中:
ss_sp
指针 指向的是协程的栈空间的起始地址,可以是用户级的栈变量指针,也可以是堆变量指针。
ss_flags
flag,在协程使用中设置该值为0。具体作用参考man sigaltstack
ss_size
表示栈空间的大小。
getcontext()、setcontext()函数介绍
// getcontext, setcontext - get or set the user context #include <ucontext.h> int getcontext(ucontext_t *ucp); int setcontext(const ucontext_t *ucp);
getcontext()
函数初始化ucp
结构体,将当前上下文保存在ucp
中 。
setcontext()
函数设置当前上下文为ucp
所指向的上下文。 ucp
所指向的上下文应该是调用 getcontext()
或 makecontext()
获得的。
makecontext()、swapcontext()函数介绍
// makecontext, swapcontext - manipulate user context #include <ucontext.h> void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
makecontext() 函数修改 ucp 指向的上下文,该上下文是之前通过调用getcontext() 获取的,也就是说在调用makecontext()之前需要调用getcontext()。且在调用makecontext()之前,调用者必须分配一个新的栈空间为该上下文,并将栈空间地址设置到 ucp->uc_stack,还要设置ucp->uc_link指向一个后继协程的上下文。如果func协程上下文中uc_link值为NULL,则执行完该协程后,线程会退出。
makecontext()函数将ucp 对应的上下文中的指令地址指向 func函数(协程)地址,argc表示func的入参个数,如果入参为空,该值设置为0。如果argc有值,入参为一系列 (int)整型的数据。
swapcontext() 保存当前协程的上下文到oucp,然后激活(切换到) ucp所指向的上下文对应的协程。返回值:当调用成功后,swapcontext()不会返回;当调用失败后,返回-1并设置合适的errno。
swapcontext() 函数可以理解成为 getcontext() 和 setcontext() 函数的组合。
ucontext组件使用举例
getcontext()、setcontext()函数使用举例
#include <stdio.h> #include <ucontext.h> #include <unistd.h> int main(int argc, const char *argv[]) { ucontext_t context; getcontext(&context); sleep(1); puts("Hello world"); setcontext(&context); return 0; }
编译并执行上述代码,结果如下:
如图所示,程序在输出第一个“Hello world"后并没有退出程序,而是持续不断的输出”Hello world“。代码执行过程如下:
1、getcontext(&context); 初始化并保存了当前的上下文;
2、执行sleep语句,接着输出"Hello world";
3、执行setcontext(&context); 语句,将当前上下文设置为context中保存的值。注意:此时指令地址指向了**sleep(1);**语句的地址;
4、代码跳转**sleep(1)**重新执行;
……
如此往复,所以导致程序不断的输出”Hello world“。
makecontext()、swapcontext()函数使用举例
下面的代码摘自man手册man makecontext
#include <ucontext.h> #include <stdio.h> #include <stdlib.h> static ucontext_t uctx_main, uctx_func1, uctx_func2; #define handle_error(msg) \ do { perror(msg); exit(EXIT_FAILURE); } while (0) static void func1(void) { printf("func1: started\n"); printf("func1: swapcontext(&uctx_func1, &uctx_func2)\n"); if (swapcontext(&uctx_func1, &uctx_func2) == -1) handle_error("swapcontext"); printf("func1: returning\n"); } static void func2(void) { printf("func2: started\n"); printf("func2: swapcontext(&uctx_func2, &uctx_func1)\n"); if (swapcontext(&uctx_func2, &uctx_func1) == -1) handle_error("swapcontext"); printf("func2: returning\n"); } int main(int argc, char *argv[]) { char func1_stack[16384]; char func2_stack[16384]; if (getcontext(&uctx_func1) == -1) handle_error("getcontext"); uctx_func1.uc_stack.ss_sp = func1_stack; uctx_func1.uc_stack.ss_size = sizeof(func1_stack); uctx_func1.uc_link = &uctx_main; makecontext(&uctx_func1, func1, 0); if (getcontext(&uctx_func2) == -1) handle_error("getcontext"); uctx_func2.uc_stack.ss_sp = func2_stack; uctx_func2.uc_stack.ss_size = sizeof(func2_stack); /* Successor context is f1(), unless argc > 1 */ uctx_func2.uc_link = (argc > 1) ? NULL : &uctx_func1; makecontext(&uctx_func2, func2, 0); printf("main: swapcontext(&uctx_main, &uctx_func2)\n"); if (swapcontext(&uctx_main, &uctx_func2) == -1) handle_error("swapcontext"); printf("main: exiting\n"); exit(EXIT_SUCCESS); }
编译并执行上述代码
结果1:
通过结果1的打印,我们可以看到:
1)程序先从main函数切换到协程func2进行执行;
2)在func2中主动挂起(yield)并进入协程func1中执行;
3)然后在func1协程中主动挂起(yield)并返回func2中(resume)执行剩余代码;
4)协程func2执行完后,根据main函数中设置的“ uctx_func2.uc_link = (argc > 1) ? NULL : &uctx_func1;”又返回协程func1中执行剩余代码;
5)协程func1执行完后,根据main函数中设置的“uctx_func1.uc_link = &uctx_main;”返回main函数执行剩余的代码;
6)最后退出。
注意:swapcontext()函数会保存当前上下文到第一个参数,然后切换到第二个参数所指向的协程上下文。
结果2:
如图所示,两个结果的差别是图2中多了一个参数(“带参数”),对应代码中的区别是:
“uctx_func2.uc_link = (argc > 1) ? NULL : &uctx_func1;”
当入参中argc值大于1时,设置uctx_func2.uc_link
值为NULL,此时表示该协程执行完后没有后继上下文,该协程执行完后线程就退出了,因为这个程序中就只有一个线程,所以程序也退出了。