为了实现一个类的不同对象之间的数据和函数共享_你好,C++(83)12.3.3 利用mutex处理线程之间的共享资源竞争...

6835f9054e42b00235506f618e62d6ad.png
想要抢先看后面的章节?打赏本文10元,即可获得带插图全本下载地址!打赏完成记得私信我哦 :p

12.3.3 利用mutex处理线程之间的共享资源竞争

通过前面的学习,我们学会了利用thread创建线程来执行线程函数,学会了利用函数参数向线程函数传递数据,也学会了利用future和promise来获得线程函数的结果数据,似乎我们只用了20%的技术就已经完成了多线程开发中80%的任务。可是,别高兴得太早,二八原则告诉我们,用来完成那剩下的20%的任务,我们不得不用上那剩下的80%的技术。

前面我们所遇到的多线程应用场景,都只是各个线程各自独立地访问自己的资源,各个线程之间并没有共享资源,也就不存在共享资源的竞争。然而在更多时候,多个线程之间往往需要共享资源,比如它们都需要访问某个共享的容器,这时就存在一个共享资源竞争的问题。当多个线程各自独立地同时访问某个共享资源时,将导致未定义的结果。比如,某个线程正在向某个容器插入数据的同时,另外一个线程正在从容器中读取数据,两个线程将在谁先完成对容器的操作上进行竞争。其最后的结果是不可预料的,有可能读操作失败,也可能写操作失败,更有可能读写都失败。这种不确定的行为对总是按照条条框框做事的程序来说是绝不容许的。为此,C++11专门提出了多种措施来处理线程之间共享资源的竞争,以保证在任何时刻都只有唯一的一个线程对共享资源进行操作,从而确保其操作行为结果的确定性和唯一性。在这些措施当中,最简单也是最常用的就是互斥机制。互斥机制主要通过mutex类来实现。我们首先在程序中创建一个全局的mutex对象,然后通过在线程函数中先后调用这个对象的lock()成员函数和unlock()成员函数来形成一个代码区域,通常称为临界区。而互斥机制保证了在任何时刻,最多只能有一个线程进入到lock()函数和unlock()函数之间的临界区执行其中的代码。当第一个线程正在临界区执行时,临界区处于锁定状态。如果后续有执行到lock()函数临界区开始位置的线程将会被阻塞,进入线程队列等待,直到第一个线程执行到unlock()函数临界区结束离开临界区,解除了临界区的锁定,其他处于线程队列中等待的线程才会按照先进先出(FIFO,First In,First Out)的规则进入临界区执行,而一旦有线程进入临界区,临界区又会被再次锁定。其他未进入临界区的线程只有在线程队列中继续等待,直到它的机会到来。由于互斥对象所确定的临界区每次只能有一个线程进入执行,如果我们将对共享资源的访问放到临界区来进行,这样就能保证每次只有一个线程在临界区对共享资源进行访问,也就避免了共享资源被多个线程同时访问的问题。例如,在前面的餐馆中有两个厨子,他们都会将炒好的菜交给一个服务员让她端给客人,这里,服务员就成了共享资源,为了避免两个厨子同时让服务员端菜,我们用mutex对象为她设立一个临界区,让她在临界区工作:

#include <mutex> // 引入mutex所在的头文件
#include <queue> // 引入queue容器所在的头文件
// 全局的互斥对象
mutex m;
// 全局的queue容器对象quFoods
// 线程函数会将炒好的菜push()到quFoods容器,所以它表示服务员
queue<Food> quFoods;
// 线程函数,创建临界区访问共享资源quFoods
void Cook(string strName)
{
 // 炒菜…
// 这些不涉及共享资源的动作是可以放在临界区之外多个线程同时进行的
Food food(strName);
  
m.lock(); // 临界区开始
// 对共享资源的操作
quFoods.push(food);// 将food对象添加到共享的容器中
m.unlock(); // 临界区结束
}
 
