内核并发检查器 (KCSAN)

内核并发检查器 (KCSAN) 是一个动态竞争检测器,它依赖于编译时插桩,并使用基于观察点(watchpoint)的采样方法来检测竞争。KCSAN 的主要目的是检测 数据竞争

用法

GCC 和 Clang 都支持 KCSAN。对于 GCC,我们要求版本 11 或更高;对于 Clang,同样要求版本 11 或更高。

要启用 KCSAN,请使用以下配置内核

CONFIG_KCSAN = y

KCSAN 提供了几个其他的配置选项来自定义行为(有关更多信息,请参阅 lib/Kconfig.kcsan 中的相应帮助文本)。

错误报告

一个典型的数据竞争报告如下所示

==================================================================
BUG: KCSAN: data-race in test_kernel_read / test_kernel_write

write to 0xffffffffc009a628 of 8 bytes by task 487 on cpu 0:
 test_kernel_write+0x1d/0x30
 access_thread+0x89/0xd0
 kthread+0x23e/0x260
 ret_from_fork+0x22/0x30

read to 0xffffffffc009a628 of 8 bytes by task 488 on cpu 6:
 test_kernel_read+0x10/0x20
 access_thread+0x89/0xd0
 kthread+0x23e/0x260
 ret_from_fork+0x22/0x30

value changed: 0x00000000000009a6 -> 0x00000000000009b2

Reported by Kernel Concurrency Sanitizer on:
CPU: 6 PID: 488 Comm: access_thread Not tainted 5.12.0-rc2+ #1
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
==================================================================

报告的头部提供了对参与竞争的函数的简短总结。后面是参与数据竞争的 2 个线程的访问类型和堆栈跟踪。如果 KCSAN 还观察到了值的变化,则在“value changed”行中分别显示观察到的旧值和新值。

另一种较少见的数据竞争报告如下所示

==================================================================
BUG: KCSAN: data-race in test_kernel_rmw_array+0x71/0xd0

race at unknown origin, with read to 0xffffffffc009bdb0 of 8 bytes by task 515 on cpu 2:
 test_kernel_rmw_array+0x71/0xd0
 access_thread+0x89/0xd0
 kthread+0x23e/0x260
 ret_from_fork+0x22/0x30

value changed: 0x0000000000002328 -> 0x0000000000002329

Reported by Kernel Concurrency Sanitizer on:
CPU: 2 PID: 515 Comm: access_thread Not tainted 5.12.0-rc2+ #1
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.14.0-2 04/01/2014
==================================================================

当无法确定另一个发生竞争的线程,但由于被观察内存位置的数据值已经改变而推断出存在竞争时,会生成此报告。这些报告总是显示“value changed”行。此类报告的一个常见原因是竞争线程中缺少插桩,但也可能由于例如 DMA 访问而发生。仅当 CONFIG_KCSAN_REPORT_RACE_UNKNOWN_ORIGIN=y 时才会显示此类报告,该选项默认启用。

选择性分析

