并发编程漫谈之 C++协程的各种实现(六)

前言:并发编程在当前的软硬件系统架构下,是一个程序员必备的知识技能。本文希望通过整理网上资料、结合自己的经验,提供一个系列分享,将基本的并发概念解释清楚。并希望在此基础上有所扩展,将各种语言的现状也所有对比。
一、并发编程漫谈之 基本概念
二、并发编程漫谈之 python多线程和多进程
三、并发编程漫谈之 协程详解–以python协程入手
四、并发编程漫谈之 C++中的各种锁
五、并发编程漫谈之 C++多进程和多线
六、并发编程漫谈之 C++协程的各种实现

文章目录

  • 一、一种最简单的实现
    • 1.1 第一个版本
    • 1.2 第二个版本
    • 1.3 第三个版本
    • 1.4 第四个版本
    • 1.5 第五个版本
    • 1.6 思考
  • 二、Protothreads的实现
    • 2.1 Protothreads的上下文
    • 2.2 Protothreads的原语和组件
    • 2.3 例子
    • 2.4 思考
  • 三、用setjmp/long_jmp实现
    • 3.1 原理简介
    • 3.2 一个简单协程实现
    • 3.3 一个复杂协程实现 Libmill
  • 四、用ucontext实现
    • 4.1 ucontext基本组件
    • 4.2 使用ucontext实现自己的线程库
    • 4.3 绝对的重量级产品 - libgo
    • 4.4 小结
  • 五、汇编实现
  • 六、C++20 中的考虑
  • 总结
  • 参考:

一、一种最简单的实现

仍然从生成器说起。在协程的演化一文中,一切是从下面的生成器开始的:

def lazy_range(max_number):
	index = 0
	while index < max_number:
		yield index
		index += 1

for i in lazy_range(10):
    # do_something(i)
    print(i)

output:
0
1
2
3
4
5
6
7
8
9

要在C中实现上述功能,可以怎么做?

1.1 第一个版本

python 的 yield 语义功能类似于一种迭代生成器,函数会保留上次的调用状态,并在下次调用时会从上个返回点继续执行。用 C 语言来写就像这样:

int function(void) {
  int i;
  for (i = 0; i < 10; i++)
    return i;   /* won't work, but wouldn't it be nice */
}

但是显然,当我们连续多次调用该函数的时候,并不会如我们所期望的得到 0 ~ 9 的数字。那该如何做?

注意到,协程其实就是 “每次返回的时候,都保存了以前的状态” ,这在C 语言中,是不是也可以很简单的实现?

int function(void) {
  static int i = 0;
  for (; i < 10; )
    return i++;   /* won't work, but wouldn't it be nice */
}

for(int i = 0; i < 10; i++)
	cout << function() << endl;

output:
0
1
2
3
4
5
6
7
8
9

完美!

再调用一次试试:

for(int i = 0; i < 10; i++)
	cout << function() << endl;

output:
10
10
10
10
10
10
10
10
10
10

OOPS!

1.2 第二个版本

让我们暂时忘记可重入的问题,先看看第一个版本的实现。

第一个版本,除了能实现上面例子中的 range 功能,其他的功能似乎很难加进去,所以并不是一种通用的实现方式。

还是返回上面说过的 “每次返回的时候,都保存了以前的状态” 这句话,能否把 状态 保存,而不仅仅是业务逻辑保存呢?

可以利用 goto 语句,同时在函数中加入一个状态变量,就可以这样实现:

int function(void) {
  static int i, state = 0;
  switch (state) {
    case 0: goto LABEL0;
    case 1: goto LABEL1;
  }
  LABEL0: /* start of function */
  for (i = 0; i < 10; i++) {
    state = 1; /* so we will come back to LABEL1 */
    return i;
    LABEL1:; /* resume control straight after the return */
  }
}

for(int i = 0; i < 10; i++)
	cout << function() << endl;

output:
0
1
2
3
4
5
6
7
8
9

这个方法是可行的。我们在所有需要 yield 的位置都加上标签:起始位置加一个,还有所有 return 语句之后都加一个。每个标签用数字编号,我们在状态变量中保存这个编号,这样就能在我们下次调用时告诉我们应该跳到哪个标签上。每次返回前,更新状态变量,指向到正确的标签;不论调用多少次,针对状态变量的 switch 语句都能找到我们要跳转到的位置。

1.3 第三个版本

但上面的实现还是难看得很。最糟糕的部分是所有的标签都需要手工维护,还必须保证函数中的标签和开头 switch 语句中的一致。每次新增一个 return 语句,就必须想一个新的标签名并将其加到 switch 语句中;每次删除 return 语句时,同样也必须删除对应的标签。这使得维护代码的工作量增加了一倍。

