从 Protothreads 和 libco 看 C/C++ 实现的协程库

同步Synchronous,异步(Asynchronous),协程(coroutine)

同步的好处是逻辑流就是代码的控制流,易于编写。但是如果碰到阻塞请求,就会卡住,因此CPU利用率不高。当然操作系统可以进行进程/线程调度,但是又有一些上下文切换的开销。
异步的好处是当线程可以不用一直阻塞在IO请求上,返回的逻辑可以写在回调里。但是这样有两个问题,一个是逻辑流不等于控制流,coder需要去适应异步的思想;二是回调时也需要保证维持一些当初请求的状态,这个时候比较繁琐,并且容易出错。
协程的主要目的是,要让所有CPU都能跑满,这样可以最大化利用计算资源。并且协程编写跟同步很相似,业务逻辑不会改变。协程相比于传统的进程/线程,有一个优势是调度在用户态,因此开销小(不用用户态->内核态切换),同时协程的并发量主要受制于内存大小。

Protothreads

Protothreads 是一个短小精悍的协程库,它对于每个协程都只需要2字节来保存状态。主页:http://dunkels.com/adam/pt/index.html (PS: 这个作者非常有名)

协程最重要需要解决的问题:

  1. 如何保存当前处理的状态(寄存器,局部变量等。。)
  2. 如何保证下次可以在当前位置继续执行。

Protothreads 解决了第二个问题,它对待第一个问题的方式是不允许用户在函数里定义局部变量 : ),换句话说你定义的局部变量这些都没用,所以是无状态的。当然你可以用全局变量去维护一些状态。
解决第二个问题的方式是使用 switch-case 语句跳转,类似于 goto。那么怎么区分跳转位置呢,采用 LABEL_行号 的方式解决(行号就是上面说的2字节),这样你第一次进入函数的时候是正常执行的,但是当你后面进入函数时,通过 switch 可以直接跳转到指定 label 继续执行。
pt 的几个宏定义:

// lc-switch.h
typedef unsigned short lc_t;

#define LC_INIT(s) s = 0;

#define LC_RESUME(s) switch(s) { case 0:

#define LC_SET(s) s = __LINE__; case __LINE__:

#define LC_END(s) }

/************************************daghlny 专有分割线***************************************/
// pt.h
#define PT_INIT(pt)   LC_INIT((pt)->lc)\

#define PT_BEGIN(pt) { char PT_YIELD_FLAG = 1; LC_RESUME((pt)->lc)

#define PT_END(pt) LC_END((pt)->lc); PT_YIELD_FLAG = 0; \
                   PT_INIT(pt); return PT_ENDED; }

#define PT_WAIT_UNTIL(pt, condition)	        \
  do {						\
    LC_SET((pt)->lc);				\
    if(!(condition)) {				\
      return PT_WAITING;			\
    }						\
  } while(0)

#define PT_YIELD(pt)				\
  do {						\
    PT_YIELD_FLAG = 0;				\
    LC_SET((pt)->lc);				\
    if(PT_YIELD_FLAG == 0) {			\
      return PT_YIELDED;			\
    }						\
  } while(0)

上面这个是一种 LABEL 策略 (定义在 lc-switch.h),另一种是使用 C语言的 &&LABEL 语法糖(定义在lc-addrlabels.h),然后在 lc.h 文件中进行选择使用。如果需要看到 pt 的具体执行,可以用 gcc -E 来打印出来宏展开之后的代码。

libco

libco 是微信给出的一个协程库,代码量也不大,并且是经过微信的业务考验的,当然文档很差(基本没有注释),并且更新很慢(commit已经很久远了),但是依然是一个比较健全的协程库。主页:https://github.com/Tencent/libco
libco 采用的是 hook 所有常用的 Unix 系统调用,包括 read, write, send, recv 等。然后在每次进行这些调用时,会先观察是否启用了 hook,然后观察是否 fd 设置了 O_NONBLOCK,然后再观察是否设置了 timeout == 0,最后才会采用自己定义的 poll 函数来注册 fd,然后将控制权移交到其他协程中去。
下面是一个 hook read 的代码:

