标准库多线程编程 – 互斥问题

DinS          Written on 2017/9/4

本文开始探讨如何使用标准库解决线程互斥问题,确保阅读过《标准库多线程编程 – 基础》,并且理解互斥的概念,有关讨论可见《Windows多线程编程 – 互斥问题》。

一、线程通信问题之互斥(基本)

使用c++的线程同样会遇到线程间通信问题,这一点都不奇怪。
先来看看互斥问题,有代码如下:

大图点击这里查看

开辟多个线程操作同一个全局变量,必然产生问题,某一次运行结果如下。

c++11提供了<mutex>来解决互斥问题。在该头文件里提供了丰富的互斥操作,先来看一个最简单的使用方法

大图点击这里查看

非常简单,跟上例比只增加了三行代码:声明一个全局互斥量,然后在线程中上锁,执行完递增后再解锁。
效果如下:

经过多次试验,都是500,这说明我们的目的达到了。接下来就是具体讲讲原因了。
互斥的原理见《Windows多线程编程 – 互斥问题》。
这里用到了mutex的两个函数,实际上还有一个有用的try_lock。
lock(),调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:(1). 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。(2). 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
unlock(), 解锁,释放对互斥量的所有权。
try_lock(),尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况,(1). 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。(2). 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

在上例中,当某个线程调用lock时,如果该锁已被其他线程获得,就阻塞在那里,直到该锁被释放。于是我们保证了++g_Count时只有一个线程在操作。
另外从解释中可以看出lock和try_lock的区别就是是否阻塞线程,比如我们可以把线程改成如下:

这样每个线程会尝试互斥量,如果没被占用,就获得所有权,递增。如果有其他线程在占用,就什么也不做。这样的结果当然是g_Count会小于500。

现在有必要跟windows提供的互斥操作做一个对比。我们知道在windows里有临界区和互斥量两个东西都可以提供互斥操作,可以一块拿来对比。

1.使用便捷度

c++提供的mutex无疑更加便捷,不需要初始化和销毁,这项工作应该是在构造函数和析构函数中完成了。
windows的临界区和互斥量都需要显式初始化和销毁。

2.使用情况

不管是c++还是windows,互斥操作的实质内容都差不多,一头一尾卡出一段代码区域。
不过三者还是有细微区别,c++的最基本的mutex与临界区类似,不过多了一个try_lock。
windows的互斥量可以使用waitforsingleobject设定等待时间,并且可以跨越进程使用(因为是内核对象),功能更强大。
不过c++肯定是考虑到等待时间这一点了,接下来要介绍timed_mutex可以实现等待特定时间。

3.效率

c++的mutex综合来说效率更高,至少比windows的互斥量运行起来要快。目测其效率跟临界区差不多,这也可以理解,因为c++的mutex不是内核对象。

4.其他功能

刚刚介绍的mutex只是c++中的最基本的互斥,c++还提供了其他类型的mutex完成更多功能,这些功能使用好了可以比windows提供的互斥操作更强大。

二、线程通信问题之互斥(扩展)

1.多层互斥锁

如果一个已经获得互斥量所有权的线程再次对该互斥量上锁,会发生什么?
很显然这是一个不太正常的操作,最好的情况就是什么也不发生,最坏的情况会产生死锁。

c++为了实现这种特殊的要求,提供了一个recursive_mutex类。这个类的操作与mutex完全一致,唯一区别是允许同一个线程对互斥量多次上锁(即递归上锁,recursive出处)。换句话说,同一个线程可以多次lock这个recursive_mutex对象,当然记得执行同样数量的unlock即可

有什么用呢?暂时还没想到具体的例子,等想出来了再补充。

2.定时互斥锁

windows里的互斥量操作可以定时,c++当然不能落后。c++提供了timed_mutex类,除了mutex的基本操作之外,timed_mutex还可以try_lock_for和try_lock_until 。

try_lock_for跟waitforsingleobject功能一致,设定一个时间范围,在时间范围内该线程如果没有获得锁则被阻塞。如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false
看一个具体的例子应该更明了。

大图点击这里查看

运行结果:

程序很简单,开辟50个线程,都在100毫秒时限内获取锁,根据是否获取到输出不同内容。输出结果应该是make sense的,由于一开始都在等待100毫秒,而获取到锁的会再等待10毫秒,因此一开始会输出若干条Got lock。在100毫秒过去后,等待的大量线程全部到时,输出Failed to get lock。在接下来的时间内,会有一个线程解锁,然后某个线程会得到锁,输出一条Got lock。

try_lock_until顾名思义就是一直等到某个时间点。不过需要配合chrono头文件里的time_point,稍微有些复杂。这里就不做介绍了,可以看cplusplus reference。

3.多层定时互斥锁

简单说就是recursive_mutex和timed_mutex的结合体。
得到recursive_timed_mutex类。功能也是前二者的叠加。一般用不上吧?

