调用约定和上下文切换

Posted on January 3, 2025

编写协程库的时候,我常常在想,上下文切换的速度,受调用约定的影响非常大。

因为用户协程的上下文切换,本质上是调用一个 函数。而这个函数需要 “切换” 非易失性寄存器。

因此,在给定的调用约定下,非易失性寄存器越多,则上下文切换时需要保留和恢复的寄存器数量就越多。

而由内核进行上下文切换(也就是线程),所有寄存器都得保留。开销是最大的。何况还有执行特权级切换的巨大开销。

在知乎上发表的批评 云风的 coroutine 库的文章的评论区里,有个人提到了 这篇文章

好家伙,他们对内联汇编玩出花来了。使用内联汇编的 clobbered registers 列表来自动生成寄存器的保存和恢复代码。

然后,他们给 clang 提了一个补丁,增加了一个叫 preserve_none 的调用约定。

所谓 preserve_none,顾名思义,就是这个调用约定下,尽可能啥寄存器都不保留,全是易失性寄存器。

于是,如果使用 preserve_none 的调用约定实现 zcontext_swap, 则代码里并不需要保留上下文。只需要切换 rsp 寄存器,然后调用下 hook 即可返回了。

zcontext_swap_preserve_none:
    // 保存frame寄存器
    push %rbp

    // 切换栈指针
    mov %rsp, (%r12)
    mov (%r13), %rsp

    //  恢复 frame 寄存器
    pop %rbp
    // if(hook_function) hook_function(argument);
    test %r14, %r14
    jne 1f
    // 返回 argument
    mov %r15, %rax
    // 返回
    ret
1:
    // 如果有 hook ,则调用 hook 后返回
    mov %r15, ARG1_REG
    mov %r15, %r12 // 同时兼容 preserve_none 调用约定 的 hook
    jmp * %r14 // 开启尾调用优化.

在 无 hook 的情况下,只需 8 条指令即完成上下文切换。

快,非常的快。

然后有人说了,zcontext_swap 是快了,但是调用它的地方,得保持寄存器,工作量并没有减少。只是换了个地方。

是的。没错。如果寄存器无论如何需要保留的话,确实保持寄存器的工作量是没有任何变化的。 但是,如果在某些情况下,调用方的寄存器,实际上也没有那么多需要保留呢?

也就是说,使用 preserve_none 调用约定。在最次的情况下,是把寄存器的保留工作交给调用方。这个和 zcontext_swap 自己进行保留工作,是没有工作量的区别的。因此,没有任何性能损失。 而在一些情况下,调用方的代码用不到那么多寄存器的情况下,反而可以省去寄存器的保留工作。

也就是说,存在可以少恢复寄存器的情况,从而减少了 cpu 的开销。

至于到底要保留多少寄存器,则取决于进行协程调度的上下文环境。这就是阿里的那篇文章说的 “上下文感知协程切换”。 切换协程的时候,通过感知上下文来自动决定切换的工作量。

这样这个协程上下文切换的开销,就和无栈协程一样小了。

Comments