有时可能需要针对特定的访问、函数、编译单元或整个子系统禁用数据竞争检测。对于静态黑名单,可以使用以下选项

  • KCSAN 理解 data_race(expr) 注解,它告诉 KCSAN 应忽略由于 expr 中的访问引起的所有数据竞争,并且在遇到数据竞争时产生的结果行为被认为是安全的。有关更多信息,请参见 LKMM 中的 “共享内存访问标记”(Marking Shared-Memory Accesses)

  • data_race(...) 类似,类型限定符 __data_racy 可用于记录对变量访问引起的所有数据竞争都是有意为之的,应当被 KCSAN 忽略

    struct foo {
        ...
        int __data_racy stats_counter;
        ...
    };
    
  • 可以通过使用函数属性 __no_kcsan 来为整个函数禁用数据竞争检测

    __no_kcsan
    void foo(void) {
        ...
    

    要动态限制为哪些函数生成报告,请参阅 DebugFS 接口的黑名单/白名单功能。

  • 要为特定的编译单元禁用数据竞争检测,请在 Makefile 中添加

    KCSAN_SANITIZE_file.o := n
    
  • 要为 Makefile 中列出的所有编译单元禁用数据竞争检测,请在相应的 Makefile 中添加

    KCSAN_SANITIZE := n
    

此外,还可以根据偏好指示 KCSAN 显示或隐藏整类数据竞争。这些可以通过以下 Kconfig 选项进行更改

  • CONFIG_KCSAN_REPORT_VALUE_CHANGE_ONLY:如果启用此选项,并且通过观察点观察到了冲突的写操作,但观察到内存位置的数据值保持不变,则不报告该数据竞争。

  • CONFIG_KCSAN_ASSUME_PLAIN_WRITES_ATOMIC:默认假设达到字大小(word size)的普通对齐写操作是原子的。假设此类写操作不会受到导致数据竞争的不安全编译器优化的影响。该选项使 KCSAN 不会报告由于冲突产生的数据竞争,其中仅有的普通访问是达到字大小的对齐写操作。

  • CONFIG_KCSAN_PERMISSIVE:启用额外的宽松规则,以忽略某些类别的常见数据竞争。与上面不同,这些规则更为复杂,涉及值变化模式、访问类型和地址。此选项依赖于 CONFIG_KCSAN_REPORT_VALUE_CHANGE_ONLY=y。有关详细信息,请参阅 kernel/kcsan/permissive.h。建议仅关注特定子系统而非整个内核报告的测试人员和维护人员禁用此选项。

要使用最严格的规则,请选择 CONFIG_KCSAN_STRICT=y,这会配置 KCSAN 尽可能紧密地遵循 Linux 内核内存一致性模型 (LKMM)。

DebugFS 接口

文件 /sys/kernel/debug/kcsan 提供了以下接口

  • 读取 /sys/kernel/debug/kcsan 会返回各种运行时统计信息。

  • /sys/kernel/debug/kcsan 写入 onoff 分别允许开启或关闭 KCSAN。

  • /sys/kernel/debug/kcsan 写入 !some_func_name 会将 some_func_name 添加到报告过滤列表中,该列表(默认情况下)将顶级堆栈帧中包含列表中函数的那些数据竞争列入黑名单并不予报告。

  • /sys/kernel/debug/kcsan 写入 blacklistwhitelist 会改变报告过滤行为。例如,黑名单功能可用于屏蔽频繁出现的数据竞争;白名单功能有助于复现和测试修复方案。

调优性能

影响 KCSAN 整体性能和缺陷检测能力的核心参数以内核命令行参数的形式公开,其默认值也可以通过相应的 Kconfig 选项进行更改。

  • kcsan.skip_watch (CONFIG_KCSAN_SKIP_WATCH):在建立另一个观察点之前,跳过的每个 CPU 的内存操作数。更频繁地建立观察点会导致观察到竞争的可能性增加。此参数对整体系统性能和竞争检测能力具有最显著的影响。

  • kcsan.udelay_task (CONFIG_KCSAN_UDELAY_TASK):对于任务,在建立观察点后暂停执行的微秒级延迟。较大的值会导致我们可能观察到竞争的时间窗口变大。

  • kcsan.udelay_interrupt (CONFIG_KCSAN_UDELAY_INTERRUPT):对于中断,在建立观察点后暂停执行的微秒级延迟。中断有更严格的延迟要求,其延迟通常应小于为任务选择的延迟。

它们可以通过 /sys/module/kcsan/parameters/ 在运行时进行调整。

数据竞争

在一次执行中,如果两个内存访问发生冲突、它们在不同的线程中并发发生、并且其中至少有一个是普通访问(plain access),则它们构成一个数据竞争;如果两者访问相同的内存位置且至少有一个是写操作,则它们发生冲突。有关更深入的讨论和定义,请参阅 LKMM 中的 “普通访问与数据竞争”(Plain Accesses and Data Races)

与 Linux 内核内存一致性模型 (LKMM) 的关系

LKMM 定义了各种内存操作的传播和排序规则,这使开发人员能够对并发代码进行推理。最终,这允许确定并发代码的可能执行情况,以及该代码是否没有数据竞争。

KCSAN 能够识别标记的原子操作READ_ONCEWRITE_ONCEatomic_* 等)以及内存屏障隐含的排序保证的子集。在 CONFIG_KCSAN_WEAK_MEMORY=y 下,KCSAN 对加载或存储缓冲进行建模,并且可以检测缺失的 smp_mb()smp_wmb()smp_rmb()smp_store_release(),以及所有带有等效隐含屏障的 atomic_* 操作。

