协程

什么是协程?

协程通俗一点可以看作一种轻量级线程。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_mcontextgetcontext(&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,setcontextswapcontext不返回;如果执行失败,除了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,循环往复。这个例子简单地展示了getcontextsetcontext 的用法。

再来看一个稍微复杂一点的非对称协程原型:

#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;
}

上述代码首先声明两个上下文对象childmain ,随后通过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是明显的调用者与被调用者(主从协程)的关系,属于非对称协程的典型。