网上协程实现原理大概有这么几种:调库、汇编、原语级别(可能会破坏原本语义)。我们今天简单学习和实践的是一种利用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函数的栈空间中的资源。因为原先协程就是在线程内运行的,原本线程切换我们用协程来切换,但一个线程还是对应一个协程,这是栈空间上的对应。