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

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

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

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

如果工作线程尝试在主线程读取值的同时写入全局变量会怎样。这是不好的、危险的,还是希望在幕后考虑到这一点?

简单的答案是肯定的。一旦变量开始在多个线程之间共享以进行读写,您将需要某种保护。 有不同的口味来实现这一点: 信号量, 锁, 互斥锁, 事件, 临界区 消息队列。 特别是当你的全局变量是引用时,事情会变得丑陋。 假设您在具有多个消费者的消费者/生产者场景中拥有全局对象列表,生产者实例化对象,消费者获取它们,对它们做一些事情并最终处置它们,如果没有某种保护,这会导致可怕的问题。 有很多关于这个主题的专业文献,也有关于这个主题的专门大学课程,以及给学生的众所周知的问题。例如餐饮哲学家问题,如何让读者作家信号量没有饥饿,......。 有趣的书:关于信号量的小书

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

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

主线程与派生线程的行为完全相同,完全没有区别。

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

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

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

如果多个线程试图更新同一个变量(例如读,加 1 写),那么你必须使用同步系统来确保值不能在两者之间被修改读取和写入。

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

§1.10 [intro.multithread](引用 N4140):

6 Two expression evaluations conflict if one of them modifies a memory location (1.7) and the other one accesses or modifies the same memory location.

23 Two actions are potentially concurrent if

  • they are performed by different threads, or
  • they are unsequenced, and at least one is performed by a signal handler.

The execution of a program contains a data race if it contains two potentially concurrent conflicting actions, at least one of which is not atomic, and neither happens before the other, except for the special case for signal handlers described below. Any such data race results in undefined behavior.

纯粹并发读取不冲突,所以安全。

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

是的。不,也许吧。

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

实际的答案并不那么容易。有点像 "This is safe, kind of, under some conditions".

在没有并发写入的情况下读取(任意数量)总是安全。在存在并发写入(即使是单个)的情况下读取(甚至是单个)正式永远不安全,但在大多数情况下它们在大多数处理器上是原子的,这可以是刚刚好。更改值(如递增计数器)几乎总是很麻烦,即使在实践中,没有明确使用原子操作。

原子性

C++ 标准要求您使用 std::atomic 或其特化之一(或更高级别的同步原语),否则您就完蛋了。恶魔会从你的鼻子里飞出来(不,他们不会......但就标准而言,他们也可能)。

所有真实的、非理论的 CPU 都通过高速缓存行专门访问内存,除非在非常特殊的情况下,您必须明确引发(例如使用写组合指令)。可以一次原子地读取或写入整个缓存行——从来没有什么不同。读取正在写入的任何内存位置可能不会给出您期望的值(如果同时已更新),但它永远不会 return "garbage" 值。
现在当然一个变量 可能 跨越缓存行,在这种情况下访问不是原子的,但除非你故意挑起它,否则这不会发生(因为整数变量是二次幂大小如 2、4 或 8,缓存行也是二次方大小和更大的大小,如 64 或 128——如果你的变量在默认情况下与前者正确对齐,它们是 自动也完全包含在后者中。总是。)。

订购

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

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

读取-修改-写入

这是一个特别邪恶的人。像 ++flag; 这样简单的事情可能是灾难性的。这 flag = 1;

完全不同

如果不使用适当的原子指令,这永远不会安全,因为它涉及(原子地)读取,然后修改,然后(原子地)写入缓存行。
问题是,虽然读和写都是原子的,但整个事情却不是。也没有关于订购的任何保证。

解决方案?

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

这实际上指向写入器线程和 reader 线程之间的竞争条件。我们 access/write 全局变量所在的位置将是代码的关键部分。理想情况下,每当我们在临界区操作时,我们都必须在 read/write 线程之间同步,否则我们可能会在代码中看到不特定的行为。

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

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