为什么这个 cppreference 摘录似乎错误地暗示原子可以保护关键部分?
Why does this cppreference excerpt seem to wrongly suggest that atomics can protect critical sections?
int main() {
std::vector<int> foo;
std::atomic<int> bar{0};
std::mutex mx;
auto job = [&] {
int asdf = bar.load();
// std::lock_guard lg(mx);
foo.emplace_back(1);
bar.store(foo.size());
};
std::thread t1(job);
std::thread t2(job);
t1.join();
t2.join();
}
这显然不能保证 运行 工作,但可以与互斥锁一起工作。但是如何用标准的正式定义来解释呢?
考虑 cppreference 中的这段摘录:
If an atomic store in thread A is tagged memory_order_release and an
atomic load in thread B from the same variable is tagged
memory_order_acquire [as is the case with default atomics], all memory writes (non-atomic and relaxed
atomic) that happened-before the atomic store from the point of view
of thread A, become visible side-effects in thread B. That is, once
the atomic load is completed, thread B is guaranteed to see everything
thread A wrote to memory.
原子加载和存储(默认或指定特定的获取和释放内存顺序)具有上述获取-释放语义。 (互斥锁的锁定和解锁也是如此。)
对该措辞的解释可能是,当线程 2 的加载操作与线程 1 的存储操作同步时,需要运行观察所有(甚至非原子的)写入发生在存储,例如矢量修改,使其定义明确。但几乎每个人都会同意这会导致分段错误,并且如果作业函数 运行 它的三行循环中肯定会这样做。
什么标准措辞可以解释这两种工具之间明显的功能差异,因为这种措辞似乎暗示 atomic 会以某种方式同步。
我知道何时使用互斥锁和原子,并且我知道该示例不起作用,因为实际上没有发生同步。我的问题是如何解释定义,使其与现实中的工作方式不矛盾。
抛开房间里的大象,none 的 C++ 容器是线程安全的,而无需使用一些 locking排序(所以忘记使用 emplace_back
而不实现锁定),并关注为什么仅原子对象是不够的问题:
您需要的不仅仅是原子对象。您还需要排序。
原子对象给你的只是当一个对象改变状态时,任何其他线程要么看到它的旧值要么看到它的新值,并且它永远不会看到任何“部分 old/partially 新”,或“中间”值。
但它不保证 何时 其他执行线程将“看到”原子对象的新值。在某个时候,他们(希望)会看到原子对象立即翻转到它的新值。什么时候?最终。这就是你从原子学中得到的全部。
一个执行线程很可能将原子对象设置为新值,但其他执行线程仍会以某种形式或方式缓存旧值,并将继续看到原子对象的旧值,并且在经过某个中间时间(如果有的话)之前,不会“看到”原子对象的新值。
排序是指定何时 对象的新值在其他执行线程中可见的规则。一口气获得原子性和易于处理排序的最简单方法是使用互斥锁和条件变量来为您处理所有困难的细节。您仍然可以使用原子,并通过仔细的逻辑使用 lock/release 栅栏指令来实现正确的排序。但它很容易出错,最糟糕的是,直到你的代码由于不正确的排序而开始偏离 rails 时,你才会知道它是错误的,并且几乎不可能准确地重现错误用于调试目的的行为。
但是对于几乎所有常见的、例行的、普通的任务,互斥锁和条件变量是正确的线程间排序的最简单的解决方案。
The idea is that when Thread 2's load operation syncs with the store operation of Thread1, it is guaranteed to observe all (even non-atomic) writes that happened-before the store, such as the vector-modification
是的,foo.emplace_back(1);
执行的所有写入都将在执行 bar.store(foo.size());
时得到保证。但是谁能保证线程 1 中的 foo.emplace_back(1);
会看到线程 2 中执行的 foo.emplace_back(1);
中的 any/all 非部分一致状态,反之亦然?他们都读取和修改 std::vector
的内部状态,并且在代码到达原子存储之前没有内存屏障。即使所有变量都是 read/modified 原子地 std::vector
状态也由多个变量组成——至少大小、容量、指向数据的指针。对所有这些的更改也必须同步,内存屏障对此还不够。
为了进一步解释,让我们创建一个简化的示例:
int a = 0;
int b = 0;
std::atomic<int> at;
// thread 1
int foo = at.load();
a = 1;
b = 2;
at.store(foo);
// thread 2
int foo = at.load();
int tmp1 = a;
int tmp2 = b;
at.store(tmp2);
现在你有两个问题:
不保证当tmp2
值为2
tmp1
值为 1
当你在原子操作之前阅读 a
和 b
时。
无法保证at.store(b)
执行 a == b == 0
或 a == 1 and b == 2
,
它可能是 a == 1
但仍然是 b == 0
.
清楚了吗?
但是:
// thread 1
mutex.lock();
a = 1;
b = 2;
mutex.unlock();
// thread 2
mutex.lock();
int tmp1 = a;
int tmp2 = b;
mutex.unlock();
你要么得到 tmp1 == 0 and tmp2 == 0
,要么得到 tmp1 == 1 and tmp2 == 2
,你看出区别了吗?
引文的意思是当B加载A存储的值时,通过观察store的发生,也可以确定B在store之前所做的一切也都发生了并且是可见的。
但是如果商店实际上还没有发生,这并不能告诉你任何事情!
实际的 C++ 标准更明确地说明了这一点。 (永远记住,cppreference 是一种经常引用或解释标准的宝贵资源,但它本身并不是标准,也不具有权威性。)从 N4861 最终的 C++20 草案中,我们在 atomics.orderp2:
An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic
operation B that performs an acquire operation on M and takes its value from any side effect in the release
sequence headed by A.
我同意 如果 线程 B 中的负载 returned 1,它可以安全地断定另一个线程已完成其存储并因此退出临界区,因此 B 可以安全地使用 foo
。在这种情况下,B 中的负载已与 A 中的存储同步,因为负载的值(即 1)来自存储(这是其自身释放序列的一部分)。
但是,如果两个线程都在任一线程执行其存储之前完成加载,则两个线程都加载 return 0 是完全可能的。值 0 并非来自任何一家商店,因此在这种情况下负载不会与商店同步。您的代码甚至不查看加载的值,因此在这种情况下两个线程可能一起进入临界区。
以下代码是一种安全但效率低下的使用原子来保护临界区的方法。它确保 A 将首先执行临界区,而 B 将等到 A 完成后再继续。 (显然,如果两个线程都等待另一个线程,那么就会出现死锁。)
int main() {
std::vector<int> foo;
std::atomic<int> bar{0};
std::mutex mx;
auto jobA = [&] {
foo.emplace_back(1);
bar.store(foo.size());
};
auto jobB = [&] {
while (bar.load() == 0) /* spin */ ;
foo.emplace_back(1);
};
std::thread t1(jobA);
std::thread t2(jobB);
t1.join();
t2.join();
}
int main() {
std::vector<int> foo;
std::atomic<int> bar{0};
std::mutex mx;
auto job = [&] {
int asdf = bar.load();
// std::lock_guard lg(mx);
foo.emplace_back(1);
bar.store(foo.size());
};
std::thread t1(job);
std::thread t2(job);
t1.join();
t2.join();
}
这显然不能保证 运行 工作,但可以与互斥锁一起工作。但是如何用标准的正式定义来解释呢?
考虑 cppreference 中的这段摘录:
If an atomic store in thread A is tagged memory_order_release and an atomic load in thread B from the same variable is tagged memory_order_acquire [as is the case with default atomics], all memory writes (non-atomic and relaxed atomic) that happened-before the atomic store from the point of view of thread A, become visible side-effects in thread B. That is, once the atomic load is completed, thread B is guaranteed to see everything thread A wrote to memory.
原子加载和存储(默认或指定特定的获取和释放内存顺序)具有上述获取-释放语义。 (互斥锁的锁定和解锁也是如此。)
对该措辞的解释可能是,当线程 2 的加载操作与线程 1 的存储操作同步时,需要运行观察所有(甚至非原子的)写入发生在存储,例如矢量修改,使其定义明确。但几乎每个人都会同意这会导致分段错误,并且如果作业函数 运行 它的三行循环中肯定会这样做。
什么标准措辞可以解释这两种工具之间明显的功能差异,因为这种措辞似乎暗示 atomic 会以某种方式同步。
我知道何时使用互斥锁和原子,并且我知道该示例不起作用,因为实际上没有发生同步。我的问题是如何解释定义,使其与现实中的工作方式不矛盾。
抛开房间里的大象,none 的 C++ 容器是线程安全的,而无需使用一些 locking排序(所以忘记使用 emplace_back
而不实现锁定),并关注为什么仅原子对象是不够的问题:
您需要的不仅仅是原子对象。您还需要排序。
原子对象给你的只是当一个对象改变状态时,任何其他线程要么看到它的旧值要么看到它的新值,并且它永远不会看到任何“部分 old/partially 新”,或“中间”值。
但它不保证 何时 其他执行线程将“看到”原子对象的新值。在某个时候,他们(希望)会看到原子对象立即翻转到它的新值。什么时候?最终。这就是你从原子学中得到的全部。
一个执行线程很可能将原子对象设置为新值,但其他执行线程仍会以某种形式或方式缓存旧值,并将继续看到原子对象的旧值,并且在经过某个中间时间(如果有的话)之前,不会“看到”原子对象的新值。
排序是指定何时 对象的新值在其他执行线程中可见的规则。一口气获得原子性和易于处理排序的最简单方法是使用互斥锁和条件变量来为您处理所有困难的细节。您仍然可以使用原子,并通过仔细的逻辑使用 lock/release 栅栏指令来实现正确的排序。但它很容易出错,最糟糕的是,直到你的代码由于不正确的排序而开始偏离 rails 时,你才会知道它是错误的,并且几乎不可能准确地重现错误用于调试目的的行为。
但是对于几乎所有常见的、例行的、普通的任务,互斥锁和条件变量是正确的线程间排序的最简单的解决方案。
The idea is that when Thread 2's load operation syncs with the store operation of Thread1, it is guaranteed to observe all (even non-atomic) writes that happened-before the store, such as the vector-modification
是的,foo.emplace_back(1);
执行的所有写入都将在执行 bar.store(foo.size());
时得到保证。但是谁能保证线程 1 中的 foo.emplace_back(1);
会看到线程 2 中执行的 foo.emplace_back(1);
中的 any/all 非部分一致状态,反之亦然?他们都读取和修改 std::vector
的内部状态,并且在代码到达原子存储之前没有内存屏障。即使所有变量都是 read/modified 原子地 std::vector
状态也由多个变量组成——至少大小、容量、指向数据的指针。对所有这些的更改也必须同步,内存屏障对此还不够。
为了进一步解释,让我们创建一个简化的示例:
int a = 0;
int b = 0;
std::atomic<int> at;
// thread 1
int foo = at.load();
a = 1;
b = 2;
at.store(foo);
// thread 2
int foo = at.load();
int tmp1 = a;
int tmp2 = b;
at.store(tmp2);
现在你有两个问题:
不保证当
tmp2
值为2tmp1
值为 1 当你在原子操作之前阅读a
和b
时。无法保证
at.store(b)
执行a == b == 0
或a == 1 and b == 2
, 它可能是a == 1
但仍然是b == 0
.
清楚了吗?
但是:
// thread 1
mutex.lock();
a = 1;
b = 2;
mutex.unlock();
// thread 2
mutex.lock();
int tmp1 = a;
int tmp2 = b;
mutex.unlock();
你要么得到 tmp1 == 0 and tmp2 == 0
,要么得到 tmp1 == 1 and tmp2 == 2
,你看出区别了吗?
引文的意思是当B加载A存储的值时,通过观察store的发生,也可以确定B在store之前所做的一切也都发生了并且是可见的。
但是如果商店实际上还没有发生,这并不能告诉你任何事情!
实际的 C++ 标准更明确地说明了这一点。 (永远记住,cppreference 是一种经常引用或解释标准的宝贵资源,但它本身并不是标准,也不具有权威性。)从 N4861 最终的 C++20 草案中,我们在 atomics.orderp2:
An atomic operation A that performs a release operation on an atomic object M synchronizes with an atomic operation B that performs an acquire operation on M and takes its value from any side effect in the release sequence headed by A.
我同意 如果 线程 B 中的负载 returned 1,它可以安全地断定另一个线程已完成其存储并因此退出临界区,因此 B 可以安全地使用 foo
。在这种情况下,B 中的负载已与 A 中的存储同步,因为负载的值(即 1)来自存储(这是其自身释放序列的一部分)。
但是,如果两个线程都在任一线程执行其存储之前完成加载,则两个线程都加载 return 0 是完全可能的。值 0 并非来自任何一家商店,因此在这种情况下负载不会与商店同步。您的代码甚至不查看加载的值,因此在这种情况下两个线程可能一起进入临界区。
以下代码是一种安全但效率低下的使用原子来保护临界区的方法。它确保 A 将首先执行临界区,而 B 将等到 A 完成后再继续。 (显然,如果两个线程都等待另一个线程,那么就会出现死锁。)
int main() {
std::vector<int> foo;
std::atomic<int> bar{0};
std::mutex mx;
auto jobA = [&] {
foo.emplace_back(1);
bar.store(foo.size());
};
auto jobB = [&] {
while (bar.load() == 0) /* spin */ ;
foo.emplace_back(1);
};
std::thread t1(jobA);
std::thread t2(jobB);
t1.join();
t2.join();
}