libco 协程库详解

协程是什么?

轻量级的调度运行单位。

  • 协程不是进程,也不是线程,它就是一个函数,一个特殊的函数 —— 可以在某个地方挂起,并且可以重新在挂起处继续运行。所以说,协程与进程、线程相比,不是一个维度的概念。
  • 一个进程可以包含多个线程,一个线程也可以包含多个协程,也就是说,一个线程内可以有多个那样的特殊函数在运行。但是有一点,必须明确,一个线程内的多个协程的运行是串行的。如果有多核 CPU 的话,多个进程或一个进程内的多个线程是可以并行运行的,但是一个线程内的多个协程绝对是串行的,无论有多少个 CPU(核)。这个比较好理解,毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内可以运行多个函数,但是这些函数都是串行运行的。当一个协程运行时,其他协程必须挂起。

协程与进程、线程的比较

虽说,协程与进程、线程不是一个维度的概念,但有时候,我们仍需要将它们作一番比较。

  1. 协程既不是进程,也不是线程,协程仅仅是一个特殊的函数,协程跟它们不是一个维度。
  2. 一个进程可以包含多个进程,一个线程可以包含多个协程。
  3. 一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在这一个线程内运行,没法利用 CPU 多核能力。
  4. 协程与进程一样,它们的切换都存在上下文切换问题。

表面上,进程、线程、协程都存在上下文切换问题,但是三者上下文切换又有明显不同,见下表:

进程线程协程
切换者操作系统操作系统用户(编程者/应用程序)
切换时机根据操作系统自己的切换策略,用户不感知根据操作系统自己的切换策略,用户不感知用户自己(的程序)决定
切换内容页全局目录、内核栈、硬件上下文内核栈、硬件上下文硬件上下文
切换内容的保存保存于内核栈中保存于内核栈中保存于用户自己的变量(用户栈或者堆)
切换过程用户态-内核态-用户态用户态-内核态-用户态用户态(没有陷入内核态)
切换效率

协程用在哪里?

  • 一个线程内的多个协程是串行执行的,不能利用多核。所以,显然,协程不适合计算密集型的场景,协程适合 I/O 阻塞型。
  • I/O 本身就是阻塞型的(相较于 CPU 的时间而言)。就目前而言,无论 I/O 的速度多快,也比不上 CPU 的速度,所以一个 I/O 相关的程序,当其在进行 I/O 操作的时候,CPU 实际上是空闲的。
  • 我们假设这样一个场景,如下图:一个线程有5个 I/O 的请求(子程序)要处理。如果我们绝对的串行化,那么当其中一个 I/O 阻塞时,其他4个 I/O 并不能得到执行,因为程序是绝对串行的,5个I/O必须一个一个排队等待处理,当一个 I/O 阻塞时,其它4个也得等着。
    在这里插入图片描述
    而协程能比较好处理这个问题,当一个协程(特殊子进程) 阻塞时,它可以切换到其它没有阻塞的协程上去继续运行,这样就能得到比较高的效率,如下图所示:
    在这里插入图片描述
    上面举得例子是 5个 I/O 处理,如果每秒 500 个,5万个或500万个呢?已经达到了 “I/O 密集型”的程度,而 “I/O密集型” 确实是协程无法应付的,因为 它没有利用多核的能力。这个时候的解决方案就是“多进程 + 协程” 了。
  • 所以说,I/O阻塞时,利用协程来处理确实有优点(切换效率比较高),但是我们也需要看到其不能利用多核的这个缺点,必要的时候,还需要使用综合方案:多进程+协程

协程实现逻辑

协程看上去是一个函数,但在执行过程中,在子程序内部可终端,然后转去执行别的子程序,在适当的时候再返回来执行。注意,在一个子程序中中断,去执行其它子程序,不是函数调用,有点类似于 CPU 的中断。

比如子程序 A、B:

void A()
{
	cout << "1" << endl;
	cout << "2" << endl;
	cout << "3" << endl;
}
void B()
{
	cout << "A" << endl;
	cout << "B" << endl;
	cout << "C" << endl;
}

