并发无序写入是否具有对共享内存未定义行为的防护?

Are concurrent unordered writes with fencing to shared memory undefined behavior?

我听说同时 read/write 到内存中的同一位置是未定义的行为,但我不确定在不涉及明确的竞争条件时是否也是如此。我怀疑 c18 标准会声明它是主体的未定义行为,因为可能会产生竞争条件,但我更感兴趣的是,当这些实例被围栏包围时,这是否仍然算作应用程序级别的未定义行为。

设置

对于上下文,假设我们有两个线程 A 和 B,设置为在内存中的相同位置上操作。可以假定这里提到的共享内存在其他任何地方都没有使用或访问。

// Prior to the creation of these threads, the current thread has exclusive ownership of the shared memory
pthread_t a, b;

// Create two threads which operate on the same memory concurrently
pthread_create(&a, NULL, operate_on_shared_memory, NULL);
pthread_create(&b, NULL, operate_on_shared_memory, NULL);

// Join both threads giving the current thread exclusive ownership to shared memory
pthread_join(a, NULL);
pthread_join(b, NULL);

// Read from memory now that the current thread has exclusive ownership
printf("Shared Memory: %d\n", shared_memory);

Write/Write

然后每个线程理论上运行 operate_on_shared_memory,这会在两个线程中同时改变 shared_memory 的值。但是需要注意的是,两个线程都试图将共享内存设置为相同的不变常量。即使是比赛条件,比赛获胜者也无关紧要。这算作未定义的行为吗?如果是,为什么?

int shared_memory = 0;

void *operate_on_shared_memory(void *_unused) {
    const int SOME_CONSTANT = 42;

    shared_memory = SOME_CONSTANT;
    return NULL;
}

可选分支Write/Write

如果以前的版本不算作未定义的行为,那么这个例子呢,它首先从 shared_memory 中读取,然后将常量写入共享内存中的第二个位置。这里的重要部分是即使一个或两个线程在 运行 if 语句中成功,它仍然应该有相同的结果。

int shared_memory = 0;
int other_shared_memory = 0;

void *operate_on_shared_memory(void *_unused) {
    const int SOME_CONSTANT = 42;

    if (shared_memory != SOME_CONSTANT) {
        other_shared_memory = SOME_CONSTANT;
    }

    shared_memory = SOME_CONSTANT;
    return NULL;
}

如果这是未定义的行为,那为什么呢?如果唯一的原因是它引入了竞争条件,那么我是否有任何理由认为一个线程可能执行额外的机器指令是不可接受的?是因为 CPU 还是编译器可能会重新排序内存操作?如果我将 atomic_thread_fence 放在 operate_on_shared_memory 的开头和结尾怎么办?

上下文

GCC 和 Clang 似乎没有任何抱怨。我在这个测试中使用了 c18,但如果更容易参考,我不介意参考后来的标准。

$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
$ gcc -std=c18 main.c -pthread -g -O3 -Wall

只要您不打算 计数 shared_memoryother_shared_memory 的每次切换,并且您不关心是否进行某些修改没有完成或不必要地完成两次,它应该有效。

例如,如果您的代码计划为最终用户简单地 monitor/show 另一个系统的 activity,那没关系:一微秒内的不匹配不是严重问题。

如果您打算对两个输入进行精确采样并获得准确的结果数组,或者对共享内存中线程的结果进行精确计算,那么您的做法非常 .

在这里,你的UB主要是你不能保证shared_memory在测试和作业之间没有被修改。

我在你的代码中标出了两行:

void *operate_on_shared_memory(void *_unused) {
    const int SOME_CONSTANT = 42;

/*1*/if (shared_memory != SOME_CONSTANT) {
        other_shared_memory = SOME_CONSTANT;
    }

/*2*/shared_memory = SOME_CONSTANT;
    return NULL;
}

当行标记为 1 时,如果您在两个值(SOME_CONSTANTSOME_CONSTANT_2)之间切换 shared_memory,因为它不是原子的 reads/writes 你可能会读到与两个使用的常量不同的东西。 在标记为 2 的行中,它是相同的:你不能 确定 你不会被 另一个 写打断,最后得到了一个不是 SOME_CONSTANTSOME_CONSTANT_2 的值,而是其他值。想想读一个的上半部分,另一个读下半部分。

此外,您可以“错过”第 1 行的 true 条件,因此错过对 other_shared_memory 的更新,或者执行两次,因为第 2 行的写入将被弄乱向上 - 因此对于下一个测试行 #1,值 不同于 SOME_CONSTANT 并且您将进行不需要的更新。

所有这一切都取决于几个因素,例如:

  • 你的 writes/reads 是原子的吗,尽管不是明确的原子?
  • 您的线程是否真的在第 1 行和第 2 行之间中断,或者您是否受到 scheduler/priorities 的“保护”(嗯...)?
  • 共享内存是否可以容忍多个并发访问,或者如果您尝试这样做,您会锁定控制它的芯片吗?

