libco源码解析(1) 协程运行与基本结构
libco源码解析(2) 创建协程,co_create
libco源码解析(3) 协程执行,co_resume
libco源码解析(4) 协程切换,coctx_make与coctx_swap
libco源码解析(5) poll
libco源码解析(6) co_eventloop
libco源码解析(7) read,write与条件变量
libco源码解析(8) hook机制探究
libco源码解析(9) closure实现
引言
这是计划的一系列文章的第一篇, 作为第一篇,我们要先来看看libco的协程如何使用,我们直接拿libco中的example_cond.cpp来对协程的运行过程做一个简单的解释。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <queue>
#include "co_routine.h"
using namespace std;
struct stTask_t
{
int id;
};
struct stEnv_t
{
stCoCond_t* cond;
queue<stTask_t*> task_queue;
};
void* Producer(void* args)
{
co_enable_hook_sys();
stEnv_t* env= (stEnv_t*)args;
int id = 0;
while (true)
{
stTask_t* task = (stTask_t*)calloc(1, sizeof(stTask_t));
task->id = id++;
env->task_queue.push(task);
printf("%s:%d produce task %d\n", __func__, __LINE__, task->id);
co_cond_signal(env->cond);
// poll中数字的调整可以调整交替速度,因为这个数字代表了在epoll中的超时时间,也就是什么时候生产者执行
// 可以简单的理解为生产者的生产速度,timeout越大,生产速度越慢
poll(NULL, 0, 1000);
}
return NULL;
}
void* Consumer(void* args)
{
printf("进入consumer\n");
co_enable_hook_sys();
stEnv_t* env = (stEnv_t*)args;
while (true)
{
if (env->task_queue.empty())
{
co_cond_timedwait(env->cond, -1);
continue;
}
// 操作队列的时候没有加锁
stTask_t* task = env->task_queue.front();
env->task_queue.pop();
printf("%s:%d consume task %d\n", __func__, __LINE__, task->id);
free(task);
}
return NULL;
}
/*
* 主协程是跟 stCoRoutineEnv_t 一起创建的。主协程也无需调用 resume 来启动,
* 它就是程序本身,就是 main 函数。主协程是一个特殊的存在
*
* 在程序首次调用 co_create() 时,此函数内部会判断当前进程(线程)的 stCoRoutineEnv_t 结构是否已分配,
* 如果未分配则分配一个,同时分配一个 stCoRoutine_t 结构,并将 pCallStack[0] 指向主协程。
* 此后如果用 co_resume() 启动协程,又会将 resume 的协程压入 pCallStack 栈
*/
int main()
{
stEnv_t* env = new stEnv_t;
env->cond = co_cond_alloc();
stCoRoutine_t* consumer_routine; // 一个协程的结构
// 协程的创建函数于pthread_create很相似
//1.指向线程表示符的指针,设置线程的属性(栈大小和指向共享栈的指针,使用共享栈模式),线程运行函数的其实地址,运行是函数的参数
co_create(&consumer_routine, NULL, Consumer, env);// 创建一个协程
// 协程在创建以后并没有运行 使用resume运行
co_resume(consumer_routine);
stCoRoutine_t* producer_routine;
co_create(&producer_routine, NULL, Producer, env);
co_resume(producer_routine);
// 没有使用pthread_join 而是使用co_eventloop
co_eventloop(co_get_epoll_ct(), NULL, NULL);
return 0;
}
首先这个代码展示了一个协程的生产者消费者模型,与线程的实现不同,协程实现的生产者消费者模型不需要加锁,因为究其本质两个协程不过是串行的执行而已。具体的原因我们后面会说
如果是初次接触协程的话,建议还是去在github上拉一份源码下来亲自运行感受一下,打印的结果当然和多线程一样是交替打印了。
首先libco的协程为了使得程序员能够更好的接收,使用了和posix标准的线程创建几乎一样的方法,即co_create
,这与pthread_create
的参数是基本一致的。我们来看看如何创建一个协程:
stCoRoutine_t* consumer_routine; // 一个协程的结构
co_create(&consumer_routine, NULL, Consumer, env);// 创建一个协程,即初始化协程结构
// 协程在创建以后并没有运行 使用resume运行
co_resume(consumer_routine);
我们可以看到这段代码中有一个stCoRoutine_t
结构,这个结构实际上就是就是协程的主体结构,存储着一个协程相关的数据。我们注意到在co_create
创建一个协程以后协程是没有运行的,这点和线程并不一样,如果我们想要使协程运行的话,还需要执行co_resume
才可以。
我们来看看stCoRoutine_t结构的内容:
// libco的协程一旦创建之后便和创建它的线程绑定在一起 不支持线程之间的迁移
struct stCoRoutine_t
{
stCoRoutineEnv_t *env; // 协程的执行环境,运行在同一个线程上的各协程是共享该结构
pfn_co_routine_t pfn; // 结构为一个函数指针 实际待执行的协程函数
void *arg; // 参数
// 用于协程切换时保存 CPU 上下文(context)的,即 esp、ebp、eip 和其他通用寄存器的值
coctx_t ctx;
// 一些状态和标志变量
char cStart; // 协程是否执行过resume
char cEnd;
char cIsMain; //是否为主协程 在co_init_curr_thread_env修改
char cEnableSysHook; //此协程是否hook库函数
char cIsShareStack; // 是否开启共享栈模式
// 保存程序系统环境变量的指针
void *pvEnv;
//这里也可以看出libco协程是stackful的,也有一些库的实现是stackless,即无栈协程
// char sRunStack[ 1024 * 128 ];
// 协程运行时的栈内存
stStackMem_t* stack_mem;
/**
* 一个协程实际占用的(从 esp 到栈底)栈空间,相比预分配的这个栈大小
* (比如 libco 的 128KB)会小得多;这样一来, copying stack
* 的实现方案所占用的内存便会少很多。当然,协程切换时拷贝内存的开销
* 有些场景下也是很大的。因此两种方案各有利弊,而 libco 则同时实现
* 了两种方案,默认使用前者
*/
// save satck buffer while confilct on same stack_buffer;
// 当使用共享栈的时候需要用到的一些数据结构
char* stack_sp;
unsigned int save_size;
char* save_buffer;
stCoSpec_t aSpec[1024];
};
env
是一个非常关键的结构,这个结构是所有数据中最特殊的一个,因为它是一个线程内共享的结构,也就是说同一个线程创建的所有协程的此结构指针指向同一个数据。其中存放了一些协程调度相关的数据,当然叫调度有些勉强,因为libco实现的非对称式协程实际上没有什么调度策略,完全就是协程切换会调用这个协程的协程或者线程。这个结构我们会在后面仔细讲解。pfn
是一个函数指针,类型为function<void*(void*)>,当然libco虽然是用C++写的,但是整体风格偏向于C语言,所以实际结构是一个函数指针。值得一提的是实际存储的函数指针并不是我们传入的函数指针,而是一个使用我们传入的函数指针的一个函数,原因是当协程执行完毕的时候需要切换CPU执行权,这样可以做到最小化入侵用户代码。arg
没什么说的,传入的指针的参数。ctx
保存协程的上下文,实际就是寄存器的值,不管是C还是C++都没有函数可以直接接触寄存器,所以操作这个参数的时候需要嵌入一点汇编代码。- 紧接着是五个标记位,功能注释中写的很清楚啦。
pvEnv
保存着环境变量相关,这个环境变量其实是与hook后的setenv,getenv类函数有关。和上面说的env没有什么关系。stack_mem
是运行是栈的结构,libco提供了两种方式,一个是每个协程拥有一个独立的栈,默认分配128KB空间,缺点是每个协程可能只用到了1KB不到,碎片较多。还有一种是共享栈模式,需要我们在创建协程的时候在Co_create中指定第二个参数,这种方法是多个协程共用一个栈,但是在协程切换的时候需要拷贝已使用的栈空间。- 剩下的就是一些在共享栈时要用到的参数了。
我们来看看协程的上下文到底长什么样子,即coctx_t结构:
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif // 保存上下文
size_t ss_size; // 栈的大小
char *ss_sp; // 栈顶指针esp
};
其中regs就是保存寄存器的值。
在32位机器下保存八个寄存器,在64位下保存14个寄存器。我们知道X86架构下有8个通用寄存器,X64则有16个寄存器,那么为什么64位只使用保存14个寄存器呢?我们可以在coctx_swap.S中看到64位下缺少了对%r10, %r11寄存器的备份,
x86-64的16个64位寄存器分别是:%rax, %rbx, %rcx, %rdx, %esi, %edi, %rbp, %rsp, %r8-%r15。其中:
- %rax 作为函数返回值使用
- %rsp栈指针寄存器,指向栈顶
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数
- %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者保护规则,简单说就是随便用,调用子函数之前要备份它,以防被修改
- %r10,%r11 用作数据存储,遵循调用者保护规则,简单说就是使用之前要先保存原值
我们来看看两个陌生的名词调用者保护&被调用者保护:
- 调用者保护:表示这些寄存器上存储的值,需要调用者(父函数)自己想办法先备份好,否则过会子函数直接使用这些寄存器将无情的覆盖。如何备份?当然是实现压栈(pushl),等子函数调用完成,再通过栈恢复(popl)
- 被调用者保护:即表示需要由被调用者(子函数)想办法帮调用者(父函数)进行备份
我们再来看看上面谈到的重要的结构env
,即stCoRoutineEnv_t
结构:
/*
1. 每当启动(resume)一个协程时,就将它的协程控制块 stCoRoutine_t 结构指针保存在 pCallStack 的“栈顶”,
2. 然后“栈指针” iCallStackSize 加 1,最后切换 context 到待启动协程运行。当协程要让出(yield)CPU 时,
3. 就将它的 stCoRoutine_t从pCallStack 弹出,“栈指针” iCallStackSize 减 1,
4. 然后切换 context 到当前栈顶的协程(原来被挂起的调用者)恢复执
*/
// stCoRoutineEnv_t结构一个线程只有一个
struct stCoRoutineEnv_t
{
// 如果将协程看成一种特殊的函数,那么这个 pCallStack 就时保存这些函数的调用链的栈。
// 非对称协程最大特点就是协程间存在明确的调用关系;甚至在有些文献中,启动协程被称作 call,
// 挂起协程叫 return。非对称协程机制下的被调协程只能返回到调用者协程,这种调用关系不能乱,
// 因此必须将调用链保存下来
stCoRoutine_t *pCallStack[ 128 ];
int iCallStackSize; // 上面那个调用栈的栈顶指针
// epoll的一个封装结构
stCoEpoll_t *pEpoll;
// for copy stack log lastco and nextco
// 对上次切换挂起的协程和嵌套调用的协程栈的拷贝,为了减少共享栈上数据的拷贝
// 在不使用共享栈模式时 pending_co 和 ocupy_co 都是空指针
// pengding是目前占用共享栈的协程
// 想想看,如果不加的话,我们需要O(N)的时间复杂度分清楚Callback中current上一个共享栈的协程实体(可能共享栈与默认模式混合)
stCoRoutine_t* pending_co;
// 与pending在同一个共享栈上的上一个协程
stCoRoutine_t* occupy_co;
};
pCallStack
结构是一个非常重要的结构,这个名字起的非常有意思,很贴切,因为这就是一个调用栈,它存储着协程的调用栈,举个例子,主协程A调用协程B,协程B的函数中又调用协程C,这个时候pCallStack中存储的数据就是[A,B,C],拿我们前面举过的生产者消费者模型距离,把生产者当做B,消费者当做C,主协程当做A,pCallStack的结构就在[A,B],[A,C],间切换。简单来说每一项前面存储着调用这个协程的协程,最少有一个元素,即主协程。pEpoll
,一个封装的epoll。- 剩下两个结构与共享栈相关,存储着与当前运行线程使用同一个栈的线程,因为共享栈可能有多个,参见sharestack结构中栈结构其实是个数组。个人认为加上的原因就是检索更快。
以上就是对于协程使用方法和基础结构的简单解析
参考:
- 博文《libco源码学习(一)》