协程详解(基于ucontext.h)

  • Post author:
  • Post category:其他


1.概述

花了点时间理解了一下协程,可能刚开始看到协程两个字会以为是什么新的事务,它其实用一组特殊的函数就可以实现,并没有深入内核。对于线程,它们的切换是由操作系统根据其系统内核中的调度器,抢占式地进行的,就各个线程间的运行的先后顺序是不能完全控制的。对于协程,它们的切换是由开发者,或者编程语言决定的,也就是说,各任务之间可以通过在某些控制点执行暂停、恢复函数,来达到多任务“协作”的目标的,它的运行的先后是可以完全由用户来控制的。这是协程和线程最直观的差异。再形象点来说协程就是有一个记忆能力的东西,在它运行过程中的任意时候它都能跳到其他函数运行,完了后又能回到之前那个地方。所以我认为协程就是普通调用的升级,为什么这么说,看下文

协程的关键特点是调度/挂起可以由开发者控制。协程比线程轻量的多。在语言层面实现协程是让其内部有一个类似栈的数据结构,当该协程被挂起时能够保存该协程的数据现场以便恢复执行。 在汇编层面理解,协程无非就是jump而已,只不过要把当前的环境保存。流程大致是保存当前的环境,然后跳转到另一个环境中。执行完再跳回来,在保存的环境中继续执行。

跟普通的函数调用很像,他们的区别是协程可以保存当前环境的所有信息,而函数调用只是把当前运行的下一条指令地址压入栈,然后又参数入栈,运行函数,然后出栈,读取之前的指令地址,继续返回运行。所以协程可以在主协程和子协程间跳来跳去,普通的函数调用只能是在一个函数里调用另一个函数,等另一个函数运行完后再返回原函数。来看看下面的例子

#include "ucontext.h"
#include <iostream>
using namespace std;

ucontext_t parent,child;

void Fun2(void* arg)
{
    for(int i = 0;i < 5; i++){
        cout<<"child "<<i<<endl;
        swapcontext(&child,&parent);
    }
}

void Fun1(void* arg)
{
    char stack[1024];
    
    getcontext(&child);

    child.uc_stack.ss_sp = stack;
    child.uc_stack.ss_size = sizeof(stack);
    child.uc_link = &parent;

    makecontext(&child,(void(*)(void))Fun2,0);

    for(int i = 0;i < 5; i++){
        cout<<"parent "<<i<<endl;
        swapcontext(&parent,&child);
    }
}

int main()
{
    Fun1(0);
    return 0;
}

可以看到可以实现在Fun1和Fun2间来回跳转,里面用到了协程相关的函数

getcontext,makecontext,swapcontext

,我们来看看这些函数的实现功能

2.具体实现

下面来看看具体怎么实现协程(注:主要是利用 ucontext组件)

ucontext里有个主要的结构体ucontext_t

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;

其主要就是保存程序某一个节点的环境即上下文。另外提供了四个函数

//获取当前ucontext_t
int getcontext(ucontext_t *ucp);   

//切换到指定ucontext_t
int setcontext(const ucontext_t *ucp);  

//设置函数指针和堆栈到对应ucontext_t保存的sp和pc寄存器中
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

//保存当前环境到oucp,并且切换到指定ucp
int swapcontext(ucontext_t *oucp, ucontext_t *ucp);

使用流程一般是先用getcontext获取当前环境,然后用makecontext绑定对应的执行函数,然后swapcontext跳转到协程执行其函数,执行后自动有回到当前环境,简单的演示代码如下

void Func(void* arg)
{
    printf("child\n");
}
void test()
{
    char stack[1024];
    ucontext_t child, parent;
    //获取当前上下文
    getcontext(&child);
    //修改当前上下文
    child.uc_stack.ss_sp = stack;
    child.uc_stack.ss_size = sizeof(stack);
    //设置执行完后返回的后继上下文
    child.uc_link = &parent;
    //给child指定执行函数
    makecontext(&child,(void(*)(void))Func,0);
    //保存当前上下文到parent中,激活child去运行其中要执行的函数
    swapcontext(&parent,&child);
    printf("parent\n");

}
int main()
{
    test();
    return 0;
}

3.深入理解

继续往下深入理解,来看看具体是如何实现协程间的切换的

从提供的函数中可以看到协程主要是有两个状态,运行和挂起,当用swapcontext是当前协程从从RUNNING 转换为 COROUTINE_SUSPEND,主要需要做的是保存当前运行栈,其中调用_save_stack 来保存运行栈

static void _save_stack(struct coroutine *C, char *top) {
    char dummy = 0;
    //top 是栈顶,top - &dummy是协程私有栈空间大小
    assert(top - &dummy <= STACK_SIZE);
    //如果协程私有栈空间大小不足以放下运行时的栈空间,则要重新扩容
    if (C->cap < top - &dummy) {
        free(C->stack);
        C->cap = top-&dummy;
        C->stack = malloc(C->cap);
    }
    C->size = top - &dummy;
    /*从栈底到栈顶的数据全都拷到 C->stack 中
     * 就是将当前运行栈保存到该协程私有栈中
    */
    memcpy(C->stack, &dummy, C->size);
}

可以看到每个协程都可以有自己的私有栈空间来保存执行现场,协程的函数是保存在公共栈空间的,top 表示栈顶,而dummy表示该用户协程当前的栈底,所以 top-dummy 就表示该用户协程运行时栈所占空间。然后将运行时栈的数据全部拷贝到该协程的C->stack中

再来看协程从 SUSPEND 到 RUNNING 状态恢复栈数据,由于C 的运行时栈空间始终是在 S->main中的,因此恢复栈空间,其实就是将各自私有的C->stack 空间中的数据恢复到S->main

memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);

所以每个协程私有栈就是从主栈里划分出一部分来保存当前环境,恢复时就将这部分拷贝到运行空间. so easy

总述:其实在c++异步编程里用函数回调跟协程作用差不多,用协程可以使代码更优雅,同时在多线程编程的有些情况下可以用协程来减少线程数,降低资源消耗,提高性能,因为它就不需要陷入内核来做线程切换,避免了大量的用户空间和内核空间之间的数据拷贝,最近在写高并发服务器,后续试试将工作线程池改为协程,就先写到这里了



版权声明:本文为sydney__原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。