请注意,KCSAN 不会报告由于缺少内存排序而导致的所有数据竞争,特别是需要内存屏障来阻止后续内存操作在屏障之前进行重排的情况。因此,开发人员应仔细考虑那些未被检查的必需内存排序要求。

超越数据竞争的竞争检测

对于具有复杂并发设计的代码,竞态条件错误并不总是表现为数据竞争。如果并发执行的操作导致了意外的系统行为,就会发生竞态条件。另一方面,数据竞争是在 C 语言级别定义的。可以使用以下宏来检查并发代码的属性,在这些并发代码中,错误不会表现为数据竞争。

ASSERT_EXCLUSIVE_WRITER

ASSERT_EXCLUSIVE_WRITER (var)

断言没有对 var 的并发写操作

参数

var

要进行断言的变量

描述

断言没有对 var 的并发写操作;允许其他读取者。此断言可用于指定并发代码的属性,其违规情况无法被检测为普通数据竞争。

例如,如果我们只有一个写入者,但有多个并发读取者,为了避免数据竞争,所有这些访问都必须被标记;甚至与单写入者发生竞争的并发标记写操作也是错误。不幸的是,由于它们被标记了,它们不再是数据竞争。对于这些情况,我们可以按如下方式使用该宏

void writer(void) {
        spin_lock(&update_foo_lock);
        ASSERT_EXCLUSIVE_WRITER(shared_foo);
        WRITE_ONCE(shared_foo, ...);
        spin_unlock(&update_foo_lock);
}
void reader(void) {
        // update_foo_lock does not need to be held!
        ... = READ_ONCE(shared_foo);
}

注意

如果适用,ASSERT_EXCLUSIVE_WRITER_SCOPED() 会在存在不期望并发写操作的清晰作用域时执行更彻底的检查。

ASSERT_EXCLUSIVE_WRITER_SCOPED

ASSERT_EXCLUSIVE_WRITER_SCOPED (var)

断言在作用域内没有对 var 的并发写操作

参数

var

要进行断言的变量

描述

ASSERT_EXCLUSIVE_WRITER() 的带作用域变体。

断言在引入它的作用域持续期间内,没有对 var 的并发写操作。与多个 ASSERT_EXCLUSIVE_WRITER() 相比,这提供了一种更好、全面覆盖封闭作用域的方法,并增加了 KCSAN 检测竞争访问的可能性。

例如,它允许查找仅由于作用域本身内部的状态变化而发生的竞态条件错误

void writer(void) {
        spin_lock(&update_foo_lock);
        {
                ASSERT_EXCLUSIVE_WRITER_SCOPED(shared_foo);
                WRITE_ONCE(shared_foo, 42);
                ...
                // shared_foo should still be 42 here!
        }
        spin_unlock(&update_foo_lock);
}
void buggy(void) {
        if (READ_ONCE(shared_foo) == 42)
                WRITE_ONCE(shared_foo, 1); // bug!
}
ASSERT_EXCLUSIVE_ACCESS

ASSERT_EXCLUSIVE_ACCESS (var)

断言没有对 var 的并发访问

参数

var

要进行断言的变量

描述

断言没有对 var 的并发访问(既无读取者也无写入者)。此断言可用于指定并发代码的属性,其违规情况无法被检测为普通数据竞争。

例如,在确定对象没有其他剩余用户之后期望独占访问,但该对象实际上并未被释放的情况。我们可以按如下方式检查此属性是否确实成立

if (refcount_dec_and_test(&obj->refcnt)) {
        ASSERT_EXCLUSIVE_ACCESS(*obj);
        do_some_cleanup(obj);
        release_for_reuse(obj);
}

注意

  1. 如果适用,ASSERT_EXCLUSIVE_ACCESS_SCOPED() 会在存在不期望并发访问的清晰作用域时执行更彻底的检查。

  2. 对于对象被释放的情况,KASAN 更适合检测释放后使用(use-after-free)错误。

ASSERT_EXCLUSIVE_ACCESS_SCOPED

ASSERT_EXCLUSIVE_ACCESS_SCOPED (var)

断言在作用域内没有对 var 的并发访问

参数

var

要进行断言的变量

