LWN: 使用KCSAN检测出缺失的memory barrier!
共 3034字,需浏览 7分钟
·
2021-12-17 08:37
关注了就能看到更多这么棒的文章哦~
Detecting missing memory barriers with KCSAN
By Jonathan Corbet
December 2, 2021
DeepL assisted translation
https://lwn.net/Articles/877200/
编写并发场景的代码的时候用锁来避免 race condition ,就已经相当考验技术了。而如果目标是使用 lockless 算法,也就是依靠 memory barrier 而不用 lock 从而避免使用锁引入的开销时,就更加困难了。在这种类型的代码中,非常容易产生错误,也很难发现。不过,不久后可能会有一些工具可以提供帮助了,Marco Elver 的 patch set 增强了
Kernel Concurrency Sanitizer(KCSAN)的能力,可以检测到某些场景下缺失的 memory barrier。
KCSAN 是通过观察对指定内存地址的那些访问,采用统计学方式来分析,从而希望能检测出可疑的 pattern。它使用的算法在之前的文章中有过介绍。不过,它当前实现的代码中只能捕捉到某些特定类型的 race condition,也就是是那些 locking 错误引起的竞态条件。其他类型的竞态问题仍然无法用这个工具来检测出来,包括一些在 lockless 代码中出现的一些可能的竞态问题。KCSAN 的设计理念就导致了它无法发现由于 CPU 和内存控制器对 memory write 操作进行重排序(reorder)时导致的各种问题。
我们拿下面的代码作为例子,它来自 Elver 的 patch set 中的文档 patch(并进行了简单的修改):
int x, flag;
void T1(void)
{
x = 1; // data race!
WRITE_ONCE(flag, 1); // should be: smp_store_release(&flag, 1)
}
void T2(void)
{
while (!READ_ONCE(flag)) // should be: smp_load_acquire(&flag)
;
... = x; // data race!
}
乍看之下,这段代码像是没有什么问题。T1() 会将数值存储到变量 x 中,然后设置 flag 来表示 x 已经是有效的(valid)了。在 T2() 中运行的另一个线程则确保在 flag 被置为 1 之前不会去读取 x,所以它取到 x 的值的时候应该可以确保都是有效的值了。只有一个小问题:由于这里没有添加 memory barrier 操作,CPU 完全有权力对这些操作进行 reorder,毕竟在 CPU 看来这些操作互相是毫无关联的。事实上,在 T1() 中对 x 的 write 可能在对 flag 的 write 之后才会被系统中的其他 CPU 真正看到。这可能导致 T2() 认为它看到的 x 值是有效的,但其实这个时候这个线程取到的其实是真正的数据写入 x 之前的 x 的值。
要修正这部分代码的话,需要使用 smp_store_release() 来写入 flag,这就会确保在这个 store 操作之前所做的所有 store 操作都先完成,然后才会让系统中其他部分能看到 flag 标志变为 1 了。同样地,需要用 smp_load_acquire() 来读取 flag,这样就不会把后面要进行的 read 操作被 reorder 到此操作之前,从而避免发生提前 read。barrier 几乎总是需要配对出现,才能确保是正确的。省略两个操作中的哪一个,都会导致产生错误的代码。
对于实现 lockless 算法的开发者来说,很难保证完全不犯这种错误。对于某个具体的访问操作来说,并不能那么容易地判断出来这里需要 memory barrier。而带有这种 bug 的代码可能在开发者的所有测试中都是能正常工作的,但在少数的生产系统所具有的一些特定条件出现的时候才暴露出 bug。这就是为什么一些开发者一旦了解了 lockless 编程的难点,就会得出结论,还是从事在 JavaScript 中实现那些让人厌烦的弹出窗口这类工作更加好一些。
在目前的内核中,KCSAN 也无法检测到这种 race。在 KCSAN 下运行的系统可能会认为 T1() 中对 x 的 store 操作很可疑,值得注意,但是 KCSAN 所采用的观察方式会导致 race 竞态条件不会出现。因为 KCSAN 即将开始监视对 x 的访问、或者在持续保持监控的期间,它会将 T1() 的后续执行推后,从而观察是否有什么可疑的访问发生了。T1() 只有在监控期间结束之后才会得以继续执行(从而来设置 flag)。因此,KCSAN 事实上延迟了对 flag 的写入,导致 T2() 可以一直等待直到这个监控期间结束。所以只要 KCSAN 在进行监控,就不可能发生乱序访问的情况,也就无法发现这里的问题了。
新的代码做了一个看似非常简单的改动,从而希望能检测出这种问题。尽管这种很简单的理念,其实需要 25 个 patch 组合起来才能实现。KCSAN 在结束了监控期之后,会在后续的每一个内存访问之后重复进行观察(watch)一直到遇到 memory barrier 或函数返回为止,而不是简单地把 x 抛诸脑后。在上面介绍的这种情况下,KCSAN 将在向 flag 赋值之后再次开始观察 x,本质上就是模拟对这两个变量的写入顺序的一个 reorder。实质上,这种重复进行的观察就是在看如果对 x 的 write 被看到的时间点比起开发者预想的要晚,那么会发生什么情况。在再次进行观察中看到的对 x 的任何访问仍然是有竞态问题的(racy),因为没有进行 memory barrier 操作来确保正确的执行顺序。所以 KCSAN 现在会在 T2() 中检测到对 x 的 read 操作是有竞态问题的,并给出警告。
这个算法可以检测到这一种由缺失障碍引起的竞态问题,但无法找到所有的。最值得注意的是,它可以用来检测出将一个对共享数据进行的访问推迟了之后的情况——就比如上面的例子中对 x 写入之后这个值就要过一段时间才会被其他人可以看到——但不能检测出比开发者预期的更早进行了这个访问的效果。尽管它的覆盖场景尚不完全,但是这个改进后的 KCSAN 很可能能够阻止一些与 barrier 有关的 bug 从而避免影响到用户。这可能会使我们这些普通人在开发时可以更容易接受 lockless 算法,甚至可能使他们中的一些人摆脱未来不得不去开发 JavaScript 的黯淡前途。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~