int main()
{ 
 thread coWang(Cook,"回锅肉"); // 王厨子炒回锅肉
  thread coChen(Cook,"盐煎肉"); // 陈厨子炒盐煎肉
// 等待厨子炒完菜… 
coWang.join();
 coChen.join();
// 输出结果
 cout<<"两位厨子炒出了"<<endl;
// 输出quFoods容器中所有Food对象的名字
// 这里只有主线程会执行,所以对共享资源的访问不需要放在临界区 
while(0 != quFoods.size() )
 {
 cout<<quFoods.front().GetName()<<endl;
 quFoods.pop(); // 从容器中弹出最先进入队列的Food对象
 }
 
 return 0;
}  

在这里,我们首先创建了一个全局的mutex对象m以及一个共享资源quFoods容器,然后在线程函数Cook()中,我们用m的lock()函数和unlock()函数形成了一个临界区。因为Food对象的创建不涉及共享资源,各个线程可以各自独立地进行,所以我们把Food对象的创建工作放在临界区之外进行。当Food对象创建完成需要添加到quFoods容器时,就涉及到了对共享资源quFoods的操作,就需要放到临界区来进行以保证任何时刻只有唯一的线程对quFoods进行操作。当多个线程在执行线程函数Cook()时,其流程如下图12-6所示:

a67f52aa11167f3edfd10e644d466661.png

图12-6 临界区的执行流程

从这里我们可以看到,互斥对象mutex的使用非常简单。我们只需要创建一个全局的mutex对象,然后利用其lock()函数和unlock()函数划定一个临界区,同时将那些对共享资源的访问放到临界区就可以保证同一时刻只有唯一的一个线程对共享资源进行访问了。互斥对象的使用是一件简单的事情,但是用好互斥对象却没那么简单。如果我们错误地使用了互斥,比如,一个线程只执行了lock()函数锁定了临界区,但因为某种原因(出现异常或者是长时间被阻塞)而没有执行相应的unlock()函数解除临界区的锁定,那么其他线程将再也无法进入临界区,从而整个程序都会被阻塞而失去响应。所以,我们在使用mutex对象的时候必须小心谨慎,要不然就很容易造成线程死锁而给程序带来灾难性的后果。同时,这种错误也难以发现,程序员往往因此而陷入水深火热之中。

为了挽救程序员于水火,C++11提供了多种措施来避免这种灾难的发生。其中最简单的是mutex对象的try_lock()函数,利用它,我们可以在锁定临界区之前进行一定的尝试,如果当前临界区可以锁定,则锁定临界区而进入临界区执行。如果当前临界区已经被其他线程锁定而无法再次锁定,则可以采取一定的措施来避免线程一直等待而形成线程的死锁。例如:

void Cook(string strName)
{
 Food food(strName);
 
// 尝试锁定临界区
if(m.try_lock())
 {
 quFoods.push(food);
 m.unlock(); // 解除锁定
 }
 else
 {
 // 在无法锁定临界区时采取的补救措施
cout<<"服务员这会儿太忙了,炒好的菜先放放"<<endl;
 }
}

除了try_lock()函数,C++11还提供了recursive_mutex互斥对象,它可以让同一线程多次进入某一临界区,从而巧妙地解决了函数递归调用中对同一个互斥对象多次执行lock()操作的问题。除此之外,C++11还提供了timed_mutex互斥对象,利用它的try_lock_for()函数和try_lock_until()函数,可以让线程只是在某段时间之内或某个固定时间点之内尝试锁定,这样就对线程等待锁定临界区的时间做了限制,从而避免了线程在异常情况下无法锁定临界区时还长时间地等待,也就有机会采取措施解决问题。与timed_mutex相对应地,C++11中也有recursive_timed_mutex对象,其使用方法与前两者类似。这些辅助性的互斥对象各有各的用途,根据我们的应用场景而选择合适的互斥对象,是利用互斥对象解决共享资源竞争问题的前提。