ssize_t read( int fd, void *buf, size_t nbyte )
{
	HOOK_SYS_FUNC( read );
	
	if( !co_is_enable_sys_hook() )
	{
		return g_sys_read_func( fd,buf,nbyte );
	}
	rpchook_t *lp = get_by_fd( fd );

	if( !lp || ( O_NONBLOCK & lp->user_flag ) ) 
	{
		ssize_t ret = g_sys_read_func( fd,buf,nbyte );
		return ret;
	}
	int timeout = ( lp->read_timeout.tv_sec * 1000 ) 
				+ ( lp->read_timeout.tv_usec / 1000 );

	struct pollfd pf = { 0 };
	pf.fd = fd;
	pf.events = ( POLLIN | POLLERR | POLLHUP );

	int pollret = poll( &pf,1,timeout );

	ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte );

	if( readret < 0 )
	{
		co_log_err("CO_ERR: read fd %d ret %ld errno %d poll ret %d timeout %d",
					fd,readret,errno,pollret,timeout);
	}

	return readret;
	
}

但是,libco 如何保存当前线程的状态呢,具体的逻辑在 co_routine.cpp/co_swap() 函数里,函数定义如下:

void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
{
 	stCoRoutineEnv_t* env = co_get_curr_thread_env();

	//get curr stack sp
	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
		stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co;
		//set pending co to occupy thest stack mem;
		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);
		}
	}
}

假设现在要从协程 A(就是参数 curr) 切换到协程 B(就是参数 pending_co),其中 save_stack_buffer() 函数是用来保存协程A栈的存储内容的,而 coctx_swap() 是用来将 A 的寄存器内容保存到到自己的 stCoRoutine_t 结构中,然后将 B 的寄存器内容恢复回来。首先,stCoRoutine_tctx 字段是 coctx_t 结构,定义如下:

struct coctx_t
{
#if defined(__i386__)
	void *regs[ 8 ];
#else
	void *regs[ 14 ];
#endif
	size_t ss_size;
	char *ss_sp;
	
};

coctx_swap() 这个函数是用汇编写的,代码是:

#elif defined(__x86_64__)
	leaq 8(%rsp),%rax
	leaq 112(%rdi),%rsp
	pushq %rax
	pushq %rbx
	pushq %rcx
	pushq %rdx

	pushq -8(%rax) //ret func addr

	pushq %rsi
	pushq %rdi
	pushq %rbp
	pushq %r8
	pushq %r9
	pushq %r12
	pushq %r13
	pushq %r14
	pushq %r15
	
	movq %rsi, %rsp
	popq %r15
	popq %r14
	popq %r13
	popq %r12
	popq %r9
	popq %r8
	popq %rbp
	popq %rdi
	popq %rsi
	popq %rax //ret func addr
	popq %rdx
	popq %rcx
	popq %rbx
	popq %rsp
	pushq %rax
	
	xorl %eax, %eax
	ret
#endif

方便起见只我只截取了 x86_64 的。函数定义中,第一句leaq 8(%rsp),%rax ,实际上是将除了返回之外的协程A栈顶放到 %ras 寄存器中,leaq 112(%rdi),%rsp 是将 %rsp 指向上面 coctx_t 结构的 regs 数组的末尾(也就是地址最大的一端)。然后通过后续的 pushq,就可以把其他的寄存器放到 regs 中进行保存。 然后将 %rsp 指向 %rsi (根据之前的某篇博文,这个参数在64位下应该是第二个传入参数),然后使用一系列 popq 指令再将协程B之前保存的寄存器状态依次从 regs 放入每个寄存器中。
然后用 pushq %rax 来将返回地址重新压栈,最后使用 ret 来保证 %rip 寄存器指向指定的指令位置,然后就恢复了 协程B 的上下文状态,这样就可以继续执行了。

posted on 2018-07-29 22:44  daghlny  阅读(3030)  评论(0编辑  收藏  举报

导航