【问题标题】:Thread Safety General Rules线程安全通用规则
【发布时间】:2011-10-18 16:47:59
【问题描述】:

认为我理解一些关于线程安全的问题,但如果您能这么好,我想澄清一下。我编程使用的特定语言是 C++、C# 和 Java。希望在描述特定语言关键字/功能时牢记这些。

1) 1 个作者,n 个读者的案例。在 n 个线程读取变量(例如在轮询循环中)和 1 个写入器更新此变量的情况下,是否需要显式锁定?

考虑:

// thread 1.
volatile bool bWorking = true;

void stopWork() { bWorking = false; }

// thread n
while (bWorking) {...}

在这里,仅仅有一个内存屏障就足够了,并用 volatile 完成这个?因为据我了解,在我上面提到的语言中,对原语的简单读取和写入不会被交错,因此不需要显式锁定,但是如果没有一些显式锁定或易失性,就无法保证内存一致性。我的假设在这里正确吗?

2) 假设我上面的假设是正确的,那么它只对 simple 读取和写入是正确的。即 bWorking = x... 和 x = bWorking;是唯一安全的操作吗? IE 复杂的赋值,如一元运算符(++、--)在这里是不安全的,+=、*= 等...?

3) 我假设如果案例 1 是正确的,那么当只涉及分配和阅读时,将该语句扩展为对 n 个作者和 n 个读者也安全是不安全的?

【问题讨论】:

  • 在 C++ 中 volatile 的意思是“编译器,我知道你很聪明,但请不要优化访问这个东西,好吗?”。 线程无关。在 C# 和 Java 中,这意味着对字段的更改在其他线程中立即可见。
  • 在 C++ 中,volatile 与多线程没有任何关系。忘记你认为是的。 C++03(当前的 C++)对多线程的支持为零;没有关键字或标准库函数可以让您到达那里。 C++0x 引入了锁和原子变量,它们将。此外,三种语言太多了,尤其是对于如此截然不同的语言。你应该把范围缩小到你目前正在从事的工作。
  • 大概您比较了锁的性能并发现了显着差异?否则,您甚至不会考虑正确性难以确定的方法。

标签: c# java c++ multithreading


【解决方案1】:

对于 Java:

1) volatile 变量在每次读取写入时从/向“主内存”更新,这意味着更新线程的更改将在下一次读取时被所有读取线程看到。此外,更新是原子的(独立于变量类型)。

2) 是的,如果您有多个编写者,像 ++ 这样的组合操作不是线程安全的。对于单个写入线程,没有问题。 (volatile 关键字确保其他线程可以看到更新。)

3) 只要您只分配和读取, volatile 就足够了 - 但如果您有多个写入器,您无法确定哪个值是“最终”值,或者哪个值将被哪个线程读取。甚至写入线程本身也无法可靠地知道自己的值是否已设置。 (如果你只有boolean,并且只会设置从truefalse,这里没有问题。)

如果您想要更多控制,请查看 java.util.concurrent.atomic 包中的类。

