两个或更多线程同时读写一个变量的真正危险性

8

同时对单个变量进行读写的真正危险是什么?

如果我使用一个线程来写入一个变量,另一个线程在while循环中读取该变量,并且在写入变量时读取变量不会有危险,那么还有什么其他的危险呢?

同时读写是否会导致线程崩溃,当确切的同时读写发生时低级别会发生什么?


1
“旧值被使用”不也是一个很重要的问题吗?如果您不关心其中一个变量的值,那么您究竟在计算什么? :D - Andrei
你甚至无法可靠地“计数”了,你有什么算法能够在变量不可预测地改变的情况下生存下来?不,即使是随机数生成器也不合格。 - Hans Passant
我正在使用while条件和额外的迭代进行循环,如果while条件接收到垃圾数据,则会通过== 0并且循环将退出。这是一个看门狗线程,在while循环中循环检查线程是否死亡。当线程函数完成时,它将设置变量= 1,并退出while循环。 - some_id
8个回答

7
如果两个线程在没有适当同步的情况下访问一个变量,并且其中至少一个访问是写入操作,则会发生数据竞争和未定义行为。
未定义行为的表现完全取决于实现。在大多数现代体系结构中,硬件不会触发陷阱或异常,它将读取某些内容或存储某些内容。问题在于,它不一定会读取或写入您预期的内容。
例如,使用两个线程递增变量,您可能会错过计数,如我在devx上的文章中所述:http://www.devx.com/cplus/Article/42725

对于单个编写者和单个读者,最常见的结果是读者看到陈旧的值,但如果更新需要多个周期或变量跨越缓存行,则还可能看到部分更新的值。然后发生的情况取决于您对其的处理方式 --- 如果它是指针并且您获取了部分更新的值,则可能不是有效的指针,并且也不会指向您打算的内容,然后由于取消引用无效指针值而导致任何类型的损坏或错误。这可能包括格式化硬盘或其他不良后果,如果不良指针值恰好指向内存映射I/O寄存器....


4
一般来说,您会得到意想不到的结果。维基百科定义了两种不同的竞态条件:
关键竞态发生在改变内部变量的顺序决定状态机最终处于的状态时。
非关键竞态发生在改变内部变量的顺序不会改变最终状态的情况下。换句话说,当移动到所需状态意味着必须同时更改多个内部状态变量时,不论这些内部状态变量以何种顺序更改,结果状态都将相同,此时就发生了非关键竞态。
因此,输出并不总是会出错,这取决于代码。为了后续代码扩展和防止可能的错误,最好始终处理竞态条件。没有什么比不能信任自己的数据更令人烦恼了。

你也可以使用原子操作,使用互斥锁可能过于繁琐。 - Puppy
@DeadMG:当 OP 意识到需要互斥锁时,我会把原子操作留给他(也就是先做,然后做对,然后做快,互斥锁是做对,原子操作是做快)。 - ninjalj
我将措辞改为更通用的“处理竞态条件”。 - orlp

2

两个线程读取同一个值没有任何问题。

当一个线程写入非原子变量并且另一个线程读取它时,问题就开始了。那么读取的结果是未定义的。因为一个线程可以随时被抢占(停止)。只有对原子变量的操作才能保证不可分割。原子操作通常是写入 int 类型变量。

如果你有两个线程访问相同的数据,最好的做法 + 通常是不可避免的是使用锁定(互斥锁,信号量)。

希望这有所帮助。

Mario


2
@Helium3:这取决于CPU。此外,变量应该对齐。 - ninjalj

1

这取决于平台。例如,在Win32上,对齐的32位值的读写操作是原子性的-也就是说,您不能半读取新值和半读取旧值,如果您写入,则当有人来读取时,他们要么得到完整的新值,要么得到旧值。当然,并非所有值或所有平台都是如此。


@Helium3:这种行为实际上是针对x86的。 - ninjalj

1

最糟糕的情况取决于实现方式。有许多完全独立的pthread实现,运行在不同的系统和硬件上,我怀疑没有人知道所有这些实现的全部细节。

