ucontext and coroutine

balus

2022-05-08

Preface

最近在看 seastar::thread 的实现,暂且不说它的巧妙(实际上目前还没有完全看懂,但是 Seastar 出品,必属精品),我首先注意到了 ucontext,这是 seastar::thread 中能够等待一个 unavailable future 的关键,之前没有接触过这套 API,网上搜索了一下相关资料才了解到它居然可以用来实现协程,顿时有了兴趣。

学习 OS 时我们就知道线程有几种实现方式:在内核态中实现、在用户态中实现以及混合实现;内核级线程我们已经在 pthread 中见识过了,用户态的线程,或者说协程,在不少语言中都有实现,比如 Lua 的 coroutine,Golang 的 goroutine。

对于 pthread 线程,操作系统会对它们进行调度,使用者需要做的更多是处理线程之间的通信与同步;而到了 协程,则不是这样了,OS 压根不知道它们的存在,所以它们的调度也得由使用者来做,比如 Lua 的 coroutine——用户需要决定该执行哪一个协程以及何时切换到其他协程,Golang 的 goroutine(GMP 模型应该看做是混合实现)则更近一步——从语言层面就接管了协程的调度,大大减少了协程的使用负担,用户只需要专注于自己的业务逻辑——放心地 go ,Golang runtime 自会让 goroutine 之间高效地配合运行。

ucontext

ucontext 也可以用来实现协程,它整体来看还是类似于 Lua coroutine —— 需要用户去调度执行;我们知道,不论是 thread 还是 coroutine 调度/切换,都需要保存当时的执行上下文,以便后续可以回去继续执行;thread 的上下文由 OS 保存维护,对用户是透明的;而这里 coroutine 要用户来进行调度,所以其上下文对用户自然也是可见的——就是 ucontext,即 user thread context——用户级线程的执行环境,其中保存着 coroutine 执行所需要的栈、寄存器、信号等信息;通过它,我们可以很方便地在 uthread 之间进行切换

ucontext_t在 Linux 5.15 中其定义如下:

typedef struct ucontext_t
  {
    unsigned long int __ctx(uc_flags);
    struct ucontext_t *uc_link;
    stack_t uc_stack;
    mcontext_t uc_mcontext;
    sigset_t uc_sigmask;
    struct _libc_fpstate __fpregs_mem;
    __extension__ unsigned long long int __ssp[4];
  } ucontext_t;

typedef struct
  {
    void *ss_sp;
    int ss_flags;
    size_t ss_size;
  } stack_t;

其中以 uc 开头的几个字段是比较常用的:

glibc 提供了以下函数用于操纵 ucontext

  1. int getcontext(ucontext_t *ucp)

这个函数会用当前协程的执行信息保存在 ucp 中来初始化它

  1. int setcontext(const ucontext_t *ucp)

如果调用成功,则 setcontext 不会返回,而是切换至 ucp 指向的协程去执行;ucp 要么是通过 getcontext() 初始化的,要么是通过 makecontext() 新创建的(后面会看到 makecontext() 之前也需要通过 getcontext() 初始化,但是我们只根据最近一次对 ucontext 的修改操作进行区分)。

这两种情况有所不同:

特别要注意二者对于 ucp->uc_link 的处理是不同的;对于通过 getcontext() 初始化的 ucp,被 setcontext() (以及后面要介绍的 swapcontext()) 调用 resume 并执行结束后,并不会切换至 ucp->uc_link 指向的 coroutine 继续执行——即使它不为空;而只会正常返回至其 caller

  1. int makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)

这是创建一个 coroutine;在创建它之前,ucp 必须通过 getcontext 初始化,并设置好 uc_stack(以及 uc_linkuc_sigmask 字段,如果需要的话);func 为该 coroutine 的入口函数,虽然 func 的签名是 void (*func)() 不带任何参数,但是事实上它是可以带的(传给makecontext 的时候强制转换一下就行,毕竟函数名也就是一个地址),其参数个数由 argc 指定,参数内容紧跟在 argc 后面传递给 makecontext;传给 makecontext 额外参数个数要和 argc 以及 func 的参数个数一定要保持一致,否则会出问题

需要注意的是,创建了的 coroutine 并不会马上执行,必须通过 setcontext 或者 swapcontext 主动执行它

  1. int swapcontext(ucontext_t *oucp, const ucontext_t *ucp)

切换到 ucp 指向的 coroutine 去执行,并将当前 coroutine 的上下文保存在 oucp

一个 🌰

这是 Wikipedia 上的一个例子,很简短但是很有意思,主要用于理解 getcontextsetcontext 的作用:

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

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

在第 8 行调用 getcontext 获取当前 coroutine 的上下文,需要注意这里并没有先 makecontext,因为 pthread 线程其实就是一个 coroutine。所以我们并不需要先通过 makecontext 创建 coroutine,也没有限制只能在 makecontextfunc 函数中调用 getcontext;这一点和 Lua coroutine 是一样的。

