问:C++多线程为什么会出现数据读取错误?
在学习C++多线程编程,为了验证线程之间共享变量读取冲突的问题,自己写了一个小例子,简单来说就是创建多个线程在多个银行账户之间转账的问题。对账户加锁前多个线程同时操作会造成最终所有账户总金额发生变化,加锁后是正常的。但是在过程中,发现了一个数据读取错误的问题。下面是具体代码:
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
struct Account
{
explicit Account(double initMoney)
: money(initMoney)
{}
void subMoney(double s)
{
money -= s;
}
void addMoney(double a)
{
money += a;
}
double getMoney() const
{
return money;
}
private:
double money;
};
int times;
std::mutex mu_;
std::vector<Account> account_list(100, Account(1000)); // 100个账号,每个初始1000元
void callback(int from, int to, double money)
{
std::unique_lock<std::mutex> lock(mu_);
account_list[from].subMoney(money);
account_list[to].addMoney(money);
++times;//执行完后已完成线程数+1
}
int main(int argc, char **argv)
{
while (1) // 如果不出错,就一直反复执行,直到后面的break...
{
times = 0;//执行完毕的线程数清零
std::vector<std::thread> thread_list{};
for (int i = 0; i < 10; ++i)
{
//创建10个线程在同时在账户间转钱
std::thread tmp(callback, rand() % 100, rand() % 100, 20);
tmp.detach();
}
while (times != 10); // 10个子线程执行完之前,阻塞主线程
double callbacksum = 0;
for (int i = 0; i < account_list.size(); ++i)
{
sum += account_list[i].getMoney();
}
if (sum != 100000) // 最后算出来的总额不是100000,说明出错了..
{
//总金额不对:
std::cerr << "并发计算的总额竟然是:" << sum << std::endl;
// 在单线程模式下,再算一次:
sum = 0;
for (int i = 0; i < account_list.size(); ++i)
{
sum += account_list[i].getMoney();
}
std::cerr << "单线程计算的总额是:" << sum << std::endl; //这次会对
break; // 仅在出错里,才结束循环 ....
}
}
}
将以上代码编译为非调试版本(Release),以实现全速运行时,很快会退出(否则,很可能会限入死循环),得到输出如:
并发计算的总额竟然是:100020 单线程计算的总额是:100000
100个账号,每个初始1000元,然后互相转来转去,无论如何如何转,最终大家的总额应该还是10万元。但最终结果中,并发计算的结果是错的,多出20元。
ccount是自定义的类型只有很简单的get,加,减3个函数
问题出现在 main.cpp中的 while(times != 10);//10个子线程执行完之前,阻塞主线程 这里,发现之后第一次全体求和有可能出现±20的差错,但是最后再次求和的验证结果是正确的,说明转账操作是没问题的。只可能是在转账操作完成前,主线程就进行了读取,但按理说times在等于10后,所有转账操作应该都已经完成,最终结果应该不会有差错才对。
南郁老师的回答
问题分析
程序员写的是:
void callback(int from,int to ,double money)
{
std::unique_lock<std::mutex> lock(mu_);
account_list[from].subMoney(money);
account_list[to].addMoney(money);
++times; //执行完后已完成线程数+1
}
尽管有锁,但挡不住在程序运行时,CPU 觉得锁后面的“三行代码”调换一下次序似乎好像仿佛也也不影响这个函数的运行结果嘛(因为在CPU的当前“视野”里,times 加不加 和另“一伙数据”( account_list 、from、to、money )没什么关系……于是某次运行时,callback函数实质是这样在CPU上过的:
void callback(int from,int to ,double money)
{
std::unique_lock<std::mutex> lock(mu_);
account_list[from].subMoney(money);
++times;//执行完后已完成线程数+1
account_list[to].addMoney(money);
}
也就是说,在后面两行代码之间,存在某个瞬间,times(次数)已经加1,但实际的钱还没有加上去,即:addMoney(money) 方法还没调用。
当然,这里为了描述及理解方便,说的是行与行之间的代码被乱序执行了,但实际上,CPU不理解代码行,只理解一条条指令,也就说,打乱的是更细粒度的指令,并且跨核的时序(比如:假设三行代码各编译出50个指令,则CPU在这150个指令间“跳着”执行)。
CPU 为什么敢这样乱来?因为在CPU的眼里,这个函数的后三行代码,不管是走:
- 支出账户扣钱 → 收入账户加钱 → 计一次转账成功
- 支出账户扣钱 → 计一次转账成功 → 收入账户加钱
- 收入账户加钱 → 计一次转账成功 → 支出账户扣钱
- ……
最终结果都是一样的。这种情况下,CPU 执行代码就会以某种方式或目的(通常是为了性能)自行优化,而不是严格按指令次序执行。
这有点像人在搬一堆小杂物。地上依次摆着:(a)一个小铁球、(b)一个大铁球、©一块小木板;假设我们的力气不足于一次性全搬走,随便一个人都会这么想:搬两个圆滚滚还重的铁球太容易脱手了,还是先搬a和c,第二趟再搬b吧。
单线程时,这样做没啥问题;但一到多线程,并且正好多个线程在读写同一个数据,问题就来了。在本例中,假设走的是:
“收入账户加钱 → 计一次转账成功 → 支出账户扣钱”
再假设第9次转账金额是20元,则在“计一次转账成功”这一步完成后,支出账户还没扣钱时,主循环中的:“while(times != 10);”
条件成立(即 times 凑够 10次),于是,并发时累加出来的总额,就会多出20元。
注意: while 语句后面直接跟着一个分号。
更为可怕的是,如果要求编译器执行更激进(但仍然合法)的优化,那么,编译器甚至可以通过自行“阅读”代码,发现在当前线程的执行上下文(while循环)中,这个 times 根本没被改变嘛?于是直接将 “while(time != 0);” 改为 “while (true) ;” 。没错,编译要是“狠”起来,CPU也不敢比。
如何避免CPU如何“不负责任”的乱序执行呢?
callback
我们注意到,在callback函数中,“扣钱、加钱、计数” 这三个操作是加了锁的;因此这三者无论用什么次序执行,都可视为是不可分割的原子操作,所以,并发10个线程同时调用callback,并不会出错,下面我们将这三句统一称呼为“一次完整的转账过程”。造成错误的代码是上面说的那句:
while(times != 10)
主线程在读取times完全没有加锁,意即:主线程完全不考虑times其实正在被其他10个线程“疯狂”的改写的事实,自顾自地、悠哉悠哉地读着 times 值,然后判断它是不是10。这种情况下,那三行代码有没有加锁,对主线程读取times的值,是毫无影响的。解决办法自然是,主线程在读取 times 时,也进行加锁,并且得和callback使用同把锁。
大家可以将“锁”理解为多线程之间的唯一信物。任何一个线程想要执行被锁住的代码,就必须手里取到这个“信物”;没有取到的线程,只能干等。while 加上锁的代码:
{
std::unique_lock<std::mutex> lock(mu_);
while (times != 10); //10个子线程执行完之前,阻塞主线程
}
我们加了一对花括号在 while(xxx); 外面,并在里面加上锁。现在,“一次完整的转账过程” 和 “检查一下times够不够10” 这两个操作,必须互相等待。意思是,只要“一次完整的转账过程” 还没执行完,主线程就不能去读 times 的值;反过来也成立:当主线程正在检查times是不是10时,那边的线程肯定不能开始新的一次转账。
所以,结论是:我们并没有避免“CPU不负责的乱序执行”,但是我们通过两边加锁,实现了:不管CPU以什么次序执行那三行代码,反正在另一个线程(本例中的主线程),都会等这三行代码执行完毕之后,才开始读次序敏感的数据(本例中的times)。
那如何避免编译器的耍狠呢?
事情还没完,当程序写好后,要正式发布了,通常就会打开编译优化开关,让编译器狠狠优化一把……可是,刚刚我们说了,在本例中,代码:
while (times != 10);
编译器是敢把它优化成:
while (true); // 典型的死循环……
并且人家这样干,还是合法的(合C++语言的规定)。怎么办?
答,可以使用 “volatile” 来修改 times 变量。
比如,这样定义 times:
volatile int times;
volatile 会告诉编译器:“我修饰的那个数据,虽然在当前上下文里,看起来不会变动,但其实它有可能会被外部环境修改”。
如果有多线程,上面那句话就好理解了:当前线程修改不了某个变量,不过,别的线程会修改它。然而,“volatile” 来自C语言,当它诞生时,多线程编程都还没有开始普及。
所以,上面那句话的更准确的解读是:
当前程序修改不了某个数据,但这个数据可能从程序外部被修改。
所以,“volatile” 其实最常用于 硬件、嵌入式里的 C/C++ 编程。比如:有个特殊的水银温度计,顶部插了一根铜线接入到某个单片机。单片机的程序肯定不能修改水银温度计表达的温度,但是,当天气一热,温度计的水银上升,数据从外部发生变化,并且这个变化借助单片机的硬件功能,最终体现到程序里的某个可读取的数据……大概就是这么一个过程:程序不能修改某一块内存,但外部硬件可以修改这块内存。
这个数据,在C/C++里面,甚至可以不是变量,而是常量,比如:
volatile const int times;
这是 C/C++编程里的一个小梗: 易变的恒定常量,听起来好矛盾,但合法,甚至推荐。
“易变的”,“不变的” ? 女人嘴里的爱情吧……
“volatile”的原理是:告诉编译器,不要假定相关数据(变量或常量)是不变的,让编译器产生一段在运行时,CPU也必须乖乖地从内存去读取这个数据的值的代码。
这里的表达,透露了一个计算机原理:CPU 读取某一变量,并不是每次都会乖乖地读取这个变量所在的内存;这一点 《白话 C++》的基础章节有提到。
那么,volatile 可不可以用于代替上述多线程程序中的加锁呢?答,不可以。volatile 只是保障CPU读取某一数据必须从源头上(数据所在内存位置)读取,并没有解决CPU在什么时间、什么次序去读取。