假设由协程执行,在执行 A 的过程中,可以随时中断,去执行 B,B也可能在执行过程中中断再去执行A,结果可能是:

1
2
x
y
3
z

但是在 A 中是没有调用 B的,所以协程的调用比函数调用理解起来要难一些。

看起来 A、B的执行有点像多线程,但协程的特点在于是一个线程执行,那和多线程相比,协程有何优点?

  1. 最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序本身控制。因此,没有线程切换的开销,和多线程相比,线程的数量越多,协程的性能优势越明显。
  2. 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

因为协程是一个线程执行,那怎么利用多核 CPU 呢?
最简单的方法还是上面已经讲过的 “多进程 + 协程”,既充分利用多核,有充分发挥协程的高效率,可获得极高的性能。


协程例子

Python对协程的支持还非常有限,用在generator中的yield可以一定程度上实现协程。虽然支持不完全,但已经可以发挥相当大的威力了。

下面来看一个例子:

传统的 生产者-消费者 模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就会死锁。

如果改用协程,生产者生产消息后,直接通过 yield 跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:

import time
 
def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        time.sleep(1)
        r = '200 OK'
 
def produce(c):
    c.next()
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()
 
if __name__=='__main__':
    c = consumer()
    produce(c)

【执行结果】

[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

注意到 consumer 函数是一个 generator(生成器),把一个 consumer 传入 produce 后,调用顺序如下:

  1. 首先调用 c.next() 启动生成器
  2. 然后,一旦生产者生产了消息,通过 c.send(n) 切换到 consumer 执行
  3. consumer 通过 yield 拿到消息,处理,有同感 yield 把处理结果传回
  4. produce 拿到 consumer 处理结果,继续生产下一条消息
  5. produce 决定不生产了,通过 c.close() 关闭 consumer,整个过程结束

整个过程无锁,由一个线程执行,produce 和 consumer 协作完成任务,所以称之为 “协程”,而非线程的抢占式多任务。


C/C++ 协程

与 Python 不同,C/C++ 语言本身不能天然支持协程。现有的 C++ 协程库基于两种方案:利用汇编代码控制协程上下文的切换,以及利用操作系统提供的 API 来实现协程的上下文切换。

典型的例子:

  • libco,Boost.context:基于汇编代码的上下文切换
  • phxrpc:基于 ucontext/Boost.context 的上下文切换
  • libmill:基于 setjump/longjump 的协程切换

一般而言,基于汇编的上下文切换要比采用系统调用的切换更加高效,这也是为什么 phxrpc 在使用 Boost.context 时要比使用 ucontext 性能更好的原因。


libco 协程的创建和切换

在介绍 coroutine 的创建之前,我们先来熟悉一下 libco 中用来表示一个 coroutine 的数据结构,即定义在 co_routine_inner.h 中的 stCoRoutine_t:

struct stCoRoutine_t{
	stCoRoutineEnv_t *env;  // 协程运行环境
	pfn_co_routine_t pfn;   // 协程执行的逻辑函数
	void *arg;				// 函数参数
	coctx_t ctx;			// 保存协程的上下文环境
	...
	char cEnableSysHook; 	// 是否运行系统 hook,即非侵入式逻辑
	char cIsShareStack;		// 是否共享栈模式
	void *pvEnv; 			
	stStackMem_t* stack_mem;// 协程运行时的栈空间
	char* stack_sp;			// 用来保存协程运行时的栈空间
	unsigned int save_size;
	char* save_buffer;
};

我们暂时只需要了解表示协程的最简单的几个参数,例如协程运行环境,协程的上下文环境,协程运行的函数以及运行时栈空间。后面的 stack_sp,save_size 和 save_buffer 与 libco 共享栈模式相关,有关共享栈的内容后续介绍


协程的创建和运行

由于多个协程运行于一个线程内部,因此当创建线程中的第一个协程时,需要初始化该协程所在的环境 stCoRoutineEnv_t ,这个环境是线程用来管理协程的。通过该环境,线程可以得知当前一共创建了多少个协程,当前正在运行哪一个协程,当前应该如何调度协程:

struct stCoRoutineEnv_t{
	stCoRoutine_t *pCallStack[128];		// 记录当前创建的协程
	int iCallStackSize;					// 记录当前一共创建了多少个协程
	stCoEpoll_t *pEpoll;				// 该线程的协程调度器
	
	//在使用共享栈模式拷贝栈内存时记录相应的 coroutine
	stCoRoutine_t* pending_co;
	stCoRoutine_t* occupy_co;
};

上述代码中 libco 允许一个线程内最多创建 128 个协程,其中 pCallStack[iCallStackSize - 1] 也就是栈顶的协程表示当前正在运行的协程。当调用函数 co_create 时,首先检查当前线程中的 coroutine env 结构是否创建。这里 libco 对于每个线程内的 stCoroutineEnv_t 使用 thread-local 的方式(例如 gcc 内置的 __thread,phxrpc 采用这种方式)来管理

// 类 __thread 的协程私有变量
static __thread stCoRoutineEnv_t* gCoEnvPerThread = NULL; 
// 初始化当前线程的协程运行环境
void co_init_curr_thread_env()
{
	gCoEnvPerThread = (stCoRoutineEnv_t*)calloc( 1, sizeof(stCoRoutineEnv_t) );
	stCoRoutineEnv_t *env = gCoEnvPerThread;

	env->iCallStackSize = 0;
	struct stCoRoutine_t *self = co_create_env( env, NULL, NULL,NULL );
	self->cIsMain = 1;

	env->pending_co = NULL;
	env->occupy_co = NULL;

	coctx_init( &self->ctx );

	env->pCallStack[ env->iCallStackSize++ ] = self;

	stCoEpoll_t *ev = AllocEpoll();
	SetEpoll( env,ev );
}
// 获取当前线程的协程运行环境
stCoRoutineEnv_t *co_get_curr_thread_env()
{
	return gCoEnvPerThread;
}

初始化 stCoRoutineEnv_t 时主要完成以下几步:

  1. 为 stCoRoutineEnv_t 申请空间并且初始化,设置协程调度器 pEpoll
  2. 创建一个空的 coroutine,初始化其上下文环境,将其加入到该线程的协程运行环境中进行管理,并且设置其为 main coroutine。这个 main coroutine 用来运行该线程的主逻辑。

当初始化完成协程环境之后,调用函数 co_create_env 来创建具体的协程,该函数初始化一个协程结构 stCoRoutine_t,设置该结构中的各项字段,例如运行的函数 pfn,运行时的栈地址等等。需要说明的是,如果使用了非共享栈模式,则需要为该协程单独申请栈空间,否则从共享栈中申请空间。

co_create_env 函数如下:

struct stCoRoutine_t *co_create_env( stCoRoutineEnv_t * env, const stCoRoutineAttr_t* attr,
		pfn_co_routine_t pfn,void *arg )
{

	stCoRoutineAttr_t at;		// 协程属性(栈大小,共享栈)
	if( attr )
	{
		memcpy( &at,attr,sizeof(at) );
	}
	if( at.stack_size <= 0 )
	{
		at.stack_size = 128 * 1024;
	}
	else if( at.stack_size > 1024 * 1024 * 8 )
	{
		at.stack_size = 1024 * 1024 * 8;
	}

	if( at.stack_size & 0xFFF ) 
	{
		at.stack_size &= ~0xFFF;
		at.stack_size += 0x1000;
	}

	stCoRoutine_t *lp = (stCoRoutine_t*)malloc( sizeof(stCoRoutine_t) );
	
	memset( lp,0,(long)(sizeof(stCoRoutine_t))); 


	lp->env = env;
	lp->pfn = pfn;
	lp->arg = arg;

	stStackMem_t* stack_mem = NULL;
	if( at.share_stack )
	{
		stack_mem = co_get_stackmem( at.share_stack);
		at.stack_size = at.share_stack->stack_size;
	}
	else
	{
		stack_mem = co_alloc_stackmem(at.stack_size);
	}
	lp->stack_mem = stack_mem;

	lp->ctx.ss_sp = stack_mem->stack_buffer;
	lp->ctx.ss_size = at.stack_size;

	lp->cStart = 0;
	lp->cEnd = 0;
	lp->cIsMain = 0;
	lp->cEnableSysHook = 0;
	lp->cIsShareStack = at.share_stack != NULL;

	lp->save_size = 0;
	lp->save_buffer = NULL;

	return lp;
}

栈空间表示如下:

struct stStackMem_t
{
    stCoRoutine_t* occupy_co;  // 使用该栈的协程
    int stack_size;            // 栈大小
    char* stack_bp;            // 栈底指针,栈从高地址向低地址增长
    char* stack_buffer;        // 栈顶指针
};

使用 co_create 创建完一个协程之后,将调用 co_resume 来将该协程激活运行:

void co_resume( stCoRoutine_t *co )
{
	stCoRoutineEnv_t *env = co->env;	// 设置协程的运行环境
	stCoRoutine_t *lpCurrRoutine = env->pCallStack[ env->iCallStackSize - 1 ];	// 获取当前正在运行的协程
	if( !co->cStart ) // 如果co协程没有启动过
	{
		coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 ); // 设置co的上下文环境
		co->cStart = 1;
	}
	env->pCallStack[ env->iCallStackSize++ ] = co; // 将协程co设置为当前正在运行的协程
	co_swap( lpCurrRoutine, co );	// 切换到协程co运行
}