描述

ASSERT_EXCLUSIVE_ACCESS() 的带作用域变体。

断言在引入它的作用域的整个持续期间内,没有对 var 的并发访问(既无读取者也无写入者)。与多个 ASSERT_EXCLUSIVE_ACCESS() 相比,这提供了一种更好、全面覆盖封闭作用域的方法,并增加了 KCSAN 检测竞争访问的可能性。

ASSERT_EXCLUSIVE_BITS

ASSERT_EXCLUSIVE_BITS (var, mask)

断言没有对 var 中位子集的并发写操作

参数

var

要进行断言的变量

mask

仅检查对 mask 中置位的位的修改

描述

ASSERT_EXCLUSIVE_WRITER() 的位粒度变体。

断言没有对 var 中位子集的并发写操作;允许并发读取者。与其他(字粒度)断言相比,此断言捕获了更详细的位级属性。仅检查 mask 中置位的位是否存在并发修改,而忽略其余位,即忽略对 ~mask 位的并发写操作(或读操作)。

将此用于某些位不得并发修改、而其他位预期可并发修改的变量。

例如,在初始化后某些位为只读,但其他位仍可被并发修改的变量。读取者可能希望按如下方式断言这是真的

ASSERT_EXCLUSIVE_BITS(flags, READ_ONLY_MASK);
foo = (READ_ONCE(flags) & READ_ONLY_MASK) >> READ_ONLY_SHIFT;

注意

假定紧跟在 ASSERT_EXCLUSIVE_BITS() 之后的访问仅访问被掩码的位,因此 KCSAN 乐观地认为它是安全的(即使存在数据竞争),从 KCSAN 的角度来看,用 READ_ONCE() 对其进行标记是可选的。不过,我们提醒说,这样做仍然可能是明智的,因为在涉及位操作时(在读取者和写入者端),我们无法推断出所有的编译器优化。如果您确信不会出问题,我们可以简单地将上面写为

ASSERT_EXCLUSIVE_BITS(flags, READ_ONLY_MASK);
foo = (flags & READ_ONLY_MASK) >> READ_ONLY_SHIFT;

可以使用此功能的另一个例子是,var 的某些位只有在持有适当锁时才能修改,但其他位仍可被并发修改。写入者在其他位可能同时更改的情况下,可以按如下方式使用该断言

spin_lock(&foo_lock);
ASSERT_EXCLUSIVE_BITS(flags, FOO_MASK);
old_flags = flags;
new_flags = (old_flags & ~FOO_MASK) | (new_foo << FOO_SHIFT);
if (cmpxchg(&flags, old_flags, new_flags) != old_flags) { ... }
spin_unlock(&foo_lock);

实现细节

KCSAN 依赖于观察到两个访问并发发生。至关重要的是,我们希望 (a) 增加观察到竞争的机会(特别是对于很少表现出来的竞争),以及 (b) 能够实际观察到它们。我们可以通过注入各种延迟来完成 (a),并通过使用地址观察点(或断点)来完成 (b)。

如果我们在为其地址设置了观察点时故意暂停内存访问,然后观察到观察点触发,则对同一地址的两个访问刚刚发生了竞争。使用硬件观察点,这是 DataCollider 中采用的方法。与 DataCollider 不同,KCSAN 不使用硬件观察点,而是依赖于编译器插桩和“软观察点”。

在 KCSAN 中,观察点使用一种高效的编码来实现,该编码将访问类型、大小和地址存储在一个长整数(long)中;使用“软 watchpoints(软观察点)”的好处是可移植性和更大的灵活性。然后 KCSAN 依赖编译器对普通访问进行插桩。对于每个被插桩的普通访问

  1. 检查是否存在匹配的观察点;如果是,且至少有一个访问是写操作,那么我们遇到了竞争访问。

  2. 定期地,如果不存在匹配的观察点,则建立一个观察点并暂停一小段随机延迟。

  3. 还在延迟前检查数据值,并在延迟后重新检查数据值;如果值不匹配,我们推断出未知来源的竞争。

为了检测普通访问和标记访问之间的数据竞争,KCSAN 还会为标记访问添加注解,但仅用于检查是否存在观察点;即 KCSAN 从不在标记访问上建立观察点。通过从不为标记操作建立观察点,如果对并发访问的变量的所有访问都被正确标记,KCSAN 将永远不会触发观察点,因此绝不会报告这些访问。

