25、宏与编译器优化:likely/unlikely 宏(分支预测),内联提示(inline),对齐提示
这一章,我们来聊聊宏和编译器优化之间的那些事儿。
很多人写C代码,觉得宏就是简单的文本替换。其实不然。宏可以成为你和编译器沟通的桥梁。你告诉编译器一些“潜规则”,编译器就能生成更高效的机器码。
我个人习惯,在性能敏感的代码里,一定会用上这些技巧。说白了,就是让编译器少走弯路。
25.1 分支预测:likely 与 unlikely 宏
先问一个问题:CPU执行代码时,遇到if-else分支,它会怎么处理?
现代CPU都有流水线。它会预判哪个分支更可能被执行,提前把指令取进来。如果猜对了,流水线不中断,速度飞快。如果猜错了,流水线要清空重来,代价很大。
这就是分支预测。
那问题来了:编译器怎么知道哪个分支更可能被执行?
嗯,它不知道。但我们可以告诉它。
核心思路:用 likely/unlikely 宏,告诉编译器哪个分支是“大概率”发生的。
Linux内核里就有这两个宏。定义很简单:
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
__builtin_expect 是GCC和Clang的内建函数。它告诉编译器:表达式x的值,大概率是1(likely)或0(unlikely)。
编译器收到这个提示后,会调整生成的汇编代码。把大概率走的分支放在紧挨着条件判断的位置,小概率分支跳转到别处。这样CPU的预取指令更准,流水线更顺畅。
举个例子:
if (unlikely(ptr == NULL)) {
// 错误处理,很少执行
return -1;
}
// 正常处理,经常执行
*ptr = 100;
这里我告诉编译器:ptr == NULL 的情况很少发生。编译器就会把正常处理的代码紧跟在条件判断后面,错误处理代码跳转到远处。
我的经验:我在做网络协议栈时,大量使用了unlikely。比如检查报文长度是否合法,这种错误路径几乎不会触发。加上unlikely后,吞吐量提升了大约3%-5%。别小看这点提升,在高并发场景下很可观。
使用时有几点要注意:
- 不要滥用。只在真正有偏向性的分支上用。如果两个分支概率接近,加了反而可能起反作用。
- 配合性能测试。加之前测一次,加之后测一次。用数据说话。
- __builtin_expect 是GCC/Clang的特性。MSVC有类似的 __assume,但用法不同。跨平台时要注意。
我曾经踩过的坑:有一次我在一个循环内部用了unlikely,结果编译器优化后把循环展开搞乱了,性能反而下降。后来我仔细看了生成的汇编才找到原因。所以,加了宏之后,最好看一眼汇编,确认编译器确实按你的意图优化了。
25.2 内联提示:inline 关键字
函数调用有开销。参数压栈、跳转、返回、栈帧恢复……这些操作在性能敏感代码里是不能忍的。
内联就是解决这个问题的。把函数体直接插入到调用点,省去调用开销。
C99标准引入了 inline 关键字。用法很简单:
static inline int add(int a, int b) {
return a + b;
}
但这里有个误区:inline 只是“建议”,不是“命令”。编译器可以忽略它。尤其是当函数体很大、或者有递归调用时,编译器通常不会内联。
那怎么强制内联?GCC提供了属性:
static inline __attribute__((always_inline)) int add(int a, int b) {
return a + b;
}
加了 always_inline,编译器就会强制内联。但要注意,滥用会导致代码膨胀,指令缓存命中率下降,反而变慢。
我的建议:只对短小、频繁调用的函数使用内联。比如 getter/setter、简单的数学运算、状态检查等。函数体超过10行,就别内联了。
还有一个细节:内联函数的定义通常放在头文件里。因为编译器需要在每个调用点看到函数体。如果放在.c文件里,其他文件调用时无法内联。
我记得有一次,团队里有人把内联函数定义在.c文件里,然后在头文件里只声明。结果链接时报了一堆重复定义错误。嗯,这个坑很多人踩过。
25.3 对齐提示:aligned 属性
CPU访问内存时,有对齐要求。比如4字节的int,最好放在4字节对齐的地址上。不对齐的话,某些架构会直接崩溃(比如ARM),x86虽然能处理,但性能会下降。
编译器默认会做对齐。但有些场景需要我们手动指定:
- 结构体中的字段需要特定对齐
- 缓冲区需要对齐到缓存行大小(通常是64字节)
- SIMD指令要求数据对齐到16字节或32字节
GCC/Clang提供了 aligned 属性:
// 对齐到16字节
int buffer[1024] __attribute__((aligned(16)));
// 结构体对齐到缓存行
struct cache_line {
int data[16];
} __attribute__((aligned(64)));
C11标准引入了 _Alignas 关键字,更通用:
#include <stdalign.h>
alignas(64) int buffer[1024];
对齐的好处是什么?
- 避免跨缓存行访问。如果一个变量跨越了两个缓存行,访问它需要两次内存读取。对齐后一次搞定。
- 避免伪共享。多线程场景下,两个线程操作不同变量,但这两个变量恰好在同一个缓存行里。一个线程修改,会导致另一个线程的缓存行失效。通过对齐把它们分开,就能避免这个问题。
我的实战经验:在做多线程队列时,我把队头和队尾指针分别放在不同的缓存行里。用 aligned(64) 确保它们不会共享同一个缓存行。结果性能提升了将近一倍。伪共享的威力,你想想看。
对齐也有代价:会浪费内存。比如你声明了一个1字节的变量,但要求对齐到64字节,那后面63个字节就空着了。所以,只在必要的地方用。
25.4 知识体系总览
下面这张图,把本章的核心逻辑串起来了:
25.5 综合使用建议
这三个技巧,可以组合使用。比如:
static inline __attribute__((always_inline))
int fast_path_check(int val) {
if (unlikely(val < 0)) {
return -1;
}
return val * 2;
}
这里我同时用了内联和分支预测。函数很小,强制内联。错误路径用unlikely标记。编译器会生成非常紧凑高效的代码。
对齐方面,我习惯在定义关键数据结构时加上对齐属性。尤其是多线程共享的数据,对齐到缓存行是基本操作。
总结一下:
- likely/unlikely:告诉编译器分支走向,优化流水线
- inline:消除小函数的调用开销
- aligned:保证数据对齐,避免性能陷阱
这三个宏和属性,是嵌入式和高性能C编程的必备工具。用好了,代码性能能上一个台阶。
嗯,这一章就到这里。记住,编译器很聪明,但它需要你的指引。这些宏就是你给编译器的“小纸条”。
公众号:蓝海资料掘金营,微信deep3321