ucontext and coroutine
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 中其定义如下:
|
|
其中以 uc
开头的几个字段是比较常用的:
uc_link
:指向另一个 ucontext,用于在当前 coroutine 结束时唤醒(resume)其他 coroutine 执行uc_stack
:coroutine 使用的栈uc_mcontext
:用于保存 machine-dependent 的数据,比如寄存器uc_sigmask
:blocked signals
glibc 提供了以下函数用于操纵 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
的作用:
|
|
在第 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
挂接到 main_co
的 uc_link
上,并使用 inited_main_co_link
来避免死循环,编译执行得到结果
|
|
可以看到在退出 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 都用上了,但是逻辑又非常简单,所以非常适合用来作为🌰,我也延续这个优良传统:
|
|
代码中的注释已经讲得非常明白了,整体就是 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,我们可以将一个指针分为两部分:
|
|
这里添加了一个 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
初始化 ucontext makecontext
的func
函数可以接受参数,但是只接收int
类型的参数uc_stack
栈的增长方向不用我们操心,我们只需要设置好ss_sp
为缓冲区首地址以及ss_size
即可- 通过
getcontext
初始化的 coroutine 退出时不会执行uc_link
指向的 coroutine - 由于这套 API 已经被标记为 deprecated,所以 macOS 上需要定义
#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 itsfunc
argumentsetcontext()
/swapcontext()
puts the address in the struct on the stack and returns to it