« 开发笔记(23) : 原子字典 | 返回首页 | 开发笔记(24) : Lua State 间的数据共享 »

C 的 coroutine 库

今天实现了一个 C 用的 coroutine 库.

我相信这个东西已经被无数 C 程序员实现过了, 但是通过 google 找了许多, 或是接口不让我满意, 或是过于重量.

在 Windows 下, 我们可以通过 fiber 来实现 coroutine , 在 posix 下, 有更简单的选择就是 setcontext

我的需求是这样的:

首先我需要一个 asymmetric coroutine 。如果你用过 lua 的 coroutine 就明白我指的是什么。

其次,我不希望使用 coroutine 的人太考虑 stack 大小的问题。就是说,用户在 coroutine 内可以使用的 C stack 大小和主线程一样多。

我需要单个 coroutine 空间占有率不要太高,因为我可能会使用上千个 coroutine ,我不希望每个都占用百万字节的堆栈。

因为,在我的应用场合,coroutine 切换的那一刻,使用的堆栈并不多(它可能调用一些需要大量堆栈的库函数,但那些库函数中并不会发生切换),所以,在切换的时刻做栈拷贝是可以接受的。coroutine 切换并不算频繁,这个切换成本是可控的。

最终,我以我的需求实现了我需要的这个版本。

当然,暂时它不支持 windows 。其实 port 到 windows 平台不算困难,只需要把 setcontext 那组 api 改成 fiber 的即可。

Comments

@Cinder
我猜测是创建context时会分配一个存储空间(S->Stack),也指明了大小,切换到对应的上下文后,会从S->Stack+size的地址开始使用这个空间,往下增长,因而save_stack中top-dummy对应的是该context已经使用的空间(dummy是在该context中最新使用的栈空间)。