函数 co_swap 的作用类似于 Unix 提供的函数 swapcontext:将当前正在运行的 coroutine 的上下文以及状态保存到结构 lpCurrRoutine 中,并将 co 设置为要运行的协程,从而实现协程的切换。

下面是 co_swap 函数的代码:

co_swap 具体要完成以下三项工作:

  1. 记录当前协程 curr 的运行栈的栈顶指针,通过 char c; curr_stack_sp = &c;实现,当下次切换回 curr 时,可以从该栈顶指针指向的位置继续,执行完 curr 后可以顺利释放该栈。
  2. 处理共享栈相关的操作,
  3. 并且调用函数 coctx_swap 来完成上下文环境的切换。注意执行完 coctx_swap 之后,执行流程将跳到新的 coroutine 也就是 pending_co 中运行,后续的代码要等下次切换回 curr 时才会执行。
  4. 当下次切换回 curr 时,处理共享栈相关的操作

对于 co_resume 函数,协程主动让出 CPU 则调用 co_yield 函数。yield 函数调用了 co_yield_env,将当前协程与当前线程中记录的其它协程进行切换

void co_yield_env( stCoRountineEnv_t *env )
{
	stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2];
	stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1];
	env->iCallStackSize--;
	co_swap( curr, last);
}

前面我们已经提到过,pCallStack 栈顶所指的即为当前正在运行的协程所对应的结构,因此该函数将 curr 取出来,并将当前正在运行的协程保存在该结构上,并切换到协程 last 上执行。

