cloudwu/coroutine 源码分析

1 与其它协程库使用对比

这个 C 协程库是云风(cloudwu) 写的,其接口风格与 Lua 协程类似,并且都是非对称 stackful 协程。这个是源代码中的示例:

#include "coroutine.h" #include <stdio.h>  struct args {     int n; };  static void foo(struct schedule *S, void *ud) {     struct args *arg = ud;     int start = arg->n;     int i;     for (i = 0; i < 5; i++)     {         printf("coroutine %d : %d/n", coroutine_running(S), start + i);         coroutine_yield(S);     } }  static void test(struct schedule *S) {     struct args arg1 = {0};     struct args arg2 = {100};      int co1 = coroutine_new(S, foo, &arg1);     int co2 = coroutine_new(S, foo, &arg2);     printf("main start/n");     while (coroutine_status(S, co1) && coroutine_status(S, co2))     {         coroutine_resume(S, co1);         coroutine_resume(S, co2);     }     printf("main end/n"); }  int main() {     struct schedule *S = coroutine_open();     test(S);     coroutine_close(S);      return 0; } 

这段代码输出:

main start coroutine 0 : 0 coroutine 1 : 100 coroutine 0 : 1 coroutine 1 : 101 coroutine 0 : 2 coroutine 1 : 102 coroutine 0 : 3 coroutine 1 : 103 coroutine 0 : 4 coroutine 1 : 104 main end 

与其等价的 Lua 代码为:

local function foo(args)     local start = args.n     for i = 0, 4 do         print(string.format('coroutine %s : %d', coroutine.running(), start + i))         coroutine.yield()     end end  local function test()     local arg1 = {n = 0}     local arg2 = {n = 100}      local co1 = coroutine.create(foo)     local co2 = coroutine.create(foo)     print('main start')     coroutine.resume(co1, arg1)     coroutine.resume(co2, arg2)     while coroutine.status(co1) ~= 'dead' and coroutine.status(co2) ~= 'dead' do         coroutine.resume(co1)         coroutine.resume(co2)     end     print('main end') end  test() 

这段代码输出:

main start coroutine thread: 000001D62BD6B8A8 : 0 coroutine thread: 000001D62BD6BA68 : 100 coroutine thread: 000001D62BD6B8A8 : 1 coroutine thread: 000001D62BD6BA68 : 101 coroutine thread: 000001D62BD6B8A8 : 2 coroutine thread: 000001D62BD6BA68 : 102 coroutine thread: 000001D62BD6B8A8 : 3 coroutine thread: 000001D62BD6BA68 : 103 coroutine thread: 000001D62BD6B8A8 : 4 coroutine thread: 000001D62BD6BA68 : 104 main end 

与其等价的 C++ 20 代码为:

#include <coroutine> #include <functional> #include <stdio.h>  struct coroutine_running {     bool await_ready() { return false; }     bool await_suspend(std::coroutine_handle<> h) {         _addr = h.address();         return false;     }     void* await_resume() {         return _addr;     }     void* _addr; };  struct return_object : std::coroutine_handle<> {     struct promise_type     {         return_object get_return_object() {             return std::coroutine_handle<promise_type>::from_promise(*this);         }         std::suspend_always initial_suspend() noexcept { return {}; }         std::suspend_always final_suspend() noexcept { return {}; }         void return_void() {}         void unhandled_exception() {}     };      return_object(std::coroutine_handle<promise_type> h) : std::coroutine_handle<>(h){} };  struct args {     int n; };  return_object foo(args* arg) {     int start = arg->n;     for (int i = 0; i < 5; i++)     {         printf("coroutine %p : %d/n", co_await coroutine_running{}, start + i);         co_await std::suspend_always{};     } }  int main() {     args arg1 = { 0 };     args arg2 = { 100 };     auto co1 = foo(&arg1);     auto co2 = foo(&arg2);     printf("main start/n");     while (!co1.done() && !co2.done())     {         co1.resume();         co2.resume();     }     co1.destroy();     co2.destroy();     printf("main end/n"); } 

这段代码输出

main start coroutine 0x607000000020 : 0 coroutine 0x607000000090 : 100 coroutine 0x607000000020 : 1 coroutine 0x607000000090 : 101 coroutine 0x607000000020 : 2 coroutine 0x607000000090 : 102 coroutine 0x607000000020 : 3 coroutine 0x607000000090 : 103 coroutine 0x607000000020 : 4 coroutine 0x607000000090 : 104 main end 

