2022-05-08
最近在看 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 也可以用来实现协程,它整体来看还是类似于 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_sigmaskstruct _libc_fpstate __fpregs_mem;
unsigned long long int __ssp[4];
__extension__ } ucontext_t;
typedef struct
{
void *ss_sp;
int ss_flags;
size_t ss_size;
} stack_t;
其中以 uc
开头的几个字段是比较常用的:
uc_link
:指向另一个 ucontext,用于在当前 coroutine
结束时唤醒(resume)其他 coroutine 执行uc_stack
:coroutine 使用的栈uc_mcontext
:用于保存 machine-dependent
的数据,比如寄存器uc_sigmask
:blocked signalsglibc 提供了以下函数用于操纵 ucontext
:
int getcontext(ucontext_t *ucp)
这个函数会用当前协程的执行信息保存在 ucp
中来初始化它
int setcontext(const ucontext_t *ucp)
如果调用成功,则 setcontext
不会返回,而是切换至
ucp
指向的协程去执行;ucp
要么是通过
getcontext()
初始化的,要么是通过
makecontext()
新创建的(后面会看到
makecontext()
之前也需要通过 getcontext()
初始化,但是我们只根据最近一次对 ucontext 的修改操作进行区分)。
这两种情况有所不同:
ucp
是用 getcontext()
初始化的,那么说明该 coroutine 先前执行过,那么
setcontext()
则会回到该 coroutine yield
的地方继续执行后面的语句 (产生的效果就好像是 getcontext()
调用返回了并继续往下执行一样)ucp
是由 makecontext
创建的,那么该
coroutine 还没有执行过,此时会开始执行 makecontext
是传入的
func
函数;当 func
执行完毕,如果
ucp->uc_link
不为空,那么会切换到 uc_link
指向的 coroutine 继续执行 (就好像在函数返回的地方执行了一句
setcontext(ucp->uc_link
一样),否则只是退出特别要注意二者对于 ucp->uc_link
的处理是不同的;对于通过 getcontext()
初始化的
ucp
,被 setcontext()
(以及后面要介绍的
swapcontext()
) 调用 resume 并执行结束后,并不会切换至
ucp->uc_link
指向的 coroutine
继续执行——即使它不为空;而只会正常返回至其 caller
int makecontext(ucontext_t *ucp, void (*func)(), int argc, ...)
这是创建一个 coroutine;在创建它之前,ucp
必须通过
getcontext
初始化,并设置好 uc_stack
(以及
uc_link
,uc_sigmask
字段,如果需要的话);func
为该 coroutine 的入口函数,虽然
func
的签名是 void (*func)()
不带任何参数,但是事实上它是可以带的(传给makecontext
的时候强制转换一下就行,毕竟函数名也就是一个地址),其参数个数由
argc
指定,参数内容紧跟在 argc
后面传递给
makecontext
;传给 makecontext
额外参数个数要和
argc
以及 func
的参数个数一定要保持一致,否则会出问题
需要注意的是,创建了的 coroutine 并不会马上执行,必须通过
setcontext
或者 swapcontext
主动执行它
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp)
切换到 ucp
指向的 coroutine 去执行,并将当前 coroutine
的上下文保存在 oucp
中
这是 Wikipedia 上的一个例子,很简短但是很有意思,主要用于理解
getcontext
和 setcontext
的作用:
#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,也没有限制只能在
makecontext
的 func
函数中调用
getcontext
;这一点和 Lua coroutine 是一样的。
第 9、10 行睡眠打印完毕之后,第 11 行恢复先前保存的
context,这会程序的执行流导致回到 getcontext()
调用的下一行
(就好像 getcontext()
调用正常返回了一样)
继续执行,循环往复从而实现了一个死循环的效果。
前面提到过通过 getcontext
初始化的 ucontext 和通过
makecontext
创建的 ucontext 的 uc_link
的处理是不同的,setcontext
或者 swapcontext
执行前者并返回时不会执行 uc_link
——不管它是否为
NULL
;我们可以写一段代码证明这一点:
, co;
ucontext_t main_coint inited_main_co_link = 0;
void co_fn() {
("co started\n");
printf}
int main(int argc, char **argv)
{
char co_stk[8192];
(&co);
getcontext.uc_stack.ss_sp= co_stk;
co.uc_stack.ss_size = sizeof(co_stk);
co(&co, co_fn, 0);
makecontext
(&main_co);
getcontext("after getcontext()\n");
printfif (!inited_main_co_link) {
("link co to main co\n");
printf= 1;
inited_main_co_link .uc_link = &co;
main_co(&main_co);
setcontext}
("main co returning");
printf}
这里将 co
挂接到 main_co
的
uc_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 方便):
main
函数中的循环,其中
swapcontext
调度 loop
函数执行loop
函数中每执行一次循环体就通过
swapcontext
调度执行 main
函数loop_context
的 uc_link
指向了main_context1
,所以当 loop
函数结束后会回到 main
函数自动从 69 行之后继续执行iterator_finished
标记避免死循环有一点需要特别注意,在调用 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 tomakecontext
.
也就是说 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
接受的参数必须为 int
2,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);
(d->loop_ctx, d->main_ctx2, d->from_iter);
loop}
这里添加了一个 loop_data_t
类型的 static
全局变量用于存储 main
和 loop
函数之间交互所需要的数据,makecontext
时将该变量的地址分为高低 32bit 两个参数;此外此时
makcontext
的 func
参数修改为
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 callingbar
, do I need to know thatbar
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.
此外在使用上我们还有一些注意点:
makecontext
之前总是应该使用
getcontext
初始化 ucontextmakecontext
的 func
函数可以接受参数,但是只接收 int
类型的参数uc_stack
栈的增长方向不用我们操心,我们只需要设置好
ss_sp
为缓冲区首地址以及 ss_size
即可getcontext
初始化的 coroutine 退出时不会执行
uc_link
指向的 coroutine#define _XOPEN_SOURCE 600
Stack Overflow 上的这个回答对这几个函数具体做了什么也进行了解释:
getcontext()
captures the address of the next
instruction into a structmakecontext()
changes the address in the struct to that
of its func
argumentsetcontext()
/swapcontext()
puts the
address in the struct on the stack and returns to it