在 x64 Windows 下快速 fibers/coroutines
Fast fibers/coroutines under x64 Windows
所以我有这个协程 API,由我扩展,基于我在这里找到的代码:https://the8bitpimp.wordpress.com/2014/10/21/coroutines-x64-and-visual-studio/
struct mcontext {
U64 regs[8];
U64 stack_pointer;
U64 return_address;
U64 coroutine_return_address;
};
struct costate {
struct mcontext callee;
struct mcontext caller;
U32 state;
};
void coprepare(struct costate **token,
void *stack, U64 stack_size, cofunc_t func); /* C code */
void coenter(struct costate *token, void *arg); /* ASM code */
void coyield(struct costate *token); /* ASM code */
int coresume(struct costate *token); /* ASM code, new */
我坚持执行coyield()。 coyield() 可以用 C 语言编写,但它是我遇到问题的程序集。这是我到目前为止得到的(MASM/VC++ 语法)。
;;; function: void _yield(struct mcontext *callee, struct mcontext *caller)
;;; arg0(RCX): callee token
;;; arg2(RDX): caller token
_yield proc
lea RBP, [RCX + 64 * 8]
mov [RCX + 0], R15
mov [RCX + 8], R14
mov [RCX + 16], R13
mov [RCX + 24], R12
mov [RCX + 32], RSI
mov [RCX + 40], RDI
mov [RCX + 48], RBP
mov [RCX + 56], RBX
mov R11, RSP
mov RSP, [RDX + 64]
mov [RDX + 64], R11
mov R15, [RDX + 0]
mov R14, [RDX + 8]
mov R13, [RDX + 16]
mov R12, [RDX + 24]
mov RSI, [RDX + 32]
mov RDI, [RDX + 40]
mov RBP, [RDX + 48]
mov RBX, [RDX + 56]
ret
_yield endp
这是对 8bitpimp 代码的直接改编。如果我正确理解这段代码,它不会做的是将 mcontext->return_address 和 mcontext->coroutine_return_address 放在堆栈上,由 ret 弹出。另外,速度快吗? IIRC,它导致 return 现代 x64 片段中的分支预测器不匹配。
这个答案只解决了问题的 "is it fast" 部分。
Return地址预测
首先,简要描述 典型 return-address 预测器的行为。
- 每次
call
被压入实际堆栈的 return 地址也存储在称为 return 地址缓冲区的 CPU 结构中或类似的东西。
- 当
ret
(return) 被生成时,CPU 假定目的地将是当前位于 return 地址缓冲区顶部的地址,并且来自 return 地址缓冲区的条目是 "popped".
效果是完美地1 预测 call
/ret
对,只要它们以通常的正确嵌套模式出现并且 ret
实际上是删除 call
在每种情况下推送的未修改的 return 地址。有关详细信息,您可以 start here.
C 或 C++(或几乎任何其他语言)中的正常函数调用通常总是遵循这种正确的嵌套模式2。因此,您无需执行任何特殊操作即可利用 return 预测。
故障模式
在 call
/ret
未正常配对的情况下,预测可能会(至少)以几种不同的方式失败:
- 如果堆栈指针或堆栈上的 return 值被操作,使得
ret
没有 return 对应的 call
压入的位置,你会得到一个 ret
的分支目标预测失败,但随后的正常嵌套 ret
指令将继续正确预测,只要它们被正确嵌套。例如,如果在函数中您向 [rsp]
处的值添加几个字节以跳过调用函数中 call
之后的指令,则下一个 ret
将预测错误,但是调用函数内部的 ret
应该没问题。
- 另一方面,
call
和 ret
函数没有正确嵌套,整个 return 预测缓冲区可能会错位,导致未来的 ret
指令,如果有的话,使用现有值来错误预测2.5。例如,如果您 call
进入一个函数,但随后对调用者使用 jmp
到 return,则存在不匹配的 call
而没有 ret
。调用者内部的ret
会预测错误,调用者的调用者内部的ret
也会预测错误,以此类推,直到用完或覆盖所有未对齐的值3.如果您有一个 ret
与相应的调用不匹配,则会发生类似的情况(这种情况对于后续分析很重要)。
除了上面的两条规则,您还可以通过跟踪代码并跟踪 return 堆栈在每个点的样子来简单地确定 return 预测器的行为。每次你有一个 ret
指令,看看它是否 returns 到 return 堆栈的当前顶部 - 如果不是,你会得到一个错误的预测。
误判成本
错误预测的实际成本取决于周围的代码。通常给出约 20 个周期的数字并且在实践中经常看到,但实际成本可能更低:例如,如果 CPU 能够 并开始获取,则低至零新路径而不中断关键路径,或更高:例如,如果分支预测失败需要很长时间才能解决并降低 long-latency 操作的有效并行度。无论如何,我们可以说,当它发生在其他只需要少量指令的操作中时,惩罚通常是 显着。
快速协程
Coresume 和 Coyield 的现有行为
现有的 _yield
(上下文切换)函数交换堆栈指针 rsp
,然后使用 ret
到 return 到与实际调用者推送的位置不同的位置(特别是,它 returns 到调用者之前调用 yield
时被推入 caller
堆栈的位置)。这通常会在 _yield
.
内的 ret
处造成错误预测
例如,考虑某些函数 A0
对 A1
进行正常函数调用的情况,然后调用 coresume
4 恢复协程 B1
,后者稍后调用 coyield
返回 A1
。在对 coresume
的调用中,return 堆栈看起来像 A0, A1
,但随后 coresume
交换 rsp
以指向 B1
的堆栈,并且该堆栈的顶部值是 B1
内的地址,紧跟在 B1
的代码中的 coyield
之后。因此 coresume
中的 ret
跳转到 B1
中的一个点,而 而不是 跳转到 A1
中的一个点,因为 [=326] =] 堆栈期望。因此你在 ret
上得到一个 mis-prediction 并且 return 堆栈看起来像 A0
.
现在考虑当 B1
调用 coyield
时会发生什么,其实现方式与 coresume
基本相同:对 coyield
的调用推送 B1
在return 堆栈现在看起来像 A0, B1
然后交换堆栈指向 A1
堆栈然后执行 ret
将 return 到 A1
.所以 ret
的预测错误会以同样的方式发生,堆栈仍然是 A0
。
所以坏消息是,对 coresume
和 coyield
的一系列紧密调用(例如,典型的 yield-based 迭代器)每次都会预测错误。好消息是现在 A1
中至少 return 堆栈是正确的(没有错位)——如果 A1
returns 到它的调用者 A0
, return 被正确预测(当 A0
returns 到 its 调用者时依此类推,等等)。所以你每次都会遭受错误预测的惩罚,但至少在这种情况下你不会错位 return 堆栈。这一点的相对重要性取决于您调用 coresume
/coyield
的频率与通常在调用 coresume
.
的函数下方调用函数的频率
让它变快
那么我们可以修复错误预测吗?不幸的是,它在 C 和外部 ASM 调用的组合中很棘手,因为调用 coresume
或 coyield
意味着 编译器插入的调用,并且它是很难在 asm 中解决这个问题。
不过,我们试试吧。
使用间接调用
一种方法是完全不使用 ret
,只使用间接跳转。
也就是说,只需将 coresume
和 coyield
调用末尾的 ret
替换为:
pop r11
jmp r11
这在功能上等同于 ret
,但对 return 堆栈缓冲区的影响不同(特别是,它不影响它)。
如果像上面那样分析 coresume
和 coyield
调用的重复序列,我们得到的结果是 return 堆栈缓冲区刚开始像 A0, A1, B1, A1, B1, ...
一样无限增长。发生这种情况是因为实际上我们在此实现中根本没有使用 ret
。所以我们不会受到 return mis-prediction 的影响,因为我们没有使用 ret
!相反,我们依靠间接分支预测器的准确性来预测 jmp11
.
该预测器的工作方式取决于 coresume
和 coyeild
的实施方式。如果它们都调用一个共享的 _yield
函数,但没有内联,那么只有一个 jmp r11
位置,这个 jmp
将交替转到 A1
和 B1
。大多数现代间接预测器都能很好地重新预测这种简单的重复模式,尽管仅跟踪单个位置的旧间接预测器不会。如果 _yield
被内联到 coresume
和 coyield
或者你只是 copy-pasted 每个函数的代码,有两个不同的 jmp r11
调用站点,每个只看到每个一个位置,并且应该是 well-predicted 任何 CPU 与间接分支预测器 6.
所以这通常应该预测一系列紧密的coyield
和coresume
调用7,但代价是抹杀return 缓冲区,因此当 A1
决定 return 到 A0
时,这将被 A0
以及随后的 return 等错误预测。此惩罚的大小受限于 return 堆栈缓冲区的大小,因此如果您进行许多紧张的 coresume/yield
调用,这可能是一个很好的权衡。
这是我在对用 ASM 编写的函数进行外部调用的约束下所能想到的最好的,因为您已经为 co
例程隐含了 call
,并且您必须从那里跳转到另一个协程,我看不出如何保持堆栈平衡,并且 return 到具有这些约束的正确位置。
调用站点的内联代码
如果您可以在协程方法的 call-site 内联代码(例如,使用编译器支持或内联 asm),那么您也许可以做得更好。
对 coresume
的调用可以像这样内联(我省略了寄存器保存和恢复代码,因为那很简单):
; rcx - current context
; rdc - context for coroutine we are about to resume
; save current non-volatile regs (not shown)
; load non-volatile regs for dest (not shown)
lea r11, [rsp - 8]
mov [rcx + 64], r11 ; save current stack pointer
mov r11, [rdx + 64] ; load dest stack pointer
call [r11]
请注意,coresume
实际上并没有进行堆栈交换 - 它只是将目标堆栈加载到 r11
,然后对 [r11]
执行 call
以跳转到协程。这是必要的,以便 call
正确地将我们应该 return 的位置推送到调用者的堆栈上。
然后,coyield
看起来像(内联到调用函数中):
; save current non-volatile regs (not shown)
; load non-volatile regs for dest (not shown)
lea r11, [after_ret]
push r11 ; save the return point on the stack
mov rsp, [rdx + 64] ; load the destination stack
ret
after_ret:
mov rsp, r11
当 coresume
调用跳转到协程时,它在 after_ret
结束,并且在执行用户代码之前,mov rsp, r11
指令交换到协程的正确堆栈被 coresume
.
藏在 r11
所以基本上 coyield
有两部分:上半部分在 yield 之前执行(发生在 ret
调用)和下半部分完成由 coresume
开始的工作.这允许您使用 call
作为执行 coresume
跳转和 ret
执行 coyield
跳转的机制。在这种情况下 call
/ret
是平衡的。
我忽略了这种方法的一些细节:例如,由于不涉及函数调用,ABI-specifiednon-volatile 寄存器并不是很特别:在内联汇编的情况下,您需要向编译器指示您将破坏哪些变量并保存其余变量,但您可以选择对您方便的任何设置。选择一组更大的破坏变量会使 coresume
/coyield
代码序列本身更短,但可能会给周围的代码带来更多的寄存器压力,并可能迫使编译器溢出更多周围的代码。也许理想的做法是声明所有内容都被破坏,然后编译器将只溢出它需要的东西。
1 当然,在实践中是有限制的:return 堆栈缓冲区的大小可能被限制为一些小的数字(例如,16 或 24)因此,一旦调用堆栈的深度超过该深度,一些 return 地址就会丢失,并且无法正确预测。此外,上下文切换或中断等各种事件可能会扰乱 return-stack 预测器。
2 一个有趣的例外是在 x86(32 位)代码中读取当前指令指针的常见模式:没有直接执行此操作的指令,因此取而代之的是可以使用 call next; next: pop rax
序列:call
到下一条指令,该指令仅用于将弹出的堆栈上的地址压入。没有对应的ret
。然而,当前 CPUs 实际上识别了这种模式,并且在这种特殊情况下不会使 return-address 预测器失衡。
2.5 这意味着有多少错误预测取决于调用函数 net return 的作用:如果它立即开始向下调用另一个深层调用链,例如,未对齐的 return 堆栈条目可能根本不会被使用。
3 或者,也许,直到 return 地址堆栈被 ret
re-aligned 而没有相应的调用,这样的情况"two wrongs make a right".
4 你实际上没有展示 coyield
和 coresume
是如何调用 _yield
,所以对于剩下的问题我假设它们的实现本质上与 _yield
一样,直接在 coyield
或 coresume
内而不调用 _yield
:即,将 _yield
代码复制并粘贴到每个功能,可能有一些小的编辑来解释差异。您也可以通过调用 _yield
来完成这项工作,但是这样您就有了额外的调用层和 rets 层,这会使分析变得复杂。
5 在某种程度上,这些术语在对称协程实现中甚至有意义,因为在这种情况下实际上没有调用者和被调用者的绝对概念。
6 当然,这个分析只适用于你有一个单一的 coresume
调用调用一个协程的简单情况 coyield
称呼。更复杂的场景是可能的,例如被调用者内部的多个 coyield
调用,或调用者内部的多个 coresume
调用(可能是不同的协程)。然而,相同的模式适用:具有拆分 jmp r11
站点的情况将比组合情况呈现更简单的流(可能以更多 iBTB 资源为代价)。
7 一个例外是第一次或两次调用:ret
预测器不需要 "warmup" 但间接分支预测器可能,尤其是当另一个协程已在此期间调用。
所以我有这个协程 API,由我扩展,基于我在这里找到的代码:https://the8bitpimp.wordpress.com/2014/10/21/coroutines-x64-and-visual-studio/
struct mcontext {
U64 regs[8];
U64 stack_pointer;
U64 return_address;
U64 coroutine_return_address;
};
struct costate {
struct mcontext callee;
struct mcontext caller;
U32 state;
};
void coprepare(struct costate **token,
void *stack, U64 stack_size, cofunc_t func); /* C code */
void coenter(struct costate *token, void *arg); /* ASM code */
void coyield(struct costate *token); /* ASM code */
int coresume(struct costate *token); /* ASM code, new */
我坚持执行coyield()。 coyield() 可以用 C 语言编写,但它是我遇到问题的程序集。这是我到目前为止得到的(MASM/VC++ 语法)。
;;; function: void _yield(struct mcontext *callee, struct mcontext *caller)
;;; arg0(RCX): callee token
;;; arg2(RDX): caller token
_yield proc
lea RBP, [RCX + 64 * 8]
mov [RCX + 0], R15
mov [RCX + 8], R14
mov [RCX + 16], R13
mov [RCX + 24], R12
mov [RCX + 32], RSI
mov [RCX + 40], RDI
mov [RCX + 48], RBP
mov [RCX + 56], RBX
mov R11, RSP
mov RSP, [RDX + 64]
mov [RDX + 64], R11
mov R15, [RDX + 0]
mov R14, [RDX + 8]
mov R13, [RDX + 16]
mov R12, [RDX + 24]
mov RSI, [RDX + 32]
mov RDI, [RDX + 40]
mov RBP, [RDX + 48]
mov RBX, [RDX + 56]
ret
_yield endp
这是对 8bitpimp 代码的直接改编。如果我正确理解这段代码,它不会做的是将 mcontext->return_address 和 mcontext->coroutine_return_address 放在堆栈上,由 ret 弹出。另外,速度快吗? IIRC,它导致 return 现代 x64 片段中的分支预测器不匹配。
这个答案只解决了问题的 "is it fast" 部分。
Return地址预测
首先,简要描述 典型 return-address 预测器的行为。
- 每次
call
被压入实际堆栈的 return 地址也存储在称为 return 地址缓冲区的 CPU 结构中或类似的东西。 - 当
ret
(return) 被生成时,CPU 假定目的地将是当前位于 return 地址缓冲区顶部的地址,并且来自 return 地址缓冲区的条目是 "popped".
效果是完美地1 预测 call
/ret
对,只要它们以通常的正确嵌套模式出现并且 ret
实际上是删除 call
在每种情况下推送的未修改的 return 地址。有关详细信息,您可以 start here.
C 或 C++(或几乎任何其他语言)中的正常函数调用通常总是遵循这种正确的嵌套模式2。因此,您无需执行任何特殊操作即可利用 return 预测。
故障模式
在 call
/ret
未正常配对的情况下,预测可能会(至少)以几种不同的方式失败:
- 如果堆栈指针或堆栈上的 return 值被操作,使得
ret
没有 return 对应的call
压入的位置,你会得到一个ret
的分支目标预测失败,但随后的正常嵌套ret
指令将继续正确预测,只要它们被正确嵌套。例如,如果在函数中您向[rsp]
处的值添加几个字节以跳过调用函数中call
之后的指令,则下一个ret
将预测错误,但是调用函数内部的ret
应该没问题。 - 另一方面,
call
和ret
函数没有正确嵌套,整个 return 预测缓冲区可能会错位,导致未来的ret
指令,如果有的话,使用现有值来错误预测2.5。例如,如果您call
进入一个函数,但随后对调用者使用jmp
到 return,则存在不匹配的call
而没有ret
。调用者内部的ret
会预测错误,调用者的调用者内部的ret
也会预测错误,以此类推,直到用完或覆盖所有未对齐的值3.如果您有一个ret
与相应的调用不匹配,则会发生类似的情况(这种情况对于后续分析很重要)。
除了上面的两条规则,您还可以通过跟踪代码并跟踪 return 堆栈在每个点的样子来简单地确定 return 预测器的行为。每次你有一个 ret
指令,看看它是否 returns 到 return 堆栈的当前顶部 - 如果不是,你会得到一个错误的预测。
误判成本
错误预测的实际成本取决于周围的代码。通常给出约 20 个周期的数字并且在实践中经常看到,但实际成本可能更低:例如,如果 CPU 能够
快速协程
Coresume 和 Coyield 的现有行为
现有的 _yield
(上下文切换)函数交换堆栈指针 rsp
,然后使用 ret
到 return 到与实际调用者推送的位置不同的位置(特别是,它 returns 到调用者之前调用 yield
时被推入 caller
堆栈的位置)。这通常会在 _yield
.
ret
处造成错误预测
例如,考虑某些函数 A0
对 A1
进行正常函数调用的情况,然后调用 coresume
4 恢复协程 B1
,后者稍后调用 coyield
返回 A1
。在对 coresume
的调用中,return 堆栈看起来像 A0, A1
,但随后 coresume
交换 rsp
以指向 B1
的堆栈,并且该堆栈的顶部值是 B1
内的地址,紧跟在 B1
的代码中的 coyield
之后。因此 coresume
中的 ret
跳转到 B1
中的一个点,而 而不是 跳转到 A1
中的一个点,因为 [=326] =] 堆栈期望。因此你在 ret
上得到一个 mis-prediction 并且 return 堆栈看起来像 A0
.
现在考虑当 B1
调用 coyield
时会发生什么,其实现方式与 coresume
基本相同:对 coyield
的调用推送 B1
在return 堆栈现在看起来像 A0, B1
然后交换堆栈指向 A1
堆栈然后执行 ret
将 return 到 A1
.所以 ret
的预测错误会以同样的方式发生,堆栈仍然是 A0
。
所以坏消息是,对 coresume
和 coyield
的一系列紧密调用(例如,典型的 yield-based 迭代器)每次都会预测错误。好消息是现在 A1
中至少 return 堆栈是正确的(没有错位)——如果 A1
returns 到它的调用者 A0
, return 被正确预测(当 A0
returns 到 its 调用者时依此类推,等等)。所以你每次都会遭受错误预测的惩罚,但至少在这种情况下你不会错位 return 堆栈。这一点的相对重要性取决于您调用 coresume
/coyield
的频率与通常在调用 coresume
.
让它变快
那么我们可以修复错误预测吗?不幸的是,它在 C 和外部 ASM 调用的组合中很棘手,因为调用 coresume
或 coyield
意味着 编译器插入的调用,并且它是很难在 asm 中解决这个问题。
不过,我们试试吧。
使用间接调用
一种方法是完全不使用 ret
,只使用间接跳转。
也就是说,只需将 coresume
和 coyield
调用末尾的 ret
替换为:
pop r11
jmp r11
这在功能上等同于 ret
,但对 return 堆栈缓冲区的影响不同(特别是,它不影响它)。
如果像上面那样分析 coresume
和 coyield
调用的重复序列,我们得到的结果是 return 堆栈缓冲区刚开始像 A0, A1, B1, A1, B1, ...
一样无限增长。发生这种情况是因为实际上我们在此实现中根本没有使用 ret
。所以我们不会受到 return mis-prediction 的影响,因为我们没有使用 ret
!相反,我们依靠间接分支预测器的准确性来预测 jmp11
.
该预测器的工作方式取决于 coresume
和 coyeild
的实施方式。如果它们都调用一个共享的 _yield
函数,但没有内联,那么只有一个 jmp r11
位置,这个 jmp
将交替转到 A1
和 B1
。大多数现代间接预测器都能很好地重新预测这种简单的重复模式,尽管仅跟踪单个位置的旧间接预测器不会。如果 _yield
被内联到 coresume
和 coyield
或者你只是 copy-pasted 每个函数的代码,有两个不同的 jmp r11
调用站点,每个只看到每个一个位置,并且应该是 well-predicted 任何 CPU 与间接分支预测器 6.
所以这通常应该预测一系列紧密的coyield
和coresume
调用7,但代价是抹杀return 缓冲区,因此当 A1
决定 return 到 A0
时,这将被 A0
以及随后的 return 等错误预测。此惩罚的大小受限于 return 堆栈缓冲区的大小,因此如果您进行许多紧张的 coresume/yield
调用,这可能是一个很好的权衡。
这是我在对用 ASM 编写的函数进行外部调用的约束下所能想到的最好的,因为您已经为 co
例程隐含了 call
,并且您必须从那里跳转到另一个协程,我看不出如何保持堆栈平衡,并且 return 到具有这些约束的正确位置。
调用站点的内联代码
如果您可以在协程方法的 call-site 内联代码(例如,使用编译器支持或内联 asm),那么您也许可以做得更好。
对 coresume
的调用可以像这样内联(我省略了寄存器保存和恢复代码,因为那很简单):
; rcx - current context
; rdc - context for coroutine we are about to resume
; save current non-volatile regs (not shown)
; load non-volatile regs for dest (not shown)
lea r11, [rsp - 8]
mov [rcx + 64], r11 ; save current stack pointer
mov r11, [rdx + 64] ; load dest stack pointer
call [r11]
请注意,coresume
实际上并没有进行堆栈交换 - 它只是将目标堆栈加载到 r11
,然后对 [r11]
执行 call
以跳转到协程。这是必要的,以便 call
正确地将我们应该 return 的位置推送到调用者的堆栈上。
然后,coyield
看起来像(内联到调用函数中):
; save current non-volatile regs (not shown)
; load non-volatile regs for dest (not shown)
lea r11, [after_ret]
push r11 ; save the return point on the stack
mov rsp, [rdx + 64] ; load the destination stack
ret
after_ret:
mov rsp, r11
当 coresume
调用跳转到协程时,它在 after_ret
结束,并且在执行用户代码之前,mov rsp, r11
指令交换到协程的正确堆栈被 coresume
.
r11
所以基本上 coyield
有两部分:上半部分在 yield 之前执行(发生在 ret
调用)和下半部分完成由 coresume
开始的工作.这允许您使用 call
作为执行 coresume
跳转和 ret
执行 coyield
跳转的机制。在这种情况下 call
/ret
是平衡的。
我忽略了这种方法的一些细节:例如,由于不涉及函数调用,ABI-specifiednon-volatile 寄存器并不是很特别:在内联汇编的情况下,您需要向编译器指示您将破坏哪些变量并保存其余变量,但您可以选择对您方便的任何设置。选择一组更大的破坏变量会使 coresume
/coyield
代码序列本身更短,但可能会给周围的代码带来更多的寄存器压力,并可能迫使编译器溢出更多周围的代码。也许理想的做法是声明所有内容都被破坏,然后编译器将只溢出它需要的东西。
1 当然,在实践中是有限制的:return 堆栈缓冲区的大小可能被限制为一些小的数字(例如,16 或 24)因此,一旦调用堆栈的深度超过该深度,一些 return 地址就会丢失,并且无法正确预测。此外,上下文切换或中断等各种事件可能会扰乱 return-stack 预测器。
2 一个有趣的例外是在 x86(32 位)代码中读取当前指令指针的常见模式:没有直接执行此操作的指令,因此取而代之的是可以使用 call next; next: pop rax
序列:call
到下一条指令,该指令仅用于将弹出的堆栈上的地址压入。没有对应的ret
。然而,当前 CPUs 实际上识别了这种模式,并且在这种特殊情况下不会使 return-address 预测器失衡。
2.5 这意味着有多少错误预测取决于调用函数 net return 的作用:如果它立即开始向下调用另一个深层调用链,例如,未对齐的 return 堆栈条目可能根本不会被使用。
3 或者,也许,直到 return 地址堆栈被 ret
re-aligned 而没有相应的调用,这样的情况"two wrongs make a right".
4 你实际上没有展示 coyield
和 coresume
是如何调用 _yield
,所以对于剩下的问题我假设它们的实现本质上与 _yield
一样,直接在 coyield
或 coresume
内而不调用 _yield
:即,将 _yield
代码复制并粘贴到每个功能,可能有一些小的编辑来解释差异。您也可以通过调用 _yield
来完成这项工作,但是这样您就有了额外的调用层和 rets 层,这会使分析变得复杂。
5 在某种程度上,这些术语在对称协程实现中甚至有意义,因为在这种情况下实际上没有调用者和被调用者的绝对概念。
6 当然,这个分析只适用于你有一个单一的 coresume
调用调用一个协程的简单情况 coyield
称呼。更复杂的场景是可能的,例如被调用者内部的多个 coyield
调用,或调用者内部的多个 coresume
调用(可能是不同的协程)。然而,相同的模式适用:具有拆分 jmp r11
站点的情况将比组合情况呈现更简单的流(可能以更多 iBTB 资源为代价)。
7 一个例外是第一次或两次调用:ret
预测器不需要 "warmup" 但间接分支预测器可能,尤其是当另一个协程已在此期间调用。