如果p不是指向易失性的指针,那么我认为符合Posix实现的编译器是被允许将以下代码转换的:

while (*p == 0) {}
exit(0);

*p的单个检查转换为一个无限循环,它根本不关心*p的值。实际上,它不会这样做,所以问题在于您想要按照标准编程还是按照您正在使用的实现的未记录观察行为进行编程。后者通常适用于简单情况,然后您可以构建代码,直到您执行的操作足够复杂,以至于它意外地不起作用。

在实践中,在没有一致性内存缓存的多CPU系统上,可能需要很长时间才能看到来自不同CPU的更改,因为如果没有内存屏障,它可能永远不会更新其对主内存的缓存视图。但是英特尔具有一致性缓存,因此您个人很可能不会看到任何长时间的延迟。如果某些可怜的人试图在更奇特的架构上运行您的代码,则可能最终不得不修复它。

回到理论上,您描述的设置可能会导致崩溃。想象一下一个假设的架构:

  • p指向一个非原子类型,例如在典型的32位架构中使用的long long
  • 该系统上的long long具有陷阱表示,例如因为它有一个用作奇偶校验的填充位。
  • 当读取发生时,对*p的写入是半完成的。
  • 半写已经更新了一些值的位,但尚未更新奇偶校验位。

哎呀,未定义行为,您读取了一个陷阱表示。可能Posix禁止C标准允许的某些陷阱表示,如果是这种情况,那么long long可能不是*p类型的有效示例,但我认为您可以找到一个类型,其中允许陷阱表示。


太好了。目前它可以在我的Mac Intel 64位上运行,但它将部署在运行Ubuntu的Panda板(双核ARM)上。 - some_id
@Helium3:啊,双核ARM,很可能没有一致的缓存。我对ARM不是很了解,但几年前我确定有一些这样的产品,所以值得检查一下。 - Steve Jessop
该变量是一个简单的int值,没有指针或任何其他类型。 - some_id
@Helium3,“目前工作”……你确定你已经充分测试过了,以捕获十亿分之一的情况吗?为此使用原子交换指令,你可以轻松找到不同CPU架构的代码。只有这样,你才能得到保证。 - Jens Gustedt
@Helium3:让工作线程发布一个信号量,等待看门狗线程等待可能更有效。这样,您就不必让看门狗线程繁忙循环(甚至睡眠循环)了。 - Steve Jessop

1

结果未定义。

考虑以下代码:

global int counter = 0;


tread()
{
   for(i=0;i<10;i++)
   {
       counter=counter+1;
   }
}

问题在于如果您有N个线程,结果可能在10和N * 10之间的任何地方。 这是因为所有线程都可能读取相同的值并增加它,然后将值+1写回。但您问过是否可以导致程序或硬件崩溃。
这要视情况而定。在大多数情况下,错误结果是无用的。

要解决这个锁定问题,您需要使用互斥体或信号量。

互斥体是代码锁定。在上例中,您将锁定代码的一部分。

counter = counter+1;

信号量是变量的锁

counter 

基本上是解决同类型问题的相同事物。

在您的线程库中检查这些工具。

http://en.wikipedia.org/wiki/Mutual_exclusion


0
如果被写入和读取的变量不能以原子方式更新或读取,则读者可能会获取到一个损坏的“部分更新”的值。

当执行类似while(intValue == 0)的检查时,写入int值的线程正在忙于写intValue = 1;,会发生什么? - some_id
@Andrei:不,只是一个普通的整数。 - some_id
然后发布代码 :) 没有volatile或内存屏障,我怀疑读取线程由于缓存不会看到写入线程的输出。 - Andrei
现在当我运行它时,它可以看到它。我只是不希望出现意外问题,比如线程崩溃。这个问题更多地涉及线程和系统的内部工作。 - some_id

0
  • 您可以看到部分更新(例如,您可能会看到一个long long变量,其中一半来自新值,另一半来自旧值)。
  • 在使用内存屏障之前,不能保证看到新值(pthread_mutex_unlock()包含隐式内存屏障)。

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