Windows多线程编程 – 互斥问题

DinS          Written on 2017/8/26

本文进入多线程编程的一个重要领域:互斥。请确保阅读过《Windows多线程编程 – 基础》。

一、线程间通信问题之互斥(mutual exclusion)

在多线程编程中,我们需要确保多个线程在关键活动中不会交叉,这就是所谓的互斥(mutual exclusion)。比如说,有多个线程要访问并修改同一个文本文件,如果不进行处理的话,这个文本文件肯定要乱套。这种问题最经典的表现就是多个线程同时访问、操作同一个全局变量。

先来看这样一段代码:

很简单,设置一个全局变量g_nCount。开辟500个线程,每一个线程就是简单地对g_nCount递增,最后等全部线程执行完毕后,输出g_nCount。
我们期待着g_nCount应该是500,运行结果如下:

what happened?
还是需要使用汇编的知识来解释。简单的一条++g_nCount对应3句汇编语句:
将g_nCount从内存地址中取出放到寄存器里;对寄存器的值加一;将寄存器的值放回内存地址中。

问题就出在取出并放回的过程中。
试想现在g_nCount在内存中为300,线程A将其取出并加一。与此同时线程B在A还未归还寄存器的值之前,将内存中的g_nCount取出了,是300。现在A归还了寄存器的值,则现在g_nCount在内存中是301.现在B对寄存器加一,得到301,并归还。结果是A、B两个线程执行完毕后,g_nCount的值是301

有几种手段可以解决互斥问题:临界区、原子操作、互斥量。下面分别介绍。

二、临界区 CRITICAL_SECTION

所谓临界区(也可以叫做关键段),就是我们定义临界区对象,然后在代码中指定进入和离开临建区的代码区域,系统保证各线程互斥地进入临界区。

我们把刚刚那个子线程函数改成这样,就定义了临界区。

结果如下

为什么不行?
从概念上来说,++g_nCount明明已经是临界区了,按理说操作g_nCount已经不会被打断了,为什么还是有问题?

要回答这个问题需要深入CRITICAL_SECTION本身的实现机制。
临界区存在“线程所有权”的概念。其实现机制是当某个线程进入了临界区,则该临界区的所有权变为该线程,此时如果其他的线程试图进入该临界区,操作系统会检查临界区的所有权,如果试图进入的线程与所有权不同,会被阻止。直到最初的线程退出该临界区,其他的线程才能再次进入。
从实现机制可以得到一个推论:已经进入临界区的线程可以反复进入临界区

明白了实现机制,可以看出上述代码有两个问题。第一个是临界区不能定义在子线程中。如果临界区对其他线程不可见,则没有意义。
第二个是自始至终都是相同的线程ThreadFun在进入临界区,因此这个临界区形同虚设。
(补:上例用for开了500个线程,按理说每个线程ID都是不同的,这说明临界区检查的不是线程ID,而是函数地址)
实际上上例根本不是多线程的范围,现在让我们写两个不同的线程,重新来模拟互斥问题。

现在有A、B两个线程同时操作g_nCount,线程内部循环增加了sleep,以便岔开操作次序,实现互斥问题。如果不加sleep,你几乎遇不到输出不是1000的情况

运行结果如下(并不是每次都这样):

现在我们为代码加入临界区,如下:

注意A和B的代码内都有临界区出入。

如果B中没有临界区出入代码,则A中的临界区也不会起作用,结合临界区实现机制应该很好理解。
假设现在只有A中有进出临界区代码,那么对于A而言,操作系统检查线程,发现是A,允许执行。对于B而言,没有临界区进出的事,直接执行,这样的结果等同于没有定义临界区,两个线程都同时执行。

经过几十次的测试,输出都是1000,问题解决。如果还没有理解为什么,再去看一遍临界区实现原理。

总结一下临界区:

1.临界区并没有锁定资源。对临界区最恰当的理解是划出了一个代码范围,对于要执行范围内代码的线程先“检查证件”
2.临界区对相同线程不起任何作用
3.临界区的作用域会很大,这是因为定义了的临界区必须要在所有相关线程中都出现才能起到效果,那么全局范围是最直接

由于将线程切换到等待状态的开销较大,因此为了提高临界区的性能,Microsoft将旋转锁合并到临界区中,出现了InitializeCriticalSectionAndSpinCount和SetCriticalSectionSpinCount函数。旋转次数一般设置为4000。
据说临界区配合旋转锁有助于提高性能,我反正没有尝试,更倾向于使用标准库的工具。

三、原子操作

在互斥中,不同线程对同一个资源的操作特别频繁,以至于使用一大堆关键段显得非常繁琐,这是程序员不能接受的。为了处理这种特定的互斥问题,windows提供了原子操作。使用原子操作可以大大简化代码的书写

