并发的无序写信与围栏与共享内存不确定的行为吗?

发布于 2025-01-21 16:08:34 字数 2112 浏览 1 评论 0原文

我听说同时读/写入同一位置是不确定的行为,但是当不涉及明确的种族条件时,我不确定是否相同。我怀疑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);

写入/写下

每个线程,然后理论上运行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;
}

可选的分支写入/写入

如果以前的版本不算为未定义的行为,那么第一次从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或编译器可以重新订购内存操作吗?如果我要在operate_on_shared_memory的开始和结束时将atomic_thread_fence放置怎么办?

上下文

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

I have heard that it is undefined behavior to read/write to the same location in memory concurrently, but I am unsure if the same is true when there are no clear race-conditions involved. I suspect that the c18 standard will state it is undefined behavior on principal due to the potential to create race conditions, but I am more interested in if this still counts as undefined behavior at an application level when these instances are surrounded by fencing.

Setup

For context, say we have two threads A and B, set up to operate on the same location in memory. It can be assumed that the shared memory mentioned here is not used or accessible anywhere else.

// 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

Each thread then theoretically runs operate_on_shared_memory which mutates the value of shared_memory at the same time across both threads. However with the caveat that both threads attempt to set the shared memory to the same unchanging constant. Even if it is a race condition, the race winner should not matter. Does this count as undefined behavior? If so, why?

int shared_memory = 0;

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

    shared_memory = SOME_CONSTANT;
    return NULL;
}

Optional Branching Write/Write

If the previous version does not count as undefined behavior, then what about this example which first reads from shared_memory then writes the constant to a second location in shared memory. The important part here being that even if one or both threads succeeds in running the if statement, it should still have the same outcome.

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;
}

If this is undefined behavior, then why? If the only reason is that it introduces a race condition, is there any reason why I shouldn't deem it acceptable for one thread to potentially execute an extra machine instruction? Is it because the CPU or compiler may re-order memory operations? What if I were to put atomic_thread_fence at the start and end of the operate_on_shared_memory?

Context

GCC and Clang doesn't seem to have any complaints. I used c18 for this test, but I don't mind referring to a later standard if they are easier to reference.

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

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(2

泪冰清 2025-01-28 16:08:34

如果对象的值随时通过非合规LVALUE读取的任何时间进行修改,则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;
}

尽管原始函数无法返回65535,但是如果 *p的值从0更改为65535,则修订后的函数可能会这样做,或从65535到0,在两个读取之间。

许多编译器的设计方式可以固有地保证任何单词大小或杂音对象的不合格的读取将始终产生对象将来所拥有或将来所拥有的值,但不幸的是,编译器很少能明确文档进行文档。这样的事情。唯一不坚持这样的保证的编译器将是那些使用某些步骤来处理代码的编译器,这些步骤与指定的步骤有所不同,但有望表现相同,而且编译器作家很少会看到任何理由枚举少的文档 - 少数文档 - - 他们可以执行的转变,但事实并非如此。

If the value of an object is modified any time near where it is read via non-qualified lvalue, machine code generated by gcc may yield behavior which is inconsistent with any particular value the object held or could have held. Situations where such inconsistencies would occur are likely rare, but I don't think there's any way of judging whether such issues could arise in machine code generated from any particular source, except by inspecting the machine code in question.

For example, (godbolt link https://godbolt.org/z/T3jd6voax) the function:

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

will be processed by gcc 10.2.1, when targeting the Cortex-M0 platform, by code equivalent to:

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

Although there is no way the original function could return 65535, the revised function may do so if the value at *p changes from 0 to 65535, or from 65535 to 0, between the two reads.

Many compilers are designed in a way that would inherently guarantee that an unqualified read of any word-size-or-smaller object will always yield a value the object has held or will hold in future, but unfortunately it is rare for compilers to explicitly document such things. The only compilers that wouldn't uphold such a guarantee would be those that process code using some sequence of steps which differs from that specified, but is expected to behave identically, and compiler writers seldom see any reason to enumerate--much less document--all of the transformations that they could perform, but don't.

咿呀咿呀哟 2025-01-28 16:08:34

只要您不打算 count shared_memoryelese_shared_memory 的每个切换T完成或完成两次,它应该起作用。

例如,如果您的代码计划简单地监视/显示另一个系统的最终用户活动活动,则很好:一个微秒期间的不匹配并不是一个严重的问题。

如果您打算精确地对两个输入进行采样,并在共享内存中对线程结果进行精确计算,那么您正在做非常错误。

在这里,您的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时,如果您在两个值之间切换shared_memory代码>),由于不是原子读/写入,因此您可能会读取与两个二手常数不同的东西。
在标记2的行上,是相同的:您不能确定您不会被另一个 drign中断,最后获得了一个不是<代码> some_constant 或some_constant_2,但其他。考虑阅读一个和另一个的下部。

