longjmp()setcontext() 的性能孰优孰劣?

这篇文章起源于 seastar-devel 上的一个 讨论。在开始之前,我们先说一下协程的背景。因为讨论涉及特定的操作系统、处理器系统架构以及调用约定,如果没有特殊说明的话,下面都以 sysv, amd64 和现代的 Linux 为例。

协程的由来

coroutine 或者 cooperative threads,中文常常叫协程。在 Linux 里面,常规的调度单位是 LWP (light weight process)。 NPTL 实现下,LWP 和用户线程在数量上是一对一的对应关系。所以,以 Linux 为例,有这么几个问题:

  • 缺省 8MB 的栈空间。虽然 8M 只是虚拟地址的空间,但是内核里面在分配栈空间的时候必须立即分配对应的页表,这个开销是无法避免的。

  • 线程调度的时候必须借助内核。换言之,上下文切换也会引起一些开销。

  • 因为内核调度线程的不可预期性,比如一个线程把自己的时间片用完了。内核可能会把它调度出去,把另一个就绪的任务换进来。为了保证数据和逻辑的一致性,在一些可能产生 racing 的地方,必须加锁。而锁的引入进一步影响了性能和并发的粒度。

所以为了避免这些问题,我们引入了协程的概念,在用户态实现 m:n 的映射。让线程自己调度自己。正是因为这种用户态线程是互相协作的,只有当一个线程主动把 CPU 让出来,另一个已经就绪的线程才能继续运行。这也是为什么协程叫做"`协程`"的原因。

协程的基本要素

协程要能自己调度自己,需要满足下面几个要求

  1. 协程在让出 CPU 的时候,需要保存现场。这样当它以后继续执行的时候,能记得起来之前在做什么,然后继续当时未完成的任务。

  2. 协程在让出 CPU 的时候,能找到另外一个就绪的协程,恢复它当初保存的现场。帮助它回忆起来之前的事情。

这有点像晚上睡前看完书的时候,大家会在书里面夹一个书签,记住看到哪一页了。下次再翻开书的时候,找到书签的位置就能从上次停下来的地方继续看。只不过一个系统里面可能会有成百上千个线程,每个线程都有自己的"`书签`"。一般来说,协程库提供两个基本的操作:

  1. yield / swap out: 把控制权让出来,保存自己的状态。也就是插书签。

  2. resume / swap in: 获取控制权,恢复自己的状态。也就是根据书签的位置,继续读书。

协程的实现

书签和上下文

书签保存的信息只有一个页码。但是对于一个线程来说,它在 CPU 上执行的状态对应着更多的信息。我们先看一个特例—​子函数的调用。假设我们在 main() 里面调用之前定义的函数 func()

int main()
{
  func();
}

为了让 func() 返回时,main() 能继续它当时未尽的事业,很明显,它需要

  1. 在跳转到 func() 的起始地址之前,保存当下的 %ip

  2. 再把 %ip 改成 func() 的地址。

  3. func() 在返回的时候,需要把 %ip 恢复成之前保存的 版本。

x86 很贴心的提供了 CALLRET 两个指令。前者把 %ip 压栈,再根据 CALL 的参数更新 %ip。要是大家还能回忆相对寻址、绝对寻址的话,CALL 是支持这些寻址方式的。要是目标地址不在一个 %cs 段,它还能把当前 %cs 也一并保存了。RET 执行的是相反的功能。它把栈上的地址恢复回 %cs%ip,如果 RET 还有参数的话,还顺带着把栈上的垃圾清理一下,也就是退栈。通常来说,调用方会把一些参数放到栈上,而参数的个数一般是确定的。所以被调用方在返回的时候,把那些参数从栈上清除也是理所当然的事情。

可以说 CALLRET 给了线程订了一张往返票,让它从一个地方走到另外一个地方出个差,然后再回来。 除了 %ip,根据 amd64 或者 x86-64 的 ABI 调用规范,在函数调用的时候,下面的寄存器是调用方负责的:

  • %rax

  • %rcx​

  • ​%rdx

  • %rdi

  • %rsi

  • %r8%r11

