前言:并发编程在当前的软硬件系统架构下,是一个程序员必备的知识技能。本文希望通过整理网上资料、结合自己的经验,提供一个系列分享,将基本的并发概念解释清楚。并希望在此基础上有所扩展,将各种语言的现状也所有对比。
一、并发编程漫谈之 基本概念
二、并发编程漫谈之 python多线程和多进程
三、并发编程漫谈之 协程详解–以python协程入手
四、并发编程漫谈之 C++中的各种锁
五、并发编程漫谈之 C++多进程和多线
六、并发编程漫谈之 C++协程的各种实现
仍然从生成器说起。在协程的演化一文中,一切是从下面的生成器开始的:
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中实现上述功能,可以怎么做?
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!
让我们暂时忘记可重入的问题,先看看第一个版本的实现。
第一个版本,除了能实现上面例子中的 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 语句都能找到我们要跳转到的位置。
但上面的实现还是难看得很。最糟糕的部分是所有的标签都需要手工维护,还必须保证函数中的标签和开头 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 */
}
}
}
上面的实现简化很多,但是状态的设置还是很麻烦,而且不方便修改。比如加了状态,可能很多状态取值都要跟着修改。
找一个变量,能自动跟着代码而变化,就可以解决上面的问题。
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 */
}
}
}
第四个版本已经实现了大部分的功能,但是 不好看!!! 。这点很重要!
把上述代码,按照我们预期的语义抽象一下,可以得到下面的版本:
#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 语义”。
目前的版本,是语义上最接近我们目标的版本。
一个值得思考的问题是:可能很多编程规范上,都不会允许出现上述代码。甚至一般的编译器都会有告警。
对此问题的一种观点是:
任何编程规范,坚持牺牲算法清晰度来换取语法清晰度的,都应该重写。---- 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 是一个全部用 ANSI C 写成的库(源码也可以从这里下载),非常精简,几乎就是原语级别。事实上 protothreads 整个库不需要链接加载,因为所有源码都是头文件:
当然这种精简是要以使用上的局限为代价的,接下来的分析会说明这一点。
上下文结构体,用以保存状态变量:
typedef unsigned short lc_t;
struct pt {
lc_t lc;
}
里面只有一个 short 类型的变量,实际上它是用来保存上一次出让点的程序计数器。
这里只列出几个显而易见的原语,其他原语的原理相似。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)
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);
}
}
从上面原语的实现,可以发现几个问题:
官网上还例举了更多实例,都非常实用。另外,一个叫 Craig Graham 的工程师扩展了 pt.h,使得 protothreads 支持 sleep/wake/kill 等操作,文件在此 graham-pt.h。
官方推荐了一些资料,可以做为补充学习。
在标准C中的头文件
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。好,下面来实战一下,猜猜下面程序的输出是什么?
#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又有不同,区别如下:
setjmp/longjmp在C中可以用来处理异常,比如内层函数发生异常,可以一次跳转到最外层函数,而不用一层层将异常传递出来,并一层层判断。
注意:setjmp/longjmp 在C++中并不适合用于异常处理,因为setjmp和longjmp并不能很好地支持C++中面向对象的语义。所以C++中还是使用"catch"和"throw"。更详细的例子参考这里。
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);
}
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” 机制是 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
先看看上下文 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中。若后续调用 setcontext
或 swapcontext
恢复该状态,则程序会沿着 getcontext
调用点之后继续执行,看起来好像刚从 getcontext
函数返回一样。
这个操作的功能和
setjmp
所起的作用类似,都是保存执行状态以便后续恢复执行,但需要重点指出的是:getcontext
函数的返回值仅能表示本次操作是否执行正确,而不能用来区分是直接从getcontext
操作返回,还是由于setcontext/swapcontex
恢复状态导致的返回,这点与setjmp
是不一样的。
int setcontext(const ucontext_t *ucp)
:设置当前的上下文为ucp,且ucp应该通过getcontext或者makecontext取得,如果调用成功则不返回。
getcontext()
取得,程序会继续从 getcontext()
处执行这个调用。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
函数,argc
为func
的参数个数,后面是func
的参数序列。当func
执行返回后,继承的上下文被激活,如果继承上下文为NULL,线程退出。
int swapcontext(ucontext_t *oucp, ucontext_t *ucp)
:保存当前上下文到oucp结构体中,然后激活upc上下文。其实相当于依次调用 getcontext
和 setcontext
。为了简化切换操作的实现,ucontext 机制里提供了swapcontext
这个函数,用来“原子”地完成旧状态的保存和切换到新状态的工作。
如果执行成功,getcontext
返回0,setcontext
和swapcontext
不返回;如果执行失败,getcontext
,setcontext
,swapcontext
返回-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;
}
上面代码完成了:
输出结果:
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结构,提供一些调度的策略即可。
所有源码在这里。
首先定义自己的上下文结构:
#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
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 |
带缓冲的channel | ch := make(chan int, 1000) | chan ch = chmake(int, 1000); | co_chan |
向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++原生的性能优势。
看几个例子:
#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;
}
ucontext大大简化了上下文的保存和程序的跳转,为实现协程提供了便利。只要自己实现调度算法就好了。网上很多人都基于ucontext实现了自己的协程库,也有很多实际产品在使用的库(如腾讯开源的一个协程库libco),有兴趣的可以自己搜一下。
但是原生的ucontext 效率并不高,一些协程库会多多少少改原生库。
汇编实现无疑是非常高效的,即使是上面的几种实现方式中,有的也融合了部分汇编来加速协程的调度。
协程对异步编程的简化作用是毋庸置疑的,很多语言都内置或有优秀的第三方协程实现,C++官方也只能紧跟潮流。目前C++20已经确定要引入协程,只是不会引入重量级的协程库,只会提供基本的原语:
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
4、使用汇编代码来切换上下文
1、一个“蝇量级” C 语言协程库
2、Portable Multithreading
3、构建C协程之setjmp/long_jmp篇
4、ucontext-人人都可以实现的简单协程库
5、构建C协程之ucontext篇