一种在C语言中用汇编指令和 System V ucontext 支撑实现的协程切换
Posted 资质平庸的程序员
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一种在C语言中用汇编指令和 System V ucontext 支撑实现的协程切换相关的知识,希望对你有一定的参考价值。
1 实现内容
此文在看了 python yield
和 yield from
机制后,觉得这种轻量级编程技术在小并发场景下优雅可爱,在大并发场景下较进程或线程而言能突破资源瓶颈,实在令人忍不住而想在C语言中实现一个。
经过一些学习后,此文在 Linux 上用C语言实现了一个。目前具体包括
[1] co_yield() —— 类似 python 的 yield,用于协程切换;
[2] co_send() —— 类似 python 生成器中的 send(),用于开始或恢复协程的运行;
[3] co_yield_from() —— 类似 python 的 yield from,用于同步基于 co_yield() 切换的协程;
[4] co_loop() —— 略似于 python 的 asyncio.loop(),用于协程并发调度。
e.g.
/**
** brief: suspending current coroutine related
with ci then switch to specific coroutine
when co_yield() called.
** param: ci, bears control-information for
corresponding coroutine.
** return: zero returned if succeeded,
otherwise errno.
** sidelight: co_yield() just like the 'yield'
in python. */
extern int
co_yield(ci_s *ci);
彩蛋:在此文最后一节进行 python yield
和 yield from
原理粗探,先进入与实现相关的主题。
此文所编写的C协程切换程序层次结构体大体如下。
简单吧^_^
。
assembly | ucontext
2 基石——协程上下文及切换实现
协程的基石是协程上下文及切换
,此文用两种方式来支撑实现。
2.1 用System V ucontext支撑协程的上下文和切换
此文先不从头开始,先看看有没有描述协程上下文及切换的现成 C 库。于是找到 System V ucontext。通过阅读 ucontext 手册,用 “ucontext一族”实现一个在 C 环境下的类似于 yield
和 yield from
机制的协程切换应该不成问题。
(1) 理解 ucontext
根据手册理解下 ucontext 数据数据结构体和相关函数吧。
#include <ucontext.h>
/* 描述协程上下文的结构体类型 ucontext_t 至少包含以下成员 */
typedef struct ucontext_t
/* uc_link,由 makecontext()
创建协程运行结束后,程序
切换到 uc_link 所指协程上
下文处运行,uc_link 为 NULL
时则整个线程退出。*/
struct ucontext_t *uc_link;
/* 用于记录在当前协程中所需屏蔽的信号 */
sigset_t uc_sigmask;
/* 协程栈空间 */
stack_t uc_stack;
/* 协程上下文,主要用于存储协程运行涉及的寄存器状态 */
mcontext_t uc_mcontext;
...
ucontext_t;
/**
** 功能:将程序当前协程级上下文保存
到 ucp 指向的类型为 ucontext_t 的
结构体中。
** 返回值:执行成功返回0;执行
失败时将错误码保存在 errno
变量中并返回-1。*/
int getcontext(ucontext_t *ucp);
/**
** 功能:用 func 地址处的协程上下文修改
由 getcontext() 在 ucp 所指结构体中
创建的协程上下文。makecontext() 支持
向 func 地址(函数)传递 argc 个 int
类型参数。
** 注:在调用 makecontext() 之前,必须为
ucp->uc_stack 分配内存用作协程运行栈,
并为 ucp->uc_link 指定 ucp 对应协程运行
结束后 将切换运行协程的协程上下文。*/
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
/**
** 功能:将当前协程上下文保存在 oucp 所指
结构体中,并跳转执行 ucp 所指协程上下文处。
** 返回值:swapcontext() 执行成功时暂不返回
(后续由 oucp 成功切换回来时,该函数会返回0);
执行失败时将错误码保存在 errno 变量中随后返回-1。
errno 为 ENOMEM时 表明所设置栈内存已不足。*/
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);
(2) 协程初次运行
由于 ucontext 已经包含了对协程上下文的描述及切换,所以只需在协程初次运行时进行一些初始化,包括对 ucontext 数据结构体和协程参数的初始设置。
int
co_start_uc(ci_s *ci)
int ret;
void *arg = NULL;
cctx_s *ctx = NULL;
cctx_s *bctx = NULL;
ctx = co_cctx(ci);
ret = getcontext(ctx);
IF_EXPS_THEN_RETURN(ret, errno);
bctx = co_bcctx(ci);
ctx->uc_link = bctx;
ctx->uc_stack.ss_sp = co_stack(ci);
ctx->uc_stack.ss_size = co_ssize(ci);
arg = co_arg(ci);
/**
* there's not matter if ci->co's type
* is not func_t, as long as ci->co
* wouldn't achieve more parameters than
* makecontext() passed. */
typedef void (*func_t)(void);
makecontext(ctx, (func_t)co_cofn(ci), 4,
(uint32_t)((uintptr_t)ci),
(uint32_t)((uintptr_t)ci >> 32),
(uint32_t)((uintptr_t)arg),
(uint32_t)((uintptr_t)arg >> 32) );
ret = swapcontext(bctx, ctx);
IF_EXPS_THEN_RETURN(ret, errno);
return ret;
在初始化 ucontext 数据结构体和协程参数即调用 swapcontext 将当前的协程上下文保存在 ci 的成员中后,调用 swapcontext 跳转执行协程函数,待协程函数调用 co_yield() 时协程函数将挂起而返回到调用 swapcontext 处——主线程继续运行。
ci_s 结构体 和 co_yield() 后续有介绍。
2.2 用汇编指令支撑协程的上下文和切换
(1) 了解协程上下文涉及内容
在C语言中,一个协程可用一个函数来表示,那么协程上下文即一个函数执行所涉及的上下文为——与栈、指令指针关联的寄存器,以及函数调用约定涉及的寄存器。
此文不知晓更标准的称呼,就以“gcc convention on __i386”和“gcc convention on __amd64”来分别代表 Linux 上32位C程序和64位C程序中函数的调用约定吧。
[1] gcc convention on __i386
ebp, ebx, esi, edi - 由被调用函数保证其值不变;
默认用栈传递参数,如
void fn(int a, int b);
在父函数中调用 fn(1, 2); 时传参方式为
push 2 --> b
push 1 --> a
即从右往左将实参依次入栈。
可以指定前3个参数以寄存器的方式传递,以
extern void __attribute__ ((__noinline__, __regparm__(2)))
fn(int a, int b)
声明fn时,形参a和b的值将分别由eax和edx两个寄存器传递。
[2] gcc convention on __amd64
rbp, rbx, r12, r13, r14, r15 - 由被调用函数保证其值不变;
前6个参数默认用寄存器传递,后续参数用栈传递,如
void fn(uint32_t a, uint32_t b,
uint32_t c, uint32_t e,
uint32_t f, uint32_t g,
uint32_t h);
在父函数中调用fn(1, 2, 3, 4, 5, 6, 7)时
edi=1 --> a, esi=2 --> b, edx=3 --> c,
ecx=4 --> e, r8d=5 --> f, r9d=6 --> g,
push 7 --> h
(2) 用汇编指令支撑协程切换和协程传参
[1] 协程切换
由于C编译器会往C函数中添加维护栈帧的指令,所以协程切换函数必须由汇编指令来完成。若不想涉及汇编程序的编译和链接,可以在C程序中使用 asm 关键字告知编译器将汇编指令嵌在C程序中。
来吧,尝试按照协程上下文涉及信息编写该协程切换函数。
__asm__(
"\\t.globl co_switch_asm\\n"
"co_switch_asm:\\n"
/* according to the call
convention:
ebp, ebx, esi, edi need
called-function to backup
on i386;
rbp, rbx, r12, r13, r14, r15
need called-function to backup
on amd64. */
#if __i386
"pushl %ebp\\n\\t"
"pushl %ebx\\n\\t"
"pushl %esi\\n\\t"
"pushl %edi\\n\\t"
/* backup current stack-top
to first(left-most) argument,
then assign co-stack-top to esp.
see: declaration of
co_switch_asm
in ln_context.h */
"movl %esp, (%eax)\\n\\t"
"movl (%edx), %esp\\n\\t"
"popl %edi\\n\\t"
"popl %esi\\n\\t"
"popl %ebx\\n\\t"
"popl %ebp\\n\\t"
/* switching.
the target-address in stack
by call or co_start_asm. */
"popl %ecx\\n\\t"
"jmpl *%ecx\\n\\t"
#elif __amd64
"pushq %rbp\\n\\t"
"pushq %rbx\\n\\t"
"pushq %r12\\n\\t"
"pushq %r13\\n\\t"
"pushq %r14\\n\\t"
"pushq %r15\\n\\t"
/* I want to save _CORET here
by instructions just like
'pushq _CORET'(etc.)to accept
coroutine return. unfortunately
fail.
so coroutines which use CCTX_ASM
to support coroutine-switching
must use co_end() to terminate
itself before return.
the same situation on __i386,
please do a favor to accept the
'return' statement of coroutine
if you owns the same faith. */
/* same meaning as i386.
see _co_arg_medium for
the argument-passing
convention. */
"movq %rsp, (%rdi)\\n\\t"
"movq (%rsi), %rsp\\n\\t"
"popq %r15\\n\\t"
"popq %r14\\n\\t"
"popq %r13\\n\\t"
"popq %r12\\n\\t"
"popq %rbx\\n\\t"
"popq %rbp\\n\\t"
/* switching.
same meaning as i386. */
"popq %rcx\\n\\t"
"jmpq *%rcx\\n"
"_CORET:"
#else
#error "coroutine-context unsupported"
" on current architecture"
#endif
);
用汇编指令实现的 co_switch_asm 可以用于协程切换,但无法完成协程参数的传递以及接受协程中 return 语句。此文解决了第一个问题——第二个问题在此文结束后也未得到解决(快来指点啊^_^
)。
[2] 协程传参
由于不是拷贝粘贴式编程,初次解决该问题还是有一定难度。不过这次皇天不负有心人,经此文思前想后得到一个解决办法——用中间函数来传递协程参数,同 co_switch_asm,这个中间函数也只能用汇编指令实现。
void
_co_arg_medium(void);
/**
* because of the instructions on
* stack-frame would be automatically
* added by C-compiler in C-functions,
* those routines can't be
* inline-assembly in C-function.
*
* @reality: the routines on __i386
* not tested by me. */
__asm__ (
"\\t.globl _co_arg_medium\\n"
"_co_arg_medium:\\n"
/* the arguments co_fn, arg, ci
prepared by co_start_asm */
#if __i386
"popl %eax\\n\\t" // get co_fn
"popl %ecx\\n\\t" // get arg
"popl %edx\\n\\t" // get ci
/* call convention on arguments
of gcc on __i386 e.g.
void fn(int a, int b);
fn(1, 2);
passing argument by stack
in caller:
push 2 --> b
push 1 --> a */
"movl $0, %esi\\n\\t"
"pushl %esi\\n\\t"
"pushl %ecx\\n\\t"
"pushl %esi\\n\\t"
"pushl %edx\\n\\t"
#elif __amd64
"popq %rax\\n\\t" // get co_fn
"popq %rsi\\n\\t" // get arg
"popq %rdi\\n\\t" // get ci
/* call convention on arguments
of gcc on __amd64 e.g.
void fn(uint32_t a, uint32_t b,
uint32_t c, uint32_t e,
uint32_t f, uint32_t g,
uint32_t h);
passing arguments by registers
and stack in caller:
edi --> a, esi --> b, edx --> c,
ecx --> e, r8d --> f, r9d --> g,
push real h --> formal h */
"movl %esi, %edx\\n\\t"
"movq %rsi, %rcx\\n\\t"
"shrq $32, %rcx\\n\\t"
"movq %rdi, %rsi\\n\\t"
"shrq $32, %rsi\\n\\t"
"jmpq *%rax\\n\\t"
#endif
);
当调用协程初次运行时,用 _co_arg_medium 函数向协程传递参数,此处参数传递兼容了 ucontext 的传参方式(4个32位整型参数)。
_co_arg_medium 是通过栈向协程传递参数的,所以在协程初次运行时将所需参数安置在栈中就可以了。只要理解当前编译器的函数调用约定,实现起来会挺容易的。
typedef struct asm_cctx_s
void **sp;
cctx_s;
int
co_start_asm(ci_s *ci)
cctx_s *ctx = NULL;
int ss = co_ssize(ci);
char *stack = co_stack(ci);
IF_EXPS_THEN_RETURN(!ci || !stack || !ss,
CODE_BADPARAM);
ctx = co_cctx(ci);
ctx->sp = (void **)(stack + ss);
/* ctx->sp points to (void *),
so arithmetic unit of
ctx->sp is sizeof(void *).
initial co-stack as follow:
------+---------+------+----+----+----+
... |co_medium| co_fn| arg| ci |NULL|
------+---------+------+----+----+----+
^ ^ ^
| | |
stack sp stack+ss */
*--ctx->sp = NULL;
*--ctx->sp = ci;
*--ctx->sp = co_arg(ci);
*--ctx->sp = co_cofn(ci);
*--ctx->sp = _co_arg_medium;
/* Reserved for subroutines to
backup registers: ebp, ebx,
esi, edi on i386; rbp, rbx,
r12, r13, r14, r15 on amd64.*/
ctx->sp -= CS_RESERVE_NR;
(void)co_switch_asm(co_bcctx(ci), ctx);
return CODE_NONE;
2.3 统一 asm 和 ucontext 对外接口
在用汇编指令和 ucontext 支撑协程切换时,此文对接口参数故意做兼容的目标是为了能对外提供统一的接口——通过预定义宏和一些少许包装就可以实现这个目标。
#ifndef _LN_CONTEXT_H_
#define _LN_CONTEXT_H_
#if defined(CCTX_ASM)
typedef struct asm_cctx_s
void **sp;
cctx_s;
#if __i386
#define CS_RESERVE_NR (4)
/* curr and next passed value by eax adn edx respectively */
extern void __attribute__ ((__noinline__, __regparm__(2)))
co_switch_asm(cctx_s *curr, cctx_s *next);
#elif __amd64
#define CS_RESERVE_NR (6)
extern void
co_switch_asm(cctx_s *curr, cctx_s *next);
#endif
#include "ln_co.h"
extern int
co_start_asm(ci_s *ci);
#define co_switch_to(curr, next) (co_switch_asm(curr, next); 0;)
#define co_start(ci) co_start_asm(ci);
#elif defined(CCTX_UC)
#include <ucontext.h>
typedef ucontext_t cctx_s;
#define co_switch_to(curr, next) swapcontext(curr, next)
#include "ln_co.h"
extern int
co_start_uc(ci_s *ci);
#define co_start(ci) co_start_uc(ci)
#endif
#endif
love nature logic
3 co_send() 和 co_yield() 的实现
支撑协程上下文和切换的基石已经编写好了,现在开始实现具体的协程切换机制吧。先实现最基本的co_send() 和 co_yield()。
3.1 协程切换数据结构体
要实现协程切换的目标机制,需要什么样的结构体呢?这是一个演变的过程,此文几经调整才得到了以下数据结构体。
/**
* used to record coroutine informations. */
typedef struct coroutine_info_s
void *co; /* coroutine subroutine */
void *arg; /* coroutine arguments */
char *id; /* coroutine id/name */
int state; /* coroutine states */
/* coroutine stack,
current coroutine context. */
char *stack;
cctx_s cctx;
/* the memory bears
coroutine's return-value */
crv_s rv;
/* switch to the coroutine
corresponded by "back" when
current coroutine switching
or terminated. */
ci_s *back;
/* the cc_s current ci_s belongs to */
cc_s *cc;
ci_s;
/**
* coroutine control unit structure */
typedef struct coroutine_control_s
ci_s *ci; /* point to the (ci_s) array */
/* the unit/total, unused ci_s numbers. */
int nr, unused;
/* coroutine stack size(byte) in
current coroutine control unit.*/
int ss;
/* magic box for logic control */
char box;
/* next coroutine control unit */
cc_s *next;
cc_s;
此文将这个数据结构体定义在.c源文件中保护起来——不让其他文件访问其成员。
3.2 co_send()
co_send() 函数根据协程当前状态执行协程,分以下几种情况。
[1] 若当前协程为诞生(BORN)状态,则调用 _co_start() 调用协程运行,_co_start() 的本质是第2节实现的“协程初次调用函数”,该函数主要为协程传参并跳转协程处运行。协程调用 co_yield() 后将返回到 co_send() 调用 _co_start() 处继续运行。
[2] 若当前协程为挂起(SUSPENDING)状态,则调用 _co_switch() 恢复协程运行,_co_switch() 的本质是第2节实现的协程切换函数。协程调用 co_yield() 后将返回到 co_send() 调用 _co_switch() 处继续运行。
[3] 若当前协程为可运行(RUNNABLE)状态,说明协程未调用 co_yield() 属于自然返回(return),此时调用 _co_end() 结束协程运行。_co_end() 主要负责置标识协程已完成运行的状态。
void *
co_send(ci_s *ci)
int state;
void *out = NULL;
int ret = CODE_NONE;
IF_EXPS_THEN_RETURN(!ci, NULL);
/* I just heard that CPU likes to predict backward jump.
yield from mechanism should be more commonly used in
practical project, i guess. */
IF_EXPS_THEN_GOTO_LABEL((BSTATE(ci) & BACKYF), _end);
state = ci->state;
char *id = ci->id ? ci->id : "unnamed_co";
IF_EXPS_THEN_TIPS_AND_RETURN(
(BORN > state) || (SUSPENDING < state),
NULL, "%s not running now\\n", id );
if (BORN == state)
ret = _co_start(ci);
else if (SUSPENDING == state)
ret = _co_switch(ci);
if (RUNNABLE == ci->state)
_co_end(ci);
out = (PREGNANT != ci->state) ? &ci->rv : out;
IF_EXPS_THEN_TIPS(ret, "co switch error: %d\\n", ret);
_end:
return out;
3.3 co_yield()
co_yield() 主要用于协程切换,即从一个协程切换到另一个协程,并置协程管理的相关状态标识该协程已挂起。
int
co_yield(ci_s *ci)
int ret;
IF_EXPS_THEN_RETURN(!ci, CODE_BADPARAM);
ci->state = SUSPENDING;
ret = co_switch_to(&ci->cctx, &BCCTX(ci));
IF_EXPS_THEN_RETURN(ret, errno);
return ret;
另外,由于汇编指令描述的协程切换还不支持协程的 return 语句,所以此文用 co_end() 函数来明确结束协程已完成运行,此函数需在协程 return 语句之前调用(若有)。
co_end() 所做的事情无非是置位协程管理的状态为可运行状态并随之完成协程切换,以契合协程运行结束的条件。
void
co_end(ci_s *ci)
IF_EXPS_THEN_RETURN(!ci, VOIDV);
ci->state |= RUNNABLE;
(void)co_switch_to(&ci->cctx, &BCCTX(ci));
也可以修改汇编版的协程切换函数以支持协程的 return 语句,但此文还没有学会支持该机制的语法(快来指点^_^
)。
4 co_yield_from() 的实现
如何实现 co_yield_from() 去同步基于 co_yield() 的协程呢?这个机制也是花了此文不少时间去思考,不过随着思考的积累而想明白后,对应的编码就不难啦—— co_yield_from() 同步协程返回后直接返回到 co_yield_from() 的父协程中即可。
void *
co_yield_from(cc_s *cc, ci_s *self,
char *id, void *co, void *arg)
int ret;
ci_s *ci = NULL;
void *t, *rv = NULL;
cc->box |= YFCO;
ci = co_co(cc, id, co, arg);
cc->box &= ~YFCO;
IF_EXPS_THEN_RETURN(!ci, NULL);
#define BSYF(ci) (ci->state && (BSTATE(ci) &= ~BACKYF))
while (BSYF(ci) && (t = co_send(ci)))
if (BSTATE(ci)) BSTATE(ci) |= BACKYF;
rv = t; self->rv = ci->rv;
self->state = SUSPENDING;
ret = co_switch_to(&self->cctx, &BCCTX(self));
IF_EXPS_THEN_RETURN(ret, NULL);
#undef BSYF
return rv;
此处 co_yield_from() 除了同步了基于 co_yield() 的协程外,还支持了边创建协程边运行的机制。从代码上看,该机制的支持仅有跟 YFCO,BACKYF 宏关联的几句代码,但他确实花了此文不少时间。
在真正实现该功能之前,此文差不多已经尝试了10多种皆未成功的逻辑控制方法呢。
#ifdef LOOP_AWHILE
/* what a milestone
for running coroutine when creating new one!
I have had try other dozens before this logic control. */
_loop_awhile(cc, ci);
giveup_sov(NAP);
#endif
5 协程并发调度的实现
此文按照以“单元”为单位管理多个协程,cc 管理一个单元,各单元上有 unit 个管理协程运行的数据结构体。各单元以链表的形式发生逻辑关联。
| <--- ci unit ---> | | <--- ci unit ---> |
+------+-----+------+ +------+-----+------+
| ci_s | ... | ci_s | | ci_s | ... | ci_s | ...
+------+-----+------+ +------+-----+------+
^ ^
+-|--+-----+------+ +-|--+-----+------+
| ci | ... | next |--->| ci | ... | next | ...
+----+-----+------+ +----+-----+------+
cc cc
协程并发运行除了边创造协程边运行的策略外,在调度环节对各个调度单元采取首尾间歇调度的方式调度协程运行,以提高时间效率。
| <--- ci unit ---> |
+------+------+-----+------+------+
| ci_s | ci_s | ... | ci_s | ci_s |
+------+------+-----+------+------+
^ ^ ^ ^
| | | |
1 3 4 2
-------------> <-------------------
对应的代码如下
int
co_loop(cc_s *cc)
bool has_co;
cc_s *p, *_cc = NULL;
IF_EXPS_THEN_RETURN(!cc, CODE_BADPARAM);
_loop:
has_co = false;
for (_cc = cc; _cc; _cc = _cc->next)
if (CONR(_cc))
has_co = true;
_cc_cos_scheduler(_cc);
else if (_cc != cc)
p->next = _cc->next;
_put_cc(_cc);
_cc = p;
p = _cc;
IF_EXPS_THEN_GOTO_LABEL(has_co && giveup_sov(NAP), _loop);
return CODE_NONE;
static void
_cc_cos_scheduler(cc_s *cc)
register int conr;
register ci_s *s, *e;
register cc_s *_cc = cc;
s = _cc->ci;
e = s + _cc->nr - 1;
while ((conr = CONR(_cc)) && (s <= e))
_running_ci(s); _running_ci(e);
++s; --e;
return ;
static void inline
_running_ci(ci_s *ci)
if ((BORN <= ci->state) &&
(SUSPENDING >= ci->state))
co_send(ci);
return ;
协程并发调度运行的本质就是开始实现的 co_send()。
7 一些提升
对于协程并发量较大时,程序中包含了一些可选的、有利于并发现象或内存资源节省的机制。
[1] 边创建边运行
边创建边运行协程除了可提升并发现象外,对于切换次数有限的协程来说,边创建边运行机制能节约内存——当切换次数有限的协程运行完毕后,后续协程可复用其内存资源。该机制特别适合切换次数较少的协程,可造就并发量无上限的可能(如 experiences 下的 _co_fn 的并发量就是无上限)。
[2] 内存预分配
在内存资源丰富的计算机上,可为所有协程预分配内存资源以免去内存申请和释放的开销。在内存资源较少的计算机上,可以内存资源复用的形式调度协程运行。
[3] 协程运行栈
经过一些测试,协程内部所需运行栈在11KiB左右。基于这个数值,用户协程可根据其协程函数所需栈空间(如查看协程对应的汇编指令)进行扩展。即同时运行 1000 个协程约需 11MB内存资源。
8 python yield,yield from 原理粗探
此文开篇提到,启发/激起此文完成此篇C程序的是 python 中的 yield, yield from 等机
以上是关于一种在C语言中用汇编指令和 System V ucontext 支撑实现的协程切换的主要内容,如果未能解决你的问题,请参考以下文章