我需要在线程之间更改通知标志时使用内存屏障吗?

6

我需要一种非常快速(在“读者成本低”而不是“延迟低”的意义上)的线程之间的变更通知机制,以便更新读取缓存:

情况

线程W(写入器)只偶尔更新数据结构(S)(在我的案例中是映射中的设置)。

线程R(读取器)维护S的缓存,并经常读取此缓存。当线程W更新S时,线程R需要在合理的时间内(10-100ms)被通知更新。

架构是ARM,x86和x86_64。我需要支持使用gcc 4.6及更高版本的C++03

代码

类似于以下内容:

// variables shared between threads
bool updateAvailable;
SomeMutex dataMutex;
std::string myData;

// variables used only in Thread R
std::string myDataCache;

// Thread W
SomeMutex.Lock();
myData = "newData";
updateAvailable = true;
SomeMutex.Unlock();

// Thread R

if(updateAvailable)
{
    SomeMutex.Lock();
    myDataCache = myData;
    updateAvailable = false;
    SomeMutex.Unlock();
}

doSomethingWith(myDataCache);

我的问题

在线程R中,"快速路径"(没有可用的更新)中不会出现锁定或屏障。这是一个错误吗?这种设计的后果是什么?

我需要将updateAvailable标记为volatile吗?

R最终会获得更新吗?

我目前的理解

数据一致性方面是否安全?

这看起来有点像"双重检查锁定"。根据http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html,可以使用内存屏障在C++中修复它。

然而,这里的主要区别在于,在读者快速路径中从未触及/读取共享资源。当更新缓存时,互斥锁保证了一致性。

R是否会获得更新?

这就是棘手的地方。据我所知,运行线程R的CPU可能会无限期地缓存updateAvailable,有效地将读取移动到实际的if语句之前。

因此,更新可能需要等到下一次缓存刷新,例如当另一个线程或进程被调度时。


1
对一个变量进行非原子读取,而该变量同时被另一个线程修改,这是未定义的行为。添加“volatile”根本不会改变这一点。标准对此非常明确,并且违反该规则是UB,因此您无法预测编译器将对您的代码执行什么操作。标准没有涉及CPU或其他核心,它说在这种情况下确保正确行为的唯一方法是使用原子操作。http://isvolatileusefulwiththreads.com/ - Jonathan Wakely
我不是在谈论避免字撕裂的“原子性”,我是在谈论C++标准所指的“原子性”。 sig_atomic_t不是原子变量,因此仍然没有原子操作,因此行为未定义。在C++03中使用https://gcc.gnu.org/onlinedocs/gcc-4.6.4/gcc/Atomic-Builtins.html或(自GCC 4.7以来)https://gcc.gnu.org/onlinedocs/gcc-4.7.4/gcc/_005f_005fatomic-Builtins.html进行原子操作。 - Jonathan Wakely
@David:代码在哪里执行这个操作?显然这是一个非原子操作。在这个问题的上下文中,通过更新当然是指存储一个常量值或加载到寄存器中。 - Hanno S.
@HannoS. 这只是一种“我想不出这个会出错的方式”的论点。这样的论点从未有效过。你无法预知编译器今天或将来可能进行的每一种优化。一个编译器可能会将j=0;优化为j=a; j-=a;,我曾经见过一些同样令人惊讶并导致代码出错的真实世界优化案例。 - David Schwartz
@JonathanWakely: atomic 意味着一次只能有一个线程使用。可见性不仅对 atomix 定义。编译器可能保证标准,但架构可能有更强的保证。而且你必须知道,在 C++ 还没有任何关于 atomix 的概念之前,人们一直在使用 volatiles 和 atomix。我不确定 ARM 是否适用,但是在 x86 上,使用 gcc 4x 的 volatile 就足够了。我已经使用它很长时间了,不止一个变量... 你可以进行一个简单的实验来证明它。我还建议你了解 seqlocks 和 spinlocks。 - BitWhistler
显示剩余12条评论
3个回答

2
使用C++原子操作,将updateAvailable设置为std::atomic<bool>类型。这样做的原因是,不仅CPU可以看到变量的旧版本,尤其是编译器也可能看不到另一个线程的副作用,因此永远不会重新获取变量的值,从而无法在线程中看到更新后的数值。此外,这样做可以获得保证的原子读取,而如果只是读取值,则无法保证。

除此之外,如果生产者只在updateAvailable为false时才生成数据,则可以潜在地摆脱锁定,因为std::atomic<>强制执行读写的正确顺序。如果不是这种情况,仍然需要锁定。


std::atomic在C++03中不可用。由于更新很少,因此无需摆脱锁定。 - Hanno S.
1
@HannoS。那么您需要使用编译器/平台的内置功能来提供原子操作。volatile仅适用于信号处理程序使用。 - JustSid

2