第 9、10 行睡眠打印完毕之后,第 11 行恢复先前保存的 context,这会程序的执行流导致回到 getcontext() 调用的下一行 (就好像 getcontext() 调用正常返回了一样) 继续执行,循环往复从而实现了一个死循环的效果。

又一个 🌰

前面提到过通过 getcontext 初始化的 ucontext 和通过 makecontext 创建的 ucontext 的 uc_link 的处理是不同的,setcontext 或者 swapcontext 执行前者并返回时不会执行 uc_link——不管它是否为 NULL;我们可以写一段代码证明这一点:

ucontext_t main_co, co;
int inited_main_co_link = 0;

void co_fn() {
  printf("co started\n");
}

int main(int argc, char **argv)
{
  char co_stk[8192];

  getcontext(&co);
  co.uc_stack.ss_sp= co_stk;
  co.uc_stack.ss_size = sizeof(co_stk);
  makecontext(&co, co_fn, 0);

  getcontext(&main_co);
  printf("after getcontext()\n");
  if (!inited_main_co_link) {
    printf("link co to main co\n");
    inited_main_co_link = 1;
    main_co.uc_link = &co;
    setcontext(&main_co);
  }
  printf("main co returning");
}

这里将 co 挂接到 main_couc_link 上,并使用 inited_main_co_link 来避免死循环,编译执行得到结果

% ./a.out
after getcontext()
link co to main co
after getcontext()
main co returning

可以看到在退出 main 函数之后并没有执行 co_fn这篇文档对此做出了说明,

The uc_link member is used to determine the context that shall be resumed when the context being modified by makecontext() returns. The application shall ensure that the uc_link member is initialized prior to the call to makecontext().

也就是说,uc_link 只适用于通过 makecontext 修改过的 ucontext,不过上面这段文字只是对这一点做了正式说明(macOS 和 Linux 的 manpage 上并没有提到这一点),但是这是为什么呢?

我猜想是因为 makecontext 用于给一个尚未执行的函数创建 context,而 getcontext 则是在当前正在执行的函数中调用并获取其 context;当我们通过 setcontext/swapcontext 执行 coroutine 时,我们常常希望 coroutine 结束后可以返回调用方,而setcontext()/swapcontext() 并不是以正宗的函数调用的方式调用 coroutine function,所以自然无法通过正常的函数返回机制返回至调用方;对于新创建的函数,它需要以 uc_link 的方式显式指定返回地址(有点 continuation 的味道),而对于已经在执行着的函数,它可能已经有一个正儿八经的函数调用 caller 了(说可能是因为它也可能是通过 makecontext 创建并通过 setcontext()/swapcontext() 调度执行的),所以结束时可以直接以函数调用的方式返回,而如果再考虑 uc_link 的话就有两条返回路径了,这肯定是不行的。

最后一个 🌰

大多数讲解 ucontext 的博客都会用下面这段来自 Wikipedia 的代码1作为示例,它把 ucontext 的这几个 API 都用上了,但是逻辑又非常简单,所以非常适合用来作为🌰,我也延续这个优良传统:

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

/* The three contexts:
 *    (1) main_context1 : The point in main to which loop will return.
 *    (2) main_context2 : The point in main to which control from loop will
 *                        flow by switching contexts.
 *    (3) loop_context  : The point in loop to which control from main will
 *                        flow by switching contexts. */
ucontext_t main_context1, main_context2, loop_context;

/* The iterator return value. */
volatile int i_from_iterator;

/* This is the iterator function. It is entered on the first call to
 * swapcontext, and loops from 0 to 9. Each value is saved in i_from_iterator,
 * and then swapcontext used to return to the main loop.  The main loop prints
 * the value and calls swapcontext to swap back into the function. When the end
 * of the loop is reached, the function exits, and execution switches to the
 * context pointed to by main_context1. */
void loop(
    ucontext_t *loop_context,
    ucontext_t *other_context,
    int *i_from_iterator)
{
    int i;
    
    for (i=0; i < 10; ++i) {
        /* Write the loop counter into the iterator return location. */
        *i_from_iterator = i;
        
        /* Save the loop context (this point in the code) into ''loop_context'',
         * and switch to other_context. */
        swapcontext(loop_context, other_context);
    }
    
    /* The function falls through to the calling context with an implicit
     * ''setcontext(&loop_context->uc_link);'' */
} 
 