另外,您可以在第1行上“错过” true条件,因此错过了对ether_shared_memory的更新,或者这样做两次,因为第2行的写入将是弄乱了 - 因此,对于下一个测试行#1,Value Will some_constant不同,您将进行不需要的更新。

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

  • 尽管没有明确的原子学,但您的写入/读取原子是否是否?
  • 您的线程是否真的可以在第1行和#2之间中断,还是通过调度程序/优先级“受保护”(humm ...)?
  • 共享存储器是否可以容忍多个并发访问,还是如果尝试进行此类尝试,您会锁定控制它的芯片吗?

你无法回复?这就是为什么它是一种不确定的行为……

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


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

仅此而已。 这不能保证遇到问题,而是没有保证不是 的保证。我听起来可能像是一个微妙的差异,但实际上这是一个巨大的差异。

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

  • 执行机器,包括当前运行的所有软件,
  • 操作系统,包括其版本和已安装的组件,
  • 编译器链,包括其版本和开关,
  • 构建系统,包括所有可能旗帜传递给了编译器链。

但是UB并不意味着“您的程序将随机行动” ...给定的一组CPU指令总是会产生相同的结果(在相同的初始条件下),这里没有随机性。显然,它们可以是您要解决的问题的错误指令,但这是可生殖的。 我们就是这样搜寻错误

顺便说一句, 的方式。绝不是“您会面对纯粹的随机性”。实际上,许多程序甚至可以利用 ub,因为它们在这个特定平台上已知,并且比以好方法更容易/更便宜/更便宜。
或者因为,即使是正式的UB,您的编译器也最终与另一件事相同(即,在将整数降低到签名 较小的整数和char> char 通常是签名...附近没有人在乎。)。

但是,一旦编写了代码,您将知道的行为是什么:对于您的平台而言,它将不再是 undfined 。更新您的操作系统或编译器,启动另一个可以弄乱调度,使用更快的CPU的程序,以及必须必须再次测试,以检查行为是否仍然相同。这就是为什么拥有UB很烦人的原因:它可以起作用现在。并稍后引起一个棘手的错误。

这是工业软件经常使用“旧” OS和/或编译器的主要原因之一:升级它们是触发/引起错误的高风险,因为更新纠正了真正的错误,但是该项目的代码利用了此错误(也许在不知不觉中!)现在,更新的软件现在崩溃了……或更糟糕的是,可能会破坏一些硬件!

我们在2022年,我仍然有一个项目,该项目使用嵌入式2008 Linux,其中包括GCC3,VS2008,C ++ 98和WinXP/QT4在用户的计算机上。项目积极维护 - 相信我,这是一个痛苦。但是升级软件/平台?决不。更好地处理已知错误,而不是发现新的错误。也便宜。

我的专业之一是软件移植,主要是从“旧平台到新平台)(通常在两者之间使用10年或更长时间)。我很多次都面对这种事情:它在旧平台上起作用,它在新平台上打破了, ,因为那时UB被利用了,现在的行为(仍然不确定。 。)不再相同了。

显然,我不谈论更改C/C ++标准或机器的端度,无论如何您都需要重写代码或处理新的OS功能(例如Windows上的UAC)。我谈论的是“正常”代码,没有任何警告,这种代码时不时地行为不同。而且您无法想象它的频率有多频率,因为没有编译器会警告您高级UB(例如,非线程安全函数)和指令级别的UB(简单的演员或别名可以完全隐藏它而无需任何警告)。

As long as you don't plan to count every toggle of shared_memory and other_shared_memory, and if you don't care if some modifications aren't done or are done twice unnecessarily, it should work.