接下来,我们以 32-bit 的系统为例来分析 libco 是如何实现协程运行环境的切换的。


协程上下文的创建和切换

libco 使用结构 struct coctx_t 来表示一个协程的上下文环境:

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

可以看到,在 i386 的架构下,需要保存 8 个寄存器信息,以及栈指针和栈大小,究竟这 8 个寄存器如何保存,又是如何使用,需要配合后续的 coctx_swap 来理解。

我们首先回顾一下 Unix-like 系统的 stack frame layout。
在这里插入图片描述

结合上图,我们需要知道关键几点:

  1. 函数调用栈是调用者和被调用者共同负责布置的。Caller(调用者) 将其参数从右往左反向压栈,再将调用后的返回地址压栈,然后将执行流程交给 Callee(被调用者)。
  2. 典型的编译器会将 Callee 函数汇编成 push %ebp; move %ebp, %esp; sub $esp N; 这种形式开头的汇编代码。这几句代码的主要目的是为了方便 Callee 利用 ebp 来访问调用者提供的参数及自身的局部变量。
  3. 当调用过程完成清楚了局部变量以后,会执行 pop %ebp;ret; 这样的指令会跳转到 RA(Return Address)也就是返回地址上面执行。这一点也是实现协程的关键:我们只需要将指定协程的函数指针地址保存到 RA 中,当调用完 coctx_swap 之后,会自动跳转到该协程的函数起始地址开始运行