无法回复?这就是为什么它是未定义的行为...

在您的特定情况下,它可能有效。或不。或者在你的机器上工作时在我的机器上失败。


“未定义的行为”通常没有被正确理解。它的真正含义是:“您无法预测或保证所有可能平台的行为”

仅此而已。 这不能保证有问题,它不能保证不会有问题。我听起来可能有点细微的差别,但实际上这是一个巨大的。

通过“平台”,我们指的是元组构建:

  • 一台执行机,包括目前所有运行个软件,
  • 一个操作系统,包括它的版本和安装的组件,
  • 一个编译器链,包括它的版本和开关,
  • 构建系统,包括传递给编译器链的所有可能标志。

但 UB 并不意味着“你的程序会随机行动”...一组给定的 CPU 指令将始终产生相同的结果(在相同的初始条件下),这里没有随机性.显然,对于您要解决的问题,它们可能是 错误的 指令,但它是 可重现的 。这就是我们寻找错误的方式,顺便说一句...

因此,在 固定 平台上,拥有 UB 意味着“您无法预测会发生什么”。而且绝不会“您将面临纯粹的随机性”。事实上,很多程序甚至可以 利用 UB,因为它们在这个特定平台上 已知 并且 easier/cheaper/faster 比做这是好方法。 或者因为,即使正式是 UB,您的编译器最终也会做与另一个相同的事情(即,当将整数向下转换为 signed 较小的整数时有一个 UB,并且 char 通常是签名的...几乎没有人关心。)。

但是一旦你的代码写好了,你就会知道行为是什么:它不再是undefined.. . 对于您的平台,并且仅适用于该平台。更新你的 OS 或你的编译器,启动另一个可以打乱调度的程序,使用更快的 CPU,并且 必须 再次测试以检查行为是否仍然相同。这就是为什么拥有 UB 很烦人:它可以工作 现在。稍后会导致一个棘手的错误。

这是工业软件经常使用“旧”OSes and/or 编译器的主要原因之一:升级它们存在 triggering/causing 错误的高风险,因为更新更正了什么是真正的错误,但项目的代码利用了这个错误(可能是在不知不觉中!)并且更新后的软件现在崩溃了......或者更糟的是,可以破坏一些硬件!

我们在 2022 年,我仍然有一个项目使用嵌入式 2008 Linux,在用户的机器上有 GCC3、VS2008、C++98 和 WinXP/Qt4。项目得到积极维护 - 相信我,这很痛苦。但是升级software/platform?决不。更好的处理已知错误而不是发现新错误。也更便宜。

我的专长之一是软件移植,主要是从“旧”平台移植到新平台(两者之间通常相隔 10 年或更长时间)。我遇到过很多次这种事情:它在旧平台上工作,它在新平台上崩溃, 因为当时 UB 被利用,现在行为(仍然undefined...) 不一样了。

我显然不会谈论改变 C/C++ 标准,或机器的字节顺序,你无论如何都需要重写代码,或处理新的 OS 特性(比如 Windows).我说的是“正常”代码,在没有任何警告的情况下编译,时不时会有不同的行为。而且您无法想象它有多频繁,因为没有编译器会警告您 high-level UB(例如,非 thread-safe 函数)和 instruction-level UB(简单的转换或别名)可以在没有任何警告的情况下完全隐藏它)。

如果对象的值在通过 non-qualified 左值读取附近的任何时间被修改,gcc 生成的机器代码可能会产生与对象持有或可能持有的任何特定值不一致的行为.发生这种不一致的情况可能很少见,但我认为没有任何方法可以判断这种问题是否会出现在从任何特定来源生成的机器代码中,除非检查有问题的机器代码。

例如,(godbolt link https://godbolt.org/z/T3jd6voax) 函数:

unsigned test(unsigned short *p)
{
    unsigned short temp = *p;
    unsigned short result = temp - (temp>>15);
    return result;
}

将由 gcc 10.2.1 处理,当以 Cortex-M0 平台为目标时,代码等效于:

unsigned test(unsigned short *p)
{
    unsigned short temp1 = *p;
    signed short temp2 = *(signed short*)p;
    unsigned short result = temp1 + (temp2 >> 15);
    return result;
}

尽管原始函数无法 return 65535,但如果 *p 处的值在两次读取之间从 0 变为 65535,或从 65535 变为 0,则修改后的函数可能会这样做。

许多编译器的设计方式本质上保证对任何 word-size-or-smaller 对象的非限定读取将始终产生该对象已经持有或将来持有的值,但不幸的是,编译器很少见明确记录这些事情。唯一不支持这种保证的编译器是那些使用与指定步骤不同但预期行为相同的步骤序列处理代码的编译器,编译器编写者很少看到任何枚举的理由——更不用说文档了—— -他们可以执行但没有执行的所有转换。