libco源码解析(3) 协程执行,co_resume

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实现

引言

我们知道协程在由co_create创建以后其实是没有运行的,需要我们显式的执行co_resume才可以,这里显然是一个比较麻烦的过程,因为这里涉及到了协程的切换,也就意味着我们需要操作寄存器,这里就需要使用到一些汇编代码。

正文

我们先来看看co_resume的函数逻辑吧!

void co_resume( stCoRoutine_t *co )
{
	// stCoRoutine_t结构需要我们在我们的代码中自行调用co_release或者co_free
	stCoRoutineEnv_t *env = co->env;

	// 获取当前正在进行的协程主体 
	stCoRoutine_t *lpCurrRoutine = env->pCallStack[ env->iCallStackSize - 1 ];
	if( !co->cStart ) // 
	{
		coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 ); //保存上一个协程的上下文
		co->cStart = 1;
	}
	// 把此次执行的协程控制块放入调用栈中
	env->pCallStack[ env->iCallStackSize++ ] = co;
	// co_swap() 内部已经切换了 CPU 执行上下文
	co_swap( lpCurrRoutine, co );
	
}

我们可以看到首先从线程唯一的env中的调用栈中拿到了调用此协程的协程实体,也就是现在正在运行的这个协程的实体。我们首先要明确一个问题,就是co_resume并不是只调用一次,伴随着协程主动让出执行权,它总要被再次执行,靠的就是这个co_resume函数,无非第一次调用的时候需要初始化寄存器信息,后面不用罢了。

co->cStart标记了这个协程是否是第一次执行co_resume,关于coctx_make,我打算用一篇单独的文章讲解,因为确实比较麻烦。我们先来看看其他的逻辑,现在暂且知道coctx_make所做的事情就是为coctx_swap函数的正确执行去初始化co->ctx中寄存器信息。

然后就是把此次执行的协程放入调用栈中并自增iCallStackSize,iCallStackSize自增后是调用栈中目前有的协程数量。

stStackMem_t && stShareStack_t

接下来就是核心函数co_swap,它执行了协程的切换,并做了一些其他的工作。不过在co_swap之前我希望先说说libco的两种协程栈的策略,一种是一个协程分配一个栈,这也是默认的配置,不过缺点十分明显,因为默认大小为128KB,如果1024个协程就是128MB,1024*1024个协程就是128GB,好像和协程“千万连接”相差甚远。且这些空间中显然有很多的空隙,可能很多协程只用了1KB不到,这显然是一种极大的浪费。所以还有另一种策略,即共享栈。看似高大上,实则没什么意思,还记得我此一次看到这个名词的时候非常疑惑,想了很久如何才能高效的实现一个共享栈,思考无果后查阅libco源码,出乎意料,libco的实现并不高效,但是能跑,且避免了默认配置的情况。答案就是在进行协程切换的时候把已经使用的内存进行拷贝。这样一个线程所有的协程在运行时使用的确实是同一个栈,也就是我们所说的共享栈了

我们先来看看栈和共享栈的结构

struct stStackMem_t
{
	stCoRoutine_t* occupy_co; // 执行时占用的那个协程实体,也就是这个栈现在是那个协程在用
	int stack_size;			//当前栈上未使用的空间
	char* stack_bp; 		//stack_buffer + stack_size
	char* stack_buffer;		//栈的起始地址,当然对于主协程来说这是堆上的空间

};

// 共享栈中多栈可以使得我们在进程切换的时候减少拷贝次数
struct stShareStack_t
{
	unsigned int alloc_idx;		// stack_array中我们在下一次调用中应该使用的那个共享栈的index
	int stack_size;				// 共享栈的大小,这里的大小指的是一个stStackMem_t*的大小
	int count;					// 共享栈的个数,共享栈可以为多个,所以以下为共享栈的数组
	stStackMem_t** stack_array;	// 栈的内容,这里是个数组,元素是stStackMem_t*
};

基本注释都说清楚了,stStackMem_t没什么说的。我们来看看stShareStack_t,其中有一个参数count,是共享栈的个数。共享栈为什么还能有多个?这是一个对于共享栈的优化,可以减少内容的拷贝数。我们知道共享栈在切换协程的时候会执行拷贝,把要切换出去的协程的栈内容进行拷贝,但是如果要当前协程和要切换的协程所使用的栈不同,拷贝这一步当然就可以省略了。