仔细想想,其实我们可以不用 switch 语句来决定要跳转到哪里去执行,而是直接利用 switch 语句本身来实现跳转:

int function(void) {
  static int i, state = 0;
  switch (state) {
    case 0: /* start of function */
    for (i = 0; i < 10; i++) {
      state = 1; /* so we will come back to "case 1" */
      return i;
      case 1:; /* resume control straight after the return */
    }
  }
}

1.4 第四个版本

上面的实现简化很多,但是状态的设置还是很麻烦,而且不方便修改。比如加了状态,可能很多状态取值都要跟着修改。

找一个变量,能自动跟着代码而变化,就可以解决上面的问题。

int function(void) {
  static int i, state = 0;
  switch (state) {
    case 0: /* start of function */
    for (i = 0; i < 10; i++) {
      state = __LINE__ + 2; /* so we will come back to "case __LINE__" */
      return i;
      case __LINE__:; /* resume control straight after the return */
    }
  }
}

1.5 第五个版本

第四个版本已经实现了大部分的功能,但是 不好看!!! 。这点很重要!

把上述代码,按照我们预期的语义抽象一下,可以得到下面的版本:

#define Begin() static int state=0; switch(state) { case 0:
#define Yield(x) do { state=__LINE__; return x; case __LINE__:; } while (0)
#define End() }
int function(void) {
  static int i;
  Begin();
  for (i = 0; i < 10; i++)
    Yield(i);
  End();
}

上面的代码,利用了 switch-case 的分支跳转特性,以及预编译的 LINE 宏,实现了一种隐式状态机,最终实现了“yield 语义”。

1.6 思考

目前的版本,是语义上最接近我们目标的版本。

一个值得思考的问题是:可能很多编程规范上,都不会允许出现上述代码。甚至一般的编译器都会有告警。

对此问题的一种观点是:

任何编程规范,坚持牺牲算法清晰度来换取语法清晰度的,都应该重写。---- Simon Tatham

这块见仁见智吧。

现在可以考虑一下我们一直在回避的一个问题了:如何可重入?

在前文协程的定义中,提出了控制块(上下文)的概念,即:协程的状态应该在控制块中保存。

至于“控制块”是什么,如何实现,那就是另外一回事了。

Simon Tatham 给出了一种实现 coroutine.h,源码非常非常非常短,实用例子如下:

// [Simple version using static variables (scr macros)]
int ascending (void) {
   static int i;

   scrBegin;
   for (i=0; i<10; i++) {
      scrReturn(i);
   }
   scrFinish(-1);
}

void main(void) {
    int i;
    do {
       i = ascending();
       printf("got number %d\n", i);
    } while (i != -1);
}

// [Re-entrant version using an explicit context structure (ccr macros)]
int ascending (ccrContParam) {
   ccrBeginContext;
   int i;
   ccrEndContext(foo);
   ccrBegin(foo);
   for (foo->i=0; foo->i<10; foo->i++) {
      ccrReturn(foo->i);
   }
   ccrFinish(-1);
}

 /* The caller of a re-entrant coroutine must provide a context  variable: */
void main(void) {
    ccrContext z = 0;
    do {
       printf("got number %d\n", ascending (&z));
    } while (z);
}

有感兴趣的可以自己看看,不过其中的 ccrContext 宏定义采用 malloc/free 的玩法,性能和维护成本太高,不敢用。。。