对比三段代码,可以看到 C 版本比 Lua 版本多了 coroutine_open 和 coroutine_close,C 版本需要保存一个全局状态 S。Lua 版本无法在创建协程的时候指定参数,必须在之后的 resume 把参数传递给 foo。C++ 20 版本需要手写第 5~35 行的框架代码才能达到同样的效果。抛开这三段代码的差异,能看到协程库的共性:创建协程(create)、恢复协程(resume)、让出协程(yield)、销毁协程(destroy)。cloudwu/coroutine 的源代码行数非常少,所以以此分析一个协程库如何实现这些机制。

在分析代码之前需要明确协程的一些基本概念,coroutine 的 co 并不是 concurrency,而是 cooperative。协程就是能协作执行的例程(routine)。函数(function)也是例程,但不是协程。协程和函数是类似概念。协程和线程不类似,和线程类似的概念是纤程(Fiber)。关于协程和纤程的区别可以看 N4024。协程按能不能在嵌套栈帧中挂起(suspend),分为:stackless 协程 和 stackful 协程。stackless 协程只能在最顶层栈帧挂起,stackful 协程能在任意栈帧挂起。C++ 20 协程是 stackless 协程。所以在上面的代码中,如果 foo 再调用一个函数 bar,bar 就无法使用 co_await。但是 C 版本和 Lua 版本的协程可以这样,所以它们是 stackful。如果 a resume b,则称 a 为 b 的 resumer。协程还可以按控制流(control flow)切换方式分为:对称(symmetric)协程和非对称(asymmetric)协程。非对称协程通过 yield 切换到其 resumer,不需要指定切换到哪个协程。对称协程每次切换都需要指定切换到哪个协程,它可以切换到任意协程。C 版本和 Lua 版本的协程都只支持非对称协程,但是可以通过非对称协程实现对称协程。C++ 20 协程两者都支持。

源代码只有两个文件 coroutine.h 和 coroutine.c。

2 coroutine.h

先看 coroutine.h,有 4 个宏定义

Name Value
COROUTINE_DEAD 0
COROUTINE_READY 1
COROUTINE_RUNNING 2
COROUTINE_SUSPEND 3

显然,这个 4 个宏定义了协程的四种状态,协程创建完还没执行是 COROUTINE_READY,正在执行是COROUTINE_RUNNING,被挂起是 COROUTINE_SUSPEND,已经结束是 COROUTINE_DEAD。为方面阅读,下面直接用 Ready、Running、Suspend、Dead 指代。

一个 schedule 结构体,保存了用来做协程调度的信息,是一个协程组的全局状态。注意协程并没有调度器,也不需要像进程和线程那样的调度算法。每次协程的切换,切换到哪个协程都是固定的。所有属于同一个 schedule 的协程可以视为一个协程组。schedule 拥有这个协程组的信息。

一个函数指针定义 coroutine_func,这个函数表示协程的执行内容,也就是协程执行的时候会调用这个函数。该函数有一个参数 ud,ud 是 user data 的缩写,user 指代调用接口的程序员,user data 被用来传递数据。Lua 的 userdata 也用于 C 和 Lua 之间数据传递。

两个针对全局状态 S 的操作:coroutine_open 和 coroutine_close

五个针对单个协程的操作:coroutine_new、coroutine_resume、coroutine_status、coroutine_running、coroutine_yield。其中 coroutine_new 也有一个 ud 和 coroutine_func 的参数对应。根据这些状态和操作可以画出协程的状态图。

stateDiagram [*] –> Ready : coroutine_new Ready –> Running : coroutine_resume Running –> Suspend : coroutine_yield Suspend –> Running : coroutine_resume Running –> Dead : normal finish / coroutine_close Dead –> [*]

协程从 Running 转变为 Dead,有两种途径,一是正常结束返回,二是调用 coroutine_close 关闭所有协程。两者都会销毁协程。协程被销毁之后,其保存的状态也不存在了,只不过用 Dead 表示其不存在。观察这个状态图,可以和 Lua 的协程状态进行类比,发现 Lua 协程还具有 normal 状态,也就是 main resume a, a resume b,a 是 normal 状态。经过测试,C 版本协程库无法嵌套 resume,a 不能 resume b。resumer 只能是 main routine。也就没有 normal 状态。

