多个线程访问同一个变量

5

我在阅读一本教材时发现了这个问题。下面也给出了解决方案。我不理解最小值为2的原因。为什么一个线程不能读取0,其他所有线程都执行并写入1呢?而且无论是1还是2,最后写入的线程仍然必须完成自己的循环吗?

int n = 0;
int main(int argc, char **argv) {
 for (i = 0; i < 5; i++) {
 int tmp = n;
 tmp = tmp + 1;
 n = tmp;
 }
 return 0;
}

如果这个应用程序只有一个线程运行,那么预期的最终输出应该是5。如果5个线程并行运行相同的循环,n能够达到的最大和最小值是什么?最大值很明显是25,因为来自5个线程的5次增量。然而,对于最小可能的值进行推理会更加困难。提示:n可以小于5,但你需要自己弄清楚原因。
解决方案:
使用五个线程运行这个五次迭代的循环,且没有保护措施防止并发访问,n能够达到的最低值为2。从最终结果反向工作时,理解如何达到这个结果最容易了。为了得到最终输出为2,一个线程必须从n中读取到1的值,将其加1,然后写入2。这意味着另一个线程写入了1,这意味着它也最初读取了0(这也是n的起始值)。这解释了5个线程中的两个线程的行为。然而,为了使这种行为发生,其他三个线程的结果必须被覆盖。有两种有效的执行方式。要么1)所有三个线程在第一个线程读取零并写入一之间开始并完成执行,要么2)所有三个线程在最后一个线程读取一并写入二之间开始并完成执行。这两种执行顺序都是有效的。

4
在C11线程中,这将导致未定义的行为,因为没有使用内存屏障或原子操作。 - M.M
@MattMcNabb 是的。我应该也包括第一部分。"您已经看到,来自多个线程的不安全访问会导致不可预测的结果。但是您还看到,可以从不安全的访问中提取一些保证(也就是说,如果每个线程都写入1,则最终值不能神奇地变成其他值)。请考虑以下代码片段:" - John
3
@UserNotDefined:绝对错误。如果每个线程都写入1,并且存在竞态条件导致未定义的行为,则结果可以是任何内容。您的应用程序可能会崩溃。 - gnasher729
4个回答

4
假设每个线程都有本地的i(即无论如何每个线程都会运行5次迭代),让我们试图使结果为1。这意味着最后一个写入值的线程必须在第5次迭代时读取0作为n的值。唯一可能发生这种情况的方式是,在该线程的第5次迭代开始时,没有任何线程写入n,但是该线程本身必须已经写入n才能处于第5次迭代状态,因此这是不可能的。
因此,最小的可能结果是2,例如可以按照以下方式实现:最后一个写入n的线程完成了4次迭代,然后另一个线程写入1,最后一个线程在其第5次迭代开始时读取1,所有其他线程在最后一个线程之前完成所有迭代,最后最后一个线程完成了它的第5次迭代,将2写入n。
免责声明:我回答了关于多线程的概念性问题 - 正如其他人所指出的那样,如果直接使用所提供的C代码,缺乏原子性可能导致未定义的行为和任意结果。根据问题中“显然”的最大数字情况,我猜测教科书的作者要么没有意识到这一点,要么是使用类似C的伪代码来说明这一概念。如果是前者,则正确答案是该书错误,但我认为在后一种情况下,答案也是有教育意义的。

2
免责声明:我正在回答关于多线程的概念性问题 - 正如其他人所指出的,写操作的非原子性可能会导致未定义的行为和任意结果。根据问题中“不言自明”的最大数情况,我猜测教科书的作者要么没有意识到这一点,要么是使用类似C语言的伪代码来说明概念。 - Arkku
作为补充说明,我做出的假设是每个线程本地化i,即使忽略未定义行为和原子性问题,也必须如此才能使教科书的答案正确。如果i是共享的,那么一个线程只能运行一次迭代(其他线程已经递增了i以使循环条件为false),并设置n = 1。因此,似乎可以安全地假设教科书的作者打算使用非共享的i - Arkku
为什么要踩我?我觉得我已经解释了我所做的假设,如果有人因为我“回答练习题”而踩我,请注意原帖作者已经从教科书中得到了答案... - Arkku

2