#define ccrBegin(x)      if(!x) {x= *ccrParam=malloc(sizeof(*x)); x->ccrLine=0;}\
                         if (x) switch(x->ccrLine) { case 0:;

二、Protothreads的实现

protothreads 是一个全部用 ANSI C 写成的库(源码也可以从这里下载),非常精简,几乎就是原语级别。事实上 protothreads 整个库不需要链接加载,因为所有源码都是头文件:

  • 总共也就 5 个头文件,
  • 有效代码量不足 100 行;
  • API 都是宏定义的,所以不存在调用开销;
  • 最后,每个协程的空间开销是 2 个字节(是的,你没有看错,就是一个 short 单位的“栈”!)

当然这种精简是要以使用上的局限为代价的,接下来的分析会说明这一点。

2.1 Protothreads的上下文

上下文结构体,用以保存状态变量:

typedef unsigned short lc_t;

struct pt {
  lc_t lc;
}

里面只有一个 short 类型的变量,实际上它是用来保存上一次出让点的程序计数器。

2.2 Protothreads的原语和组件

这里只列出几个显而易见的原语,其他原语的原理相似。Protothreads 提供了一套相对完整的协程机制,可以灵活的控制协程。

#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) }

#define PT_INIT(pt)   LC_INIT((pt)->lc)
#define PT_THREAD(name_args) char name_args
#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)

2.3 例子

static int protothread1_flag, protothread2_flag;

static int protothread1(struct pt *pt)
{
  PT_BEGIN(pt);
  while(1) {
    PT_WAIT_UNTIL(pt, protothread2_flag != 0);
    printf("Protothread 1 running\n");

    protothread2_flag = 0;
    protothread1_flag = 1;
  }
  PT_END(pt);
}

static int protothread2(struct pt *pt)
{
  PT_BEGIN(pt);
  while(1) {
    /* Let the other protothread run. */
    protothread2_flag = 1;
    PT_WAIT_UNTIL(pt, protothread1_flag != 0);
    printf("Protothread 2 running\n");
    protothread1_flag = 0;
  }
  PT_END(pt);
}

static struct pt pt1, pt2;
int main(void)
{
  PT_INIT(&pt1);
  PT_INIT(&pt2);
  
  while(1) {
    protothread1(&pt1);
    protothread2(&pt2);
  }
}

2.4 思考

从上面原语的实现,可以发现几个问题:

  • 尽量不要使用局部变量,除非该变量对于协程状态是无关紧要的,同理可推,协程所在的代码是不可重入的。
  • 如果非要保留局部变量做为状态,考虑将其加入自己的上下文(扩展上下文结构)。
  • 协程使用 switch-case 原语封装,禁止在实际应用中使用 switch-case 语句。

官网上还例举了更多实例,都非常实用。另外,一个叫 Craig Graham 的工程师扩展了 pt.h,使得 protothreads 支持 sleep/wake/kill 等操作,文件在此 graham-pt.h。

官方推荐了一些资料,可以做为补充学习。

三、用setjmp/long_jmp实现

3.1 原理简介

在标准C中的头文件中定义了一组函数 setjmp / long_jmp 用来实现“非本地跳转”的功能,setjmp用于保存当前的上下文(不包括r0-r4);longjmp用于回复到以前保存下的上下文,并且使得setjmp返回指定的值。

  • int setjmp(jmp_buf env) 保存当前执行状态,作为后续跳转的目标。调用时,当前状态会被存放在env指向的结构中,env将被 long_jmp 操作作为参数,以返回调用点 — 跳转的结果看起来就好像刚从setjmp返回一样。 直接调用setjmp保存状态后,返回值是0;而从long_jmp操作返回时,返回值是非0的 — 通过判断setjmp的返回值,就可以判断当前执行状态。
  • void long_jmp(jmp_buf env, int value) 该函数用来恢复env中保存的执行状态,另一参数value用来传递返回值给跳转目标 — 如果value值为0,则跳转后返回setjmp处的值为1;否则,返回setjmp处的值为value。
  • 当使用longjmp()时,env的内容被销毁。

好,下面来实战一下,猜猜下面程序的输出是什么?

#include 
#include 
#include 

int main()
{
    jmp_buf buf;
    setjmp(buf);
    printf("Hello world!\n");
    sleep(1);
    longjmp(buf, 1);
    return 0;
}

正确答案:

Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
^C

即实现了一个简单的死循环。

再给一个例子:

#include  
#include 
 
jmp_buf buf; 

banana()
{ 
    printf("in banana() \n"); 
    longjmp(buf,1); 
    printf("you'll never see this,because i longjmp'd"); 
} 

main() 
{ 
    if(setjmp(buf)) 
        printf("back in main\n"); 
    else
    { 
        printf("first time through\n"); 
        banana(); 
    } 
}

output:
first time through
in banana()
back in main

尽管longjmp会导致转移,但它和goto又有不同,区别如下:

  • goto语句不能跳出C语言当前的函数(这也是“longjmp”取名的由来,它可以跳的很远,甚至可以跳到其他文件的函数中)。
  • 用longjmp只能跳回到曾经到过的地方。在setjmp的地方仍留有一个过程活动记录。从这个角度讲,longjmp更像是“从何处来(come from)“而不是”往哪里去(go to)”。

setjmp/longjmp在C中可以用来处理异常,比如内层函数发生异常,可以一次跳转到最外层函数,而不用一层层将异常传递出来,并一层层判断。

注意:setjmp/longjmp 在C++中并不适合用于异常处理,因为setjmp和longjmp并不能很好地支持C++中面向对象的语义。所以C++中还是使用"catch"和"throw"。更详细的例子参考这里。

3.2 一个简单协程实现

setjmp-longjmp-ucontext-snippets 是一个用 setjmp 和 longjmp实现的协程,同时还提供了一个简单的 Channel 实现,以供协程间通信。

其提供的原语:

  • void coro_allocate (int num_cores) 在程序开始时调用,静态预分配 num_cores 个协程空间,程序中最大运行的协程数不能超过 num_cores 个。
    jmp_buf* bufs;
    int* used_pids;
    
    void coro_allocate(int num_coros) {
        // want n slots + slot '0' = num_coros + 1
    	coro_max = num_coros + 1;
    	bufs = malloc(sizeof(jmp_buf) * (coro_max));
    	used_pids = calloc(coro_max, sizeof(int));
    	used_pids[0] = 1;
    	coro_pid = 0;
    	
    	grow_stack(0, num_coros);
    }
    
  • int coro_spawn(coro_callback f, void *user_state) 启动一个协程,入口函数由第一个参数 f 指定, user_state 是 f 的参数。
  • int coro_runnable(int pid) 返回该 pid 的协程。
  • void coro_yield(int pid) 让出处理器,并切换到以 pid 为编号的其他协程继续执行。
    void coro_yield(int pid) {
      int saved_coro_pid = coro_pid;
      if (!setjmp(bufs[coro_pid])) {
        // before you do a longjmp, set current pid to new one
        coro_pid = pid;
        longjmp(bufs[pid], 1);
        assert(0);
      } else {
        // if we return from setjmp, reset the coro_pid 
        // to what it used to be
        coro_pid = saved_coro_pid;
        return; // keep doing what we were doing!
      }
    }	
    

从源码看,coro_spawn 只能生成一种类型的协程,不能连续多次生成不同种类的协程,应该是该协程库只是个尝试吧,应该不会继续完善用于产品了。

下面是官方的一个例子,看看就好:

#include 
#include 

#include "coroutines.h"

static void test_one(void*);

int main() {
  // Make space for 10 coroutines and set coro_pid to 0
  // Note that this calls grow_stack(n) for each n, which does a setjmp for bufs[n]
  // The first yield will yield into this, which will fall into the second half of
  // grow_stack, which calls the function (which will be test_one)
  coro_allocate(10);
  printf("main: coro_allocate finished\n");
  int p;
  int pids[10];
  int valid_pid_count;

  // Each coro_spawn will:
  // 1. Set spawned_fun = test_one
  // 2. Set spawned_user_state = NULL
  // 3. Mark used_pids[p] = 1 == runnable
  // 4. Yield to the pid, p
  // 5. Yield sets saved_coro_pid = 0 == scheduler
  // 6. Setjmp locally for p
  // 7. longjmp back to scheduler
  for (p = 0; p < 10; p++) {
    pids[p] = coro_spawn(test_one, NULL);
    printf("main: coro_spawn %d\n", pids[p]);
  }
  assert(coro_pid == 0);
  do {
    valid_pid_count = 0;
    for (p = 0; p < 10; p++) {    
      printf("main: yielding %d\n", pids[p]);
      coro_yield(pids[p]);
      valid_pid_count += coro_runnable(pids[p]);
    }
  } while (valid_pid_count > 0);
  printf("main: finished\n");
  return 0;
}

// Each call to yield will, the first time through, pass through the !setjmp section and yield back to 0.
// When we're yielded to again it simply returns and execution continues.
static void test_one(void* _) {
  coro_yield(0);
  int p;
  for (p = 0; p < 2; p++) {
    printf("test_one(%d): %i\n", coro_pid, p);
    coro_yield(0); // yield to top context
  }
  printf("test_one(%d): done\n", coro_pid);
  coro_yield(0);
}

3.3 一个复杂协程实现 Libmill

Libmill 是一个力求提供与golang 相似语义原语的协程库,先来感受一下:

   
   
   
   

Go

Libmill

go foo(arg1, arg2, arg3) go(foo(arg1, arg2, arg3));
ch := make(chan int) chan ch = chmake(int, 0);
ch := make(chan int, 1000) chan ch = chmake(int, 1000);
ch <- 42 chs(ch, int, 42);
i := <- ch int i = chr(ch, int);
close(ch) chdone(ch, int, 0);
chclose(ch);
select {
case ch <- 42:
foo()
case i := <- ch:
bar(i)
default:
baz()
}
choose {
out(ch, int, 42):
foo();
in(ch, int, i):
bar(i);
otherwise:
baz();end
}

Libmill 使用的是 sigsetjmp 和 siglongjmp,与 setjmp/long_jmp 的区别是可以多保存信号量信息,用于捕捉信号并处理,详细可见 这里 和 这里。

Libmill 实现了协程调度,无需用户手动处理协程上下文切换;实现了一套异步的网络操作原语,用于网络异步编程。不过这也意味着跟网络相关的第三方库全部无法使用(至少不修改是无法使用的,接口不一致)。

官网有一些例子,来个例子感受一下:

coroutine void worker(int count, const char *text) {
    int i;
    for(i = 0; i != count; ++i) {
        printf("%s\n", text);
        msleep(now() + 10);
    }
}

int main() {
    go(worker(4, "a"));
    go(worker(2, "b"));
    go(worker(3, "c"));
    msleep(now () + 100);
    return 0;
}

再来个带channel的:

coroutine void sender(chan ch) {
    chs(ch, int, 42);
    chclose(ch);
}

int main() {
    chan ch = chmake(int, 0);
    go(sender(chdup(ch)));
    int i = chr(ch, int);
    assert(i == 42);
    chclose(ch);
    return 0;
}

四、用ucontext实现

所谓 “ucontext” 机制是 GNU C 库提供的一组用于创建、保存、切换用户态执行“上下文”(context)的API,可以看作是 “setjmp/long_jmp” 的“升级版”。

先来看看wiki上面的一个例子:

#include 
#include 
#include 
 
int main(int argc, const char *argv[]){
    ucontext_t context;
 
    getcontext(&context);
    puts("Hello world");
    sleep(1);
    setcontext(&context);
    return 0;
}

猜猜程序运行的结果会是什么样?

Hello world
Hello world
Hello world
Hello world
Hello world
^C

4.1 ucontext基本组件

先看看上下文 ucontext_t 的结构,不同环境可能不同,但至少要包含以下字段:

typedef struct ucontext_t {
	struct ucontext_t *uc_link;
	sigset_t uc_sigmask;
	stack_t uc_stack;
	mcontext_t uc_mcontext;
	...
} ucontext_t;
  • uc_link:保存当前context结束后继续执行的context记录(即下一行代码的环境);
  • uc_sigmask:记录该context运行阶段需要屏蔽的信号;
  • uc_stack:是该context运行的栈信息;
  • uc_mcontext:则保存具体的程序执行上下文——如PC值、堆栈指针、寄存器值等信息——具体结构依赖于底层运行的系统架构,是平台、硬件。

对保存内容比较详细的说明,可以看这里。

ucontext主要包括以下四个函数:

  • int getcontext(ucontext_t *ucp):初始化ucp结构体,将当前的上下文保存到ucp中。若后续调用 setcontextswapcontext 恢复该状态,则程序会沿着 getcontext 调用点之后继续执行,看起来好像刚从 getcontext 函数返回一样。

    这个操作的功能和 setjmp 所起的作用类似,都是保存执行状态以便后续恢复执行,但需要重点指出的是:getcontext 函数的返回值仅能表示本次操作是否执行正确,而不能用来区分是直接从 getcontext 操作返回,还是由于 setcontext/swapcontex 恢复状态导致的返回,这点与 setjmp 是不一样的。

  • int setcontext(const ucontext_t *ucp):设置当前的上下文为ucp,且ucp应该通过getcontext或者makecontext取得,如果调用成功则不返回。

    • 如果ucp是通过调用 getcontext()取得,程序会继续从 getcontext() 处执行这个调用。
    • 如果ucp是通过调用 makecontext()取得,程序会调用 makecontext() 函数的第二个参数指向的函数func,如果func函数返回,且该ucp中的 uc_link 不空,则从该ucp的上下文开始执行。
  • void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)makecontext 修改通过 getcontext 取得的上下文 ucp (这意味着调用makecontext前必须先调用getcontext),一般需要显式给该上下文指定一个栈空间ucp->stack,设置后继的上下文ucp->uc_link
    当上下文通过setcontext或者swapcontext激活后,先执行func函数,argcfunc的参数个数,后面是func的参数序列。当func执行返回后,继承的上下文被激活,如果继承上下文为NULL,线程退出。

  • int swapcontext(ucontext_t *oucp, ucontext_t *ucp):保存当前上下文到oucp结构体中,然后激活upc上下文。其实相当于依次调用 getcontextsetcontext。为了简化切换操作的实现,ucontext 机制里提供了swapcontext 这个函数,用来“原子”地完成旧状态的保存和切换到新状态的工作。