3 coroutine.c

在这个文件里包含了 ucontext.h,说明这个库使用 ucontext 进行 context 切换。查看 ucontext_t 的定义,可以看到它保存了 CPU 的寄存器值和其它状态信息,包括通用寄存器 rax、rbx、rcx、rdx …,用于浮点数计算的XMM寄存器,中断屏蔽字 sigmask 等。

STACK_SIZE 定义了所有协程栈的容量为 1MB,DEFAULT_COROUTINE 定义了初始的协程数量为 16。注意初始的协程数量为 16,并不代表创建了 16 个协程,只是分配了大小为 16 的协程指针数组。

schedule 结构体里的 statck 是大小为 STACK_SIZE 的字节数组。表示协程组所有协程的工作栈,这个栈是所有协程共享的,它是固定分配的,不会动态扩容。当任意一个协程被恢复,这个协程会使用这个工作栈执行代码,分配栈变量。main 表示 main routine 的 ucontext。nco 表示当前存在的协程数量。cap 是 capacity 的缩写,表示协程指针数组的容量,初始大小是 DEFAULT_COROUTINE。running 表示当前正在执行的协程 id。co 是协程指针的指针,也就是一个协程指针数组。为什么使用协程指针数组,而不直接使用协程数组?因为扩容的时候效率更高。co 扩容的时候,如果使用协程指针数组,挪动的每个格子大小为 sizeof(struct coroutine *)。反之,直接使用协程数组,挪动的每个格子大小为 sizeof(coroutine)。哪个效率高,一目了然。

coroutine 结构体保存了每个协程的状态信息。func 表示协程的执行内容,由 coroutine_new 的 func 参数指定。ud 表示 func 的参数,由 coroutine_new 的 ud 参数指定。ctx 表示该协程的 ucontext。sch 指向全局状态 S,也就是每个协程可以反向查找到其属于哪个协程组。cap 是该协程的私有栈容量,size 是实际占用大小。status 表示该协程的状态,也就是前面提到的四种状态,但是 status 永远不会被置为 Dead。因为一个协程销毁之后,就会在 S 中的协程指针数组中对应格子置为 NULL。status 没有机会被置为 Dead。stack 表示该协程的私有栈,是动态扩容的。

共享栈是所有协程的工作栈,同一时间有且只有协程正在执行,所有只需要一个工作栈就行了。每个协程都有自己的栈数据,因此每个协程都需要自己私有栈。为什么不直接用私有栈作为工作栈,这样还省去了共享栈和私有栈之间数据复制?因为这里采取的策略是用时间换取空间。假设没有共享栈,所有协程使用自己的私有栈作为工作栈。要保证同协程栈变量地址一致性,也就是一个局部变量在协程切出去和切回来之后的地址是相同的,工作栈无法动态扩容。因为一旦扩容,无法保证这个一致性,user code 可能会出问题。所以工作栈无法动态扩容,所有私有栈大小都为 STACK_SIZE。假设有 1024 个协程都处于 Ready 状态,即使还没执行,也占用了 1 GB 的内存。共享栈保证了同协程栈变量地址一致性,但是无法保证异协程栈变量地址一致性。例如,协程 a 访问协程 b 的一个栈变量地址,访问的是共享栈的地址,user code 无法正常工作。通常来说这种使用场景非常少,也可以将访问栈变量地址改为访问堆变量地址来规避这个问题。

下面从头文件的接口函数入手,逐个进行分析

3.1 coroutine_open

该函数为 S 分配内存,初始化其每个成员,为协程指针数组 co 预留 DEFAULT_COROUTINE 个格子,并全部置为 NULL。如果仔细观察,可以发现 stack 并没有进行 memset 操作,是忘记写了吗?其实是故意为之。因为栈内存的初始化应该由 user code 承担,这里没必要进行初始化。main 也没有进行初始化,实际上所有 ucontext_t 结构体都不需要初始化,它们在被使用之前都应该使用 getcontext 赋值。

3.2 coroutine_close

该函数销毁所有协程,然后销毁全局状态 S。这里直接遍历了所有的格子,因为现存的协程可能并不是正好占据第 0 ~ nco – 1 个格子。

3.3 coroutine_new