以上例的情况为蓝本,不使用临界区而使用原子操作。
首先声明全局变量

volatile long g_nCount;

这里的问题是volatile是什么?
这是一个修饰语,跟const属于一个层面的。const即constant,告诉编译器这个是不变的量,任何企图修改const的操作都是非法的,编译器应该拒绝这些操作并告知程序员。
volatile的意思有很多,这里使用的是飘忽不定的意思。这是什么意思呢?在编译代码时,编译器会进行优化,比如写了如下代码:
int a = 0;
a = 1;
a = 2;
a = 3;
这是4行代码,但是编译器会发现中间的两行没有任何意义,所以在生成汇编的时候会直接忽略掉中间两行,但是如果变量加了volatile修饰词,则编译器不会进行任何优化。

volatile解释清楚了,但是为什么要加这个修饰词呢?
直接原因是原子操作的函数接受的参数必须是volatile型,所以我们必须这么写
更深入的原因是多线程中一个变量的值可能是飘忽不定的,如果编译器进行了优化可能中间会错过一些值变更的情况

原子操作一共就4个函数
1.增减操作
LONG __cdecl InterlockedIncrement(LONG volatile* Addend);
LONG __cdecl InterlockedDecrement(LONG volatile* Addend);
返回变量执行增减操作之后的值。
LONG __cdec InterlockedExchangeAdd(LONG volatile* Addend, LONGValue);
返回运算后的值,注意!加个负数就是减。
2.赋值操作
LONG __cdecl InterlockedExchange(LONG volatile* Target, LONGValue);
Value就是新值,函数会返回原先的值。

在这个例子里我们只需要递增,将两个线程中的代码改成如下即可:

很简单,不需要使用复杂的临界区同样实现了效果

四、互斥量 Mutex

Mutex即mutual exclusion的缩写。互斥量是一个内核对象,行为与临界区非常相似,并且因为是内核的,所以可以用来在不同进程中达到互斥效果。

其工作原理就是对象内部维护一个计数器,计数器或为1或为0。在要实现互斥的代码段之前用一个WaitForSingleObject,等待这个互斥量。当互斥量计数器为1时,wait生效。当计数器为0时,代码继续往下走。
那么怎么增减互斥量呢?ReleaseMutex函数可以减计数器。增加的话貌似会自动增加,可能是wait通过后系统会自动记录当前线程并把mutex加1,很类似进入和离开临界区。

依然以两个线程计数问题为蓝本,使用mutex完成任务

大图点击这里

整体的风格跟临界区差不多,区别就是具体的写法了。
两个子线程中同样一头一尾卡出一段代码区,只不过enter变成wait,leave变成release

运行的结果仍然是正常的

但是如果你真的去试试,就会发现明显要比临界区运行的慢,为什么?
直观理解,mutex的功能要比临界区强大,可以做不同进程间互斥,因此底层做的东西更复杂,运行的就更慢。

那就来试试不同进程间的通信吧。
这是第一个程序的代码

这里会遇到字符集的问题。控制台默认是unicode,因此这里CreateMutex的第三个参数接受wchar_t,字符集是另外一个问题,见字符集专题
这里在项目属性里设置成多字节字符集即可。

(注:return前要再加一个system(“Pause”);不然一闪而过)

这是另外一个程序

(注:return前要再加一个system(“Pause”);不然一闪而过)

几个switch-case看起来有些复杂,之后试验时会说明

首先看一个正常的情况,启动第一个程序

再启动第二个程序

第一个程序输入一个字符

第二个程序的显示

这样就使用mutex简单实现了进程间的通信,线程通信也是一样的,这里不展开了。让我们看看异常的结果。如果不打开第一个程序,直接运行第二个程序,那么因为互斥量并未创建,会返回错误结果

另一种异常就是第一个进程中的互斥量并没有释放掉进程就退出了。此时第二个进程不会傻等,系统会发现这种情况并处理,这就是abandoned的含义。
打开第一个程序,再打开第二个程序,此时直接关掉第一个程序,第二个程序显示结果

五、小结

临界区、原子操作、互斥量都能解决进程间互斥问题。
原子操作的适用范围最小,因此效率最高,使用起来也最方便。
如果仅仅是一个进程内处理线程互斥问题,首选临界区,这是轻量级的互斥问题解决方案,效率比互斥量高。
如果要解决不同进程间线程的互斥问题,那只能选择互斥量,这也是互斥量的用武之地。当然使用互斥量还能够简单实现进程间通信

互斥只是线程间通讯的一个问题,另一个更难的问题是同步,关于这方面的讨论见《Windows多线程编程 – 同步问题》。

参考资料:秒杀多线程http://blog.csdn.net/morewindows/article/details/7392749