【问题标题】:Can an integer be shared between threads safely?线程之间可以安全地共享整数吗?
【发布时间】:2015-02-10 20:59:27
【问题描述】:

在没有任何同步实用程序的 C 程序中,多个线程在 pthread 之间使用相同的整数内存位置是否存在问题?

为了简化问题,

  • 只有一个线程会写入整数
  • 多个线程将读取整数

这个伪 C 说明了我的想法

void thread_main(int *a) {
  //wait for something to finish
  //dereference 'a', make decision based on its value
}

int value = 0;

for (int i=0; i<10; i++)
  pthread_create(NULL,NULL,thread_main,&value);
}
// do something
value = 1;

我认为它是安全的,因为一个整数占用一个处理器字,而读/写一个字应该是最原子的操作,对吧?

【问题讨论】:

  • 同步和互斥是有细微差别的。由于处理器字的原子加载/存储,您的代码保证互斥。但是,您没有在问题中突出显示任何同步(在什么之前/之后发生什么)要求。

标签: c thread-safety pthreads


【解决方案1】:

嗯,我猜它是安全的,但你为什么不直接声明一个函数,将值返回给其他线程,因为它们只会读取它?

因为将指针传递给单独的线程的简单想法在我看来已经是安全失败了。我要告诉你的是:当你只需要值时,为什么要给出一个(可修改的、公共可访问的)整数地址?

【讨论】:

  • 该函数必须从某个地方获取值。将读取从线程主函数移动到某个辅助函数不会改变任何内容(除非辅助函数位于不同的编译单元中并且禁用了整个程序优化,在这种情况下,无法跨函数调用进行优化可能会导致效果)。
  • 不至少避免扔掉指针吗?如果您必须移动或更改数据的内存地址,这也很有帮助。如果您有 n 个实例上的指针,则必须将新地址一一发送。顺便说一句,我明白你的意思了:)
【解决方案2】:

编辑: Ben 是正确的(我说他不是,我是个白痴),cpu 可能会重新排序指令并同时在多个管道中执行它们。这意味着 value=1 可能在执行“工作”的管道完成之前设置。在我的辩护中(不是一个十足的白痴?)我从来没有在现实生活中看到过这种情况,我们确实有一个广泛的线程库,我们确实运行了详尽的长期测试,并且这种模式贯穿始终。如果它发生,我会看到它,但我们的测试都没有崩溃或产生错误的答案。但是……本是正确的,这种可能性是存在的。它可能一直在我们的代码中发生,但是重新排序并没有足够早地设置标志,以至于受标志保护的数据的消费者可以在数据完成之前使用数据。我将更改我们的代码以包含障碍,因为不能保证这将继续在野外工作。我相信正确的解决方案是这样的:

读取值的线程:

...
if (value)
{
  __sync_synchronize();  // don't pipeline any of the work until after checking value
  DoSomething();
}
...

设置值的线程:

...
DoStuff()
__sync_synchronize();  // Don't pipeline "setting value" until after finishing stuff
value = 1;  // Stuff Done
...

话虽如此,我发现this 是对障碍的简单解释。

编译器屏障 内存屏障会影响 CPU。编译器障碍会影响编译器。 Volatile 不会阻止编译器重新排序代码。 Here 了解更多信息。

我相信您可以使用此代码来防止 gcc 在编译时重新排列代码:

#define COMPILER_BARRIER() __asm__ __volatile__ ("" ::: "memory")

所以也许这才是真正应该做的?

#define GENERAL_BARRIER() do { COMPILER_BARRIER(); __sync_synchronize(); } while(0)

读取值的线程:

...
if (value)
{
  GENERAL_BARRIER();  // don't pipeline any of the work until after checking value
  DoSomething();
}
...

设置值的线程:

...
DoStuff()
GENERAL_BARRIER();  // Don't pipeline "setting value" until after finishing stuff
value = 1;  // Stuff Done
...

使用 GENERAL_BARRIER() 可以防止 gcc 重新排序代码,也可以防止 cpu 重新排序代码。现在,我想知道 gcc 是否不会在其内置的内存屏障 __sync_synchronize() 上重新排序代码,这会使 COMPILER_BARRIER 的使用变得多余。

X86 正如 Ben 所指出的,不同的架构对于如何在执行管道中重新排列代码有不同的规则。英特尔似乎相当保守。因此,英特尔可能不需要那么多障碍。不过,这不是避开障碍的好理由,因为这可能会改变。