如果执行成功,getcontext返回0,setcontextswapcontext不返回;如果执行失败,getcontextsetcontextswapcontext返回-1,并设置对应的errno.

看个实际的例子,就很好理解了:

#include 
#include 
 
void func1(void * arg)
{
    puts("1");
    puts("11");
    puts("111");
    puts("1111");
 
}
void context_test()
{
    char stack[1024*128];
    ucontext_t child,main;
 
    getcontext(&child); //获取当前上下文
    child.uc_stack.ss_sp = stack;//指定栈空间
    child.uc_stack.ss_size = sizeof(stack);//指定栈空间大小
    child.uc_stack.ss_flags = 0;
    child.uc_link = &main;//设置后继上下文
 
    makecontext(&child,(void (*)(void))func1,0);//修改上下文指向func1函数
 
    swapcontext(&main,&child);//切换到child上下文,保存当前上下文到main
    puts("main");//如果设置了后继上下文,func1函数指向完后会返回此处
}
 
int main()
{
    context_test();
 
    return 0;
}

上面代码完成了:

  • 调用getcontext获得当前上下文、
  • 修改当前上下文ucontext_t来指定新的上下文,如指定栈空间极其大小,设置用户线程执行完后返回的后继上下文(即主函数的上下文)等
  • 调用makecontext创建上下文,并指定用户线程中要执行的函数
  • 切换到用户线程上下文去执行用户线程(如果设置的后继上下文为主函数,则用户线程执行完后会自动返回主函数)。