了解了这些,我们就来看一下协程上下文环境的初始化函数(以32位为例) coctx_make。

int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
  // make room for coctx_param
  char* sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);
  sp = (char*)((unsigned long)sp & -16L);

  coctx_param_t* param = (coctx_param_t*)sp;
  void** ret_addr = (void**)(sp - sizeof(void*) * 2);
  *ret_addr = (void*)pfn;
  param->s1 = s;
  param->s2 = s1;

  memset(ctx->regs, 0, sizeof(ctx->regs));

  ctx->regs[kESP] = (char*)(sp) - sizeof(void*) * 2;
  return 0;
}

coctx_make 将函数地址 pfn 写入协程变量 regs[ kEIP ] 中,pfn 即为 CoRoutineFunc 的指针,ss_sp 为 128k 协程栈低地址,ss_size 为 128k,将 ss_sp + ss_size - sizeof(coctx_param_t) - sizeof(void*) 作为 esp 开始位置,记录在 regs[ kESP ]。因为栈从高到低增长,所以真正的栈空间从高地址 ss_sp + ss_size - sizeof(coctx_param_t) - sizeof(void*) 增长到低地址 ss_sp。这部分空间虽然是协程栈,但实际是通过 stack_mem->stack_buffer = (char*)malloc(stack_size); 申请的堆空间。CoRoutineFunc、其调用的函数、其调用的函数的函数均在该128k的堆空间里。
首先为函数 coctx_pfn_t 预留 2 个参数的栈空间并对齐到 16 字节,之后将实参设置到预留的栈空间上。最后在 ctx 结构中填入相应的值,其中 reg[ kEIP ] 记录了函数指针 pfn,regs[ kESP ] 记录了栈顶指针减去一个指针长度,这个减去的空间是为返回地址 RA 预留的。当调用 coctx_swap 时,reg[ kEIP ] 会被放到返回地址 RA 的位置,待 coctx_swap 执行结束,自然会跳转到函数 pfn 处执行。

coctx_swap(ctx1, ctx2) 在 coctx_swap.S 中实现。这里可以看到,该函数并没有使用 push %ebp; mov %ebp, %esp; sub %esp N; 开头,因此栈空间分布中不会出现 ebp 的位置。coctx_swap 函数主要分为两段,其首先将当前的上下文环境保存到 ctx1 结构中:

    leal 4(%esp), %eax     // eax = old_esp + 4                                             
    movl 4(%esp), %esp     // 将 esp 的值设为 &ctx1(即ctx1的地址)        
    leal 32(%esp), %esp    // esp = (char*)&ctx1 + 32,给 8 个寄存器留出空间            
                                              
    pushl %eax         //  ctx1->regs[EAX] = %eax 
    pushl %ebp         //  ctx1->regs[EBP] = %ebp
    pushl %esi         //  ctx1->regs[ESI] = %esi
    pushl %edi         //  ctx1->regs[EDI] = %edi
    pushl %edx         //  ctx1->regs[EDX] = %edx
    pushl %ecx         //  ctx1->regs[ECX] = %ecx
    pushl %ebx         //  ctx1->regs[EBX] = %ebx
    pushl -4(%eax)     //  ctx1->regs[EIP] = RA, 注意:%eax-4=%old_esp   