借助于C++11所提供的这些辅助性策略,我们可以很好地避免线程因长时间等待进入临界区而形成线程死锁。但是,这些方法都只是亡羊补牢的方法,让我们在临界区无法锁定的情况下有机会采取措施解决问题。而且,临界区无法锁定的情况往往是程序员自己造成的,很多时候,我们只是调用lock()函数锁定了临界区,但忘了调用unlock()函数来解除临界区的锁定,或者是因为程序逻辑的问题而跳过了unlock()函数的调用,最终导致lock()和unlock()的不匹配,才造成了临界区无法锁定的情况。《黄帝内经》上说,圣人“不治已病治未病”,与其在出现临界区无法锁定的情况下采取补救措施解决问题,不如事先就管理好互斥对象,让它的lock()和unlock()完全配对,也就不会出现临界区无法锁定的情况了。为此,C++11的标准库中专门地提供了锁(lock)对象用于互斥对象的管理,其中最简单也最常用的就是lock_guard类。

lock_guard类实际上是一个类模板,我们只需要在合适的地方(需要mutex互斥对象锁定的地方),首先使用需要管理的互斥对象类型(比如,mutex或timed_mutex等)作为其类型参数特化这个类模板而得到一个特定类型的模板类,然后使用一个互斥对象作为其构造函数参数而创建一个lock对象,从此这个lock对象与这个互斥对象建立联系,lock对象开始对互斥对象进行管理。当以互斥对象为参数创建lock对象的时候,它的构造函数会自动调用互斥对象的lock()函数,锁定它所管理的互斥对象。而当代码执行离开lock对象的作用域时,作为局部变量的lock对象会被自动释放,而它的析构函数则会自动调用互斥对象的unlock()函数,自动解除它所管理的互斥对象的锁定。通过lock对象的帮助,借助其构造函数和析构函数的严格匹配,互斥对象的锁定(lock())和解锁(unlock())也同样做到了自动地严格匹配,从此让程序员们再也不用为错过调用互斥对象的unlock()函数造成线程死锁而头疼了。利用lock对象,我们可以将上一小节的例子改写为:

// 使用lock_guard来管理mutex对象
void Cook(string strName)
{
Food food(strName);
// 使用需要管理的mutex对象作为构造函数创建lock对象
// 构造函数会调用mutex对象的lock()函数锁定临界区
lock_guard<mutex> lock(m);
// m.lock(); // 不用直接调用mutex对象lock()函数
// 对共享资源的访问
quFoods.push(food);
// lock对象被释放,其析构函数被自动调用,
// 在其析构函数中,会调用mutex对象的unlock()函数解除临界区的锁定
// m.unlock();
}

在这里,我们创建了一个lock_guard对象lock对mutex对象m进行管理,当lock对象被创建的时候,其构造函数会调用m的lock()函数锁定临界区,而当线程函数执行完毕后,作为局部变量的lock对象会被自动释放,其析构函数也会被自动调用,而在其析构函数中,m对象的unlock()函数会被调用,从而随着lock对象的析构自动地解除了临界区的锁定。这样,利用局部变量lock对象构造函数和析构函数相互匹配的特性,就自动完成了mutex对象的lock()与unlock()的匹配,很大程度上避免了因lock()和unlock()无法匹配而形成的线程死锁问题。

除了只提供构造函数和析构函数的lock_guard类之外,C++11标准库还提供了拥有其他成员函数的unique_lock类,从而让我们可以对锁对象进行更多的控制。比如,我们可以利用它的try_lock()函数尝试锁定它所管理的互斥对象,也可以用try_lock_for()函数在某一段时间内尝试锁定等等,其使用方法与互斥对象相似。

另外需要注意的是,无论是互斥对象还是锁对象,某种意义上它们都代表着某种共享资源的所有权,它们往往是一一对应的。另外对于互斥对象而言,只有当它对至少两个线程可见时,它才是有意义的,所以它往往是全局的。而对于锁对象,我们需要利用它的构造函数和析构函数来完成临界区的锁定与解锁,所以它往往是局部的。因为共享资源的唯一性,也同样决定了互斥对象和锁对象的唯一性。所以它们都是不可以被复制的,因为共享资源只有一个,如果它们被复制,我们就无法确定到底哪一个副本应该拥有这唯一的共享资源,从而造成所属关系上的混乱。但是,它们是可以被移动的,也就是相当于我们将这个资源的所有权从一个局部环境转移或共享到了另外一个局部环境。