4.使用mutex更加偷懒的方法

程序员总是想偷懒的,能少写一句就少写一句。而且因为使用mutex总需要记得unlock,很有可能代码一长写着写着就忘了,导致逻辑出现问题。c++考虑到了这一点,并且提供了一个unique_lock方便程序员偷懒。

unique_lock在线程中使用,从一个普通的mutex构造而来。其功能是在该unique_lock的生命周期内自动负责该普通mutex的lock和unlock,这样程序员就从其中解脱出来了。
并且unique_lock可以保证即使在有异常抛出时普通mutex仍然可以unlock掉。
不过注意unique_lock的效果只持续到其析构。对于普通mutex的更长的生命周期没有作用。
来看一段代码:

这段代码应该似曾相识了,就是在上一部分做的示例。
那时我们使用了g_mtx.lock和g_mtx.unlock手动管理互斥锁,现在直接使用unique_lock自动管理。
运行结果:

说明成功了,原理其实很简单。
我们定义unique_lock后,其对g_mtx自动上锁,等线程ThreadFun结束时该unique_lock析构,对g_mtx自动解锁。

另外c++还提供了一个lock_guard,使用方法跟unique_lock一样,功能也是类似的,区别就是lock_guard没有成员函数,但是unique_lock有5个成员函数:lock/try_lock/try_lock_for/try_lock_until/unlock
虽然没有在示例中使用,但是可以看到这些方法就是之前介绍的mutex和timed_mutex函数。
也就是说,unique_lock除了可以自动管理锁,也提供了手动管理的空间。

比如说我们可以把timed_mutex那个例子改为使用unique_lock:

大图点击这里查看

同样可以实现目的。

应该注意到unique_lock的构造函数跟刚刚不太一样,多了一个参数。如果换成只有一个参数的,运行时会报错。
实际上unique_lock对互斥锁的第一次操作就是通过构造函数的第二个参数完成的。
简单的来说,如果没有第二个参数,则默认执行互斥锁的lock成员函数,即上锁。
如果第二个参数是defer_lock,则不进行任何操作,且认为当前线程没有拥有该锁。
如果第二个参数是adopt_lock,则不进行lock操作,并且认为当前线程已经拥有该锁。
如果第二个参数是try_to_lock,则调用互斥锁的try_lock成员函数。
如果第二个参数是duration类型(比如chrono::seconds(5)),则调用互斥锁的try_lock_for成员函数。
如果第二个参数是time_point类型,则调用互斥锁的try_lock_until成员函数。

也就是说,上述代码可以改写成如下形式:

大图点击这里查看

达到的效果是一样的,即unique_lock在构造后就调用try_lock_for,然后根据是否获得了锁输出不同内容。

可以这样理解:unique_lock相当于代理了真正的mutex,我们通过unique_lock来执行真正mutex的成员函数。
获得的好处是unique_lock帮助我们管理真正mutex的lock和unlock状态,并且能够处理异常。
由此说来unique_lock真是程序员的福音。

三、原子操作

之前一直在说互斥量,应该回想起windows里还提供了原子操作用于简化某类互斥问题。c++必然也是提供了类似功能的,在头文件<atomic>里。
原子操作既然仅仅针对某个对象,应该达到高效率和代码简洁的效果,否则就是失败的。
下面看一个示例:

结果:

即正确又快速还简洁,实际上已经不能比这个更简洁了。
代码中只有一个++g_Count,跟操作普通变量一模一样。

下面解释一下atomic,这是一个模板,定义的写法就更示例中一样。
不过我们看到的简洁性其实是经过修饰了的,具体而言,这里发生了称为模板特化的现象。

当定义的atomic中的模板为泛int类型或者指针时,c++会提供一系列便捷的操作,这包括++/–,以及fetch_add、fetch_sub等。
比如说这里写g_Count.fetch_add(1);就等同于++g_Count;
更多的操作可以参考cplusplus reference。

为什么提供这些便捷操作呢?因为int型太常用了。
原始的操作是什么?
主要有三个:

store-把一个值存入原子对象

load-从原子对象读取值

exchange-把一个值存入原子对象,得到之前的值

一般而言使用特化的原子对象够用了。
另外<atomic>里还有atomic_flag和memory_order,但我感觉这些不太常用或者反而让简单问题变得复杂,所以这里就不介绍了。

四、关于互斥问题的小结

c++提供的互斥操作绝对不比windows提供的差,并且使用起来很方便。
对于操作变量之类的问题,首选<atomic>。
如果需要使用互斥锁,首选unique_lock,一是方便,二是可以跟conditional_varible配合,下面会说到。

接下来将探讨如何使用标准库解决线程同步问题,是比较难的一块,见《标准库多线程编程 – 同步问题

 

参考资料:http://www.cnblogs.com/haippy/p/3284540.html