赞
踩
本文基于linux4.4
问题的来源:
研究的起始来源与发现linux4.4内核在x86机器上text_poke_bp函数会向每个在线cpu发送IPI,执行do_sync_core动作。
虽然do_sync_core只是在每个cpu上执行一下简单的CPUID指令,但毕竟会触发中断,打断当前进程,对系统确定性造成影响。
下面主要分析下这个IPI的来龙去脉。
一种分支判断优化方法:
要说明这个问题,需要从内核开发者的一个性能优化开始讲起,通常调试信息的输出需要控制,通过控制来决定什么时候输出和不输出。常见的控制方法是通过一个变量来实现,通过if语句进行判断。
不过这种方法对应内核开发者来说有个问题,会造成性能开销。性能的一个影响在于现代cpu都有预测功能,这个变量的判断有可能会造成硬件预测失败,影响流水线性能。
在内核增加了越来越多调试信息的情况下,这就是一个问题。
如果某个判断分支在大多数情况下都是只走一个特定路径,除了加上likely和unlikely告诉编译器进行优化,是否还有其他方法?
内核开发者就开发了这样一种新的方法:通过动态替换内存中的代码段,去掉分支的判断条件,让代码根据动态设置要么直接执行a分支,要么直接执行b分支。
这种技术在底层是通过将汇编中的nop指令替换成jmp,或者将jmp指令替换成nop实现的。具体的实现和体系相关。
当然,这种技术最初就用于ftrace,决定动态决定打印信息的输出。不过后面开发者将这个功能抽象出来,形成Static Keys机制。
Static Keys:
简单的说,如果你代码对性能很敏感,而且大多数情况下分支路径是确定的,可以考虑使用Static Keys。
Static Keys可以代替使用普通变量进行分支判断。
下面使用例子说明一下:
//定义一个Static Keys,并且默认这个值是false。
DEFINE_STATIC_KEY_FALSE(key);
…
//代码使用Static Keys代替普通变量进行判断,static_branch_unlikely是一个宏,展开后不会有真正的判断,而是直接执行false分支,即do likely code。
if (static_branch_unlikely(&key))
do unlikely code
else
do likely code
…
这样的好处是,上述代码的性能和没有分支判断的性能差不多,具体可能只差一个nop指令的执行时间。
对应的汇编代码中将不会有类似test等的判断代码。
当然,如果某种情况发生了,需要改变分支的执行路径,可以调用下面的接口:
static_branch_enable(&key);
执行static_branch_enable(&key)后,底层通过gcc提供的asm goto功能,再结合c代码编写的动态更改内存功能,就可以让使用key的代码从执行false分支变为执行true分支。
当然这个更改代价是比较昂贵的,不是所有情况都适用。
asm goto:
这是gcc4.5版本以后提供的一个功能,它是实现Static Keys的基础。
所有我们不难理解为什么高版本linux内核会对gcc版本有要求了,因为内核的一些新功能需要gcc的支持。
简单的说就是asm goto提供了一种在嵌入式汇编中跳转到c代码的label的方法,而不需要执行test指令。
asm goto具体语法实现就是在outputs,inputs,registers-modified之外提供了嵌入式汇编的第四个“:”,后面可以跟一系列的c语言的label,然后你可以在嵌入式汇编中go to到这些label中一个。
下面例子中,error就是c语言的标号。
int foo(int x)
{
int y;
asm goto (“foo %%r5, %1; jc %l[error]; mov (%2), %%r5”
: : “r”(x), “r”(&y) : “r5”, “memory” : error);
return y;
error:
return -1;
}
有了asm goto,实现动态更改分支就简单了,这就是下面介绍的jump_lable。
jump_lable:
jump_lable屏蔽不同体系更改机器代码的不同,向上提供一个统一接口。不同体系会提供给jump_lable一个体系相关的实现。
jump_lable的实现原理很简单,就是通过替换内存中机器代码的nop空指令为jmp指令,或者替换机器代码的jmp指令为nop空指令,实现分支的切换。
下面是x86的jump_label实现,可以看到使用了上面提到的asm_volatile_goto功能。
static __always_inline bool arch_static_branch(struct static_key *key, bool branch)
{
asm_volatile_goto(“1:”
“.byte ” __stringify(STATIC_KEY_INIT_NOP) “\n\t”
“.pushsection __jump_table, \”aw\” \n\t”
_ASM_ALIGN “\n\t”
_ASM_PTR “1b, %l[l_yes], %c0 + %c1 \n\t”
“.popsection \n\t”
: : “i” (key), “i” (branch) : : l_yes);
return false;
l_yes:
return true;
}
DO_ONCE:
这里还有一个问题,通常linux 内核是没有打开ftrace功能的,那么是谁在使用Static Keys?谁在动态改内存代码呢?
这就是DO_ONCE机制。
DO_ONCE机制想法很简单,有些函数只应该调用一次,那么这些函数调用一次后,如果下次再调用就应该直接返回。
以前我们要实现这种功能总是需要判断一个变量,而使用DO_ONCE机制,自动帮你完成判断,保证函数只会在第一次调用的时候被执行,以后都直接返回。
很自然,DO_ONCE机制就采用了Static Keys来实现。
有兴趣的同学可以看看DO_ONCE的实现,除了使用static_key外,另一个关键在__do_once_done中:
({ \
bool ___ret = false; \
static bool ___done = false; \
static struct static_key ___once_key = STATIC_KEY_INIT_TRUE; \
if (static_key_true(&___once_key)) { \
unsigned long ___flags; \
___ret = __do_once_start(&___done, &___flags); \
if (unlikely(___ret)) { \
func(__VA_ARGS__); \
__do_once_done(&___done, &___once_key, \
&___flags); \
} \
} \
___ret; \
})
IPI的来源:
好了说了这么多,马上要进入正题了,因为内核很多子系统使用了DO_ONCE,所以即使没有开启内核调试,也会存在动态修改内存的情况。
上面不是说问题的最初来源是text_poke_bp函数会向每个在线cpu发送IPI,执行do_sync_core动作吗,这个text_poke_bp函数实际上就是完成内存代码替换的工作,比如上面提到的nop空指令替换为jmp指令。
但是替换工作是比较麻烦的,因为要考虑同步问题,因此替换工作分为了3步:
第一步:在需要替换的地方,插入int3指令,这就是通常的调试中断,调试器的单步等功能就是基于这个中断实现的。
第二步:在需要替换的的地方,保留第一个字节即刚才替换的int3指令,然后把其余部分替换
第三步:替换需要替换地方的第一个字节,即int3指令
理论上要保证在SMP上的正确性最好使用一个原子操作完成上述步骤,不过因为采用了int3的小技巧,可以分为三步进行。
但是,每一步之间还是需要在SMP上进行同步。如何进行同步呢?就是向每个在线cpu发送IPI,执行do_sync_core动作。
解决方案:
理清了流程,问题就比较容易解决了。目前4.4内核,只要是x64,jump_label和DO_ONCE是默认选上的,不能去掉。
对应确定性要求比较高的系统,其实不需要在意jump_label带来的性能提升。
在smp上因为修改内存而向所有cpu发送IPI的行为是对于高确定性系统是个问题,我们直接禁止这种优化即可。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。