该函数分配一个 coroutine 结构体并执行初始化。可以看到,此时协程的状态被置为 Ready。协程的私有栈没有被分配,等到协程真正要被执行的时候再分配。然后把协程指针保存到 S 中,但是 S 的 co 格子数量可能不够,需要动态扩容。这里的扩容策略是系数为 2 的线性增长。这里扩容使用了 realloc 函数,这个函数比使用 malloc + free 效率更高。如果格子数量足够就从第 nco 个格子开始查找空位。如果达到 cap – 1,就回绕从第 0 个格子继续查找。也就是进行循环查找,能够最大化利用 co 的所有空格。从第 nco 个格子开始查找具有一定的效率优势,例如假设现存 10 个协程,也没有协程被销毁,在分配第 11 个协程时,能够直接找到第 10 个格子,它正好是空格子。但是,一旦协程被随机销毁,这种优势会消失,占用的格子会被随机分布,找第 nco 个格子和随机选择一个格子没什么区别。assert(0) 是一个运行期断言,如果走到这里,说明 S 的数据被破坏了,直接执行 abort() 终止程序。

3.4 coroutine_resume

该函数挂起当前的函数,恢复指定的目标协程。assert(S->running == -1); 说明调用者不能是属于 S 协程组的协程。因为如果调用者是协程,runnning 不可能为 -1,程序会直接退出。assert(id >=0 && id < S->cap); 校验 id 是否合法。接下来根据目标协程的状态进行操作。可以看到只能 resume 一个状态为 Ready 或 Suspend 的协程。

可以看到,恢复一个 Ready 协程,使用到了三个 UNIX 函数,分别为 getcontext、makecontext、swapcontext。实际上这组 UNIX 函数有四个,只不过只用了这三个。

#include <ucontext.h>  int getcontext(ucontext_t *ucp); int setcontext(const ucontext_t *ucp);  void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); int swapcontext(ucontext_t *restrict oucp, const ucontext_t *restrict ucp); 

getcontext 保存当前的 context 到 ucp。setcontext 用 ucp 设置当前的 context,也就是激活 ucp。这个函数一旦调用成功,就不会返回。这种调用成功不返回的函数,与 longjmp 类似,它也是这样。实际上,用户态 context 切换机制经历了三个发展阶段。第一阶段是 setjmp/longjmp。第二阶段是 sigsetjmp/siglongjmp,它能够控制中断屏蔽字。第三个阶段是 getcontext/setcontext,它能够提供更加完整的控制。

makecontext 可以修改一个 context,它可以修改 ucp 的里面的一些寄存器和状态,使得 ucp 被激活后会调用 func(…) 函数。argc 表示后面的参数个数,后面可以传递任意个数的参数。但是为了可移植性,必须都是 int 类型。代码里面使用的是 uint32_t 与 int 相符。Linux x86_64 的数据模型是 LP64,也就是 long 和 pointer 都是64位。因此,如果要传递指针 S,则需要将 S 拆成两个 32 位的 uintptr_t。当然这段代码也支持 Linux x86,高 32 位都是 0。在调用 makecontext 之前必须设置 ucp->uc_stack 和 ucp->uc_link。ucp->uc_stack 表示 ucp 激活之后,func 函数在哪个栈上执行。因此需要对 uc_stack.ss_sp 和 uc_stack.ss_size 赋值。代码里面直接设置了工作栈 S->stack,和其大小 STACK_SIZE。这里需要注意的是 uc_stack.ss_sp 中的 sp 并不像 rsp 寄存器那样表示 stack pointer,指向栈顶。x86 架构的栈都是从高地址向低地址扩展。如果 ss_sp 表示 stack pointer 栈顶,则应该赋值为 S->stack + STACK_SIZE。实际上,uc_stack.ss_sp 的 sp 表示 starting pointer,表示工作栈的起始地址,也就是 S->stack。当 ucp 被激活后,会根据 CPU 架构自动设置 rsp,所以不必考虑这些。ucp->uc_link 表示 func 函数执行完成之后激活哪个 context。显然,协程执行完成之后,应该继续执行其 resumer,也就是 main routine。注意,代码里的 uc_link 赋值时,S->main 还没有任何有效内容。但是 uc_link 只需要保存 ucontext_t 的指针,所以可以这样做。S->running 赋值为 id。这里协程还没有真正执行,context 也没有切换,只是方便传参,mainfunc 可以通过 S 拿到 id。

swapcontext 并不是交换其两个参数 oucp 和 ucp。而是 oucp、当前 context、ucp 三者之间进行交换。 流程如下图所示:

flowchart RL cucp(当前 context) ucp — 2 –> cucp — 1 –> oucp

必须按顺序先将当前context 保存到 oucp,再激活 ucp。不然 S->main 的内容还是无效的,就被切换到了 mainfunc。这里使用 swapcontext,而不是分两次调用 getcontext 和 setcontext。假设使用 getcontext + setcontext,context 切换出去,后面再切回来的时候,会回到 getcontext 刚刚执行之后,又会再次执行 setcontext,这显然不行。使用 swapcontext 可以规避这个问题,当 context 切出去再切回来时,会正好回到 swapcontext 刚刚执行完毕。

为什么切换到 mainfunc,而不是直接切换到 C->func ?因为当 C->func 执行完成之后,需要销毁协程对象 C,也需要修改 S 的信息。所以使用 mainfunc 对 C->func 进行了包装。可以看到 mainfunc 执行了 C->func 之后确实进行了一些清理操作。所以协程执行完毕之后不必手动进行 destroy 操作,会自动释放内存。如果想在 user code 里面提前销毁被挂起的协程怎么办?作者预留了 _co_delete 接口,它没有出现在头文件中,但也没有用 static 修饰。也就是说,可以自己在 user code 写一遍 _co_delete 的声明,再手动清理。

恢复一个 Suspend 协程则相对简单得多。C->ctx 早已保存有效的信息。不需要再次设置,也不需要调用 makecontext。这是需要 restore 协程私有栈的内容到工作栈 S->stack。由于私有栈仅保存 user code 中已经分配的占内存,所以只复制 C->size 大小的内存。这里默认栈使用从高地址向低地址的扩展方式,也就是该代码只适用于这种栈扩展方式的架构,如 x86 架构。私有栈内容复制到共享栈的地址对齐如下图所示,栈底的 S->stack + STACK_SIZE 和 C->stack + size 都是实际占用内存的下一个地址。

                                         S->stack       C->stack High Addr    S->stack + STACK_SIZE ---> +-------+       +-------+ <-- C->stack + size     |                                   |       |       |       |     |                                   |       |       |       |     |        S->stack + STACK_SIZE      |       |       |       |     |              - C->size       ---> |-------|<----- +-------+ <-- C->stack     |                                   |       |     V                                   |       | Low Addr                  S->stack ---> +-------+ 

3.5 coroutine_yield

执行 coroutine_yield 会挂起当前协程,并且切换到其 resumer 继续执行。如果简单地使用 swapcontext,会出现问题。如协程 a 挂起,main routine 再恢复协程 b 执行一些操作,b 再挂起,main routine 再恢复 a。这时 a 的栈变量可能已经被 b 修改了。这是因为 a 和 b 共享一个工作栈。所以在 swapcontext 之前需要将当前协程的工作栈内容 store 到其私有栈。在调用 _save_stack 函数之前有一个断言,assert((char *)&C > S->stack); 这个断言只能用来校验 &C 是否超过 S->stack 的下界。虽然 C 是一个指针,但是也是一个栈变量,其地址和当前工作栈的栈顶 S->stack 进行比较。如果 &C > S->stack 则表示 &C 的地址没有超过下界。_save_stack 里面还有 assert(top - &dummy <= STACK_SIZE); ,这个断言作用也是如此。为什么只检测下界而不检测上界?因为这里默认栈扩展方式为从高地址向低地址扩展。所以只比较栈顶 S->stack。代码里将 S->stack + STACK_SIZE 命名为 top,实际为栈底,但地址更高。

_save_stack 的 dummy 非常巧妙,其地址与栈底 top 之差就是从 dummy 到 top 之间的栈大小,栈顶就是 &dummy。假设在 dummy 之后又有声明了局部变量 x,这个局部变量并没有保存下来。因此,在 _save_stack 和 swapcontext 之间声明的任何局部变量都不应该在 swapcontext 之后被使用。显然,这里的代码符合这个规范,不会出现问题。私有栈的内存也在 _save_stack 中分配。这里采取的分配策略是按需分配,只增不减。为什么这里又不使用 realloc 而是使用 free + malloc?因为 realloc 除了重新分配内存之外,还会复制原有数据到新的内存块中。这里私有栈扩容之后,原有数据可以直接丢弃。

swapcontext 保存当前 context 到 C->ctx,再激活 S->main,也就是 main routine。