int main(void)
{
    /* The stack for the iterator function. */
    char iterator_stack[SIGSTKSZ];

    /* Flag indicating that the iterator has completed. */
    volatile int iterator_finished;

    getcontext(&loop_context);
    /* Initialise the iterator context. uc_link points to main_context1, the
     * point to return to when the iterator finishes. */
    loop_context.uc_link          = &main_context1;
    loop_context.uc_stack.ss_sp   = iterator_stack;
    loop_context.uc_stack.ss_size = sizeof(iterator_stack);

    /* Fill in loop_context so that it makes swapcontext start loop. The
     * (void (*)(void)) typecast is to avoid a compiler warning but it is
     * not relevant to the behaviour of the function. */
    makecontext(&loop_context, (void (*)(void)) loop,
        3, &loop_context, &main_context2, &i_from_iterator);
   
    /* Clear the finished flag. */      
    iterator_finished = 0;

    /* Save the current context into main_context1. When loop is finished,
     * control flow will return to this point. */
    getcontext(&main_context1);
  
    if (!iterator_finished) {
        /* Set iterator_finished so that when the previous getcontext is
         * returned to via uc_link, the above if condition is false and the
         * iterator is not restarted. */
        iterator_finished = 1;
       
        while (1) {
            /* Save this point into main_context2 and switch into the iterator.
             * The first call will begin loop.  Subsequent calls will switch to
             * the swapcontext in loop. */
            swapcontext(&main_context2, &loop_context);
            printf("%d\n", i_from_iterator);
        }
    }
    
    return 0;
}

代码中的注释已经讲得非常明白了,整体就是 loop 函数与 main 函数的来回切换执行,并通过一个全局变量交换数据(这一点不如 Lua 的 coroutine 方便):

有一点需要特别注意,在调用 makecontext 之前我们总是需要先调用 getcontext,这一点在这个回答以及这个回答中有所解释:

makecontext writes the function info into a context, and it will remain there until it is overwritten by something else. getcontext overwites the entire context, so would overwrite any function written there by a previous call to makecontext.

也就是说 makecontext 并不足以保存整个上下文, 所以才需要在此之前调用 getcontext, 然后用 makecontext 覆盖其入口函数.

🌰 中的两个小问题

实际上这几个🌰并不能编译(在我的 macOS 12.3 上),并报错:

The deprecated ucontext routines require _XOPEN_SOURCE to be defined #error The deprecated ucontext routines require _XOPEN_SOURCE to be defined

因为 ucontext 已经被标记为废弃,不推荐使用。参考 Stack Overflow 上的一个回答,在文件的最开头加上 #define _XOPEN_SOURCE 600 之后就可以编译。

尽管编译成功,但是第二个例子一运行就 coredump,这里又有一个坑: makecontext 隐含着一个要求:其 func 接受的参数必须为 int2,Linux 上的 manpge 对此做出了说明(macOS 没有):

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(), to permit this on some 64-bit architectures (e.g., x86-64).

而这里直接将 3 个指针传递给了 loop,所以才会出问题。参考 Seastar 中的做法3,我们可以将一个指针分为两部分:

struct loop_data_t {
    ucontext_t *loop_ctx;
    ucontext_t *main_ctx2;
    int        *from_iter;
};

loop_data_t loop_data{&loop_context, &main_context2,
                      const_cast<int *>(&i_from_iterator)};

void s_loop(int lo, int hi) {
    uint64_t q = uint64_t(lo) | (uint64_t(hi) << 32);
    auto    *d = reinterpret_cast<loop_data_t *>(q);
    loop(d->loop_ctx, d->main_ctx2, d->from_iter);
}

这里添加了一个 loop_data_t 类型的 static 全局变量用于存储 mainloop 函数之间交互所需要的数据,makecontext 时将该变量的地址分为高低 32bit 两个参数;此外此时 makcontextfunc 参数修改为 s_loop 函数,它其实就是 loop 函数的一个 wrapper:它会将它的两个 int 参数还原为 loop_data 的地址,从而获取到交互数据并调用 loop 函数

问题 & 总结

在使用上 ucontext 感觉和 Lua coroutine 很相似,但是一个很大的不同点在于 Lua coroutine 是 stackless 的,而 ucontext coroutine 则是 stackful 的,借用 Vyacheslav Egorov 的解释4

stackless/stackful are easy to define: if I am in the function foo and I am calling bar, do I need to know that bar is yielding? (“suspending” in Kotlin terms) if I do need to know that you have stackless “coroutines”, if you don’t - then you have a proper stackful coroutine.

此外在使用上我们还有一些注意点:

Stack Overflow 上的这个回答对这几个函数具体做了什么也进行了解释:

Reference


  1. https://www.wikiwand.com/en/Setcontext↩︎

  2. https://stackoverflow.com/questions/42894552/why-makecontexts-func-only-accepts-integer-arguments↩︎

  3. https://github.com/scylladb/seastar/blob/master/src/core/thread.cc#L221↩︎

  4. https://twitter.com/mraleph/status/935811340740038656↩︎