这里需要注意指令 leal 和 movl 的区别。leal 将 esp 的值设置为 esp 的值加 4,而 movl 将 esp 的值设置成 esp+4 所指向的内存上的值,也就是参数 ctx1 的地址。之后该函数将 ctx2 中记录的上下文恢复到 CPU 寄存器中,并跳转到其函数地址处运行

    movl 4(%eax), %esp //  将 esp 的值设为 &ctx2(即ctx2的地址)
    popl %eax          // %eax = ctx1->regs[EIP],也就是 &pfn
    popl %ebx          // %ebx = ctx1->regs[EBP]
    popl %ecx          // %ecx = ctx1->regs[ECX]
    popl %edx          // %edx = ctx1->regs[EDX]
    popl %edi          // %edi = ctx1->regs[EDI]
    popl %esi          // %esi = ctx1->regs[ESI]
    popl %ebp          // %ebp = ctx1->regs[EBP]
    popl %esp          // %esp = ctx1->regs[ESP],即(char*)(sp) - sizeof(void*)
    pushl %eax         // RA = %eax = &pfn,注意此时esp已经指向了新的esp
	
    xorl %eax, %eax    // reset eax
    ret
  1. 首先将 esp 设置为参数 ctx2 的地址,后续的 popl 操作均在 ctx2 的内存空间上运行
  2. 然后将 ctx2->regs[] 中的内容恢复到相应的寄存器中。还记得前面 coctx_make 中设置了 regs[ kESP ] 和 regs[ EIP ] 吗?这里刚好就对应恢复了相应的值。
  3. 但恢复完寄存器中的值, esp 已经指向了 ctx2 中新的栈顶指针,由于在 coctx_make 中预留了一个指针长度的 RA 空间,pushl %eax 正好将新的函数指针 &pfn 设置到该 RA 上。
  4. 最后执行 ret 指令,函数流程将跳到 pfn 处执行。这样,整个协程上下文的切换就完成了。

如何使用 libco

我们首先以 libco 提供的例子 example_echosvr.cpp 来介绍应用程序如何使用 libco 来编写服务端程序。在 example_echosvr.cpp 的 main 函数中,主要执行如下几步:

  1. 创建 socket,监听在本机的 1024 端口,并设置为非阻塞;
  2. 主线程使用函数 readwrite_coroutine 创建多个读写协程,调用 co_resume 启动协程运行直到其挂起。这里我们忽略掉无关的多进程 fork 的过程;
  3. 主线程继续创建 socket 接收协程 accept_co,同样调用 co_resume 启动协程直到其挂起。
  4. 主线程调用函数 co_eventloop 实现事件的监听和协程的循环切换

函数 readwrite_coroutine 在外层循环中将新创建的读写协程都加入到队列 g_readwrite 看成一个 coroutine pool。当加入到队列之后,调用函数 co_yield_ct 函数让出 CPU,此时控制权回到主线程。

主线程中的函数 co_eventloop 监听网络事件,将来自于客户端新进的连接交由协程 accept_co 处理,关于 co_eventloop 如何唤醒 accept_co 的细节将在后续介绍。accept_co 调用函数 accept_routine 接受新连接。

accept_routine 函数的流程如下:

  1. 检查队列 g_readwrite 是否有空闲的读写 coroutine,如果没有,调用函数 poll 将该协程加入到 Epoll 管理的定时器队列中,也就是 sleep(1000) 的作用;
  2. 调用 co_accept 来接收新连接,如果接受连接失败,那么调用 co_poll 将服务端的 listen_fd 加入到 Epoll 中来触发下一次连接事件;
  3. 对于成功的连接,从 g_readwrite 中取出一个读写协程来负责处理读写

再次回到函数 readwrite_coroutine 中,该函数会调用 co_poll 将新建立的连接的 fd 加入到 Epoll 监听中,并将控制流程返回到 main 协程;当有读或写事件发生时,Epoll 会唤醒对应的 coroutine,继续执行 read 函数以及 write 函数。

上面的过程大致说明了控制流程是如何在不同的协程之间切换,接下来我们介绍具体的实现细节,即如何通过 Epoll 来管理协程,以及如何对系统函数进行改造以满足 libco 的调用。


通过 Epoll 管理和唤醒协程