想了很久,一直没想明白,_save_stack传参是S->stack,但是S->stack是堆上的内存地址,然后调用的时候是_save_stack(struct coroutine *C, char *top) {
// why does it work?
char dummy = 0;
assert(top - &dummy <= STACK_SIZE);

这里的dummy变量不是栈上的吗?这样不是会出现一个负数吗?实在没能读懂这段代码,希望可以指点一下

在mainfunc结束前_co_delete了,那结束mainfunc后有个隐含的swapcontext(C->ctx.uc_link)这个也被释放了,跑一次两次问题不大,多跑几次或者多线程下,肯定出问题。

局部变量不能用于异步回调赋值。

struct schedule *s;

void
work_done_cb(async_result_t *res, void *user_data)
{
async_result_t **resp = user_data;

*resp = res;
}

void
task_func(void *arg)
{
async_result_t *res = NULL;

do_work_async(work_done_cb, &res);

while(!res)
{
coroutine_yield(s);
}
// BUG: 就算 work_done_cb 被调用了,你的堆栈内容恢复机制也会覆盖掉 work_done_cb 中对 res 的赋值,因此无法执行到这里
...
}

@bughoho 我觉得你对协程的运行原理理解错了。基于协程,只能使用overlap io,本身这个技术是不支持block io.

膜拜

应该可以直接用 libevent 吧。

Python 里面 gevent 实现协程,底层就是用的这个库

请问一下这种情况:
在一个协程中触发了异常后,在异常处理块中(这时并未NtContinue继续执行)yield到另外一个协程中会有问题吗?

我认为你的这个实现是错误的。
因为栈数据是不能拷贝到别的地址的,这样做将导致指向此栈上变量的指针失效。
如 伪码
char buf[64];
read_from_net(socket, buf);

在此函数里导致协程挂起,等待io完成并重新调度回来,而buf的地址在原来malloc分配的块,这个块已经被另作他用,这个a是野的。

不好意思,看到id重复利用的地方了~

学习了!

不过有个地方,协程id的管理实在有点简单,id是无法回收的。所以这样理解不知是否有误:频繁创建/销毁协程,会有内存泄露。

我是结合epoll,在任务激活时获取栈,一个requist结束时归还栈。

我也做了一个,跟你的需求差不多。

我也做了一个,跟你的需求差不多。

我也做了一个,跟你的需求差不多。

真是功力深厚啊

这应该是此需求下最合理的实现了

top - &dummy这个是什么意思,没太明白,求@cloud 解释下

本来很好奇切换上下文时是怎样把函数栈帧保存下来的,后来看了main函数第一句分配了schedule结构体,而stack就在结构体第一个变量就明白了。确实巧妙!

不过不需要每个协程维护一个上下文,因为每个协程已经有自己的stack了。由于程序没有并发,只要保留两个context之间切换即可,一个用于当前,一个用于上一个。

貌似没啥人去实现WIN32版本的,就看了一下然后改造成WIN fiber的版本,按说只需要替换接口,不过WINDOWS的CreateFiber(Ex)并不能直接读取/替换堆栈是吧?一时间感觉无从下手。

今天再折腾了一下,做了个测试程序,好像用CreteFiberEx的话,直接把commit设置比较小(随便弄了个32bytes),然后reserved弄1M,相当于就会自己根据大小去调整尺寸了(用任务管理器看提交大小或者工作集,弄一个1M的数组,可以看到内存会自己调整,程序并不会出现崩溃)不知道是不是和操作系统有关系。

改造完成能跑上面的main例子,把源程序堆栈复制的部分都屏蔽了(因为每一个堆栈都会“自动”按需扩容……),就不知道是不是支持大量并发,暂时没大量的例子。

PS:另外做了一个commit > reserved的测试也正常运行,看来微软保护算是做得挺完整了……

@plain

不可以

一个疑问,可以在不同的线程下使用coroutine_resume吗?

这是不是跟golang里面的gorouting一样一样的?

c coroutine
中使用了 swapcontext
这个在gdb coredump的时候
貌似无法查看到调用coroutine的栈
只能看到切换上下文后的栈。
我查看了好些资料,这个貌似无法解决。 云风大哥给个方案吧

@Cloud

hi, 接触协程不久, 刚试了一下你的coroutine库, 我想在各个coroutine之间共享sys/queue.h里的队列作消息通信, 但是运行时显示断错误.可否指点一二.

@dannoy @Cloud
是我理解错了。
本以为栈是使用的malloc分配的空间。
原来是每次拷贝,还是用的主栈的空间。

@Cloud
谢谢回复。
是我自己的失误,相当然的把makecontext里的函数看成了C->func,其实是mainfunc

@Genius
运行时的stack是S->stack,每次在运行前恢复了先前保存的栈

@dannoy

可以 man makecontext

On architectures where int and pointer types are the same size (e.g., x86-32, where both types are 32 bits), you may be able to get away with passing pointers as arguments to makecontext() following argc. However, doing this is not guaranteed to be portable, is undefined according to the standards, and won't work on architectures where pointers are larger than ints. Nevertheless, starting with version 2.8, glibc makes some changes to makecontext(3), to permit this on some 64-bit architectures (e.g., x86-64).

@Cloud

有一个疑问请教一下,line 135的代码:
makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
1、为什么这样传的S指针过去?是因为运行的系统是64bit吗?
2、为什么没有看到传参数ud呢,但是运行又是正确的,能讲一下原理吗?我把代码这行代码改为以下代码也可正常运行:
uintptr_t ptr2 = (uintptr_t)C->ud;
makecontext(&C->ctx, (void (*)(void)) mainfunc, 4, ptr, ptr>>32,ptr2, ptr2>>32);
所以我猜测原来的代码也是有某种机制来传第二个参数的,请指教。
谢谢!

总觉得那个_save_stack函数会有问题:
具体在第153行,if条件语句
1.从调用coroutine_yield到_save_stack函数过程中使用的栈都是C->stack吧,也就是说free函数翻译时,这个空间正在被使用中!
2.仅仅直接改C->stack指针能行么?esp ebp等寄存器都没变呢,栈跟本没改。这样在后面使用的其实仍然是之前分配的空间,swapcontext保存下来的上下文也是。潜在的BUG !

请楼主测试函数的该分支条件

好把,我只膜拜下。

不懂,但膜拜

140元的还是DDR3 1600频率的,1333的频率更加便宜呢。

1万个co-routine, 平均每个64K堆栈( 已经是很大的堆栈了),才640兆。现在4G的PC ddr3内存是140元左右, 服务器内存可能贵一点,但是贵不太多。
:D

@David Xu

的确, 在 64bit 系统上, coroutine 占用的堆栈内存是不会有太多问题. 应该是我多虑了.

我现在有几百个实体, 在 10 条左右的 os thread 上调度. (调度单位是实体内的一个函数调用) 每个实体里需要上十个 coroutine.

粗略算起来是万级的 coroutine 左右.

在64位系统里,堆栈申请的大或者co-routine比较多,导致总体堆栈内存空间申请的大,不一定会有很大的影响。为什么呢?因为你申请到的是虚拟内存,只要你实际不去触碰(touch)那些内存,例如mmap申请堆栈后又来一个没必要的memset()成0,实际内存需求将按函数执行时的需求来,所以总体上实际物理内存需求可能也不大,libc里嵌套最深的函数,也许实际不超过几K堆栈需求。

为了测试堆栈是否溢出,如你给co-routine的堆栈太小了,建议的方式是内存通过mmap()申请,然后在申请到的这块内存的开头,你用mprotect把他砍下来,成为红区,不能读写。这样,当堆栈从高地址往低地址涨的时候,如果涨太多就碰到红区,这段内存不可读写,程序马上就segment fault。这有利于检查程序的问题。我在线程库libthr上就是这样做的。
例如假设红区大小是一个guard page (4096字节 ):
那总共要申请的就是
guard_page + stack_size.

p = (char *)mmap(guard_page+stack_size).
然后
mprotect(p, 4096, PROT_NONE);
return (p+4096);

@cloud

不知道我这样对不对:如果想准备一个新的co-routine,可以用makecontext()先建立一个初始化上下文。它的原型是这样的: void
makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...);