最大的应该是显而易见的:25,从5个线程增量为5个。

完全错误。无论谁说过这句话都不应该听取(至少在涉及线程的事情上),没有例外。

 int tmp = n;
 tmp = tmp + 1;
 n = tmp;

想象一下一个CPU没有增量操作,但有一个高效的“加10”操作和一个高效的“减9”操作。在这样的CPU上,“tmp = tmp + 1;”可以被优化为“tmp += 10; tmp -= 9;”。编译器也可以通过对n进行操作来完全优化掉tmp。因此,这段代码就可以等同于:
n += 10;
n -= 9;

现在想象一下这种情况:所有五个线程都加上10,所以n现在是50。第一个线程读取了50,其他四个线程减去9。第一个线程从它读取的50中减去9并写入41。因此,当所有操作完成时,n等于41。

因此,所谓的不言自明是完全错误的。写这篇文章的人不理解C语言中的线程。

如果每个线程都写入1,那么最终值就不能神奇地变成其他值

也是完全错误的。考虑一个CPU,它通过首先写入0然后递增值来写入1。如果这发生在两个核心上,最终结果可能是2。这本教科书是由一个根本不理解线程和未定义行为的人编写的。

(我假设这本教科书不局限于某些特殊情境,其中它所说的是真实的。例如,它可能使用“类似C”的代码作为平台中立的汇编语言,并且它可能对具有特定保证的对齐整数的平台进行假设。但如果是这样,它所教的内容根本不适用于C代码,并且只适用于在符合教科书假设的CPU上编写汇编代码的人。)


即使编译器进行了优化,线程在运行时仍然可能以串行方式运行,并以n=25结束,即(1+1+1+1+1)*5。 - John
没错,但关键是最大值并不明显是25。即使只有五个线程运行一次循环,n也可能是41! - David Schwartz
即使没有任何编译器优化? - John
1
@UserNotDefined 是的,即使排除任何编译器优化,因为编译器可以做的任何优化都可以由CPU或系统的其他组件完成。(你要么有保证,要么没有保证,你不能通过排除可能违反保证的事情来合成保证。) - David Schwartz
@UserNotDefined 因为编译器必须天真地将C代码转换为最接近的汇编代码或者进行“优化”这一想法是错误的。可能存在某些CPU根本没有增量操作,因此将增量操作分解成更小的操作是必要的,而不是优化。这是C代码,它意味着它所需的操作。 - David Schwartz
如果有人给我点踩,我真的很希望他们能解释一下为什么。如果我对某些事情不清楚,我想澄清一下。如果我错了,我想纠正它。 - David Schwartz

2

补充一些见解:在C语言中使用加、减等运算符进行运算,不仅仅是一个操作。在汇编层面上,加法操作由多条指令组成。如果多个线程同时访问一个变量,并且这些指令的交错顺序不合理,最终结果可能会非常不正确 -> 这就是为什么我们需要像互斥锁、信号量和条件变量这样的东西的另一个原因。


这是一个关于C语言的问题,不是C++。 - M.M
@MattMcNabb 更正了,但对于 C 仍然正确。 - Riptyde4
1
加法是一项单独的操作,但是加载和存储是分开的(即使在 CISC 上似乎编码为单个指令)。 - o11c
@o11c 在 C 标准中我在哪里可以找到这个?或者你是指它恰好出现在某些平台上? - David Schwartz
@o11c 我指的是在 C++ 级别上进行的加法操作,并且只是简单地想要表达 C++ 的加法操作会转换为多个汇编指令,而这些指令本身都有可能出现错误的交错问题。 - Riptyde4
显示剩余2条评论

0

重点是线程共享相同的数据实例。此外,似乎假定所有其他线程以相同的执行速率运行。

因此,每个线程在循环中一轮结束时(到达i ++部分的for),它们几乎同时递增i,因此就好像代码是这样编写的:

 for (i = 0; i < 5; i++, i++, i++, i++, i++)
    ...

至少在极端情况下,可以得到最小迭代次数。


但是对我来说,最少的迭代次数似乎是5,你能解释为什么最终答案是2吗? - John
@UserNotDefined:步骤是,i = 0(第一次迭代),然后i=5。因此它实际上只能执行一次迭代。我认为答案“2”是不正确的。 - wallyk

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接