Epoll 监听 fd
上面我们提到了协程可以通过函数 co_poll 来将 fd 交由 Epoll 管理,待 Epoll 的相应的事件触发时,再切换回来执行 read 或 write 操作,从而实现由 Epoll 管理协程的功能。co_poll 函数原型如下:

int co_poll(stCoEpoll_t *ctx, struct pollfd fds[], nfds_t nfds, int timeout_ms);

stCoEpoll_t 是为 libco 定制的 Epoll 的相关数据结构,fds 是 pollfd 的文件句柄,nfds 为 fds 数组的长度,最后一个参数表示定时器时间,也就是在 timeout 毫秒之后触发处理这些文件句柄。这里可以看到,co_poll 能够同时将多个文件句柄同时加入到 Epoll 管理中。我们先看 stCoEpoll_t 结构:

struct stCoEpoll_t{
	int iEpollFd;			// Epoll 主Fd
	static const int _EPOLL_SIZE = 1024 * 10; // Epoll 可以监听的句柄总数

	struct stTimeout_t *pTimeout;	//时间轮定时器
	struct stTimeoutItemLink_t *pstTimeoutList;	//已经超时的事件
	struct stTimeoutItemLink_t *pstAciveList;	//活跃的事件
	co_epoll_res *result;	// Epoll 返回的事件结果
};

以 stTimeout_ 开头的数据结构与 libco 的定时器管理有关,我们在后面介绍。co_epoll_res 是对 Epoll 事件数据结构的封装,也就是每次触发 Epoll 事件时的返回结果,在 Unix 和 MaxOS 下,libco 将使用 Kqueue 代替 Epoll,因此这里也保留了 kevent 的数据结构。

struct co_epoll_res{
	int size;
	struct epoll_event *events;	// for Linux epoll
	struct kevent *eventlist;	// for Unix or MaxOS kqueue
};

co_poll 函数实际是对函数 co_poll_inner 的封装。我们将 co_epoll_inner 函数的结构分为上下两半段。在上半段中,调用 co_poll 的协程 CC 将其需要监听的句柄数组 fds 都加入到 Epoll 管理中,并通过函数 co_yield_env 让出 CPU;当 main 协程的事件循环 co_eventloop 中触发了 CC 对应的监听事件时,会恢复 CC 的执行。此时,CC 将开始执行下半段,即将上半段添加的句柄 fds 从 epoll 中移除,清理残留的数据结构。

下面的流程图简要说明了控制流的转移过程:
在这里插入图片描述
有了上面的基本概念,我么来看具体的实现细节。co_poll 首先在内部将传入的文件句柄数组 fds 转化为数据结构 stPoll_t,这一步主要是为了方便后续处理。该结构记录了 iEpollFd,nfds,fds 数组,以及该协程需要执行的函数和参数。有两点需要说明的是:

  1. 对于每一个 fd,为其申请一个 stPollItem_t 来管理对应 Epoll 事件以及记录回调函数。libco 在此做了一个小的优化,对于长度小于 2 的 fds 数组,直接在栈上定义相应的 stPollItem_t 数组,否则从堆中申请内存。这也是一种比较常见的优化,毕竟从堆中申请内存比较耗时;
  2. 函数指针 OnPollProcessEvent 封装了协程的切换过程。当传入指定的 stPollItem_t 结构时,即可唤醒对应于该结构的 coroutine,将控制权交由其执行;

co_poll 的第二步,也是最关键的一步,就是将 fd 数组全部加入到 Epoll 中进行监听。协程 CC 会将每一个 epoll_event 的 data.ptr 域设置为对应的 stPollItem_t 结构。这样当事件触发时,可以直接从对应的 ptr 中取出 stPollItem_t 结构,然后唤醒指定协程。

如果本次操作提供了 Timeout 参数,co_poll 还会将协程 CC 本次操作对应的 stPoll_t 加入到定时器队列中。这表明在 Timeout 定时触发之后,也会唤醒 协程 CC 的执行。当整个上半段都完成后,co_poll 立即调用 co_yield_env 让出 CPU,执行流程跳转到 main 协程中。

