可能同时从不同的线程读取全局变量是否危险

Is it dangerous to read global variables from separate threads at potentially the same time?

本文关键字:读取 全局变量 是否 线程 危险      更新时间:2023-10-16

所以我正在编写这个整洁的小程序来自学线程,我正在使用boost::thread和C++来做到这一点。

我需要主线程与工作线程进行通信,为此我一直在使用全局变量。它按预期工作,但我忍不住感到有点不安。

如果工作线程在主线程读取值的同时尝试写入全局变量,该怎么办。这是糟糕的、危险的,还是希望在幕后考虑到的??

§1.10[介绍.多线程](引用N4140):

6如果其中一个表达式求值修改了存储位置(1.7),而另一个访问或修改该位置内存位置。

23如果,两个动作可能同时发生

  • 它们由不同的线程执行,或者
  • 它们是无序列的,并且至少有一个是由信号处理器执行的

程序的执行包含数据竞赛,如果它包含两个潜在的并发冲突操作,其中至少有一个是不是原子的,两者都不发生在另一个之前,除了下面描述的信号处理程序的特殊情况。任何此类数据竞赛导致未定义的行为。

Purely并发读取不会发生冲突,因此是安全的。

如果至少有一个线程向某个内存位置写入,而另一个线程从该位置读取,则它们会发生冲突并可能并发。结果是数据竞赛,因此是未定义的行为,除非使用适当的同步,要么对所有读写使用原子操作,要么使用同步原语在读和写之间的关系之前建立

如果不同的线程只读取全局变量的值,就没有问题。

如果有多个线程尝试更新同一变量(例如读取、添加1个写入),则必须使用同步系统来确保在读取和写入之间不能修改该值。

如果只有一个线程在写,而其他线程在读,这取决于情况。如果不同的变量不相关,比如说篮子里的苹果和橙子的数量,那么只要你接受的值不完全准确,你就不需要任何同步。但是,如果这些值是相关的,比如两个银行账户上的金额,以及它们之间的转账,你需要同步,以确保你读到的内容是一致的。当你使用它时,它可能太旧了,因为它已经更新了,但你有一致的值。

简单的答案是肯定的。一旦变量开始在多个线程之间共享以进行读取和写入,您将需要某种保护。有不同的口味可以实现这一点:信号量,锁,互斥,事件,临界截面消息队列。尤其是当全局变量是引用时,事情可能会变得很难看。假设在一个有多个消费者的消费者/生产者场景中,你有一个全局对象列表,生产者实例化对象,消费者拿走它们,对它们做一些事情,最后处理它们,而没有某种保护,这会导致可怕的问题。有很多关于这个话题的专业文献,也有关于这个主题的专门大学课程,以及给学生们的众所周知的问题。例如,用餐哲学问题,如何让读者在没有饥饿的情况下写作。有趣的书:关于信号量的小书。

是。不,也许吧

形式上正确的答案是:这是不安全的。

实际的答案并不是那么容易。这就像"在某些情况下,这是安全的"

在没有并发写入的情况下进行读取(任意数量的读取)总是安全的。存在并发写入(即使是单个写入)的情况下的读取(即使是一个读取)在形式上是永远不安全的,但在大多数情况下,它们在大多数处理器上都是原子的,这可能就足够了。更改值(比如递增计数器)几乎总是很麻烦,即使在实践中,没有明确使用原子操作也是如此。

原子性

C++标准要求您使用std::atomic或它的某个专业化(或更高级别的同步原语),否则您就注定要失败。恶魔会从你的鼻子里飞出来(不,他们不会……但就标准而言,他们也可能)。

所有真实的、非理论的CPU都只能通过缓存线访问内存,除非在非常特殊的条件下,您必须明示(例如使用写组合指令)。一次可以原子式地读取或写入整个缓存行——从来没有什么不同。读取正在写入的任何内存位置可能不会给出您期望的值(如果同时更新了它),但它永远不会返回"垃圾"值
当然,变量可能会越过缓存线,在这种情况下,访问不是原子的,但除非您故意激发它,这种情况不会发生(因为积分变量是两个大小的幂,如2、4或8,缓存线也是两个大小和更大的幂,例如64或128——如果默认情况下您的变量与前者正确对齐,它们将自动也完全包含在后者中。始终。)。

订购

尽管你的读取(和写入)可能是原子的,你可能会说你只关心某个标志是否为零,所以即使值是乱码的,谁也在乎,但你不能保证事情按照你期望的顺序发生
"正常"的预期是,如果你说A发生在B之前,那么A确实发生在B之后,并且A可以在B之前被其他人看到,这通常是不正确的。换句话说,工作线程完全有可能准备一些数据,然后设置ready标志。您的主线程看到ready标志已设置,并开始读取一些随机垃圾,而实际数据仍在缓存层次结构中的某个位置。或者,它的一半已经对主线程可见,但另一半不可见。

为此,C++11引入了内存顺序的概念。这意味着,除了有原子性的保证外,您有一种请求先发生后保证的方法
大多数时候,这只会阻止编译器在加载和存储之间移动,但在某些体系结构上,这可能会导致发出特殊指令(但这不是你的问题)。

读取修改写入

这是一个特别邪恶的事件。像++flag;这样简单的东西可能是不稳定的。这与flag = 1; 完全不同

如果不使用适当的原子指令,这是never安全的,因为它涉及(原子)读取、修改,然后(原子)写入缓存行
问题是,虽然阅读和写作都是原子的,但整个事情并非如此。订购也没有任何保证。

解决方案

对条件变量使用std::atomic或块。前者将涉及旋转,这可能是有害的,也可能不是有害的(取决于频率和延迟要求),而后者将是CPU保守的
您也可以使用mutex来同步对全局变量的访问,但如果您涉及重量级原语,您还可以选择条件变量,而不是旋转(这将是"正确"的方法)。

这确实取决于许多因素,但通常是个坏主意,可能会导致比赛条件。您可以通过锁定值来避免这种情况,这样读取和写入都是原子的,因此不会发生冲突。

您必须创建一个互斥对象,一次只有一个线程可以拥有该互斥对象,并使用它来控制对变量的访问。https://msdn.microsoft.com/en-us/library/z3x8b09y.aspx

这实际上指向写入线程和读取线程之间的竞争条件。我们访问/写入全局变量的地方将是代码的关键部分。理想情况下,每当我们在关键部分操作时,我们必须在读/写线程之间同步,否则我们可能会在代码中看到不特定的行为。

您的问题类似于读写器问题,我们必须使用信号量、互斥和其他锁定机制进行同步,以避免竞争条件。假设一个编写器和多个读取器,我们可以使用以下代码来避免未定义的行为:

// Using read and write semaphores
semaphore rd, wrt; 
int readCount;
// Writer Thread 
do
{
...
// Critical Section Starts  
wait(wrt);
    global variable = someValues;   // Write to the global Variable.
signal(wrt);
// Critical Section Ends  
...
} while(1)
// Reader thread 
do
{
...
// Critical Section 1 Starts  
wait(rd)
readcount++;
    if(readCount == 1) 
        wait(wrt);
signal(rd);
// Critical Section 1 Ends
// Do Reading 
// Critical Section 2 Starts
wait(rd)  
    readcount--;
    if(readCount == 0)
        signal(wrt);
signal(rd)
// Critical Section 2 Ends
...
} while(1)

并发写入是不安全的。并发读取和写入总是安全的(假设是原子写入),但您永远不知道是在写入之前还是之后读取了值。

主线程的行为和派生线程一样,没有任何区别。

因此,对于并发写入,您将需要互斥。