For example, if your code is planned to simply monitor/show another system's activity for end users, it's fine: a mismatch during one microsecond isn't a serious issue.

If you plan to sample precisely two inputs and get an accurate array of results, or do precise computations on thread's results in shared memory, then you're doing it very wrongly.

Here, your UB is mostly that you can't guarantee that shared_memory isn't modified between the test and the assignment.

I've numbered two lines in your code:

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;
}

When on line marked 1, if for example you're toggling shared_memory between two values (SOME_CONSTANT and SOME_CONSTANT_2), since it isn't atomic reads/writes you MAY read something different than the two used constants.
On line marked 2, it's the same: you can't be sure that you won't be interrupted by another write, and got finally a value that isn't SOME_CONSTANT or SOME_CONSTANT_2, but something else. Think about reading the upper part of one, and the lower part of the other.

Also, you can "miss" a true condition on line #1, and therefore miss an update to other_shared_memory, or do it twice because the write at line #2 will be messed up - so for next test line #1, value will be different from SOME_CONSTANT and you'll do an unwanted update.

All this depends on several factors, like:

  • Are your writes/reads atomic anyway, despite not being explicitely atomics?
  • Can your threads be really interrupted between lines #1 and #2, or are you "protected" (humm...) by scheduler/priorities?
  • Is shared memory tolerant to multiple concurrent access, or will you lock the chip that controls it if you do such an attempt?

You can't reply? That's why it's an undefined behavior...

In your particular situation, it MAY works. Or not. Or fails on my machine while working on yours.


"Undefined behavior" is usually not properly understood. What it really means is: "You cannot predict nor guarantee what the behavior will be for ALL possible platforms".

Nothing more, nothing less. It's not a guarantee of having problems, it's the absence of a guarantee of NOT having them. I may sounds like a subtle difference, but in fact it's a huge one.

By "platform", we mean the tuple build with:

  • An execution machine, including all currently running softwares,
  • An operating system, including its version and installed components,
  • A compiler chain, including its version and switches,
  • A build system, including all possible flags passed to compiler chain.

But UB doesn't mean "your program will act randomly"... A given set of CPU instruction will always produce the same result (in the same initial conditions), there is no randomness here. Obviously, they can be the wrong instructions for the problem you wish to solve, but it's reproductible. That's how we hunt bugs, BTW...

So, on a fixed platform, having an UB means "you can't predict what will happen". And in no way "you'll face pure randomness". In fact, a LOT of programs can even exploit UB, because they're known on this particular platform and it's easier/cheaper/faster than doing it the good way.
Or because, even if officially an UB, your compiler does finally the same thing as the other (i.e. there is an UB when downcasting an integer to a signed smaller integer, and char is usually signed... Near nobody cares.).

But once your code is written, you'll know what the behavior is: it won't be undefined anymore... For YOUR platform, and ONLY this platform. Update your OS or your compiler, launch another program that can mess the scheduling, use a faster CPU, and you MUST test again to check if behavior is still the same. And that's why it's annoying to have UB: it can works NOW. And cause a tricky bug a bit later.

It's one of the major reasons why industrial software often use "old" OSes and/or compiler: upgrading them is a HIGH risk of triggering/causing a bug because an update corrected what was a real bug, but the project's code exploited this bug (maybe unknowingly!) and the updated software now crash... Or worse, can destroy some hardware!

We're in 2022, I still have a project that uses an embedded 2008 Linux, with a GCC3, VS2008, C++98, and WinXP/Qt4 on user's machine. Project is actively maintained - and trust me, it's a pain. But upgrading software/platform? No way. Better deal with known bugs rather than discovering new ones. Cheaper, too.

One of my speciality is softwares porting, mostly from "old" platforms to new ones (often with 10 years or more between the two). I've faced this kind of things a LOT of times: it worked on old platform, it breaks on new one, and only because an UB was exploited then, and now the behavior (still undefined...) is not the same anymore.

I obviously don't speak about changing C/C++ standard, or machine's endianness, where you need anyway to rewrite code, or dealing with new OS features (like UAC on Windows). I speak about "normal" code, compiled without any warning, that behaves differently now and then. And you can't imagine how frequent it is, since no compiler will warn you about neither high-level UB (for example, non thread-safe functions) nor instruction-level UB (a simple cast or alias can fully hide it without ANY warning).

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文