输出结果:

1
11
111
1111
main

如果将代码中修改

child.uc_link = &main;

child.uc_link = NULL;

输出结果:

1
11
111
1111

执行为func1后直接退出,而没有返回主函数。

可以看出,用ucontext机制实现一个“协程”系统并不困难。 实际上,每个运行上下文(ucontext_t)就直接对应于“协程”概念,对于协程的“创建”(Create)、“启动” (Spawn)、“挂起” (Suspend)、“切换” (Swap)等操作,很容易通过上面的4个API及其组合加以实现,需要的工作仅在于设计一组数据结构保存暂不运行的context结构,提供一些调度的策略即可。


4.2 使用ucontext实现自己的线程库

所有源码在这里。

首先定义自己的上下文结构:

#define DEFAULT_STACK_SZIE (1024*128)

typedef void (*Fun)(void *arg);
enum ThreadState{FREE,RUNNABLE,RUNNING,SUSPEND};
 
typedef struct uthread_t
{
    ucontext_t ctx;
    Fun func;
    void *arg;
    enum ThreadState state{FREE};
    char stack[DEFAULT_STACK_SZIE];
}uthread_t;

定义一个调度器结构:

typedef std::vector<uthread_t> Thread_vector;
 
typedef struct schedule_t
{
    ucontext_t main;
    int running_thread;
    Thread_vector threads;
 
    schedule_t():running_thread(-1){}
}schedule_t;

