libco源码解析(1) 协程运行与基本结构

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];

};
  1. env是一个非常关键的结构,这个结构是所有数据中最特殊的一个,因为它是一个线程内共享的结构,也就是说同一个线程创建的所有协程的此结构指针指向同一个数据。其中存放了一些协程调度相关的数据,当然叫调度有些勉强,因为libco实现的非对称式协程实际上没有什么调度策略,完全就是协程切换会调用这个协程的协程或者线程。这个结构我们会在后面仔细讲解。
  2. pfn是一个函数指针,类型为function<void*(void*)>,当然libco虽然是用C++写的,但是整体风格偏向于C语言,所以实际结构是一个函数指针。值得一提的是实际存储的函数指针并不是我们传入的函数指针,而是一个使用我们传入的函数指针的一个函数,原因是当协程执行完毕的时候需要切换CPU执行权,这样可以做到最小化入侵用户代码。
  3. arg没什么说的,传入的指针的参数。
  4. ctx保存协程的上下文,实际就是寄存器的值,不管是C还是C++都没有函数可以直接接触寄存器,所以操作这个参数的时候需要嵌入一点汇编代码。
  5. 紧接着是五个标记位,功能注释中写的很清楚啦。
  6. pvEnv保存着环境变量相关,这个环境变量其实是与hook后的setenv,getenv类函数有关。和上面说的env没有什么关系。
  7. stack_mem是运行是栈的结构,libco提供了两种方式,一个是每个协程拥有一个独立的栈,默认分配128KB空间,缺点是每个协程可能只用到了1KB不到,碎片较多。还有一种是共享栈模式,需要我们在创建协程的时候在Co_create中指定第二个参数,这种方法是多个协程共用一个栈,但是在协程切换的时候需要拷贝已使用的栈空间。
  8. 剩下的就是一些在共享栈时要用到的参数了。

我们来看看协程的上下文到底长什么样子,即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;
};
  1. pCallStack结构是一个非常重要的结构,这个名字起的非常有意思,很贴切,因为这就是一个调用栈,它存储着协程的调用栈,举个例子,主协程A调用协程B,协程B的函数中又调用协程C,这个时候pCallStack中存储的数据就是[A,B,C],拿我们前面举过的生产者消费者模型距离,把生产者当做B,消费者当做C,主协程当做A,pCallStack的结构就在[A,B],[A,C],间切换。简单来说每一项前面存储着调用这个协程的协程,最少有一个元素,即主协程。
  2. pEpoll,一个封装的epoll。
  3. 剩下两个结构与共享栈相关,存储着与当前运行线程使用同一个栈的线程,因为共享栈可能有多个,参见sharestack结构中栈结构其实是个数组。个人认为加上的原因就是检索更快。

以上就是对于协程使用方法和基础结构的简单解析

参考:

posted @ 2022-07-02 13:17  李兆龙的博客  阅读(127)  评论(0编辑  收藏  举报