带有 volatile 的多线程代码明显不安全行为的真实世界示例
Real world example of noticeable unsafe behavior of multithreaded code with volatile
我读过很多 answers and articles 说明为什么 volatile
不能使多线程 C++ 代码安全。
我理解推理,我想理解可能的危险,我的问题是我无法创建或找到任何示例代码或提及使用它进行同步的程序会产生实际可见的错误或意外行为的情况。我什至不需要它是可重现的(因为即使进行了优化,当前的编译器似乎也在尝试生成安全代码),这只是一个真实发生的例子。
看下面的例子:
两个线程使用相同的函数递增一个变量。如果未定义 USE_ATOMIC,增量本身将在 var 的原子副本中完成,因此增量本身是线程安全的。但是正如你所看到的,对 volatile 变量的访问不是!如果你 运行 没有 USE_ATOMIC 的例子,结果是未定义的。如果设置USE_ATOMIC,结果总是一样的!
发生的事情很简单:
volatile
只是意味着可以在编译器的控制之外更改变量。这意味着,编译器必须在修改和写回结果之前读取变量。但这与同步无关。更重要的是:在多核 CPU 上,变量可以存在两次(例如在每个缓存中)并且没有缓存同步完成!在基于线程的编程中,还有很多东西必须被认识。这里 memory barrier
是缺少的主题。
#include <iostream>
#include <set>
#include <thread>
#include <atomic>
//#define USE_ATOMIC
#ifdef USE_ATOMIC
std::atomic<long> i{0};
#else
volatile long i=0;
#endif
const long cnts=10000000;
void inc(volatile long &var)
{
std::atomic<long> local_copy{var};
local_copy++;
var=local_copy;
}
void func1()
{
long n=0;
while ( n < cnts )
{
n++;
#ifdef USE_ATOMIC
i++;
#else
inc( i );
#endif
}
}
int main()
{
std::thread t1( func1 );
std::thread t2( func1 );
t1.join();
t2.join();
std::cout << i << std::endl;
}
假设你有一个计数器,你想用它来跟踪某个操作完成了多少次,每次递增计数器。
如果您 运行 在多线程中执行此操作,那么除非计数器 std::atomic
或受锁保护,否则您将得到意想不到的结果,volatile
将无济于事。
这里有一个简化的例子,它重现了不可预知的结果,至少对我来说是这样:
#include <future>
#include <iostream>
#include <atomic>
volatile int counter{0};
//std::atomic<int> counter{0};
int main() {
auto task = []{
for(int i = 0; i != 1'000'000; ++i) {
// do some operation...
++counter;
}
};
auto future1 = std::async(std::launch::async, task);
auto future2 = std::async(std::launch::async, task);
future1.get();
future2.get();
std::cout << counter << "\n";
}
这里我们使用 std::async
启动两个任务,使用 std::launch::async
启动策略强制它异步启动。每个任务只是将计数器递增一百万次。两个任务完成后,我们预计计数器为 200 万。
但是,增量是读取计数器和写入计数器之间的读写操作,另一个线程可能也写入了它,增量可能会丢失。理论上,因为我们已经进入了未定义行为的领域,所以绝对有可能发生任何事情!
如果我们将计数器更改为 std::atomic<int>
,我们将获得预期的行为。
此外,假设另一个线程正在使用 counter
检测操作是否已完成。不幸的是,没有什么能阻止编译器在完成操作之前重新排序代码并递增计数器。同样,这可以通过使用 std::atomic<int>
或设置必要的内存栅栏来解决。
有关详细信息,请参阅 Scott Meyers 的 Effective Modern C++。
我读过很多 answers and articles 说明为什么 volatile
不能使多线程 C++ 代码安全。
我理解推理,我想理解可能的危险,我的问题是我无法创建或找到任何示例代码或提及使用它进行同步的程序会产生实际可见的错误或意外行为的情况。我什至不需要它是可重现的(因为即使进行了优化,当前的编译器似乎也在尝试生成安全代码),这只是一个真实发生的例子。
看下面的例子:
两个线程使用相同的函数递增一个变量。如果未定义 USE_ATOMIC,增量本身将在 var 的原子副本中完成,因此增量本身是线程安全的。但是正如你所看到的,对 volatile 变量的访问不是!如果你 运行 没有 USE_ATOMIC 的例子,结果是未定义的。如果设置USE_ATOMIC,结果总是一样的!
发生的事情很简单:
volatile
只是意味着可以在编译器的控制之外更改变量。这意味着,编译器必须在修改和写回结果之前读取变量。但这与同步无关。更重要的是:在多核 CPU 上,变量可以存在两次(例如在每个缓存中)并且没有缓存同步完成!在基于线程的编程中,还有很多东西必须被认识。这里 memory barrier
是缺少的主题。
#include <iostream>
#include <set>
#include <thread>
#include <atomic>
//#define USE_ATOMIC
#ifdef USE_ATOMIC
std::atomic<long> i{0};
#else
volatile long i=0;
#endif
const long cnts=10000000;
void inc(volatile long &var)
{
std::atomic<long> local_copy{var};
local_copy++;
var=local_copy;
}
void func1()
{
long n=0;
while ( n < cnts )
{
n++;
#ifdef USE_ATOMIC
i++;
#else
inc( i );
#endif
}
}
int main()
{
std::thread t1( func1 );
std::thread t2( func1 );
t1.join();
t2.join();
std::cout << i << std::endl;
}
假设你有一个计数器,你想用它来跟踪某个操作完成了多少次,每次递增计数器。
如果您 运行 在多线程中执行此操作,那么除非计数器 std::atomic
或受锁保护,否则您将得到意想不到的结果,volatile
将无济于事。
这里有一个简化的例子,它重现了不可预知的结果,至少对我来说是这样:
#include <future>
#include <iostream>
#include <atomic>
volatile int counter{0};
//std::atomic<int> counter{0};
int main() {
auto task = []{
for(int i = 0; i != 1'000'000; ++i) {
// do some operation...
++counter;
}
};
auto future1 = std::async(std::launch::async, task);
auto future2 = std::async(std::launch::async, task);
future1.get();
future2.get();
std::cout << counter << "\n";
}
这里我们使用 std::async
启动两个任务,使用 std::launch::async
启动策略强制它异步启动。每个任务只是将计数器递增一百万次。两个任务完成后,我们预计计数器为 200 万。
但是,增量是读取计数器和写入计数器之间的读写操作,另一个线程可能也写入了它,增量可能会丢失。理论上,因为我们已经进入了未定义行为的领域,所以绝对有可能发生任何事情!
如果我们将计数器更改为 std::atomic<int>
,我们将获得预期的行为。
此外,假设另一个线程正在使用 counter
检测操作是否已完成。不幸的是,没有什么能阻止编译器在完成操作之前重新排序代码并递增计数器。同样,这可以通过使用 std::atomic<int>
或设置必要的内存栅栏来解决。
有关详细信息,请参阅 Scott Meyers 的 Effective Modern C++。