编写协程库的时候,我常常在想,上下文切换的速度,受调用约定的影响非常大。
因为用户协程的上下文切换,本质上是调用一个 函数。而这个函数需要 “切换” 非易失性寄存器。
因此,在给定的调用约定下,非易失性寄存器越多,则上下文切换时需要保留和恢复的寄存器数量就越多。
而由内核进行上下文切换(也就是线程),所有寄存器都得保留。开销是最大的。何况还有执行特权级切换的巨大开销。
在知乎上发表的批评 云风的 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