初见ucontext
协程
什么是协程?
协程通俗一点可以看作一种轻量级线程。408中深入学习过进程与线程,它们都是操作系统级的并发/并行方式,而协程可以实现用户级的并发编程。
具体来说,协程在执行过程中可以实现用户级的yield(暂停)和resume(恢复):协程中,函数可以在执行过程中yield,然后切换至另一个函数中执行另一段代码。执行完后再返回执行暂停的函数。而不像正常的函数一样执行过程中无法被异步打断(除非发生了线程/进程切换)
为什么需要协程?
既然我们已经有了线程,可以借助线程进行并发编程,那为什么还需要协程呢?
协程比线程更轻量:线程级的并发所涉及的上下文创建、切换开销远大于协程,因为线程切换涉及到os的用户态与内核态的切换、管理线程优先级和状态等,通常需要3-5us;但协程的创建和切换全程在用户态下完成,通常只需要几十纳秒。这个性能差异在高并发环境下影响非常显著。换句话说,协程非常适合多核高并发条件下的任务
资源占用小:线程所需要的栈空间通常也远大于协程。协程一般只需要1-2KB的栈空间,而线程栈空间通常达到MB量级
适合IO密集型任务:协程可以在IO阻塞时快速切换至其他协程,一个协程阻塞时另一个协程可以执行其他任务
协程有什么缺点?
协程除了更容易各种开发上的细节小困难(调试,维护状态困难等)之外,最大的缺点是无法仅靠协程利用多核资源。
由于协程是用户级并发,而CPU调度的最小单位是线程,单线程中的协程是无法分配到多个CPU核心中的。如果仅在单线程中使用协程,那永远只能利用一个核心的性能,只能并发而不能并行。
因此,为了发挥协程的性能优势,协程往往需要配合多线程、多进程一起使用。
ucontext
ucontext是 POSIX 标准中定义的一组函数,用于实现用户级上下文切换。它允许程序保存和恢复执行上下文(如寄存器、程序计数器、栈等),常用于实现协程、轻量级线程或任务调度等功能
因此ucontext非常适合用于编写协程库,虽然查阅资料后发现ucontext已经被标记为"已过时",但毕竟写这个项目的初心是为了学习深入理解协程与并发编程,ucontext曾作为非常广泛的协程实现方式之一,用来写一个轻量协程库还是没问题的
网上大部分讲解都写的很晦涩,这里用自己的理解重新记录一下,方便之后回来复习
ucontext结构体定义
下面是ucontext_t基本的结构体定义。由于这个结构体与平台相关,这里记录所有平台都至少会包含的4个成员:
typedef struct ucontext_t {
// 当前上下⽂结束后,下⼀个激活的上下⽂对象的指针,只在当前上下⽂是由makecontext创建时有效
//后继指针,仅在当前上下⽂是由makecontext创建时有效
//具体见后面对makecontext的讲解
struct ucontext_t *uc_link;
// 当前上下⽂的信号屏蔽掩码
sigset_t uc_sigmask;
// 当前上下⽂使⽤的栈内存空间,只在当前上下⽂是由makecontext创建时有效
stack_t uc_stack;
// 平台相关的上下⽂具体内容,包含寄存器的值等
// 这个其实无需关心,因为它的值都是用后面要介绍的成员函数设置的
mcontext_t uc_mcontext;
...
} ucontext_t;
ucontext核心成员函数
四个核心的成员函数:
// 获取当前的上下⽂
int getcontext(ucontext_t *ucp);
// 将当前上下文设置为ucp
// 效果相当于将程序运行状态切换为ucp
int setcontext(const ucontext_t *ucp);
//将ucp与一个函数绑定,并指定函数的参数
//可以指定ucp中的uc_link,这样后续func执行结束后就会自动切换至uc_link对应的上下文
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
// 恢复ucp指向的上下⽂,同时将当前的上下⽂存储到oucp中,
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
下面逐个讲解这四个成员函数,后面会写两个例子加深理解:
getcontext
int getcontext(ucontext_t *ucp);
用法类似于getcontext(&ucp)
,它能获取程序当前上下文并赋值给ucp中的uc_mcontext
。getcontext(&ucp)
是最常用的获取ucp的方式。
setcontext
int setcontext(const ucontext_t *ucp);
恢复ucp指向的上下文,这个函数不会返回,而是会跳转到ucp上下文对应的位置中执行,这个位置既可以是同一个函数内,也可以是绑定的其他函数。如果是绑定了其他函数,它就相当于变相调用了函数。这个ucp可以由getcontext或makecontext获得。
比如使用getcontext(&ucp)
后,在另一处调用setcontext(ucp)
,此时就会跳转到getcontext(&ucp)
的下一行执行,恢复了当时的上下文。
makecontext
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
它可以将ucp与函数func绑定,绑定后,若使用setcontext或swapcontext恢复ucp对应的上下文,程序就会跳转运行函数func。
在使用makecontext之前,必须手动为ucp分配栈空间至ucp->uc_stack
比如:
char stack[1024*128];//设置栈的空间
ucontext_t child,main;//设置两个上下文
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);
swapcontext(&main,&child);//切换到child上下文,保存当前上下文到main
这里通过三行代码明确指定了child的栈空间,然后调用makecontext
,将child与func1绑定。随后调用swapcontext
时,就会跳转执行func1。
swapcontext
int swapcontext(ucontext_t *oucp, ucontext_t *ucp);
顾名思义,swapcontext恢复ucp的上下文,并将当前上下文保存在oucp中,就像是先暂存当前上下文,再恢复ucp的交换过程(仔细想想这个"交换")。它是非对称协程的核心,后面再详细讲。
以上四个成员函数,如果执行成功,getcontext
会返回0,setcontext
和swapcontext
不返回;如果执行失败,除了makecontext
之外,其他三个函数都会返回-1
,并设置对应的errno。
两个简单实例
先来看一个最简单的demo:
//example.cpp
#include<stdio.h>
#include<ucontext.h>
#include<unistd.h>
int main(int argc,const char *argv[]){
ucontext_t context;//创建ucontext对象
getcontext(&context);//获取上下文,保存至context中
puts("Hello world");//输出Hello world
sleep(1);
setcontext(&context);//恢复getcontext指向的上下文
return 0;
}
终端通过gcc example.c -o example
执行后会无限输出Hello world。
上述代码getcontext(&context)
将当前上下文保存至context,输出一次Hello world;随后通过setcontext(&context)
恢复context,于是程序回到getcontext(&context)
刚结束的状态,继续输出Hello world,循环往复。这个例子简单地展示了getcontext
和setcontext
的用法。
再来看一个稍微复杂一点的非对称协程原型:
#include<ucontext.h>
#include<stdio.h>
//此函数用来给makecontext绑定
void func1(void *arg)
{
puts("1111");
}
int main()
{
char stack[1024*128];//设置栈的空间
ucontext_t child,main;//设置两个上下文
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")//如果设置了后继上下文也就是uc_link指向了其他ucontext_t的结构体对象则makecontext中的函数function
//执行完成后会返回此处打印main,如果指向的为nullptr就直接结束
return 0;
}
上述代码首先声明两个上下文对象child
和main
,随后通过getcontext(&child)
将当前上下文保存至child,并为child设置栈空间大小。
分配栈空间是为了使用makecontext绑定函数:makecontext(&child,(void(*)(void))func1,0)
将child与func1绑定,这一行为实质上改变了child->uc_mcontext
,于是在swapcontext(&main,&child)
后,程序就跳转至func1执行了
同时child.uc_link=&main
将后继上下文设置为main,由于swapcontext(&main,&child)
还把当前上下文保存在了main,于是child所绑定的fun1执行结束后,就会自动恢复main的上下文,也就是swapcontext(&main,&child)
的后一行。
这个例子很好地展示了非对称协程的特点:我们称child为协程1,main为协程2。控制权首先位于main函数(协程2),随后swapcontext
将上下文切换至child,即控制权交给func1(协程1)。func1执行完毕后控制权又回到main函数(协程2)。整个过程就像是协程2执行的途中切换到了协程1,协程1执行完毕后又返回了协程2。协程2和协程1是明显的调用者与被调用者(主从协程)的关系,属于非对称协程的典型。