从上面的流程图可以看出,当执行流程再次跳回时,表明协程 CC 添加的读写等监听事件已经触发,即可以执行相应的读写操作了。此时 CC 首先将其在上半段中添加的监听事件从 Epoll 中删除,清理残留的数据结构,然后调用读写逻辑。


定时器实现
协程 CC 在将一组 fds 加入 Epoll 的同时,还能为其设置一个超时时间。在超时时间到期时,也会再次唤醒 CC 来执行。libco 使用 Timing-Wheel 来实现定时器。关于 Timing-Wheel 算法,其优势是 O(1) 的插入和删除时间复杂度,缺点是只有有限的长度,在某些场合下不能满足需求。
在这里插入图片描述

再回过去看 stCoEpoll_t 结构,其中 pTimeout 代表时间轮,通过函数 AllocateTimeout 初始化为一个固定大小(601000)的数组。根据 Timing-Wheel 的特性可知,libco 只支持最大 60s 的定时事件。而实际上,在添加定时器时,libco 要求定时时间不超过 40s。成员 pssTimeoutList 记录在 co_eventloop 中发生超时的事件,而 pstActiveList 记录当前活跃的事件,包括超时事件。这两个结构都将在 co_eventloop 中进行处理。

下面我们简要分析一下加入定时器的实现:

int AddTimeout( stTimeout_t *apTimeout, stTimeoutItem_t *apItem, unsigned long long allNow )
{
    if( apTimeout->ullStart == 0 )  // 初始化时间轮的基准时间
    {
        apTimeout->ullStart = allNow;
        apTimeout->llStartIdx = 0;  // 当前时间轮指针指向数组0
    }
    // 1. 当前时间不可能小于时间轮的基准时间
    // 2. 加入的定时器的超时时间不能小于当前时间
    if( allNow < apTimeout->ullStart || apItem->ullExpireTime < allNow )
    {
        return __LINE__;
    }
 
    int diff = apItem->ullExpireTime - apTimeout->ullStart;
    if( diff >= apTimeout->iItemSize )  // 添加的事件不能超过时间轮的大小
    {
        return __LINE__;
    }
    // 插入到时间轮盘的指定位置
    AddTail( apTimeout->pItems + 
        (apTimeout->llStartIdx + diff ) % apTimeout->iItemSize, apItem );
 
    return 0;
}

定时器的超时检查在函数 co_eventloop 中执行。


EPOLL 事件循环

main 协程通过调用函数 co_eventloop 来监听 Epoll 事件,并在相应的事件触发时切换到指定的协程执行。有关 co_eventloop 与 应用协程的交互过程在上一节的流程图中已经比较清楚了,下面我们主要介绍一下 co_eventloop 函数的实现。


上文中也提到过,通过 epoll_wait 返回的事件都保存在 stCoEpoll_t 结构的 co_epoll_res 中。因此 co_eventloop 首先为 co_epoll_res 申请空间,之后通过一个无限循环来监听所有 coroutine 添加的所有事件

for(;;)
{
	int ret = co_epoll_wait(ctx->iEpollFd, result, stCoEpoll_t::_EPOLL_SIZE, 1);
	...
}
  • 对于每一个触发的事件,co_eventloop 首先通过指针域 data.ptr 取出保存的 stPollItem_t 结构,并将其添加到 pstActiveList 列表中;之后从定时器轮盘中取出所有已经超时的事件,也将其全部添加到 pstActiveList 中,pstActiveList 中的所有事件都作为活跃事件处理。
  • 对于每一个活跃事件,co_eventloop 将通过调用对应的 pfnProcess 也就是上图中的 OnPollProcessEvent 函数来切换到该事件对应的 coroutine,将流程跳转到该 coroutine 处执行。
  • 最后 co_eventloop 在调用时也提供一个额外的参数来供调用者传入一个函数指针 pfn。该函数将会在每次循环完成之后执行;当该函数返回 -1时,将会终止整个事件循环。用户可以利用该函数来控制 main 协程的终止或者完成一些统计需求。
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值