也就是你可以传入一个新的入口函数地址,以及argc个参数给这个入口函数。这个ucp所指向的ucontext_t是你用getcontext()获得的,没什么理由,就是为了得到一个合理的寄存器状态数据,而不是随机的垃圾。然后,你用这个ucontext里的uc_stack设置你想给这个co-routine的新的堆栈:
例如:
ucp->uc_stack.ss_size = 4096;
ucp->uc_stack.ss_sp = my_stack_memory。

然后调用makecontext.
makecontext将会为你设置好机器相关的堆栈指针:
它可能是ss_size+ss_sp(在x86上),然后再往低地址填充一些东西。
总之,它给你一个合理的初始化上下文,入口指向你给出的函数。

所以,你创建一个co-routine,它的堆栈是可以预先固定大小的。

至于你说的拷贝,我就不了解了。

在FreeBSD上你可以参考libc里的makecontext的实现:
/usr/src/lib/libc/i386/gen/makecontext.c

然后继续使用setjmp+longjmp.

@David Xu

我先实现了一个 longjmp 版的. 但是切换堆栈会比较繁琐.

其实开销是可以容忍的. copy stack 的开销也很大. 尽量减少开销次数了.

更需要的是实现大量的 coroutine, 但是活动状态的并不多. 所以不希望每个 coroutine 都占用太多内存.

希望我没理解错,我只看了几分钟。我觉的你是要轻量级东西。但是目前我知道setcontext和getcontext都是系统调用。开销比较大。建议你还是用setjmp+longjmp.
setcontext和getcontext更多的是偏向支持实时抢占方式的,所以上下文保存都是通过内核调用。目前编译器生成的浮点指令都以函数调用为边界,浮点处理器状态并不跨函数调用而存在,所以你这种非实时抢先的co-routine,setjmp+longjmp就可以了。lua用到浮点单元,但是setjmp已经做了必要的状态字保存,问题不大。

另,好久不见,不知道现在怎样了。

不知道楼主是否知道gnupth项目,我们的项目就在使用它,gnupth除了实现了coroutine,还有许多其他的好处,比如访问到阻塞调用,可以直接切换到另一个coroutine执行。

这样的方式, 在设置全局变量为协程的堆栈变量(引用地址), 协程切换后再引用全局变量时会有问题. 通常的假设是协程未结束, 则堆栈地址有效.

虽然coroutine有时很方便,但我觉得这种模式不是必需的,总有其它框架模式可以替代,同样能够实现需求.

膜拜

沙发

沙发

Post a comment

非这个主题相关的留言请到:留言本