
分支预测是CPU为了吃饱饭练出来的肌肉记忆而likely和unlikely就是告诉编译器这口饭大概率要从哪边吃的暗号。很多写了几年C的朋友看到内核源码里一堆likely()、unlikely()以为是花架子其实这俩宏在性能敏感型代码里是实打实的优化利器。这篇文章我会从分支预测的底层逻辑讲起掰开揉碎说说这俩宏到底在干什么、应该在哪儿用、用的时候有什么坑最后再用几个实际案例展示一下优化前后的差异。1. 为什么一段if-else也要讲究性能先明确一件事likely和unlikely不是C语言标准里的东西而是GCC、Clang这些编译器提供的分支预测优化手段。它们最常见的定义来自Linux内核#define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0)这个宏本身不改变程序逻辑它只是给编译器一个这条分支更可能走这边的提示。核心神奇之处在于__builtin_expect这个内建函数——它是告诉编译器x的期望值是1或0编译器根据这个期望值重新组织指令排列顺序从而让CPU的分支预测器更容易猜对。那为什么分支预测这么重要现代高性能处理器都是流水线架构取指、译码、执行、写回一个周期里同时跑好几条指令。一旦遇到分支CPU没法干等判断结果它得先猜一个方向继续流水下去。猜对了流水线顺畅跑着猜错了已经推进去的那十几条指令全作废流水线要清空重建这一下子就是十几个周期的惩罚。我拿日常生活打个比方你每天上班出门前都要判断今天堵不堵车如果判断错了走错了路导航重新规划路线、掉头、绕路耽误的时间往往比直接走对路多得多。CPU的分支预测器就是那个判断堵不堵车的机制它做得越快越准程序跑得越快。likely和unlikely就是在帮分支预测器做减法——它不直接告诉CPU该往哪边走而是让编译器在生成代码时把更可能执行的那条路径放在紧跟条件跳转之后的位置上。现代CPU的静态分支预测逻辑通常是顺序执行优先所以编译器这么一调整多数情况下CPU天生就能猜对省掉了动态预测的学习成本。2. 从__builtin_expect看编译器是怎么领会的2.1 宏定义的细节双感叹号到底图什么很多初学者看到!!(x)会以为是想把x转成布尔值——这个理解方向对但不够完整。在宏里写!!(x)是为了确保传给__builtin_expect的一定是严格的0或1而不受x原始类型的影响。举个例子如果x是某个指针它可能是非零的地址值比如0x12345678。如果直接传给__builtin_expect也没问题——编译器识别非零值作为真——但语义上不够严谨。加上!!之后指针变为1空指针变为0统一成标准的布尔量表达更干净。内核里大量宏都遵循这个习惯它的历史可以追溯到早期GCC对__builtin_expect参数期望严格整型表达式的时代。虽然在现代编译器中直接传指针也已经能工作但这个防御性写法保留了下来当你自己写宏的时候也值得模仿。2.2 编译器拿到这个提示后做了什么__builtin_expect不会改变程序的执行逻辑不管x的实际值是什么判断结果都是真实的。它只影响编译器生成的指令布局。拿一个典型的错误检查来拆解if (unlikely(ptr NULL)) { return -EINVAL; } ret process(ptr);没有unlikely时编译器通常会生成先比较ptr是否为NULL如果相等则跳转到错误处理代码否则顺序往下走的布局。有了unlikely之后编译器倾向于把错误处理代码挪到函数的冷区比如.text.unlikely段主路径变成先比较ptr是否为NULL如果不相等则直接顺序执行process(ptr)。比较指令和跳转指令的位置都变了CPU顺序预取的时候直接顺着主路径走命中率大增。有一点很多人不知道__builtin_expect除了影响指令布局还参与GCC的概率分析机制。编译器在后续的优化里比如循环展开、if-conversion、向量化决策会拿这个概率值做参考权重。所以likely/unlikely的影响范围比你想象得大不只是一条跳转指令的摆放问题。2.3 静态预测和动态预测是怎么协作的早期的x86处理器只有静态分支预测规则简单粗暴条件跳转指令向前跳backward默认预测为发生向后跳forward默认预测为不发生。编译器优化分支布局本质就是在迎合这套静态规则。现代处理器早就引入了动态分支预测靠分支目标缓冲器BTB记录每条分支指令的历史走向。按理说动态预测已经很聪明了为什么likely/unlikely还有用实际上第一道防线还是静态布局。BTB的学习需要时间和历史样本当一个分支第一次被执行时BTB里没有记录CPU只能靠静态规则猜——这时候编译器排好的布局就直接决定了对错。在程序刚启动、分支刚开始执行的那几次静态布局对了优势巨大。另外在分支很多、BTB被不断冲刷的场景下静态布局作为兜底策略依然发挥关键作用。我实测过一个数据包的快速路径处理函数加不加unlikely对首次调用延迟影响明显动态预测完全生效之后两者差距缩小但在真实网络抖动场景下你永远会有大量首次遇到的分支所以布局的优势持续存在。3. 什么场景最适合用unlikely/likely3.1 最典型的三类应用场景第一类是错误处理。这是最常见也最没争议的使用场景正常执行走的路径是主路径错误处理是异常路径两者概率天差地别。文件系统IO错误、网络超时、内存分配失败、参数校验失败这些都属于低频事件非常契合unlikely。buf kmalloc(size, GFP_KERNEL); if (unlikely(!buf)) { pr_err(memory allocation failed\n); return -ENOMEM; }第二类是边界条件判断。循环到了末尾、计数器归零、队列到底了这些条件90%的情况下都是假的一旦为真就要跳去处理特殊逻辑。比如遍历链表寻找节点while (pos) { if (unlikely(pos-key target)) { break; } pos pos-next; }key匹配属于偶发事件如果每次都匹配你根本不需要遍历查找让编译器把匹配成功后的跳出逻辑放在偏僻位置主循环的遍历路径保持顺畅。第三类是快速路径与慢速路径的区分。这是系统设计层面的用法——同一个函数处理两类请求一类是高频的简单操作一类是低频的复杂操作。最典型的就是Linux里socket读数据的路径正常有数据走快速路径缓冲区空着走慢速路径触发调度、等待。这类热路径/冷路径的划分特别适合用likely/unlikely去显式标注。3.2 什么时候不该乱用有两个反向场景是新手特别容易犯错的。一是热路径上的平分支。如果一个if的两个分支各50%概率比如解析某个标志位这时加likely纯粹是瞎指挥编译器按你指定的方向排了布局结果一半的概率猜错性能还可能不如编译器自己用启发式规则排出来的结果。编译器在没有任何概率提示时会采用一些通用策略并不一定比重排过的差。另一个是逻辑复杂的表达式。比如if (likely(a 0 b MAX status ! STOP))这种多个条件组合的判断——likely作用于整个表达式编译器没法细分每个子条件的概率分布。这种时候不如把最高频的子条件单独拆开来标注或者干脆不加。还有一点需要特别警惕likely/unlikely绝对不可以改变你对逻辑的判断方式。曾见过有人把错误处理写成if (likely(success)) { ... }逻辑本身没错但如果业务上success和failure比例并没有那么悬殊这么做是无效甚至有害的。优化的前提是概率确实有压倒性偏向装出来的偏向会让编译器产生误导。3.3 标准库、内核、开源项目里你能直接参考的用法Linux内核是likely/unlikely最大的使用现场目录里随手一抓就是几百上千处。举两个我看过的典型样例网络收包路径里if (unlikely(skb-len sizeof(struct ethhdr))) goto drop;报文长度小于以太网头这种奇葩情况正常网络环境下十年碰不到一次标注unlikely让主路径少一条跳转。调度器里if (likely(!p-on_rq)) goto out;大部分被唤醒的任务并不在运行队列上这是高概率事件标注likely让唤醒路径首选顺序执行。除了内核高性能用户态库也都看重这个技巧。Nginx、Redis都用了类似思路虽然不是直接用GCC的宏但本质都是给编译器提供分支概率提示。我见过某个DPDK收包Demo里的缓存命中判断加了likely之后在特定benchmark下PPS提升了好几个百分点——虽然是微基准但量大了就是实打实的收益。4. 实操示例一段模拟热路径的优化实例4.1 一个直观的对比实验为了让你直观看到优化效果我构造一个贴近实际的模拟场景一个处理网络请求的函数90%的请求长度都是合法的正常范围10%是超长异常请求核心循环对请求长度做一次上限判断。先看未优化的版本代码里没有任何分支提示int handle_request(char *req, int len) { if (len MAX_LEN) { log_error(oversized request: %d, len); return -1; } /* 主处理逻辑 */ return process(req, len); }再看加了unlikely的版本int handle_request(char *req, int len) { if (unlikely(len MAX_LEN)) { log_error(oversized request: %d, len); return -1; } /* 主处理逻辑 */ return process(req, len); }两者的C语言逻辑完全一致唯一的差别就是多了个unlikely。用-O2编译成汇编对比差异立刻显形。未加宏版本里process(req, len)的调用和正常返回路径处在顺序指令流中加了宏的版本编译器会把整段错误处理逻辑挪出主路径紧接着if判断的下一条指令就是调用process这样CPU顺序取指直接走进主处理逻辑完全不会踩进错误处理的指令区域。我在一台具体机器上反复测试过用10万个请求90%正常10%异常加了unlikely的版本整体吞吐量有2%~4%的领先异常比例越高优势越小——这正好印证了它依赖概率偏向的本质。如果你的请求里错误分支有30%那这优化基本没用。4.2 汇编层面到底发生了什么要是你第一次在汇编里看到加了宏之后的变化可能会惊讶于代码布局的大改动。GCC在-O2下对于if (unlikely(...))会生成类似这样的结构x86-64简化示意cmp $4096, %esi # 比较 len 和 MAX_LEN jbe .Lnormal # len MAX_LEN 时跳转到 normal 路径 call log_error # 错误处理冷路径 mov $-1, %eax ret .Lnormal: call process ...关键在jbe .Lnormal这条指令它是一个向后跳转而且跳转目标紧跟错误处理逻辑之后。CPU顺序预取时先碰到的是.Lnormal之后的正常路径代码虽然BTB可能还没记住这条分支但静态规则已经让CPU大概率走对方向。同时错误处理代码被推到后面不占据主取指窗口。4.3 在CLang里的行为有何不同谈到编译器差异CLang对__builtin_expect的处理策略和GCC略有不同。Clang同样会重新排布分支布局但它倾向于在适当条件下生成branch weights元数据影响后续的优化决策。实测同一个函数在GCC和Clang下编译出来的汇编布局不完全一样但在性能表现上两者都能从likely/unlikely中拿到收益。写跨编译器代码时用内核那套宏定义就够用——GCC、Clang、以及较新版本的MSVC它有__assume之类的机制虽然风格不太一样都认识__builtin_expect。5. 实际使用中的坑与经验总结5.1 使用likely/unlikely导致代码逻辑错误的经典案例网上最常见的反面教材长这样int check_value(int *p) { if (likely(p ! NULL)) { return *p; } return 0; }这段代码在语义上是正确的——p非空时返回解引用值p为空时返回0。但问题在于开发者可能在后续维护中产生了这代码已经假设p一定非空的想法于是把上面的NULL检查删掉直接用*p。逻辑上这就是事故导火索。我见过不止一次因为likely用多了、写得像断言最后真的把防御检查写成空操作的项目事故。正确姿势likely/unlikely只给编译器提示不要让它在代码里暗示业务不变量。你在阅读代码时应该把它当作这是一条极少走的路径的文档不把它当作这条路不存在。5.2 热路径上的错误标注让代码变慢实际优化效果不是永远为正。有一个场景我踩过坑一个状态机上反复检查一个标志位A状态到B状态的迁移大约占60%剩下40%走其他路径。我在状态判断处加了likely(A_TO_B)结果性能反而略降。原因很简单分支概率没有绝对优势编译器按60%概率重新排布代码结果发生40%走其他分支时分支预测失败的代价比原本50%对50%的布局更大。所以我的建议是概率优势至少在80%以上再动手标注60%/40%的场景交给CPU的动态预测器就好。另外标注之前先用perf看一眼branch-miss率如果分支丢失率已经很低说明CPU动态预测已经学得足够好你再布局收益很有限不如把时间花在别处。5.3__builtin_expect在分支预测器面前并非万能说得扎心一点现代CPU的TAGE类分支预测器对大多数有规律的分支模式学得非常准甚至能反制编译器的静态布局。你今天加了likely让静态布局正确率提升过几天CPU预测器已经记住了分支行为自己能猜对这时布局合理与否的影响就变小了。但这不代表没意义——新增分支上冷启动性能依然受静态布局影响而且多核、虚拟机迁移后BTB被清空的情况也一直存在。把likely/unlikely看成是给CPU预测器一个初始值的手动干预而不是替代它的魔法优化心态就对了。5.4 内核风格的实践惯例Linux内核开发里对likely/unlikely有不成文的规定优先把它用在真正的热路径上而且每处使用都应该有对应的性能注释。大量的错误路径标注并不是为了微秒级的快而是为了让代码阅读者一眼看出这个条件期望的结果是什么顺带让编译器在布局上更清爽。在用户态代码里使用我建议维护一个单独的宏定义头文件别到处手写__builtin_expect便于将来切换编译器策略或统一调整。6. 进阶话题unlikely和冷函数、冷段的关系GCC还有一个隐藏组合技__builtin_expect配合__attribute__((cold))函数属性能把同一条冷路径上的多个错误处理函数全部挪到独立的冷段里进一步压缩主路径的指令缓存占用。比如下面的写法就很适合错误路径特别多的接口__attribute__((cold, noreturn)) void fatal_error(const char *msg) { log_error(%s, msg); abort(); }cold属性告诉GCC这个函数很少执行编译器会把它放在.text.unlikely里。当主路径函数调用它时生成的CALL指令会跳转到偏僻的冷段主函数的指令密度更高缓存友好性更好。顺带提一个新GCC12的黑科技__builtin_expect_with_probability它可以显式给出一个百分比概率值if (__builtin_expect_with_probability(x 0, 1, 0.9)) { ... }这函数告诉编译器x0为真的概率大约是90%。我试过它对编译器后续的优化决策影响更精细只是GCC 12以下不支持要用的话记得检查编译环境。7. 性能观测方案怎么验证你的优化确实有效加一个宏很容易但你要知道它到底帮了多少。推荐一套标准的验证流程直接照着做第一步用perf stat记录优化前后的分支丢失率perf stat -e branch-misses,branches ./your_program重点关注branch-misses/branches的比例。如果优化前已经在2%以下说明分支预测器已经学得很好likely/unlikely收益很有限。如果比例超过5%说明分支模式对CPU预测器不太友好这时候布局对齐了往往效果明显。第二步用perf record -e branch-misses配合perf report定位分支丢失主要发生在哪些指令上。我建议关注的是那些每次执行都丢的分支——如果一条分支指令持续丢失说明它的走向毫无规律编译器再怎么改布局都没用排序减轻分支压力才是正道。第三步做A/B对比时保持输入分布不变。很多人测出来差异忽大忽小就是因为请求集变了。比如全是错误请求的测试里unlikely示例还会反噬性能。让测试输入贴近真实分布是最重要的一点。8. 我的一些个人心得用likely/unlikely这么多年我最大的体会是它是一门关于概率表达的艺术不是单纯的性能魔法。你在代码里写unlikely的时候实质上是在向编译器传递你对业务概率的建模顺带向阅读代码的人传递你对这段路径的预期。它的价值一半在编译器另一半在可读性。在真正的热代码里花半小时理清分支概率分布、手动标注上likely/unlikely比盲目调整算法带来的收益还要稳定。我也犯过错误在最不该用的地方堆了一堆宏最后测试结果教做人了。从那时起我的原则就三条没有压倒性概率不标注标注了以后跑perf验证收益代码注释里写清楚为什么这个分支是有偏向的。这几天如果你正打算优化代码里的热路径建议从让编译器看清楚你的分支概率开始也许比换一个精巧的数据结构实在得多。