【问题标题】:The data racing issue cannot be reproduced with this snippet of code这段代码无法重现数据竞速问题
【发布时间】:2022-01-02 09:07:49
【问题描述】:

我有下面的 sn-p 代码,我想用它来展示 C 多线程编程中数据竞争的情况。我在 x86 Linux 系统(只有一个内核)上使用gcc test.c -O0 -lpthread -o test && ./test 编译并运行了近一百次,但我没有看到任何打印值不正确(200000)的情况。这是否意味着 x86 或编译器可以保证对 int 变量的每次修改都是线程安全的?还是我的程序有什么问题?

编辑:所以,正如我和@Sinic 测试的那样,问题将更新为: 为什么在单核 CPU 上很少发生数据竞速?或者在单核 CPU 上不会出现任何数据竞速问题? 因为,AFAIK,即使在单核 CPU 上线程也会随机调度。所以,结果也是一团糟。

// test.c
#include <threads.h>
#include <stdio.h>
#define THREAD_COUNT 20
#define THREAD_LOOP 10000
int counter = 0; 
int run(void* data) {
  for (int i = 0; i < THREAD_LOOP; i++)
    counter++;  // <- each thread would modify the global variable counter here.
  printf("Thread %d terminates.\n", *((int*) data));
  return thrd_success;
}
int main(void) {
#ifndef __STDC_NO_THREADS__
  int ids[THREAD_COUNT];
  thrd_t threads[THREAD_COUNT];  
  for (int i = 0; i < THREAD_COUNT; i++) {
    ids[i] = i + 1;
    thrd_create(&threads[i], run, ids + i);
  }
  for (int i = 0; i < THREAD_COUNT; i++)
    thrd_join(threads[i], NULL);
  printf("Counter value is: %d.\n", counter); 
#endif
  return 0; 
} 

得到了run函数的汇编代码截图如下图,还指出了counter++的对应代码。

【问题讨论】:

  • 即使禁用了优化,您确定编译器根本不会在run 函数中展开循环吗?你看过生成的汇编代码吗?您是否尝试过使counter 变量volatile?
  • @Someprogrammerdude 我得到了run函数的汇编实现截图。
  • 我在 Ryzen 3900X(12 核 + SMT)上循环运行了 10000 次代码:计数器从未准确达到 200000。它的范围从 13770 到 107813,平均值为 13770 到 107813。 30774 个。
  • @Sinic 这个问题可能与我在单核 CPU 上运行它有关吗?
  • @Becavalier 绝对。如果我只将过程固定到一个核心,则(在 10000 次测试中)有 ~9548 个正确结果。所以超过95%。我认为这是因为在单核机器上发生计数器更新时,与在多核机器上相比,它不太可能被准确安排。两个核心的正确结果下降到 47%,三个核心的正确结果下降到 9%

标签: c multithreading


【解决方案1】:

这是否意味着 x86 或编译器可以保证对 int 变量的每次修改都是线程安全的?

否,在 x86/x86-64 平台上,这不是线程安全的(就像在大多数平台上一样)。汇编代码证明该操作不是原子完成的。

或者我的程序有什么问题?

嗯,可能。不保证线程在当时被执行。如果操作系统需要一些时间来创建每个线程,则循环实际上可能是串行执行的。 10000 次迭代并不多,现代主流处理器可以在很短的时间内执行循环(例如几微秒),通常少于创建新线程所需的时间。您可以通过使用更多迭代和障碍来缓解问题。

我没有看到任何打印值不正确(200000)的情况。

在我不使用simultaneous multithreading(例如超线程)的机器上,情况并非如此。这表明问题取决于执行或硬件。

这个问题是否可能与我在单核 CPU 上运行它有关?

是的,确实如此。

竞争条件主要来自一个核心可以请求另一个线程使用的给定缓存行这一事实。获取的值可能不是最新的。在 x86/x86-64 平台上,当一个核心写入缓存行时,它会使其他核心中的副本无效。但是,它仍然会导致问题,因为操作不是原子完成的。

如果您只使用 1 个内核和 1 个硬件线程,则软件线程在给定的量子期间串行(以交错方式)执行,这可能比执行循环的时间大得多 .在这种情况下,这意味着整个执行是顺序的,您应该看不到任何问题。如果quantum小于执行循环的时间,则应该出现问题,因为线程可以在循环中间被中断(同时获取的值会被其他线程修改)。

您可以在 Linux 上使用 numactl --physcpubind=0 将您的进程固定到给定的核心。在我的机器上,我可以确认如果只使用一个内核,问题不会出现。但是,它确实出现了至少 2 个内核。如果我还将 THREAD_LOOP 设置为大 10000 倍的值,则结果不再相同了。这证实了上面解释的量子假设。

【讨论】:

  • “[...] 可以在很短的时间内执行循环 [...] 少于创建新线程所需的时间”似乎是正确的。如果我进行 100000 次迭代,当固定到单个核心时,正确结果的数量(在 1000 次运行中测试)下降到 0.4%。 (而不是 10000 次迭代时的 98.6%)
  • 你的意思是线程重新调度的间隔比循环的执行时间长吗?那么,每个线程都可以在下一个线程创建或下一个线程开始之前完成它的执行吗?
  • 这正是我认为如果循环没有足够长的时间会发生的情况。这将导致线程的串行处理,从而减轻竞争条件。因此,如果您增加单核机器上的循环大小,您应该会看到类似的行为
  • 就您而言,我认为创建线程的时间并不重要。重要的是分配给执行每个线程的量子。实际上,即使创建了线程,它们也不会直接调度在 1 核硬件(具有 1 个硬件线程)上。操作系统 (OS) 将为给定时间片(例如 1 毫秒)安排其中一个。问题是目标线程只是执行循环的一小部分时间(例如 10 us)。一旦函数返回,操作系统就会调度另一个线程,依此类推。最后,整个执行是顺序的没有上下文切换。
  • 如果迭代次数大得多,那么 1 个量程不足以执行线程的所有迭代,并且操作系统会在循环中间进行一些上下文切换,从而使竞争条件可见.是不是更清楚了?
【解决方案2】:

这是否意味着 x86 或编译器可以保证对 int 变量的每次修改都是线程安全的?

没有。当您测试时,某些东西有效并不意味着有保证——永远。

或者我的程序有什么问题?

是的,您的程序存在数据竞争。修复错误,谜团就会消失。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2021-11-13
    • 2015-04-05
    • 1970-01-01
    • 1970-01-01
    • 2020-01-14
    • 2018-09-23
    • 1970-01-01
    • 2012-05-20
    相关资源
    最近更新 更多