我们来看看在co_create中的co_get_stackmem是如何在共享栈结构中分配栈的:

static stStackMem_t* co_get_stackmem(stShareStack_t* share_stack)
{
	if (!share_stack)
	{
		return NULL;
	}
	int idx = share_stack->alloc_idx % share_stack->count;
	share_stack->alloc_idx++;

	return share_stack->stack_array[idx];
}

我们可以看到逻辑非常简单,就是一个轮询。

好了,准备知识说完了,可以开始co_swap的解析了。

co_swap

// 当前准备让出 CPU 的协程叫做 current 协程,把即将调入执行的叫做 pending 协程
void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
{
 	stCoRoutineEnv_t* env = co_get_curr_thread_env();

	// get curr stack sp
	// 获取esp指针, 
	char c;
	curr->stack_sp= &c; 

	if (!pending_co->cIsShareStack)
	{ 
		env->pending_co = NULL;
		env->occupy_co = NULL;
	}
	else // 如果采用了共享栈
	{
		env->pending_co = pending_co;
		// get last occupy co on the same stack mem
		// 获取pending使用的栈空间的执行协程
		stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co;
		// 也就是当前正在执行的进程
		// set pending co to occupy thest stack mem
		// 将该共享栈的占用者改为pending_co
		pending_co->stack_mem->occupy_co = pending_co;

		env->occupy_co = occupy_co;
		if (occupy_co && occupy_co != pending_co)
		{
			// 如果上一个使用协程不为空,则需要把它的栈内容保存起来
			save_stack_buffer(occupy_co); 
		}
	}

	//swap context 这个函数执行完, 就切入下一个协程了
	coctx_swap(&(curr->ctx),&(pending_co->ctx) );

	//stack buffer may be overwrite, so get again;
	stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
	stCoRoutine_t* update_occupy_co =  curr_env->occupy_co; 
	stCoRoutine_t* update_pending_co = curr_env->pending_co;
	
	if (update_occupy_co && update_pending_co && update_occupy_co != update_pending_co)
	{
		//resume stack buffer
		if (update_pending_co->save_buffer && update_pending_co->save_size > 0)
		{
			// 如果是一个协程执行到一半然后被切换出去然后又切换回来,这个时候需要恢复栈空间
			memcpy(update_pending_co->stack_sp, update_pending_co->save_buffer, update_pending_co->save_size);
		}
	}
}

首先我们可以看到令人疑惑的一句代码:

	char c;
	curr->stack_sp= &c; 

说实话,在第一次看到的时候,实在是疑惑至极,这玩意在干嘛,但仔细想,我们上面说了当协程选择共享栈的时候需要在协程切换是拷贝栈上已使用的内存。问题来了,拷贝哪一部分呢?

|------------|
|	 used    |
|------------|
|     esp    |
|------------|
|   ss_size  |
|------------|
|stack_buffer|
|------------|

答案就是usd部分,首先栈底我们可以很容易的得到。就是栈基址加上栈大小,但是esp怎么获取呢?再写一段汇编代码?可以,但没必要。libco采用了一个极其巧妙的方法,个人认为这是libco最精妙的几段代码之一。就是直接用一个char类型的指针放在函数头,获取esp。这样我们就得到了需要保存的数据范围了。

然后就是一段更新env中pengdingoccupy_co的代码,最后执行共享栈中栈的保存。

coctx_swap是libco的核心,即一段汇编代码,功能为把传入的两个把当前寄存器的值存入curr中,把pending中的值存入寄存器中,也就是我们所说的上下文切换。这段代码和coctx_make一样放在下一篇文章中,现在知其然就可以啦。

我们脑子要清楚一件事情。就是coctx_swap执行完以后,CPU就跑去执行pendding中的代码了,也就是说执行完执行coctx_swap的这条语句后,下一条要执行的语句不是stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();,而是pedding中的语句。这一点要尤其注意。

那么时候执行coctx_swap这条语句之后的语句呢?就是在协程被其他地方执行co_resume了以后才会继续执行这里。

后面就简单啦,切换出去的时候要把栈内容拷贝下来,切换回来当然又要拷贝到栈里面啦。

这就是协程的执行过程。

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