原帖: 我们一直这样做。它非常安全(并非适用于所有情况,但很多情况下)。我们的应用程序在一个巨大的农场中的 1000 台服务器上运行,每台服务器有 16 个实例,并且我们没有竞争条件。您想知道为什么人们使用互斥锁来保护已经原子操作是正确的。在许多情况下,锁定是浪费时间。在大多数架构上读取和写入 32 位整数是原子的。不要尝试使用 32 位位域!

处理器写入重新排序不会影响一个线程读取另一个线程设置的全局值。事实上,使用锁的结果与不使用锁的结果是一样的。如果您赢得比赛并在更改之前检查值......这与赢得比赛锁定值相同,因此在您阅读它时没有其他人可以更改它。功能相同。

volatile 关键字告诉编译器不要将值存储在寄存器中,而是继续引用原始内存位置。除非您正在优化代码,否则这应该无效。我们发现编译器对此非常聪明,并且还没有遇到 volatile 改变任何东西的情况。编译器似乎很擅长提出寄存器优化的候选者。我怀疑 const 关键字可能会鼓励对变量进行寄存器优化。

如果编译器知道最终结果不会不同,它可能会重新排序函数中的代码。我没有看到编译器对全局变量执行此操作,因为编译器不知道更改全局变量的顺序将如何影响立即函数之外的代码。

如果某个函数正在起作用,您可以使用 __attrribute__ 在函数级别控制优化级别。

现在,也就是说,如果您使用该标志作为网关,只允许组中的一个线程执行某些工作,这将不起作用。示例:线程 A 和线程 B 都可以读取标志。线程 A 被调度。线程 B 将标志设置为 1 并开始工作。线程 A 唤醒并将标志设置为 1 并开始工作。哎呀!为了避免锁定并仍然执行类似的操作,您需要研究原子操作,特别是 gcc atomic builtins,例如 __sync_bool_compare_and_swap(value, old, new)。这允许您设置 value = new 如果 value 当前是旧的。在前面的示例中,如果 value = 1,则只有一个线程(A 或 B)可以执行 __sync_bool_compare_and_swap(&value, 1, 2) 并将 value 从 1 更改为 2。丢失的线程将失败。 __sync_bool_compare_and_swap 返回操作成功。

当你使用 atomic builtins 时,有一个“锁”,但它是一个硬件指令,与使用互斥锁相比非常快。

也就是说,当您必须同时更改大量值时,请使用互斥锁。原子操作(截至今天)仅在所有必须以原子方式更改的数据都可以放入连续的 8、16、32、64 或 128 位时才有效。

【讨论】:

  • “处理器写入重新排序不会影响一个线程读取另一个线程设置的全局值。”实际上,这正是它的影响。好吧,一个线程读取由其他线程设置的多个值。关键是,当工作线程看到a 为真时,它不能信任任何其他状态的值,除非使用了内存屏障。
  • @Ben 谢谢。我现在编辑了我的帖子以说明这一点。看起来怎么样?
  • 我认为读者需要在测试value 之后和使用value 用于同步的变量之前放置围栏。有几点需要注意:x86 CPU 的排序保证比 C++ 标准提供的要强得多,而且许多 OS 函数都包含内存屏障。因此,您现有的代码完全有可能永远不会失败(但由于实现细节而不是任何保证,并且这些细节可能会在未来的平台上发生变化)。
  • @johnny:你不必侮辱自己。像“Ben 指出了我没想到的事情”这样的措辞比“白痴”这样的措辞更有效。
  • @johnny:我不能确定这适用于每个编译器,但我认为编译器不知道__sync_synchronize 是否触及全局变量,因此无法重新排序过去了。或者它识别它并特别对待它,但仍然不会重新排序过去的东西。函数调用是编译器重新排序的障碍,除非它们是内联函数(当然,可以重新排序从未获取地址的局部变量,但这始终是安全的)。所以我认为__sync_synchronize(或Win32 MemoryBarrier)就足够了。
【解决方案3】:

与显式启动线程的 java 不同,posix 线程立即开始执行。
因此,不能保证您在 main 函数中设置为 1 的值(假设这是您在伪代码中引用的值)将在线程尝试访问它之前或之后执行。
因此,虽然同时读取整数是安全的,但如果您需要写入该值以供线程使用,则需要进行一些同步。
否则无法保证他们将读取的值是多少(以便根据您记下的值采取行动)。
您不应该对多线程做出假设,例如在访问值之前每个线程中都有一些处理等。
没有任何保证

