8.3 用 KCSAN 抓住并发 Bug
既然靠人脑硬抗 LKMM 这种级别的复杂度已经有些吃力了,我们最好是找个工具来帮忙。这节要出场的,就是内核并发领域的「安检神器」——KCSAN。
KCSAN 是什么
KCSAN(Kernel Concurrency Sanitizer,内核并发消毒器)是一个在运行时动态检测数据竞争的框架。它是在 2020 年 8 月的 5.8 版本合并进主线内核的。目前它的主战场是 x86_64 架构,ARM64 的支持来得比较晚(直到 2022 年 3 月的 5.17 内核才稳定)。
如果没有看过上一节关于「数据竞争」的定义,强烈建议你现在回去翻一下,否则接下来的讨论会缺少根基。
KCSAN 的运作逻辑(极简版)
KCSAN 的核心任务只有一个:发现并报告数据竞争。
为了做到这一点,它默认做了一个假设:所有对齐的、不超过处理器字长的写操作都是原子的(无论你有没有用 WRITE_ONCE 标记)。在这个假设下,KCSAN 只需要盯着一个特定的模式:没有标记的普通读操作 竞争 针对同一地址的任意写操作。
这听起来有点松?是的。如果这些写操作本身也是普通的 C 语言写操作(没加锁也没标记),按照严格的 LKMM 定义这绝对是个 Bug,但默认配置下的 KCSAN 可能会放它一马。这是为了降低误报率做的妥协(这一点我们后面在配置环节会细说)。
Syzbot:不知疲倦的机器人
KCSAN 本质上是一个自动化机器人。配合 Syzbot(也就是 syzkaller 机器人),它能持续不断地对内核主线进行扫描。
Syzbot 专门搞模糊测试,它给内核喂各种乱七八糟的系统调用序列,试图把藏在深处的 Bug 逼出来。而 KCSAN 则负责在后台守着,一旦 Syzbot 的操作触发了并发访问冲突,KCSAN 就会记下来。
这项工作从 2019 年 10 月就开始了,直到现在还在跑。你可以在这里看到它的战果:
https://syzkaller.appspot.com/upstream?manager=ci2-upstream-kcsan-gce
这里有个关键技术:软观察点
并发 Bug 最让人头疼的是它们的 Heisenbug 属性——当你试图调试它们时,它们就消失了。为了捕捉这些依赖微妙时序的 Bug,KCSAN 必须在代码执行路径上人为地制造一点「扰动」。
它通过 编译器插桩 和 软观察点 机制来工作:
- 设点:KCSAN 在一个内存地址上设置软观察点。
- 故意拖延:当有代码访问这个地址时,KCSAN 会故意让它短暂停顿一小会儿(这个时间是可以配置的,对于任务上下文默认是 80 微秒,中断上下文是 20 微秒)。
- 收网:在这个拖延的空档,如果另一个线程或中断也来访问这个地址,两个观察点就会同时触发。
KCSAN 检查这两个访问的性质。如果满足数据竞争的条件(比如一个是未标记的读,另一个是写),它就会立刻报告。
它会告诉你发生了什么,数据有没有变(旧值和新值),最重要的是,它会给出 双方的调用栈回溯,让你看到这两个鬼是怎么撞到一起去的。
只有未标记的访问才会上当 如果你的访问是用
READ_ONCE()、WRITE_ONCE()或者atomic_*宏标记过的,KCSAN 就不会在那里设置观察点。它认为既然你标记了,你就是知道自己在干什么的。
配置你的内核以启用 KCSAN
光说不练假把式。要把 KCSAN 跑起来,你需要重新配置并编译内核。
前置依赖清单
这玩意儿不是打开开关就能用的,它有不少硬性要求:
- 架构:目前主要支持 x86_64。ARM64 支持较新(内核 5.17+)。
- 内核版本:x86_64 至少要 5.8(2020年8月);ARM64 至少要 5.17。
- 编译器:GCC 或 Clang 版本必须在 11 或以上。内核配置项
CONFIG_HAVE_KCSAN_COMPILER负责检查这个。 - 调试开关:必须开启
CONFIG_DEBUG_KERNEL=y。注意,这仅仅是把调试菜单亮出来,并没有自动帮你勾选具体的工具。 - 排他性:KASAN 和 KCSAN 水火不容。你只能二选一,不能同时开启。原因很简单,两者都做了大量的插桩,堆在一起会炸。
- KCOV 冲突:如果是用 Clang 编译,KCSAN 和 KCOV(代码覆盖率工具)也不能同时开启。
当你勾选 CONFIG_KCSAN=y 时,它会自动选中 CONFIG_STACKTRACE,因为报告Bug时需要打印详细的调用栈。
开启 KCSAN
在 make menuconfig 里,你可以通过以下路径找到它:
Kernel hacking -> Generic Kernel Debugging Instruments -> KCSAN: dynamic data race detector
如果你在菜单里根本看不到它,先别急着骂娘,检查一下上面的依赖条件是不是都满足了。最简单的办法是在一个比较新的 x86_64 Ubuntu 虚拟机(比如 21.10)里搞。
进入 KCSAN 的子菜单后,你会看到一堆参数。默认值通常比较保守,适合日常使用。表 8.1 列出了一些关键参数(为了简洁,这里不直接贴图,用文字说明核心逻辑):
CONFIG_KCSAN_ASSUME_PLAIN_WRITES_ATOMIC
- 默认:
y - 作用:假设对齐的普通写操作是原子的。这是导致很多初学者困惑的源头,后面我们踩坑时会专门讲。
CONFIG_KCSAN_REPORT_VALUE_CHANGE_ONLY
- 默认:
y - 作用:只有当竞争真的导致数据发生变化时才报告。这能过滤掉一些无害的理论上的竞争。
CONFIG_KCSAN_SKIP_WATCH
- 默认:
4000 - 作用:这是影响性能的终极旋钮。它代表每 4000 次内存访问,KCSAN 才会抽检一次。设得越小,抓得越准,但系统卡顿越严重。
实战演练:捕捉一个简单的数据竞争
理论讲完了,上板子。
我们写了一个简单的内核模块 ch8/kcsan_datarace 来演示这个机制。代码里初始化了两个工作队列,分别跑 do_the_work1 和 do_the_work2。如果通过模块参数开启 race_2plain_w=y,这两个函数就会疯狂地读写同一个全局变量 gctx->data,而且 没有任何锁保护。
这就是教科书级别的数据竞争代码:
// ch8/kcsan_datarace.c
static void do_the_work1(struct work_struct *work1)
{
int i; u64 bogus = 32000;
PRINT_CTX();
if (race_2plain_w) {
pr_info("data race: 2 plain writes:\n");
for (i=0; i<iter1; i++)
gctx->data = (u64)bogus + i;
/* 无保护的普通写操作 */
}
}
static void do_the_work2(struct work_struct *work2)
{
int i; u64 bogus = 98000;
PRINT_CTX();
if (race_2plain_w) {
pr_info("data race: 2 plain writes:\n");
for (i=0; i<iter2; i++)
gctx->data = (u64)gctx->y + i;
/* 无保护的普通写操作 */
}
}
第一个坑:它为什么不报警?
你把模块插进去,看着日志发呆——KCSAN 一声不吭。
这很反直觉。两个线程都在疯狂地未加锁写同一个地址,这都不报,那 KCSAN 是在摆烂吗?
不是的。还记得刚才那个 CONFIG_KCSAN_ASSUME_PLAIN_WRITES_ATOMIC 配置项吗?它默认是 y。
在这个默认假设下,KCSAN 认为对齐的普通写(Plain Write)是原子的。既然是原子的,写写互撞虽然不符合 LKMM 的严格定义,但在 KCSAN 的「宽松模式」下,它不认为这是一个必须要报的错(特别是如果 CONFIG_KCSAN_REPORT_VALUE_CHANGE_ONLY 也开着,且数据恰好看起来没乱的话)。
修正配置,重现 Bug
现在,我们要当一次严格律己的工程师。回到内核源码目录,运行 make menuconfig,找到 KCSAN_ASSUME_PLAIN_WRITES_ATOMIC,把它关掉(设为 n)。
这意味着:KCSAN,不要再做任何假设了,任何未标记的并发写,只要撞上了,就是 Bug。
重新编译内核,重启。
再次插入我们的测试模块。这一次,控制台上立刻炸出了报告(图 8.5 是它的截图):
BUG: KCSAN: data-race in do_the_work1 / do_the_work2
write to 0xffff9fc3cc9e3238 of 8 bytes by task kworker/0:1 on cpu 0:
do_the_work1+0x...
process_one_work+0x...
...
write to 0xffff9fc3cc9e3238 of 8 bytes by task kworker/1:2 on cpu 1:
do_the_work2+0x...
process_one_work+0x...
...
Reported by Kernel Concurrency Sanitizer on:
...
如何读懂这份报告
KCSAN 的报告其实很有章法,我们把它拆开来看:
-
第一行(红色标题):
BUG: KCSAN: data-race in func_x / func_y这直接告诉你:func_x和func_y打起来了。在我们的例子里是do_the_work1和do_the_work2,但这里往往会显示底层的封装函数(比如process_one_work,因为工作队列是通过这个函数来分发任务的)。 -
访问信息:
write to 0xffff... of 8 bytes by task <PID> on cpu <ID>这一行告诉你:- 是读还是写(这里是
write)。 - 是否标记(如果是
read/write (marked),说明用了READ_ONCE之类;这里没标记)。 - 内核虚拟地址(确认一下是不是你的那个全局变量)。
- 谁干的(哪个任务,哪个 CPU)。
- 是读还是写(这里是
-
调用栈: 紧跟在访问信息后面的是完整的栈回溯。这是你最需要的东西,顺着栈往上找,就能定位到具体的代码行。
运行时的统计游戏
KCSAN 既然是基于统计抽检的,那它抓到 Bug 就带点运气成分。
为了验证这一点,原作者写了一个简单的 Shell 脚本 tester.sh,通过改变循环次数来测试大概需要跑多少次才能触发一次报警。
结论很直观:循环次数越多,访问次数越密集,触发 KCSAN 观察点的概率就越大。但因为 CONFIG_KCSAN_REPORT_ONCE_IN_MS(默认 3000ms,即 3 秒只报一次)的限制,你可能会发现即便跑了很多次,日志里也只记录了第一次。
此外,内核还自带了一个更专业的测试模块 CONFIG_KCSAN_TEST。如果开启它,会编译出一个 kcsan-test.ko。这个模块利用 KUnit 和 Torture 框架,故意制造各种刁钻的并发场景来折磨系统,跑一次大概要 7 分钟,如果你跑完它还没有 Panic,说明你的 KCSAN 环境基本稳定。
运行时控制:debugfs 接口
除了编译时配置,你还可以在运行时通过 debugfs 和 KCSAN 交互(当然需要 root 权限):
文件路径:/sys/kernel/debug/kcsan
- echo "on" > .../kcsan:开启 KCSAN(默认就是开的)。
- echo "off" > .../kcsan:关闭 KCSAN。如果你觉得当前性能压力太大,可以临时关掉。
- cat .../kcsan:查看当前的统计信息(比如已经检查了多少次,抓了多少个 race)。
面对报告的正确姿势:别急着盖棺定论
这是最重要的一条建议。
当你看到 KCSAN 报了一个数据竞争时,千万不要像膝跳反射一样,随手就甩一个 READ_ONCE() 或者 WRITE_ONCE() 上去,想着「只要警告没了就好」。
为什么要忍住?
这种做法是在掩盖问题,而不是解决问题。
KCSAN 的设计哲学是:共享变量的访问本身就不应该竞争。如果你不加锁地访问共享数据,这就是一个逻辑 Bug。如果你只是简单地把访问标记为 READ_ONCE,你只是告诉 KCSAN 「闭嘴,我知道这是竞争」,但数据不一致的风险依然存在。正确的做法应该是:
- 加锁保护。
- 或者使用原子操作。
- 或者使用无锁技术。
什么时候可以用 data_race()?
当然,凡事有例外。如果你确实在写那种统计代码、诊断代码,或者某种为了性能而故意牺牲一致性的逻辑(比如读取一个没有锁保护的统计计数器,读出来是 100 还是 101 都无所谓),这时你可以用 data_race() 宏。
它明确告诉 KCSAN(以及读代码的人):我知道这里有竞争,但它是良性的,别管了。
// 示例:内核 fork.c 中的代码
/* 这是一个防 fork 炸弹的检查,稍微不准一点(竞态)也没关系 */
if (data_race(nr_threads >= max_threads))
goto bad_fork_cleanup_count;
此外,如果你觉得某个函数太热了,插桩导致性能下降,可以用 __no_kcsan 属性标记整个函数,但这通常是为了隔离已知的误报或第三方代码。
KCSAN 的独门绝技: Advisory Lock 的检测
传统的锁调试工具(比如 Lockdep)主要检测的是死锁、锁依赖错误这类问题。但它有个盲点:Advisory Lock(建议性锁)。
如果你持有一个锁,但你忘了用它来保护某个共享变量的访问,这个访问依然会成功(因为内核不会强制物理内存隔离),但这会引发数据竞争。Lockdep 对此无能为力,因为它只看你拿锁的顺序对不对。
KCSAN 在这方面非常强大。它通过 ASSERT_EXCLUSIVE*() 宏,可以验证某段代码在访问内存时,是否真的在独占地持有某种锁。如果没有持有锁却发生了并发写,KCSAN 照样会抓你。这也是为什么说 KCSAN 能够在不依赖锁语义的情况下,通过编译器插桩发现深层次的并发问题。
本章小节:从工具到实战
到这里,我们对 KCSAN 的认识应该已经比较全面了。它不仅仅是一个报错工具,更是一个帮助我们理解内核并发语义的导师。
既然我们已经掌握了如何发现并发 Bug,下一节我们将进入实战环节。我们将分析几个真实的、因为锁缺陷而导致的内核 Bug。看看在大佬们的代码里,这些潜伏的危机是如何被引入,又是被如何修复的。