【讨论】:

    【解决方案2】:

    进行锁定。如果您正在编写多线程代码,则无论如何都需要锁定。 C# 和 Java 使它相当简单。 C++ 稍微复杂一些,但您应该能够使用 boost 或制作自己的 RAII 类。鉴于您将在所有地方都被锁定,请不要尝试查看是否有几个地方可以避免它。一切都可以正常工作,直到您在 3 月的一个星期二使用新的英特尔微码在 64 路处理器上运行代码,并在一些关键任务客户系统上运行。然后砰的一声。

    人们认为锁很贵;他们真的不是。内核开发人员花费大量时间优化它们,与一次磁盘读取相比,它们完全是微不足道的;然而似乎从来没有人花费这么多精力来分析每一个最后的磁盘读取

    添加关于性能调优弊端的常用语句,来自 Knuth、Spolsky ......等等的明智说法,

    【讨论】:

      【解决方案3】:

      对于 C++

      1) 这很容易尝试,而且通常会奏效。但是,请记住以下几点:

      您正在使用布尔值进行操作,因此这似乎是最安全的。其他 POD 类型可能也不那么安全。例如。在 32 位机器上设置 64 位双精度可能需要两条指令。所以这显然不是线程安全的。

      如果布尔值是您唯一关心线程共享的事情,那么这可能会起作用。如果您将它用作双重检查锁范式的变体,那么您会遇到其中的所有陷阱。考虑:

      std::string failure_message;  // shared across threads
      
      // some thread triggers the stop, and also reports why
      failure_message = "File not found";
      stopWork();
      
      // all the other threads
      while (bWorking) {...}
      log << "Stopped work:  " << failure_message;
      

      这看起来不错,因为 failure_message 是在 bWorking 设置为 false 之前设置的。然而,实际情况可能并非如此。编译器可以重新排列语句,并首先设置 bWorking,从而导致对 failure_message 的线程不安全访问。即使编译器没有,硬件也可能。多核 cpu 有自己的缓存,事情就没有这么简单了。

      如果它只是一个布尔值,它可能没问题。如果不止于此,它可能会偶尔出现问题。您正在编写的代码有多重要,您能冒这个风险吗?

      2) 正确,++/--,+=,其他操作符会占用多条cpu指令,线程不安全。根据您的平台和编译器,您可能能够编写不可移植的代码来执行原子增量。

      3) 正确,这在一般情况下是不安全的。当你有一个线程时,你可能会有点吱吱声,写一个布尔一次。一旦引入多次写入,最好有一些真正的线程同步。

      关于 cpu 指令的注意事项

      如果一个操作需要多条指令,您的线程可能会在它们之间被抢占 - 并且该操作将部分完成。这显然不利于线程安全,这也是 ++、+= 等不是线程安全的原因之一。

      然而,即使一个操作需要一条指令,也不一定意味着它是线程安全的。对于多核和多 cpu,您必须担心更改的可见性 -- cpu 缓存何时刷新到主内存。

      所以虽然多指令确实暗示不是线程安全的,但假设单指令暗示线程安全是错误的>

      【讨论】:

      • 即使设置一个值只需要一条指令,它也不是线程安全的。它需要的指令数量在这里无关紧要。您需要能见度保证。
      • @Martinho,真的。但是,如果您所做的只是设置一个标志来向线程发出信号,并且您并不真正关心线程的循环是否会在看到更改的布尔值之前继续进行几次迭代,那么您就可以摆脱困境。
      • @Tim:你不明白。如果 CPU #1 更新变量,则该更新只会位于缓存中,对任何其他 CPU 不可见。它需要将该更改刷新到内存中以供其他 CPU 看到。
      • @GMan,我明白。在刷新发生之前可能需要一段时间。但有时其他线程再等一会儿也没什么大不了的。显然,在一般情况下编写这样的代码并不是最佳实践,但在某些极端情况下,额外的延迟并不重要。
      • @Tim:在您的回答中提到的指令数量具有误导性。它给人的印象是,如果它接受一条指令就可以了。你应该改写。
      【解决方案4】:

      使用 1 字节的布尔值,您可能可以在不使用锁定的情况下逃脱,但由于您无法保证处理器的内部结构,这仍然不是一个好主意。当然,对于任何超过 1 个字节的内容,例如整数,您都不能。一个处理器可能正在更新它,而另一个处理器正在另一个线程上读取它,您可能会得到不一致的结果。在 C# 中,我将围绕对 bWorking 的访问(读取或写入)使用 lock { } 语句。如果它是更复杂的东西,例如对大内存缓冲区的 IO 访问,我会使用 ReaderWriterLock 或它的一些变体。在 C++ 中, volatile 不会有太大帮助,因为这只会阻止某些类型的优化,例如寄存器变量,这会完全导致多线程问题。您仍然需要使用锁定构造。

      所以总而言之,如果不以某种方式锁定它,我永远不会在多线程程序中读写任何东西。

      【讨论】:

      • 即使是 1 字节的布尔值,视情况而定。如果没有锁定,您可能无法逃脱。很简单,因为“一个处理器可能正在更新它,而另一个处理器正在另一个线程上读取它”。此外,1 字节不提供任何可见性保证。
      • 同意,这就是为什么我说这仍然是个坏主意。
      【解决方案5】:
      1. 在任何合理的现存系统上更新布尔值都将是原子的。然而,一旦你的作者写完,你的读者会读多久,尤其是考虑到多核、缓存、调度程序的异常等等。

      2. 递增和递减(++、--)和复合赋值(+=、*=)的部分问题在于它们具有误导性。它们暗示某些事情正在原子地发生,实际上发生在几个操作中。但即使是简单的赋值也可能是不安全的,因为你已经远离了布尔变量的纯洁性。保证像x=foo 这样简单的写入是原子的,这取决于您平台的细节。

      3. 我假设线程安全,你的意思是无论作者做什么,读者总是会看到一个一致的对象。在您的示例中,情况总是如此,因为布尔值只能评估为两个值,两个值都有效,并且该值仅从真到假转换一次。在更复杂的场景中,线程安全将变得更加困难。

      【讨论】:

      • 更新布尔值将是原子的,因为读者将看到真假而不是其他数字。在某些系统上,它可能不是原子的,因为它的所有副作用都在其他操作之前完成。有可能 x 和 y 开始为假,一个线程将设置 x 和 y 为真,按此顺序,第二个线程线程将看到 x 真,y(仍然)假,y 真,按此顺序,而第三个会看到 y true, x (still) false, x true, in that order.
      • 有趣的是,过去人们认为写一个布尔值是唯一的“原子”操作;现在还有其他更有用的操作,例如 LX/SX(加载/存储条件)和 CAS(代表比较和交换,即使它意味着比较和存储)。
      猜你喜欢
      • 2020-06-19
      • 1970-01-01
      • 2018-10-19
      • 2019-07-05
      • 2016-06-27
      • 1970-01-01
      • 2016-11-23
      • 1970-01-01
      相关资源
      最近更新 更多