libco 协程库详解

文章目录

    • 协程是什么?
    • 协程用在哪里?
    • 协程实现逻辑
    • 协程例子
    • C/C++ 协程
    • libco 协程的创建和切换
    • 协程的创建和运行
    • 协程上下文的创建和切换
    • 如何使用 libco
    • 通过 Epoll 管理和唤醒协程
    • EPOLL 事件循环

协程是什么?

轻量级的调度运行单位。

  • 协程不是进程,也不是线程,它就是一个函数,一个特殊的函数 —— 可以在某个地方挂起,并且可以重新在挂起处继续运行。所以说,协程与进程、线程相比,不是一个维度的概念。
  • 一个进程可以包含多个线程,一个线程也可以包含多个协程,也就是说,一个线程内可以有多个那样的特殊函数在运行。但是有一点,必须明确,一个线程内的多个协程的运行是串行的。如果有多核 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个也得等着。
    libco 协程库详解_第1张图片
    而协程能比较好处理这个问题,当一个协程(特殊子进程) 阻塞时,它可以切换到其它没有阻塞的协程上去继续运行,这样就能得到比较高的效率,如下图所示:
    libco 协程库详解_第2张图片
    上面举得例子是 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。
libco 协程库详解_第3张图片

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

  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 中移除,清理残留的数据结构。

下面的流程图简要说明了控制流的转移过程:
libco 协程库详解_第4张图片
有了上面的基本概念,我么来看具体的实现细节。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) 的插入和删除时间复杂度,缺点是只有有限的长度,在某些场合下不能满足需求。
libco 协程库详解_第5张图片

再回过去看 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 协程的终止或者完成一些统计需求。

你可能感兴趣的:(C/C++,协程)