定义API:

  • int uthread_create(schedule_t &schedule, Fun func, void *arg):创建一个协程,该协程的会加入到schedule的协程序列中,func为其执行的函数,arg为func的执行函数。返回创建的线程在schedule中的编号。
  • void uthread_yield(schedule_t &schedule):挂起调度器schedule中当前正在执行的协程,切换到主函数。
  • void uthread_resume(schedule_t &schedule,int id):恢复运行调度器schedule中编号为id的协程
  • int schedule_finished(const schedule_t &schedule):判断schedule中所有的协程是否都执行完毕,是返回1,否则返回0。

API的大致实现:

int uthread_create(schedule_t &schedule,Fun func,void *arg)
{
    int id = get_new_id();
    
    uthread_t *t = &(schedule.threads[id]);

    t->state = RUNNABLE;
    t->func = func;
    t->arg = arg;

    getcontext(&(t->ctx));
    
    t->ctx.uc_stack.ss_sp = t->stack;
    t->ctx.uc_stack.ss_size = DEFAULT_STACK_SZIE;
    t->ctx.uc_stack.ss_flags = 0;
    t->ctx.uc_link = &(schedule.main);
    schedule.running_thread = id;
    
    makecontext(&(t->ctx),(void (*)(void))(func),1, arg);
    swapcontext(&(schedule.main), &(t->ctx));
    
    return id;
}


void uthread_yield(schedule_t &schedule)
{
    if(schedule.running_thread != -1 ){
        uthread_t *t = &(schedule.threads[schedule.running_thread]);
        t->state = SUSPEND;
        schedule.running_thread = -1;
 
        swapcontext(&(t->ctx),&(schedule.main));
    }
}

void uthread_resume(schedule_t &schedule , int id)
{
    if(id < 0 || id >= schedule.max_index){
        return;
    }

    uthread_t *t = &(schedule.threads[id]);

    if (t->state == SUSPEND) {
        swapcontext(&(schedule.main),&(t->ctx));
    }
}

实际的例子:

#include "uthread.h"
#include 
 
void func2(void * arg)
{
    puts("22");
    puts("22");
    uthread_yield(*(schedule_t *)arg);
    puts("22");
    puts("22");
}
 
void func3(void *arg)
{
    puts("3333");
    puts("3333");
    uthread_yield(*(schedule_t *)arg);
    puts("3333");
    puts("3333");
}
 
void schedule_test()
{
    schedule_t s;
 
    int id1 = uthread_create(s,func3,&s);
    int id2 = uthread_create(s,func2,&s);
 
    while(!schedule_finished(s)){
        uthread_resume(s,id2);
        uthread_resume(s,id1);
    }
    puts("main over");
}
int main()
{
    schedule_test();
    return 0;
}

