跳到主要内容

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 必须在代码执行路径上人为地制造一点「扰动」。

它通过 编译器插桩软观察点 机制来工作:

  1. 设点:KCSAN 在一个内存地址上设置软观察点。
  2. 故意拖延:当有代码访问这个地址时,KCSAN 会故意让它短暂停顿一小会儿(这个时间是可以配置的,对于任务上下文默认是 80 微秒,中断上下文是 20 微秒)。
  3. 收网:在这个拖延的空档,如果另一个线程或中断也来访问这个地址,两个观察点就会同时触发。

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_work1do_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 的报告其实很有章法,我们把它拆开来看:

  1. 第一行(红色标题)BUG: KCSAN: data-race in func_x / func_y 这直接告诉你:func_xfunc_y 打起来了。在我们的例子里是 do_the_work1do_the_work2,但这里往往会显示底层的封装函数(比如 process_one_work,因为工作队列是通过这个函数来分发任务的)。

  2. 访问信息write to 0xffff... of 8 bytes by task <PID> on cpu <ID> 这一行告诉你:

    • 是读还是写(这里是 write)。
    • 是否标记(如果是 read/write (marked),说明用了 READ_ONCE 之类;这里没标记)。
    • 内核虚拟地址(确认一下是不是你的那个全局变量)。
    • 谁干的(哪个任务,哪个 CPU)。
  3. 调用栈: 紧跟在访问信息后面的是完整的栈回溯。这是你最需要的东西,顺着栈往上找,就能定位到具体的代码行。

运行时的统计游戏

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 「闭嘴,我知道这是竞争」,但数据不一致的风险依然存在。正确的做法应该是:

  1. 加锁保护。
  2. 或者使用原子操作。
  3. 或者使用无锁技术。

什么时候可以用 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。看看在大佬们的代码里,这些潜伏的危机是如何被引入,又是被如何修复的。