Yi's Blog

胸中有丘壑,眼里存山河

cloudwu/coroutine 在 macOS 上的 bug

周末阅读了云风八年前用 C 语言实现的协程库。代码非常简洁,只有二百来行,展示了如何基于 ucontext 函数库实现非对称协程(Asymmetric Coroutine),且不同的协程共享同一个栈空间。

运行测试程序的时候发现,测试程序在 Linux 上可以成功运行,然而在 macOS 上却会进入一个死循环,看上去是由于 ucontext 在不同平台的实现不同引起的。于是,花了一些时间阅读 ucontext 在 macOS 上的实现,理解了协程无法继续运行的原因,并提供了一个解决思路。

什么是 ucontext?

ucontext 是指 C 语言库函数中用来控制函数调用上下文(context)的一组函数:getcontext, setcontext, makecontext 和 sawapcontext。这组函数可以被用来实现协程。这组函数作为 POSIX 标准只存在了短短七年时间(从 POSIX.1-2001 到 POSIX.1-2008),如今已经被移除了 POSIX 标准,类似的功能改为由 pthreads (POSIX Threads) 提供。尽管这几个函数已经被移除了标准,我们还能在大部分操作系统中找到实现。以苹果为例,macOS 的 ucontext 实现在其网站开源:https://opensource.apple.com/source/Libc/Libc-825.25/x86_64/gen云风开源的协程库就是在 ucontext 之上实现的。

光说不练假把式,以下是一个简单的用例:

#define _XOPEN_SOURCE 600L

#include <stdio.h>
#include <stdlib.h>
#include <ucontext.h>

static ucontext_t uctx_main, uctx_func1;

#define handle_error(msg) \
  do { perror(msg); exit(EXIT_FAILURE); } while (0)

static void func1(void) {
  printf("func1: started\n");
}

int main(int argc, char *argv[]) {
  char func1_stack[1024*1024]; // 131072

  if (getcontext(&uctx_func1) == -1)
    handle_error("getcontext");

  uctx_func1.uc_stack.ss_sp = func1_stack;
  uctx_func1.uc_stack.ss_size = sizeof(func1_stack);
  uctx_func1.uc_link = &uctx_main;
  makecontext(&uctx_func1, func1, 0);

  printf("main: swapcontext(&uctx_main, &uctx_func1)\n");
  if (swapcontext(&uctx_main, &uctx_func1) == -1)
    handle_error("swapcontext");
  
  printf("main: exiting\n");

  exit(EXIT_SUCCESS);
}

// Output:
//   main: swapcontext(&uctx_main, &uctx_func1)
//   func1: started
//   main: exiting

swapcontext

要说清楚 bug 的原因,需要阅读 swapcontext 在 macOS 上的实现。由于非常简洁,只有短短十五行,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp) {
  int ret;

  if ((oucp == NULL) || (ucp == NULL)) {
    errno = EINVAL;
    return (-1);
  }
  oucp->uc_flags &= ~UCF_SWAPPED;
  ret = getcontext(oucp);
  if ((ret == 0) && !(oucp->uc_flags & UCF_SWAPPED)) {
    oucp->uc_flags |= UCF_SWAPPED;
    ret = setcontext(ucp);
  }
  return (ret);
}

这里的 8~11 行让人费解,先是将 uc_flags 的 UCF_SWAPPED 位设为 0,然后判断该位是不是为0(难道不是一定为 0 ?),如果为 0, 设为 1 并且执行 setcontext,否则直接返回。

打了断点调试了一下才发现,原来这里的判断语句会被执行两次。如图,从 Main 这个 context 来看,第一次是在首次执行 switchcontext 时,因为 UCF_SWAPPED 位刚刚被设为 0,所以会执行 11~12 行,即切换到 Func context,第二次是在 context 被恢复时,因为 getcontext 记录的是其返回地址,所以恢复之后会从第 10 行继续执行,而由于在第一次执行时,UCF_SWAPPED 已经被设为 1 了,所以这次不会执行 11~12 行。

了解了上述机制之后,再看 coroutine 是如何保存堆栈,然后使用 switchcontext 的。

1
2
3
4
5
6
7
8
9
10
void coroutine_yield(struct schedule * S) {
  int id = S->running;
  assert(id >= 0);
  struct coroutine * C = S->co[id];
  assert((char *)&C > S->stack);
  _save_stack(C,S->stack + STACK_SIZE);
  C->status = COROUTINE_SUSPEND;
  S->running = -1;
  swapcontext(&C->ctx , &S->main);
}

第 6 行保存堆栈信息,然后在第 9 行调用 swapcontext,由于函数的返回地址是被保存在栈中的,所以在恢复这里保存的 context 时,使用的是第 6 行保存的堆栈,即返回地址为第 7 行,而在 swapcontext 执行完 10~15 行之后,会继续执行 coroutine_yield 的 7~9 行,也就导致 swapcontext 再次被调用,context 被再次切换回 S->main,然而在 S->main 当中会等待协程完成,所以程序进入了死循环。

理解了造成 bug 的原因,修复起来也就不难了。自行实现 swapcontext 的内容,在同一个函数里保存堆栈,然后再执行原有的程序内容即可。

static int save_and_swapcontext(
    ucontext_t *oucp, 
    const ucontext_t *ucp, 
    struct schedule * S, 
    struct coroutine *C) {

  _save_stack(C, S->stack + STACK_SIZE);
  
  C->status = COROUTINE_SUSPEND;
  S->running = -1;
  
  int ret;

  if ((oucp == NULL) || (ucp == NULL)) {
    errno = EINVAL;
    return (-1);
  }

  oucp->uc_flags &= ~UCF_SWAPPED;
  ret = getcontext(oucp);
  if ((ret == 0) && !(oucp->uc_flags & UCF_SWAPPED)) {
    oucp->uc_flags |= UCF_SWAPPED;
    ret = setcontext(ucp);
  }
  return (ret);
}
...
void coroutine_yield(struct schedule * S) {
  int id = S->running;
  assert(id >= 0);
  struct coroutine * C = S->co[id];
  assert((char *)&C > S->stack);
  save_and_swapcontext(&C->ctx , &S->main, S, C);
}

总结

因为是第一次尝试阅读汇编语言,单步调试操作系统提供的函数,再加上函数调用时内存空间的分布在脑子里不是一清二楚,最终花费了一天半的时间,才算彻底弄清楚造成死循环的原因。至于为什么相同的代码在 Linux 上可以成功运行,还有待于进一步阅读 swapcontext 在 GNU 中的实现。

在解决这个 bug 的过程中获益良多:阅读和单步调试了一些汇编语言,复习了一些在《程序员的自我修养》中读到的关于函数调用过程的知识,也算对线程/协程是如何实现的有了一些更底层的认识和理解。

参考链接

直接相关

苹果 ucontext 实现相关

汇编语言相关

调试 DYLD 相关:

LLDB 相关

Linux 的 swapcontext 实现相关

Stack Unwinding / _Unwind_Frames_Extra on Linux 相关

其他