换句话说,如果调用方觉得它无所谓函数返回之后这些寄存器的状态是否改变了,那么它完全可以选择不保存它们。其中,函数调用的前六个参数保存在 %rdi, %rsi%rdx%rcx, %r8d, %r9d

而被调用方则有义务保存:

  • %rbx

  • %rbp

  • %rsp

  • %r12%r15

也就是说,在函数返回之后,这些寄存器的值应该保持不变。这些要求定义了一个函数调用的行为规范,确保编译器能编译出有效率的代码,而不用花时间分析被调用的函数到底修改了哪些寄存器。所以一般来说,我们的 yield 实现也应该遵守这些基本的规范,保证调用方行为不受到干扰。

那么从一个线程到另外一个线程呢?除了函数调用规范要求的那些寄存器,还有哪些状态需要保存呢?

  • pthread(7) 总结了一下。它说,POSIX.1 要求一个进程里面的线程有共同的一系列属性,比如说 process IDuid、文件描述符以及 signal handler。它们也有自己的独立的属性,比如 errnosignalprocmask 还有 sigaltstack。这些属性有着各自不同的实现方式。

    • errno 它是 libc 实现的接口,让 libc 的函数能告诉调用方具体的错误号。 libc 一般把它保存在 %fs 段里面。但是如果我们不需要:

       int ret  = fstat(...);
       yield_to(another_thread);
       if (ret != 0) {
         perror("fstat failed");
       }

      那么就没有必要保存和恢复 errno 了。

    • sigprocmask 如果调度的线程 sigmask 不一样,那么我们的确需要保存恢复它们各自的 sigprocmask。但是如果它们的 sigmask 都一样的话,就可以不用管这个属性了。sigaltstack 也是类似的。

  • 函数调用使用栈来保存返回地址,传递一些参数。而每个线程都有自己的栈。在切换线程的时候,%rsp%rbp 也需要指向新的线程自己的栈。

  • 浮点处理器的运行环境。这包括一系列寄存器。可以参考 FSTENVFLDENV 这两个指令。

libc 的书签

我们管这些林林总总的状态叫做"`上下文`"。 为了保存和恢复上下文,libc 提供了

  • setjmp() 保存当前的 %rbx, %rbp, %r12, %r13, %r14, %r15, %rsp, %rip 到指定的 jmp_buf 中。

  • longjmp() 从指定的 jmp_buf 恢复 %rbx, %rbp, %r12, %r13, %r14, %r15, %rsp 中。

可以参考 musl-libc 的实现。可以说 setjmp()longjmp() 是相当简练的。只提供了两个功能,一个是记录当前的位置,另一个是跳转到指定的位置。

但是 glibc 的 longjmp 还更啰嗦一些,它在调用平台相关的__longjmp()之前,还调用了

  1. _longjmp_unwind()

  2. __sigprocmask()

libc 的 context

虽然 setjmp()longjmp() 很简练。但是它们只能允许我们回到一个已知的地方。这和之前书签的例子很像,如果之前没有用 setjmp() 得到 jmp_buf,那么是无法跳转到 jmp_buf 指示的地方的。如果我们希望实现协程的话。假设我们一开始启动了一个 POSIX 线程,当这个线程执行的函数希望 yield,把执行权交给另一个任务,而这个任务还从没执行过。那么 不手动修改jmp_buf 是无法实现这个功能的。读者可能会说,如果开始这个新任务的函数之前执行过,那么是不是在函数开始的时候用 setjmp()加个书签就可以了呢?这样会导致两个协程互相重用一个栈,导致原来的线程在返回时可能会读到错误的数据,也可能干脆跑飞掉。

所以 glibc 干脆提供了下面这几个函数:

int getcontext(ucontext_t *ucp);
int setcontext(const ucontext_t *ucp);
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

提供了比 setjmp()longjmp() 更强大的功能。