【讨论】:

    【解决方案4】:

    在任何时候你至少应该声明共享变量volatile。但是,在所有情况下,您都应该更喜欢其他形式的线程 IPC 或同步;在这种情况下,condition variable 似乎是您真正需要的。

    【讨论】:

      【解决方案5】:

      假设你在线程函数中做的第一件事是休眠一秒钟。所以之后的值肯定是 1。

      【讨论】:

        【解决方案6】:

        我不会指望它。编译器可能会发出代码,假设它知道 CPU 寄存器中任何给定时间的 'value' 的值是什么,而无需从内存中重新加载它。

        【讨论】:

        • 是吗?是否有必要在任何共享变量上使用volatile,或者编译器是否足够聪明来确定这一点?
        • volatile 影响寄存器中的缓存值,但至少在某些编译器上它不会影响处理器的内部写入重新排序,这可能同样致命。
        • @Kos:编译器确定变量是否被外部修改是不切实际的,因为编译器对线程一无所知 - 它无法知道任何两个函数将同时运行。它总是可以假设最坏的情况并且永远不会优化内存读取,但这反而会破坏优化的对象!即使它是线程感知的,编译器也不会看到在单独的编译单元中修改的变量。在任何中等复杂的系统中证明并发访问所需的静态分析量巨大
        • 哪个正常线程同步问题之上我还应该注意使每个共享变量易失?这是否也适用于 OpenMP?
        • 注意volatile在你使用pthreads时不是必须的,因为pthread_mutex_lock和相关的函数被指定为使得编译器在编译调用者时必须假设锁操作可能已经修改了任何变量在可访问的程序中。
        【解决方案7】:

        您的伪代码不安全。

        虽然访问一个字大小的整数确实是原子的,这意味着您永远不会看到中间值,但无论是“写入前”还是“写入后”,这对于您概述的算法来说都是不够的。

        您依赖于写入a 的相对顺序并进行一些其他更改以唤醒线程。这不是原子操作,在现代处理器上无法保证。

        您需要某种内存栅栏来防止写入重新排序。否则不能保证其他线程会看到新值。

        【讨论】:

        • 在 x86 和任何具有合理内存排序要求的平台上,或者在所有线程轮流在单个 cpu 上运行的非 SMP 机器上,使用变量 volatile 就足够了。另一种方法是通过外部函数写入变量,该函数接受指向变量及其新值的指针。这将防止重新排序,并且在病态架构上,您可以将内存栅栏/屏障操作添加到此函数的特定于 cpu 的实现中。
        • 我会澄清的。这在使用 gcc 编译的 x86 上可以正常工作。如果优化器在“值”上使用了寄存器优化......使用 volatile,尽管 gcc 避免使用访问全局变量的代码进行 reg opt。即使您在“值”周围放置互斥锁和锁,编译器仍然可以进行寄存器优化。此外,获取锁的竞争条件与读取值的竞争条件相同。最后,您建议使用额外的代码来强制已经原子操作的原子性!
        • @jonnycrash:“获取锁的竞争条件”?我在哪里说过锁定?而且您仍然专注于原子性,即使在我的回答清楚地解释这里的问题是顺序而不是原子性之后。最后,由于伪代码没有暗示 volatile,所以它是错误的(即使在 x86 上,请注意问题从未说过代码只能在 x86 上运行)。最后,您的“修复”是特定于编译器的。 C++ 标准不需要用于易失性访问的内存栅栏,而 C++ 内存模型处理顺序一致性,它(尚未)涵盖并行执行。
        • Visual C++ "volatile" creates a memory fence since VC++2005,也许 gcc 也可以,但早期版本没有。另外,volatile 过于矫枉过正,它以显式内存屏障不会消除的方式消除了安全有效的优化。
        • 我必须为 -1 道歉并弄清楚如何撤消它。我认为我的困惑是您是正确的,CPU 可以在另一个管道完成循环之前重新排序指令并执行 'value=1' ,但在现实生活中这种情况很少发生。所以我对我们线程库的详尽测试从未暴露过这一点。你所说的内存栅栏是__sync_synchronize,它位于'value = 1'行之前。我提到了 x86 b/c 我没有在其他拱门上测试过这个。我把“重新排序”误认为是编译器重新排序,因此很不稳定。
        猜你喜欢
        • 2017-10-02
        • 1970-01-01
        • 1970-01-01
        • 2021-03-03
        • 2011-02-13
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多