读取原子修改的值是否需要内存屏障?
Is a memory barrier required to read a value that is atomically modified?
鉴于以下情况:
class Foo
{
public:
void Increment()
{
_InterlockedIncrement(&m_value); // OSIncrementAtomic
}
long GetValue()
{
return m_value;
}
private:
long m_value;
};
读取m_value
是否需要内存屏障?我的理解是 _InterlockedIncrement
将生成一个完整的内存屏障,并确保在任何后续加载发生之前存储该值。所以从这方面来说这听起来很安全,但是,m_value
是否可以被缓存,即 GetValue()
return 是否可以成为一个过时的值,即使是在原子递增时?
Jeff Preshing 的优秀文章供参考:https://preshing.com/20120515/memory-reordering-caught-in-the-act/
其他上下文:
我正在关注有关无锁编程的一系列文章,特别是在此处查看 unfinishedJobs
变量的用法和 HasJobCompleted
的潜在实现:
https://blog.molecular-matters.com/2015/08/24/job-system-2-0-lock-free-work-stealing-part-1-basics/
void Wait(const Job* job)
{
// wait until the job has completed. in the meantime, work on any other job.
while (!HasJobCompleted(job))
{
Job* nextJob = GetJob();
if (nextJob)
{
Execute(nextJob);
}
}
}
Determining whether a job has completed can be done by comparing unfinishedJobs with 0.
那么,在这种情况下,HasJobCompleted
的可能实现是否需要内存屏障?
是的,障碍应该是双方的:阅读和写作。想象一下,您有一些 write-buffer 和一个 loading-queue,其中所有内容都可能是 out-of-order。所以你在写你的东西时刷新 write-buffer 但你需要处理的 loading-queue 在另一个线程(处理器)上,它对你的刷新一无所知。所以它总是一个成对的过程。
您也可以从编译器的角度来考虑它:除非编译器被迫序列化访问,否则它可以自由地重新排序它可以安全地(根据它的观点)做的任何事情。
就是说,这都是关于序列化而不是原子性的。这完全是另一回事。你的写作是原子的 _InterlockedIncrement
但阅读不是原子的 return m_value
。这就是一场比赛。
此外,我看到您的代码需要原子性,但我认为不需要序列化。你没有用你的 m_value
保护任何东西。至于“过时”的价值:通常你不能保证在某个时间点你不会有过时的价值,即使有障碍。 RMW-operations 需要最新值,而其他则不需要。所以有一个障碍将有助于更快地获得最新的价值,但仅此而已。有了你的代码而忘记了比赛,编译器可能会安全地假设你没有修改 m_value
并缓存它。关于 CPU.
也可以这样说
综上所述:当您需要不受保护的变量时,只需使用 std::atomic
。它将确保该值不被任何实体缓存。
简答:是。
你的“阅读”应该有“获取”的顺序,所以你的Increment()
结果将在另一个线程中可见,当它在 incremnet 之后执行“释放”时。
不,您不需要障碍,但如果 reader 和编写者在不同的线程中调用这些函数,您的代码无论如何都会被破坏。特别是如果 reader 在循环中调用读取函数。
TL:DR:使用 C++11 std::atomic<long> m_value
,增量为 return m_value++
,reader 为 return m_value
。这将为您提供 data-race-free 程序中的顺序一致性:执行将像线程 运行 一样工作,并带有一些源顺序交错。 (除非您违反规则并拥有其他非 atomic
共享数据。)如果您希望执行增量的线程永远知道它们的值,那么您肯定希望 return 来自 Increment
的值生产的。对于像 int sequence_num = shared_counter++;
这样的用例,在 count++; tmp = count;
.
之间可以看到另一个线程的增量,在之后执行单独的加载完全被破坏了
如果在与 reader/writer 相同的线程中对 other 对象的操作不需要如此强的顺序,return m_value.load(std::memory_order_acquire)
就足够了对于大多数用途,m_value.fetch_add(1, std::memory_order_acq_rel)
。很少有程序实际上在任何地方都需要 StoreLoad 障碍;即使使用 acq_rel
,原子 RMW 实际上也不能重新排序。 (在 x86 上,它们的编译结果与您使用 seq_cst
相同。)
您不能在线程之间强制排序;负载要么看到该值,要么不看到该值,具体取决于读取线程是在获取/尝试获取负载值之前还是之后看到来自编写器的无效。线程的全部意义在于它们 不 运行 在 lock-step 中彼此。
Data-race UB:
循环读取 m_value
可以将负载提升到循环之外,因为它不是 atomic
(甚至 volatile
作为 hack)。这是 data-race UB,编译器会破坏你的 reader。参见 this and
障碍不是这里的 problem/solution,只是强制 re-checking 内存(或当前 CPU 看到的 cache-coherent 内存视图;实际 CPU L1d 和 L2 这样的缓存对此不是问题)。这不是障碍的真正作用;他们命令该线程访问一致的缓存。 C++ 线程仅 运行 跨内核,具有一致的缓存。
但是如果没有非常令人信服的理由,请认真对待自己的原子。 When to use volatile with multi threading? 几乎从来没有。该答案解释了缓存一致性,并且您不需要障碍来避免看到陈旧的值。
在许多 real-world C++ 实现中,像 std::atomic_thread_fence()
这样的东西也将是一个“编译器障碍”,它迫使编译器甚至从内存中重新加载 non-atomic 变量,即使没有 volatile
,但这是一个实现细节。所以它可能碰巧在某些 ISA 的某些编译器上工作得很好。而且对于编译器发明的多重加载仍然不是完全安全的;有关详细信息的示例,请参阅 LWN 文章 Who's afraid of a big bad optimizing compiler?;主要针对 Linux 内核如何使用 volatile
滚动自己的原子,de-facto 受 GCC/clang.
支持
“最新值”
初学者经常对此感到恐慌,并认为 RMW 操作在某种程度上更好,因为它们的指定方式。由于它们是读 + 写绑定在一起的,并且每个内存位置都有一个修改顺序 分开 ,RMW 操作必须等待对缓存行的写访问,这意味着在单个位置序列化所有写入和 RMW。
原子变量的普通加载仍然保证运行(通过实际实现)及时查看值。 (ISO C++ 只建议值 应该 在有限的时间内及时看到,但当然实际实现可以做得更好,因为它们 运行 在 cache-coherent CPU硬件。)
两个线程之间没有“立即”这样的东西;另一个线程中的加载看到存储的值,或者在存储对其他线程可见但不可见之前 运行。通过线程调度等,总是有可能一个线程会加载一个值,但很长时间都不会使用它;加载时是新鲜的。
所以这与正确性几乎无关,剩下的就是担心 inter-thread 延迟。在某些情况下,障碍可能会有所帮助(以减少以后内存操作的争用,而不是更快地主动刷新存储,障碍只是等待以正常方式发生)。所以这通常是一个非常小的影响,而不是使用额外屏障的理由。
见 . And see my comments on https://github.com/dotnet/runtime/issues/67330#issuecomment-1083539281 and 通常没有, 如果有,也不多。
当然不足以让 reader 放慢 reader 的速度只是为了让它比其他 atomic
变量更晚地查看这个 atomic
变量,如果你没有'需要正确的顺序。或者放慢编写器的速度,让它坐在那里什么都不做 也许 让它更快地完成一个 RFO 几个周期,而不是完成其他有用的工作。
如果您对线程的使用在 inter-core 延迟方面遇到瓶颈,那么值得,这可能是您需要重新考虑的迹象k 你的设计。
没有障碍或顺序,只需 std::atomic
和 memory_order_relaxed
,您通常仍会在 40 纳秒内看到其他内核上的数据(在现代 x86 desktop/laptop 上),大约就像两个线程都使用原子 RMW 一样。它不可能被延迟任何显着的时间,比如一微秒,如果你为很多早期的商店创造了很多争用,所以他们都需要很长时间才能提交,然后才能提交。您绝对不必担心长时间看不到商店。 (这当然只适用于 atomic
或 hand-rolled 具有 volatile
的原子。普通 non-volatile 加载可能只在循环开始时检查一次,然后再也不会。那是为什么它们不能用于多线程。)
鉴于以下情况:
class Foo
{
public:
void Increment()
{
_InterlockedIncrement(&m_value); // OSIncrementAtomic
}
long GetValue()
{
return m_value;
}
private:
long m_value;
};
读取m_value
是否需要内存屏障?我的理解是 _InterlockedIncrement
将生成一个完整的内存屏障,并确保在任何后续加载发生之前存储该值。所以从这方面来说这听起来很安全,但是,m_value
是否可以被缓存,即 GetValue()
return 是否可以成为一个过时的值,即使是在原子递增时?
Jeff Preshing 的优秀文章供参考:https://preshing.com/20120515/memory-reordering-caught-in-the-act/
其他上下文:
我正在关注有关无锁编程的一系列文章,特别是在此处查看 unfinishedJobs
变量的用法和 HasJobCompleted
的潜在实现:
https://blog.molecular-matters.com/2015/08/24/job-system-2-0-lock-free-work-stealing-part-1-basics/
void Wait(const Job* job)
{
// wait until the job has completed. in the meantime, work on any other job.
while (!HasJobCompleted(job))
{
Job* nextJob = GetJob();
if (nextJob)
{
Execute(nextJob);
}
}
}
Determining whether a job has completed can be done by comparing unfinishedJobs with 0.
那么,在这种情况下,HasJobCompleted
的可能实现是否需要内存屏障?
是的,障碍应该是双方的:阅读和写作。想象一下,您有一些 write-buffer 和一个 loading-queue,其中所有内容都可能是 out-of-order。所以你在写你的东西时刷新 write-buffer 但你需要处理的 loading-queue 在另一个线程(处理器)上,它对你的刷新一无所知。所以它总是一个成对的过程。
您也可以从编译器的角度来考虑它:除非编译器被迫序列化访问,否则它可以自由地重新排序它可以安全地(根据它的观点)做的任何事情。
就是说,这都是关于序列化而不是原子性的。这完全是另一回事。你的写作是原子的 _InterlockedIncrement
但阅读不是原子的 return m_value
。这就是一场比赛。
此外,我看到您的代码需要原子性,但我认为不需要序列化。你没有用你的 m_value
保护任何东西。至于“过时”的价值:通常你不能保证在某个时间点你不会有过时的价值,即使有障碍。 RMW-operations 需要最新值,而其他则不需要。所以有一个障碍将有助于更快地获得最新的价值,但仅此而已。有了你的代码而忘记了比赛,编译器可能会安全地假设你没有修改 m_value
并缓存它。关于 CPU.
综上所述:当您需要不受保护的变量时,只需使用 std::atomic
。它将确保该值不被任何实体缓存。
简答:是。
你的“阅读”应该有“获取”的顺序,所以你的Increment()
结果将在另一个线程中可见,当它在 incremnet 之后执行“释放”时。
不,您不需要障碍,但如果 reader 和编写者在不同的线程中调用这些函数,您的代码无论如何都会被破坏。特别是如果 reader 在循环中调用读取函数。
TL:DR:使用 C++11 std::atomic<long> m_value
,增量为 return m_value++
,reader 为 return m_value
。这将为您提供 data-race-free 程序中的顺序一致性:执行将像线程 运行 一样工作,并带有一些源顺序交错。 (除非您违反规则并拥有其他非 atomic
共享数据。)如果您希望执行增量的线程永远知道它们的值,那么您肯定希望 return 来自 Increment
的值生产的。对于像 int sequence_num = shared_counter++;
这样的用例,在 count++; tmp = count;
.
如果在与 reader/writer 相同的线程中对 other 对象的操作不需要如此强的顺序,return m_value.load(std::memory_order_acquire)
就足够了对于大多数用途,m_value.fetch_add(1, std::memory_order_acq_rel)
。很少有程序实际上在任何地方都需要 StoreLoad 障碍;即使使用 acq_rel
,原子 RMW 实际上也不能重新排序。 (在 x86 上,它们的编译结果与您使用 seq_cst
相同。)
您不能在线程之间强制排序;负载要么看到该值,要么不看到该值,具体取决于读取线程是在获取/尝试获取负载值之前还是之后看到来自编写器的无效。线程的全部意义在于它们 不 运行 在 lock-step 中彼此。
Data-race UB:
循环读取 m_value
可以将负载提升到循环之外,因为它不是 atomic
(甚至 volatile
作为 hack)。这是 data-race UB,编译器会破坏你的 reader。参见 this and
障碍不是这里的 problem/solution,只是强制 re-checking 内存(或当前 CPU 看到的 cache-coherent 内存视图;实际 CPU L1d 和 L2 这样的缓存对此不是问题)。这不是障碍的真正作用;他们命令该线程访问一致的缓存。 C++ 线程仅 运行 跨内核,具有一致的缓存。
但是如果没有非常令人信服的理由,请认真对待自己的原子。 When to use volatile with multi threading? 几乎从来没有。该答案解释了缓存一致性,并且您不需要障碍来避免看到陈旧的值。
在许多 real-world C++ 实现中,像 std::atomic_thread_fence()
这样的东西也将是一个“编译器障碍”,它迫使编译器甚至从内存中重新加载 non-atomic 变量,即使没有 volatile
,但这是一个实现细节。所以它可能碰巧在某些 ISA 的某些编译器上工作得很好。而且对于编译器发明的多重加载仍然不是完全安全的;有关详细信息的示例,请参阅 LWN 文章 Who's afraid of a big bad optimizing compiler?;主要针对 Linux 内核如何使用 volatile
滚动自己的原子,de-facto 受 GCC/clang.
“最新值”
初学者经常对此感到恐慌,并认为 RMW 操作在某种程度上更好,因为它们的指定方式。由于它们是读 + 写绑定在一起的,并且每个内存位置都有一个修改顺序 分开 ,RMW 操作必须等待对缓存行的写访问,这意味着在单个位置序列化所有写入和 RMW。
原子变量的普通加载仍然保证运行(通过实际实现)及时查看值。 (ISO C++ 只建议值 应该 在有限的时间内及时看到,但当然实际实现可以做得更好,因为它们 运行 在 cache-coherent CPU硬件。)
两个线程之间没有“立即”这样的东西;另一个线程中的加载看到存储的值,或者在存储对其他线程可见但不可见之前 运行。通过线程调度等,总是有可能一个线程会加载一个值,但很长时间都不会使用它;加载时是新鲜的。
所以这与正确性几乎无关,剩下的就是担心 inter-thread 延迟。在某些情况下,障碍可能会有所帮助(以减少以后内存操作的争用,而不是更快地主动刷新存储,障碍只是等待以正常方式发生)。所以这通常是一个非常小的影响,而不是使用额外屏障的理由。
见
当然不足以让 reader 放慢 reader 的速度只是为了让它比其他 atomic
变量更晚地查看这个 atomic
变量,如果你没有'需要正确的顺序。或者放慢编写器的速度,让它坐在那里什么都不做 也许 让它更快地完成一个 RFO 几个周期,而不是完成其他有用的工作。
如果您对线程的使用在 inter-core 延迟方面遇到瓶颈,那么值得,这可能是您需要重新考虑的迹象k 你的设计。
没有障碍或顺序,只需 std::atomic
和 memory_order_relaxed
,您通常仍会在 40 纳秒内看到其他内核上的数据(在现代 x86 desktop/laptop 上),大约就像两个线程都使用原子 RMW 一样。它不可能被延迟任何显着的时间,比如一微秒,如果你为很多早期的商店创造了很多争用,所以他们都需要很长时间才能提交,然后才能提交。您绝对不必担心长时间看不到商店。 (这当然只适用于 atomic
或 hand-rolled 具有 volatile
的原子。普通 non-volatile 加载可能只在循环开始时检查一次,然后再也不会。那是为什么它们不能用于多线程。)