getcontext() 记录当前的上下文。这个上下文可以作为一个模板,如果我们需要让它使用另一个栈,没问题!如果我们想让调度它的时候,运行 serve_request(),好的!对了,这个函数还应该有几个参数,嗯,我想在这里设置这些参数…​…​当然可以!这些函数满足了用户对协程的所有要求。但是它们也带来了一些问题

  • 过于完整的线程支持。setcontext()swapcontext() 除了做了 longjmp() 的工作,还:

    • 用系统调用设置 sigprocmask

    • 设置 %fs,这是段寄存器。TLS 的变量都保存在这里面。

  • 不跨平台。 POSIX.1 已经把这几个函数去掉了。musl-libc 干脆[12][不实现他们]。

  • context 串起来。调用当初设置的函数,要是执行完了,看看 uc_link,要是还有下一个 context。有的话,再调用 setcontext(),开始执行它。

Seastar 的 thread

Seastar 为了避免使用重量级的 swapcontext() 进行上下文切换,只是在开始的时候用 getcontext()makecontext() 来初始化 context,而在平时调度的时候继续用 setjmp()longjmp() 的组合。

首先,每个用户态线程都有自己的 context,这个 context 包含

  • 一个 128KB 的栈

  • 一个 jmp_buf

  • 指向原来的 context 的指针

在这里,ucontext 就像是一个通向 jmp_buf 的跳板。

  1. 在初始化用户态线程的时候,Seastar 新建一个 ucontext,让它使用自己的栈,并把它指向一个静态函数 s_main(),这个函数的参数其实就是 thread_context 的地址,所以它得以调用 this->main()。后者才会调用真正的任务函数。

  2. 每个线程都用 TLS 保存着自己的 thread_context ,在工作线程调度到新的任务的时候,新的任务对应着新的 thread_context 实例。在这个新的 thread_context 开始运行之前,我们把当前的 context 作为成员变量保存在新的 thread_context 里面。然后用 setjmp() 把当前上下文保存在原来的 context 中。这时保存了原来 context 的上下文。

  3. 不过我们并不保存这个新建的 ucontext,我们的目标是调度到 this->main()。接下来用 setcontext() 跳转到这个 ucontext 完成调度。

  4. 下一次要 yield 就简单很多,只需要 setjmp(this->jmpbuf),然后 longjmp(link->jmpbuf) 就行了。

  5. 类似的,如果是 resume,则是相反的操作。

  6. 如果希望销毁这个用户态线程,则直接 longjmp(link->jmpbuf) 。跳过保留上下文的步骤。

Boost::context

Boost::context 用汇编实现了平台相关的 fcontext_t ,它的性能据说比 ucontext_ 高一到两个数量级fcontext_ 保存的上下文

这两个寄存器状态和 Intel TSX 机制有关系。TSX (Intel Transactional Synchronisation Extensions) 是 Intel 实现的硬件内存事务机制,可以粗略地理解,它使用 L1 cache 跟踪读集合和写集合,如果出现冲突的话,就放弃当前核上的修改,不把它刷到内存里面去,导致不一致。我们可以在另外一篇文章里面继续讨论内存一致性、可见性和多核系统里面乱序执行的问题。不过这里保存它们的原因是因为,如果浮点 TSX 的事务中发现浮点状态字有变化,那么这个事务肯定会 终止。所以为了支持 TSX,Boost 也保存这些浮点寄存器。顺便说一下,内核里面是不能用浮点操作的。所以那边我们不需要关心这种问题。

基于这套实现,Boost 实现了自己的协程库。

seastar-devel 上的 讨论也是围绕着这一点。 Christian 觉得手工实现 longjmp() 会比较高效。Avi 提到当初他也考虑过 Boost::context。因为它比较简单明了,同时没有 glibc 中 _longjmp_unwind()__sigprocmask() 的开销,所以对于广大的 glibc 用户来说,使用 Boost::context 性能会更好一些。 不过 Boost::context 在 1.55/1.56 中的实现还不成熟。为了精炼版的 longjmp(),只能有两条路,

  1. 要求用户使用新版的 Boost

  2. fcontext_t 使用的汇编代码移植到 Seastar 里面去。

不过 Avi 提到,glibc 中的 longjmp() 在上下文切换操作中占用的时间其实并不算多。所以就没有必要手撸汇编了。