C++协程项目之协程库学习与实践(协程函数学习、线程切换实践)

网上协程实现原理大概有这么几种:调库、汇编、原语级别(可能会破坏原本语义)。我们今天简单学习和实践的是一种利用linux下库函数实现的协程。

首先来看这样一段代码:

#include <iostream>
#include <ucontext.h>
#include <unistd.h>

int main(int argc, char** argv) {
    ucontext_t context;
    getcontext(&context);
    
    std::cout << "hello world" << std::endl;
    sleep(1);
    
    setcontext(&context);
    return 0;
}

编译运行指令如下:

g++ new.cpp -o new

./new

注意这个库只有在linux下才支持,可以用虚拟机进行尝试。

头文件unistd.h是unix std标准命名空间的缩写,我们用到了sleep休眠函数,所以要包含这个头文件。

运行效果如图,如果不用Ctrl+C或者Ctrl+Z终止进程的话,就会一直运行。这是怎么回事呢?

其实是我们用getcontext函数保存了context地址处的上下文信息,具体就是这个结构体保存的信息,这个解释起来有些复杂,最简单的解释就是这个结构体包含一个该结构体指针类型的变量,和众多的上下文信息,包括上下文中的阻塞信号,使用的内存栈的地址、寄存器名字等等。我们这里用到的是恢复到原先设置好的栈位置上去,让其达到了循环运行的效果。

这也就是协程的奇妙之处了。

ucontext.h库中还包含了这样几个函数:

int getcontext(ucontext_t *uc);

int setcontext(ucontext_t *uc);

void makecontext(ucontext_t *uc, void (*func)(), int argc, char** argv);

int swapcontext(ucontext_t *tuc, ucontext_h *nuc); 

其中的参数名我按照自己的风格改写了下,第四个函数第一个参数是保存地址,第二个参数是唤醒的协程的上下文地址。

第三个函数是将指定上下文中栈地址处的值设置为该函数和其参数,方便直接执行该函数。

第一第二个上面已经使用过了。就不看了。

需要注意的是三个有返回值的函数在失败时返回-1。成功时getcontext返回0,其他两个不返回。

下面我们尝试用这几个函数实现两个线程之间的切换。

需求如下:

#include <iostream>
#include <ucontext.h>

ucontext_t main_context;
ucontext_t thread_context;

void thread1() {
    int n = 3;
    while (n--)
        std::cout << "I'm thread1" << std::endl;
    setcontext(&main_context);
}

void thread2() {
    int n = 4;
    while (n--)
        std::cout << "I'm thread2" << std::endl;
    setcontext(&main_context);
}

void thread3() {
    int n = 5;
    while (1)
        std::cout << "I'm threa3" << std::endl;
    setcontext(&main_context);
}

int main(int argc, char** argv) {
    makecontext(&thread_context, &thread1, 0);
    while (swapcontext(&main_context, &thread_context) == -1);
    makecontext(&thread_context, &thread2, 0);
    while (swapcontext(&main_context, &thread_context) == -1);
    makecontext(&thread_context, &thread3, 0);
    while (swapcontext(&main_context, &thread_context) == -1);
    return 0;
}

效果如下:

简化后代码如下:

#include <iostream>
#include <ucontext.h>

void thread1() {
    int n = 3;
    while (n--)
        std::cout << "I'm thread1" << std::endl;
}

void thread2() {
    int n = 4;
    while (n--)
        std::cout << "I'm thread2" << std::endl;
}

void thread3() {
    int n = 5;
    while (n--)
        std::cout << "I'm threa3" << std::endl;
}

int main(int argc, char** argv) {
    ucontext_t main_context;
    ucontext_t thread_context;
    char stack[1024*128];
    getcontext(&thread_context);
    thread_context.uc_stack.ss_sp = stack;
    thread_context.uc_stack.ss_size = sizeof(stack);
    thread_context.uc_stack.ss_flags = 0;
    thread_context.uc_link = &main_context;
    makecontext(&thread_context, (void (*)())thread1, 0);
    swapcontext(&main_context, &thread_context);
    makecontext(&thread_context, (void (*)())thread2, 0); 
    swapcontext(&main_context, &thread_context);
    makecontext(&thread_context, (void (*)())thread3, 0);
    swapcontext(&main_context, &thread_context);
    return 0;
}

实现的思路如下:

定义两个ucontext_t变量,main_context用来存放main函数上下文,thread_context用来存放线程上下文。首先要用getcontext取当前上下文到thread_context,方便我们后续对它进行操作,注意,不先取当前上下文而是用其他方式进行初始化,包括用new,都会引发段错误!这背后的原因大概是ucontext_t本身是个结构体,没有提供足够安全的构造函数,所以我们要用现成的main函数提供的上下文来改造。同样会引发段错误的还有不给栈空间赋值,这里用char的原因在于ucontext_t本身的大小我们并不确定,为了避免bus error,我们选择了每个元素只有一个字节的char数组。另外,char数组也不能用new,否则就不是栈空间了。而且指定栈空间之后,也是一定要指定大小,否则也会引发段错误。ss_flags倒是无所谓,是个指定栈增长方向的,默认情况下是不会报错的。。

上面主要是会引发段错误的一些情况,下面简要说下思路。将thread_context的后继(这里使用其内部指针元素uc_link实现)设置为main_context,这样每次该线程执行完之后都会返回main函数中,然后简单调用makecontext函数将对应函数设定在thread_context处就可以正常使用了,线程运行结束后会自动返回到main中。这里其实也是一开始就要将当前上下文保存的原因了——保证栈空间在main函数开始栈空间之后,使用main函数的栈空间中的资源。因为原先协程就是在线程内运行的,原本线程切换我们用协程来切换,但一个线程还是对应一个协程,这是栈空间上的对应。

上一篇:Ps 滤镜:视频


下一篇:Python 植物大战僵尸