3.6 coroutine_status

该函数返回指定协程的状态。正如前面所说,Dead 协程已经被销毁,只是该函数返回 COROUTINE_DEAD。

3.7 coroutine_running

该函数返回协程组 S 正在执行的协程 id。

4 对协程库的扩展

4.1 实现嵌套 resume

如果只有一个协程组是无法实现嵌套 resume 的。但是可以创建多个协程组来达到这个目的。以下图所示的 resume 关系为例

stateDiagram main –> co_a : resume co_a –> main : yield co_a –> co_b : resume co_b –> co_a : yield

设计思路是 main 作为 co_a 的 resumer,co_a 作为 co_b 的 resumer,需要创建两个协程组。具体代码如下:

#include "coroutine.h" #include <stdio.h>  struct args {     struct schedule *next_S;     int next_co; };  void fa(struct schedule *S, void *ud) {     struct args *arg = (struct args *)ud;     printf("fa1/n");     coroutine_resume(arg->next_S, arg->next_co);     printf("fa2/n");     coroutine_resume(arg->next_S, arg->next_co);     printf("fa3/n");     coroutine_yield(S);     printf("fa4/n"); }  void fb(struct schedule *S, void *ud) {     struct args *arg = (struct args *)ud;     printf("fb1/n");     coroutine_yield(S);     printf("fb2/n"); }  int main() {     struct args arg1 = {0};     struct args arg2 = {0};      struct schedule *S_a = coroutine_open();     struct schedule *S_b = coroutine_open();      int co_a = coroutine_new(S_a, fa, &arg1);     int co_b = coroutine_new(S_b, fb, &arg2);      arg1.next_S = S_b;     arg1.next_co = co_b;      printf("main start/n");      while (coroutine_status(S_a, co_a))     {         coroutine_resume(S_a, co_a);         printf("main/n");     }      printf("main end/n");     coroutine_close(S_a);     coroutine_close(S_b); } 

这段代码输出:

main start fa1 fb1 fa2 fb2 fa3 main fa4 main main end 

4.2 实现对称协程

使用非对称协程可以实现对称协程,但是这个协程库的 yield 操作无法传递参数,只能借助全局变量。以下是实现代码:

#include "coroutine.h" #include <stdio.h>  #if __APPLE__ && __MACH__ #include <sys/ucontext.h> #else #include <ucontext.h> #endif  #define STACK_SIZE (1024 * 1024)  struct schedule {  char stack[STACK_SIZE];  ucontext_t main;  int nco;  int cap;  int running;  struct coroutine **co; };  struct schedule_extra {  int target_co; };  struct schedule_extra S_extra = {-1};  void co_symmetric_transfer(struct schedule *S, int id) {  if (coroutine_running(S) == -1)  {   // resumer call this func   coroutine_resume(S, id);   if (S_extra.target_co != -1 && coroutine_status(S, id))   {    co_symmetric_transfer(S, S_extra.target_co);   }  }  else  {   // coroutine call this func   S_extra.target_co = id;   coroutine_yield(S);  } }  struct args {  int n;  int co_other; };  static void foo(struct schedule *S, void *ud) {  struct args *arg = ud;  int start = arg->n;  int i;  for (i = 0; i < 5; i++)  {   printf("coroutine %d : %d %d/n", coroutine_running(S), start + i, arg->co_other);   co_symmetric_transfer(S, arg->co_other);  } }  static void test(struct schedule *S) {  struct args arg1 = {0};  struct args arg2 = {100};   int co1 = coroutine_new(S, foo, &arg1);  int co2 = coroutine_new(S, foo, &arg2);   arg1.co_other = co2;  arg2.co_other = co1;   printf("main start/n");   co_symmetric_transfer(S, co1);  co_symmetric_transfer(S, co2);   printf("main end/n"); }  int main() {  struct schedule *S = coroutine_open();  test(S);  coroutine_close(S);   return 0; } 

这段代码输出:

main start coroutine 0 : 0 1 coroutine 1 : 100 0 coroutine 0 : 1 1 coroutine 1 : 101 0 coroutine 0 : 2 1 coroutine 1 : 102 0 coroutine 0 : 3 1 coroutine 1 : 103 0 coroutine 0 : 4 1 coroutine 1 : 104 0 main end 

商匡云商
Logo
对比商品
  • 合计 (0)
对比
0
购物车