C++多线程编程时的数据保护
在编写多线程程序时,多个线程同时访问某个共享资源,会导致同步的问题,这篇文章中我们将介绍C++11多线程编程中的数据保护。
数据丢失
让我们从一个简单的例子开始,请看如下代码:
#include<iostream> #include<string> #include<thread> #include<vector> usingstd::thread; usingstd::vector; usingstd::cout; usingstd::endl; classIncrementer { private: intcounter; public: Incrementer():counter{0}{}; voidoperator()() { for(inti=0;i<100000;i++) { this->counter++; } } intgetCounter()const { returnthis->counter; } }; intmain() { //Createthethreadswhichwilleachdosomecounting vector<thread>threads; Incrementercounter; threads.push_back(thread(std::ref(counter))); threads.push_back(thread(std::ref(counter))); threads.push_back(thread(std::ref(counter))); for(auto&t:threads) { t.join(); } cout<<counter.getCounter()<<endl; return0; }
这个程序的目的就是数数,数到30万,某些傻叉程序员想要优化数数的过程,因此创建了三个线程,使用一个共享变量counter,每个线程负责给这个变量增加10万计数。
这段代码创建了一个名为Incrementer的类,该类包含一个私有变量counter,其构造器非常简单,只是将counter设置为0.
紧接着是一个操作符重载,这意味着这个类的每个实例都是被当作一个简单函数来调用的。一般我们调用类的某个方法时会这样object.fooMethod(),但现在你实际上是直接调用了对象,如object().因为我们是在操作符重载函数中将整个对象传递给了线程类。最后是一个getCounter方法,返回counter变量的值。
再下来是程序的入口函数main(),我们创建了三个线程,不过只创建了一个Incrementer类的实例,然后将这个实例传递给三个线程,注意这里使用了std::ref,这相当于是传递了实例的引用对象,而不是对象的拷贝。
现在让我们来看看程序执行的结果,如果这位傻叉程序员还够聪明的话,他会使用GCC4.7或者更新版本,或者是Clang3.1来进行编译,编译方法:
g++-std=c++11-lpthread-othreading_examplemain.cpp
运行结果:
[lucas@lucas-desktopsrc]$./threading_example 218141 [lucas@lucas-desktopsrc]$./threading_example 208079 [lucas@lucas-desktopsrc]$./threading_example 100000 [lucas@lucas-desktopsrc]$./threading_example 202426 [lucas@lucas-desktopsrc]$./threading_example 172209
但等等,不对啊,程序并没有数数到30万,有一次居然只数到10万,为什么会这样呢?好吧,加1操作对应实际的处理器指令其实包括:
movlcounter(%rip),%eax addl$1,%eax movl%eax,counter(%rip)
首个指令将装载counter的值到%eax寄存器,紧接着寄存器的值增1,然后将寄存器的值移给内存中counter所在的地址。
我听到你在嘀咕:这不错,可为什么会导致数数错误的问题呢?嗯,还记得我们以前说过线程会共享处理器,因为只有单核。因此在某些点上,一个线程会依照指令执行完成,但在很多情况下,操作系统会对线程说:时间结束了,到后面排队再来,然后另外一个线程开始执行,当下一个线程开始执行时,它会从被暂停的那个位置开始执行。所以你猜会发生什么事,当前线程正准备执行寄存器加1操作时,系统把处理器交给另外一个线程?
我真的不知道会发生什么事,可能我们在准备加1时,另外一个线程进来了,重新将counter值加载到寄存器等多种情况的产生。谁也不知道到底发生了什么。
正确的做法
解决方案就是要求同一个时间内只允许一个线程访问共享变量。这个可通过std::mutex类来解决。当线程进入时,加锁、执行操作,然后释放锁。其他线程想要访问这个共享资源必须等待锁释放。
互斥(mutex)是操作系统确保锁和解锁操作是不可分割的。这意味着线程在对互斥量进行锁和解锁的操作是不会被中断的。当线程对互斥量进行锁或者解锁时,该操作会在操作系统切换线程前完成。
而最好的事情是,当你试图对互斥量进行加锁操作时,其他的线程已经锁住了该互斥量,那你就必须等待直到其释放。操作系统会跟踪哪个线程正在等待哪个互斥量,被堵塞的线程会进入"blockedonm"状态,意味着操作系统不会给这个堵塞的线程任何处理器时间,直到互斥量解锁,因此也不会浪费CPU的循环。如果有多个线程处于等待状态,哪个线程最先获得资源取决于操作系统本身,一般像Windows和Linux系统使用的是FIFO策略,在实时操作系统中则是基于优先级的。
现在让我们对上面的代码进行改进:
#include<iostream> #include<string> #include<thread> #include<vector> #include<mutex> usingstd::thread; usingstd::vector; usingstd::cout; usingstd::endl; usingstd::mutex; classIncrementer { private: intcounter; mutexm; public: Incrementer():counter{0}{}; voidoperator()() { for(inti=0;i<100000;i++) { this->m.lock(); this->counter++; this->m.unlock(); } } intgetCounter()const { returnthis->counter; } }; intmain() { //Createthethreadswhichwilleachdosomecounting vector<thread>threads; Incrementercounter; threads.push_back(thread(std::ref(counter))); threads.push_back(thread(std::ref(counter))); threads.push_back(thread(std::ref(counter))); for(auto&t:threads) { t.join(); } cout<<counter.getCounter()<<endl; return0; }
注意代码上的变化:我们引入了mutex头文件,增加了一个m的成员,类型是mutex,在operator()()中我们锁住互斥量m然后对counter进行加1操作,然后释放互斥量。
再次执行上述程序,结果如下:
[lucas@lucas-desktopsrc]$./threading_example 300000 [lucas@lucas-desktopsrc]$./threading_example 300000
这下数对了。不过在计算机科学中,没有免费的午餐,使用互斥量会降低程序的性能,但这总比一个错误的程序要强吧。
防范异常
当对变量进行加1操作时,是可能会发生异常的,当然在我们这个例子中发生异常的机会微乎其微,但是在一些复杂系统中是极有可能的。上面的代码并不是异常安全的,当异常发生时,程序已经结束了,可是互斥量还是处于锁的状态。
为了确保互斥量在异常发生的情况下也能被解锁,我们需要使用如下代码:
for(inti=0;i<100000;i++) { this->m.lock(); try { this->counter++; this->m.unlock(); } catch(...) { this->m.unlock(); throw; } }
但是,这代码太多了,而只是为了对互斥量进行加锁和解锁。没关系,我知道你很懒,因此推荐个更简单的单行代码解决方法,就是使用std::lock_guard类。这个类在创建时就锁定了mutex对象,然后在结束时释放。
继续修改代码:
voidoperator()() { for(inti=0;i<100000;i++) { lock_guard<mutex>lock(this->m); //Thelockhasbeencreatednow,andimmediatlylocksthemutex this->counter++; //Thisistheendofthefor-loopscope,andthelockwillbe //destroyed,andinthedestructorofthelock,itwill //unlockthemutex } }
上面代码已然是异常安全了,因为当异常发生时,将会调用lock对象的析构函数,然后自动进行互斥量的解锁。
记住,请使用放下代码模板来编写:
voidlong_function() { //somelongcode //Justapairofcurlybraces { //Tempscope,createlock lock_guard<mutex>lock(this->m); //dosomestuff //Closethescope,sotheguardwillunlockthemutex } }