到这里,我们学习了C++11中关于多线程开发的大部分技术,我们知道了如何利用thread对象创建线程执行某个线程函数,知道了如何利用future和promise对象在线程之间传递数据,也知道了如何利用mutex对象来管理线程之间共享资源的访问竞争。可以说,利用C++11所提供的这些技术创建一个多线程的程序是十分容易的,也可以再次吃到那份美味的免费午餐。但是,因为多线程程序在逻辑上的复杂性,搞不好就会出现线程死锁等严重影响性能的问题,所以要想把多线程程序做好,却却又没有那么容易。C++11所提供的这些支持多线程开发的技术,就像一把锋利的刀子,用好了削铁如泥,没用好也容易伤到自己。所以我们唯一的办法就是不断地去实践,从实践中积累经验。当我们手上的伤疤足够多的时候,我们自然也就能把手上的锋利刀子运用自如了。

知道更多:OpenMP——thread对象之外的另一种选择

程序员大约是这个世界上最懒的一类人了。虽然利用thread对象可以轻松简便地将一个程序并行化,可程序员们仍不满足。他们觉得,虽然有了thread对象,创建线程是简单了很多,可是依然需要他们去创建thread对象,依然需要他们去利用mutex对象处理线程之间共享资源的竞争,在将一些单线程程序改写为多线程程序时,甚至还需要他们对算法进行重新设计。他们在想,有没有一种方法可以自动将一个单线程程序并行化而无需程序员做什么额外的工作?
这个世界的发展一定是被懒人推动的。正是因为程序员们有这种懒人想法,才有了OpemMP(open multi-processing)这种懒人多线程方案。它是一套支持跨平台的、用于共享内存并行系统的多线程程序设计的编译指令、函数和一些能够影响运行行为的环境变量,目前已经受到主流编译器的支持。
OpenMP提供对并行算法的高层抽象的描述,程序员只需要通过在原始的串行代码中加入专用的编译指令来指明他们的意图,编译器就会根据这些指令自动地创建线程、分配线程任务、处理共享资源竞争,从而几乎是全自动地完成程序的并行化。当选择忽略代码中的这些编译指令时,或者是编译器不支持OpenMP时,程序又可退化为原始的串行代码。这样就做到了进可攻退可守,极大地增加了代码的灵活性。
要想在程序中使用OpenMP非常简单,只需要在编译器选项中启用对OpenMP的支持(例如,gcc编译器使用-fopenmp,Visual C++编译器使用/openmp),并在代码中引入OpenMP的头文件,然后就可以在原始代码中那些可以并行执行的代码(比如,某个for循环,某个对数组的操作等)前加入相应的OpenMP编译指令来将程序并行化。下面看一个简单的例子:

// 引入OpenMP的头文件
#include <omp.h>
 
using namespace std;
 
void foo()
{
 // …
}
int main()
{
 // 用pragma指令指明这是一个可以并行执行的for循环
 // 编译器会根据这些指令自动创建多个线程,
 // 对for循环进行相应的并行处理
 #pragma omp parallel for
 for (int i = 0; i < 100; ++i)
 foo();
 
 return 0;
}

在这里,我们只是简单地用一个pragma编译指令告诉编译器接下来的for循环是一个可以并行处理的for循环,编译器就会根据程序员的这个意图表达自动地创建多个线程并行地执行这个for循环。在整个过程中,程序员只需要使用编译指令告诉编译器“嘿,下面这个for循环需要并行执行”,然后编译器就会自动为我们创建线程来完成for循环的并行执行,根本不用我们操心。想干啥就有人去帮你干,这恐怕是懒人的最高境界了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值