对弱内存进行建模

KCSAN 检测由于缺少内存屏障而产生的数据竞争的方法是基于对访问重排的建模(使用 CONFIG_KCSAN_WEAK_MEMORY=y)。为之设置了观察点的每个普通内存访问,同时也会在其函数作用域内被选中进行模拟重排(最多 1 个正在进行中的访问)。

一旦某个访问被选中进行重排,它将与每个其他访问一起进行检查,直至函数作用域结束。如果遇到适当的内存屏障,该访问将不再被考虑进行模拟重排。

当内存操作的结果应当由屏障排序时,KCSAN 便可以检测冲突仅由于缺少屏障而发生的数据竞争。考虑以下示例

int x, flag;
void T1(void)
{
    x = 1;                  // data race!
    WRITE_ONCE(flag, 1);    // correct: smp_store_release(&flag, 1)
}
void T2(void)
{
    while (!READ_ONCE(flag));   // correct: smp_load_acquire(&flag)
    ... = x;                    // data race!
}

当启用弱内存建模时,KCSAN 可以将 T1 中的 x 视为用于模拟重排。在写入 flag 之后,再次检查 x 是否存在并发访问:由于 T2 能够在写入 flag 之后继续执行,因此检测到了数据竞争。如果有正确的屏障到位,在正确释放 flag 之后,x 将不会被考虑进行重排,并且也不会检测到数据竞争。

复杂度上的刻意权衡以及实际限制意味着只能检测到由于缺少内存屏障而产生的数据竞争的子集。借助当前可用的编译器支持,该实现局限于对“缓冲”(延迟访问)的效果进行建模,因为运行时无法“预取”访问。还要回想一下,观察点仅为普通访问建立,并且这是 KCSAN 模拟重排的唯一访问类型。这意味着不对标记访问的重排进行建模。

上述情况的一个结果是,获取(acquire)操作不需要屏障插桩(无预取)。此外,引入地址或控制依赖关系的标记访问不需要特殊处理(标记访问不能被重排,后续依赖的访问不能被预取)。

关键属性

  1. 内存开销:根据配置的不同,总内存开销仅为几 MiB。当前实现使用一个很小的长整数(longs)数组来编码观察点信息,这是微不足道的。

  2. 性能开销:KCSAN 的运行时旨在保持最小化,它使用了一种高效的观察点编码,在快速路径(fast-path)中不需要获取任何共享锁。对于在拥有 8 个 CPU 的系统上启动内核

    • 使用默认 KCSAN 配置时速度变慢 5.0 倍;

    • 仅由于运行时快速路径开销导致速度变慢 2.8 倍(设置非常大的 KCSAN_SKIP_WATCH 并取消设置 KCSAN_SKIP_WATCH_RANDOMIZE)。

  3. 注解开销:在 KCSAN 运行时之外只需要极少的注解。因此,随着内核的发展,维护开销是极小的。

  4. 检测来自设备的竞争写操作:由于在建立观察点时会检查数据值,因此还可以检测到来自设备的竞争写操作。

  5. 内存排序:KCSAN 仅了解 LKMM 排序规则的一个子集;这可能会导致漏掉数据竞争(假阴性)。

  6. 分析准确性:对于观察到的执行,由于使用了采样策略,该分析是不健全的(可能存在假阴性),但旨在做到完备(无假阳性)。

考虑过的替代方案

内核的另一种数据竞争检测方法可以在 内核线程检查器 (KTSAN) 中找到。KTSAN 是一个“发生在前”(happens-before)的数据竞争检测器,它显式地建立内存操作之间的“发生在前”顺序,然后可用于确定 数据竞争 中定义的数据竞争。

为了构建正确的“发生在前”关系,KTSAN 必须了解 LKMM 的所有排序规则和同步原语。不幸的是,任何遗漏都会导致大量的假阳性,这在包含大量自定义同步机制的内核上下文中尤其有害。为了跟踪“发生在前”关系,KTSAN 的实现需要每个内存位置的元数据(影子内存,shadow memory),对于每个页面,这对应于 4 个页面的影子内存,在大型系统上可能会转化为几十 GiB 的开销。