在这里,您需要使用内存障碍。如果没有障碍,就无法保证更新将在其他线程上被看到。在C++03中,您可以选择使用平台特定的ASM代码(Intel上的mfence,对于ARM没有想法)或使用操作系统提供的原子设置/获取函数。


C++标准要求保证,写入任何位置的数据将在合理的时间内被其他线程看到。如果您需要精确的措辞 - 我可以在标准中找到它。 - Tsyvarev
1
@Tsyvarev 不对。你很困惑。这些保证只适用于使用原子操作时。 - SergeyA
29.3.13:实现应该在合理的时间内使原子存储对原子加载可见。是的,这不是关于对任何变量的访问,而是原子存储,包括使用memory_order_relaxed顺序的.store,即没有栅栏。至于给定示例中的栅栏,它们由.lock()和.unlock()操作提供。 - Tsyvarev
一位同事告诉我,“ever” 部分由 CPU 的缓存一致性协议保证。x86 和 armv7mp 都有缓存一致性协议。你能对此发表评论吗? - Hanno S.
1
@HannoS。只有在你试图解释特定平台上的事情如何工作时,这才是相关的。它与你拥有的保证无关。 - David Schwartz
我正在尝试了解特定平台上发生的情况。这就是为什么我说“架构是ARM、x86和x86_64。我需要使用gcc 4.6及更高版本支持C++03。”我并不是在询问是否有一种通用的方法可以在任何平台上使用C++03来实现这一点。(因为C++03不知道线程的存在) - Hanno S.

1

我需要将 updateAvailable 定义为 volatile 吗?

由于在 C++ 中,volatile 与线程模型无关,因此应使用原子操作使程序严格符合标准:

C++11 或更新版本中,最好使用带有 memory_order_relaxed 存储/加载的 atomic<bool>

atomic<bool> updateAvailable;

//Writer
....
updateAvailable.store(true, std::memory_order_relaxed); //set (under mutex locked)

// Reader

if(updateAvailable.load(std::memory_order_relaxed)) // check
{
    ...
    updateAvailable.store(false, std::memory_order_relaxed); // clear (under mutex locked)
    ....
}

自gcc 4.7以来,它支持类似于原子内建函数的功能。

至于gcc 4.6,似乎没有严格符合规范的方法可以避免在访问updateAvailable变量时使用内存栅栏。实际上,内存栅栏通常比10-100毫秒的时间顺序要快得多。因此,您可以使用其自己的原子内建函数

int updateAvailable = 0;

//Writer
...
__sync_fetch_and_or(&updateAvailable, 1); // set to non-zero
....

//Reader
if(__sync_fetch_and_and(&updateAvailable, 1)) // check, but never change
{
    ...
    __sync_fetch_and_and(&updateAvailable, 0); // clear
    ...
}

这个方案在数据一致性方面是安全的吗?

是的,它是安全的。你的理由在这里是完全正确的:

在 Reader 快速路径中,共享资源从未被触碰或读取。


这不是双重检查锁定!

问题本身明确说明了。

如果updateAvailable为false,Reader线程将使用变量myDataCache,该变量是线程本地的(没有其他线程使用它)。使用双重检查锁定方案时,所有线程都直接使用共享对象。

为什么这里不需要内存栅栏/屏障

唯一同时访问的变量是updateAvailable。使用互斥保护访问myData变量,这提供了所有必需的屏障。myDataCacheReader线程本地的局部变量

Reader线程看到updateAvailable变量为false时,它使用myDataCache变量,该变量由线程本身更改。程序顺序保证在这种情况下更改的正确可见性。

至于变量updateAvailable的可见性保证,即使没有屏障,C++11标准也为原子变量提供了这样的保证。29.3 p13说:

实现应该在合理的时间内使原子存储对原子加载可见。 Jonathan Wakely已经确认,即使是使用memory_order_relaxed访问在聊天中,这段话也适用。

3
首先,volatile 在 ARM 上的工作方式不同,它在 Intel 上适用是因为 Intel 提供了严格的顺序保证,而 ARM 没有提供。其次,在 C++ 之外仍然存在原子操作——请参阅我的回答。 - SergeyA
好的,由于在C++线程模型中volatile不是严格安全的,我已经重新修改了我的答案。 - Tsyvarev
我在C++11标准中找到了这个条款:29.3.13: 实现应该使原子存储对于原子加载在合理的时间内可见。 我认为它适用于memory_order_relaxed的存储和加载。我的回答是否正确?(请注意,我的建议是使用atomic而不是volatile)。 - Tsyvarev
1
@JustSid:为什么加载操作不能是松弛操作呢?根据提问者的描述,恰恰是加载操作应该尽可能快。 - Tsyvarev
1
@DavidSchwartz:这里没有双重检查锁定!这在问题帖子本身中明确说明。我已经在我的答案中添加了这个注释,请检查一下。 - Tsyvarev
显示剩余7条评论

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