可以猜猜运行的结果是什么?

3333
3333
22
22
22
22
3333
3333
main over

4.3 绝对的重量级产品 - libgo

libgo 是一个用C++11编写的能支持百万级协程并发的库, 还是先看语法对比:

库/语言 Go Libmill Libgo
定义协程 go foo(arg1, arg2, arg3) go(foo(arg1, arg2, arg3)); go foo;

go []{
   everyThingYouWant();
};
不带缓冲的channel ch := make(chan int) chan ch = chmake(int, 0); co_chan ch;
带缓冲的channel ch := make(chan int, 1000) chan ch = chmake(int, 1000); co_chan ch(1000);
channel发数据 ch <- 42 chs(ch, int, 42); ch << 42;
channel读数据 i := <- ch int i = chr(ch, int); ch >> i;
关闭channel close(ch) chdone(ch, int, 0); ch.Close();
channel回收 chclose(ch);
channel选择 select {
case ch <- 42:
    foo()
case i := <- ch:
    bar(i)
default:
    baz()
}
choose {
out(ch, int, 42):
    foo();
in(ch, int, i):
    bar(i);
otherwise:
    baz();
end
}

使用libgo编写并行程序,即可以像golang一样开发迅速且逻辑简洁,又有C++原生的性能优势。

  • 提供golang一般功能强大协程,基于corontine编写代码,可以以同步的方式编写简单的代码,同时获得异步的性能
  • 支持海量协程, 创建100万个协程只需使用2GB内存
  • 允许用户自由控制协程调度点,随时随地变更调度线程数;
  • 支持多线程调度协程,极易编写并行代码,高效的并行调度算法,可以有效利用多个CPU核心
  • 可以让链接进程序的同步的第三方库变为异步调用,大大提升其性能。再也不用担心某些DB官方不提供异步driver了,比如hiredis、mysqlclient这种客户端驱动可以直接使用,并且可以得到不输于异步driver的性能。
  • 动态链接和静态链接全都支持,便于使用C++11的用户静态链接生成可执行文件并部署至低版本的linux系统上。
  • 提供协程锁(co_mutex), 定时器, channel, 线程局部变量,defer等特性, 帮助用户更加容易地编写程序.
  • 网络性能强劲,在Linux系统上超越ASIO异步模型;尤其在处理小包和多线程并行方面非常强大

看几个例子:

#include "coroutine.h"
#include "win_exit.h"
#include 

void foo()
{
    printf("function pointer\n");
}

int main()
{
    go foo;

    for (int i = 0; i < 4; ++i)
        go []{
            co_sleep(100);
            printf("lambda\n");
        };

    go []{
        printf("%s\n", co::CoDebugger::getInstance().GetAllInfo().c_str());
    };

    go []{
        co_sleep(50);
        printf("%s\n", co::CoDebugger::getInstance().GetAllInfo().c_str());
    };

    // 200ms后安全退出
    std::thread([]{ co_sleep(200); co_sched.Stop(); }).detach();

    co_sched.Start();
    return 0;
}

再看一个用channel的例子:

#include "coroutine.h"
#include "win_exit.h"
#include 

int main(int argc, char** argv)
{
    co_chan<std::shared_ptr<int>> ch_1(1);

    go [=] {
        std::shared_ptr<int> p1(new int(1));

        // 向ch_1中写入一个数据, 由于ch_1有一个缓冲区空位, 因此可以直接写入而不会阻塞当前协程.
        ch_1 << p1;
        
        // 再次向ch_1中写入整数2, 由于ch_1缓冲区已满, 因此阻塞当前协程, 等待缓冲区出现空位.
        ch_1 << p1;
    };

    go [=] {
        std::shared_ptr<int> ptr;

        // 由于ch_1在执行前一个协程时被写入了一个元素, 因此下面这个读取数据的操作会立即完成.
        ch_1 >> ptr;

        // 由于ch_1缓冲区已空, 下面这个操作会使当前协程放弃执行权, 等待第一个协程写入数据完成.
        ch_1 >> ptr;
        printf("*ptr = %d\n", *ptr);
    };

    // 200ms后安全退出
    std::thread([]{ co_sleep(200); co_sched.Stop(); }).detach();

    co_sched.Start();
    return 0;
}

4.4 小结

ucontext大大简化了上下文的保存和程序的跳转,为实现协程提供了便利。只要自己实现调度算法就好了。网上很多人都基于ucontext实现了自己的协程库,也有很多实际产品在使用的库(如腾讯开源的一个协程库libco),有兴趣的可以自己搜一下。

但是原生的ucontext 效率并不高,一些协程库会多多少少改原生库。

五、汇编实现

汇编实现无疑是非常高效的,即使是上面的几种实现方式中,有的也融合了部分汇编来加速协程的调度。

六、C++20 中的考虑

协程对异步编程的简化作用是毋庸置疑的,很多语言都内置或有优秀的第三方协程实现,C++官方也只能紧跟潮流。目前C++20已经确定要引入协程,只是不会引入重量级的协程库,只会提供基本的原语:

  • co_return:A coroutine returns from its function body with co_return.
  • co_yield:Immediately after the call, the execution of the coroutine will be suspended.
  • co_await:co_await eventually causes that the execution of the coroutine to be suspended and resumed. The expression exp in co_await exp has to be a so-called awaitable expression. exp has to implement a specific interface. This interface consists of the three functions e.await_ready, e.await_suspend, and e.await_resume.

看一个generator的例子,这个也是协程的开始:

#include 
#include 

generator<int> generatorForNumbers(int begin, int inc= 1){
  for (int i= begin;; i += inc){
    co_yield i;
  }
}

int main(){
  std::cout << std::endl;
  auto numbers= generatorForNumbers(-10);
  for (int i= 1; i <= 20; ++i) std::cout << numbers << " ";
  std::cout << "\n\n";

  for (auto n: generatorForNumbers(0, 5)) std::cout << n << " ";
  std::cout << "\n\n";
}

再来个服务器的例子:

Acceptor acceptor{443};
while (true){
  Socket socket= co_await acceptor.accept();           
  auto request= co_await socket.read();              
  auto response= handleRequest(request);     
  co_await socket.write(responste);                 
}

标准中提出的关键字,还是比较基础的,与其他第三方库比起来,还有很多可以封装的,比如调度、多线程n:m、channel等。所以坐等更丰富的新库吧。

对C++20协程关键更详细的说明,可以看这里。

总结

从协程的实现方式上,可以有下面几种:
1、利用setjmp 和 longjmp实现

  • setjmp-longjmp-ucontext-snippets,还提供了一个简单的 Channel 实现,供学习研究还行;

  • 一种协程的 C/C++ 实现:用创建线程的方式保存上下文,思路比较新奇,但无法实用。并不完善,仅供学习

  • libconcurrency,源码项目托管在google code,github上有fork。使用了“栈拷贝”技术 — 每个协程的运行栈是通过malloc在堆空间动态分配的,然后再将原始的栈帧数据复制到新的栈上。正因如此,其系统的可扩展性比较好,协程可以动态创建,且理论上没有上限。

  • Cilkplus,Intel 基于自家 X86 / X86_64 平台的特点,实现的一个高效的“协程”框架(其关注点在并发,而不是为了协程),源代码在这里。Cilkplus 运行时环境所使用的 setjmp / long_jmp 并非 C 库中提供的版本,而是编译器内嵌版本_builtin_setjmp / _builtin_longjmp。

    Cilkplus是目前所知利用 setjmp / long_jmp 机制实现 “N:M” 协程系统的唯一实现(即可以在多个线程间调度,且有实现有协程在线程间steal负载的算法),并且经过多年发展已经非常成熟。 目前,Cilkplus不仅为Intel自家的ICC编译器所支持,同时已合并到GCC主干,成为了GCC支持的语言。另外,基于Clang/LLVM的编译器也已经开源并已初具规模。

  • Libmill,完成度挺高,语法比较易用,不过源码不太好懂,整个框架的通用性不高,无法做为产品框架,拿来学习还是不错的。

  • State Threads:完成度也挺高的一个协程库,这里有一个中文的介绍。


2、利用glibc 的 ucontext组件

  • uthread:一个相当简单易懂的实现,用于入门非常合适,对应的解释在这里;

  • 云风的coroutine:云风自己的blog 上也有简单的介绍。整个库可以作为入门级教材使用。

  • libco:腾讯出品,可以用于产品,品质还是有保障的。但是用起来可能并不是很顺手,学习成本较高,对开发人员的要求很高,深谙底层机制才能写出没有问题的代码。

  • libtask:golang的前身,要想更深入的理解golang的运作机制,libtask 是一定要学习源码的。google的源码要,github上有fork;

  • libgo:魅族出品的一个协程库,魅族内部已经在部署使用,高效易用,稳定可靠,功能强大,而且是学习C++11的很好的资源。非常推荐尝试。


3、利用C语言语法switch-case

  • Protothreads

4、使用汇编代码来切换上下文

  • 实现c协程

参考:

1、一个“蝇量级” C 语言协程库
2、Portable Multithreading
3、构建C协程之setjmp/long_jmp篇
4、ucontext-人人都可以实现的简单协程库
5、构建C协程之ucontext篇

你可能感兴趣的:(☀并发编程)