Windows多线程编程 – 同步问题

DinS          Written on 2017/8/26

本文进入多线程编程最难的地方:同步。确保阅读过《Windows多线程编程 – 互斥问题》。

一、线程间通信问题之同步(synchronization)

所谓同步问题,即确定正确顺序的问题。
比如说,有多个线程要使用打印机,我们必须确定一个先后次序,否则打印出来的就是乱七八糟的东西。

在这里有必要讨论一下同步与互斥之间的关系。应该注意到,如果我们能够把不同的线程排出先后次序,则自然实现了互斥。也就是说,互斥是一种特殊的同步,同步是比互斥要求更高的领域。

回到最初的第一个多线程实例,那里我们开辟了5个线程,分别输出文本串和线程号,但是输出的显示却是混乱的。在我们着手解决前,先探究一下为什么会这样。如果我们使用printf来输出,结果如何呢?

就好了。那为什么用printf就好了呢?

个人理解是,printf是一句完整的代码,因此输出的内容被完整的执行了。
而使用流运算符,虽然我们写在一行里,也只有一个;,但是实际上却是多次输出。流运算之间的顺序被多线程打乱了,因此看到的是乱码。
证据就是打断全部出现在流运算符分隔处,“Hello World from thread”这一句话从没有被打断过

那我们又想使用流运算符,又要输出正常,怎么办?使用同步来解决。

二、事件 Event

事件跟互斥量一样,也是一个内核对象,这意味着可以跨进程使用。这里我们先只看同一进程内的使用方法。

事件通过触发和未触发状态来实现同步。我们对一个事件调用WaitForSingleObject,如果该事件处于未触发状态,则会一直等下去;如果处于触发状态,就继续执行。
使用SetEvent将事件变为触发状态,使用ResetEvent将事件设置为未触发状态

下面看一段代码

我们增加了一个事件来处理输出同步问题。
运行结果如下

成功了,但是为什么呢?
看代码执行流程,在main中将事件初始化为未触发,然后进入while循环。
假设代码此时正在准备新线程,然后碰到了WaitForSingleObject,因为事件并未触发,所以此时会一直等下去。当新线程执行完输出后,调用SetEvent,则事件进入触发,Wait通过,开始准备下一个线程。又因为初始化的事件是自动置位,所以此时又变为未触发,因此必须等待新线程输出完后Wait才能通过。

也就是说,通过事件我们保证了在同一时刻只有一个线程在cout,所以输出的内容是有顺序的。如果在初始化事件时设置为触发,那么结果还会是乱的,读者可以自己试一试。

因为事件是内核对象,所以可以跨进程使用,方法跟互斥量类似,都是通过OpenEvent。

至此我们成功使用了事件完成同步,但是我感觉有必要梳理一下事件、临界区和互斥量的异同,同样还是使用试验的方式。
首先我们将上述代码跟事件有关的地方换成临界区试一试。
核心代码就是子线程,其他的不贴了

结果会如何呢?

结果就是也可以正常输出,cout不会乱。

也就是说,在这个例子里临界区和事件可以互换。换言之,我们其实使用了事件来完成了一个互斥任务。至于为什么可以这里就不分析了,读过之前的文章应该能明白。那么既然临界区可以完成这项任务,互斥量一定也可以。使用事件来处理这个输出问题是大材小用了。
下面看一个必须用到同步的问题

三、信号量 semaphore

信号量由Dijkstra于1965年提出。Semaphore是荷兰语信号灯的意思。
信号量维持了一个整数,可以把这个整数视为可同时调用线程的最大数量。当一个线程操作信号量时,如果大于0,则表示有空余,则信号量减一线程继续执行;如果等于0,则表示已经满负荷,则线程进入等待状态。
一个进程完事后,对信号量release,则信号量加一。
操作系统保证对信号量的整数操作是原子操作,这一点很关键。

当时Dijkstra在论文中使用的是P(Proberen,尝试,表示减一)和V(Verhogen,增高,表示加一),所以看到PV操作的话知道这是指信号量即可。

让我们通过一个问题学习信号量和同步

程序很简单,一个for循环,循环的次数作为线程的编号传给线程,然后线程打印该编号。
运行结果如下

序号顺序是混乱的,并不意外,因为这里没有对线程进行任何处理。
另外这里出现了一个神奇的问题,居然打印出来的序号不是从0到9!
怎么解释这个现象呢?难道是传进去的参数错了?
让我们在开辟线程之前加一句代码看看

结果如下

到底发生了什么事?
现在各个线程输出的序号居然自己排序了!我们没有进行任何同步操作!
但是线程里输出的序号依然跟for里的不一样,加了一个1,如何解释呢?

对比两个例子,我们可以进行一些推测:
首先是线程的序号问题。先来看有规律的那个,每一个都加了1,这是表面现象,关键的线索在头两行输出:连续两个for中的printf。
这说明当第一个线程开辟时,for已经循环了两次。我们给线程传入的是&i,也就是说当线程真正接收到参数时,此时的i已经是1了,于是线程就打印出了1。这个解释在乱序中也是合理的,并且恰巧解释了为什么有那么多10,这是因为开辟线程的速度远远低于for循环速度,当for循环要结束时,即i=10时,才开辟了4个线程,于是后续的线程接收到的参数全部是10。

其次是顺序莫名其妙地正常了。这不是巧合,试了多次都是这样。为什么?
肯定跟for中的printf有关。应该注意到,这里的规律是一条for中的printf,接一条线程中的printf。个人推测是跟printf的执行效率有关。输出是一个麻烦事,很显然比开辟线程麻烦。于是造成的结果是,当新线程开辟后,程序要花很长时间才能执行完for的下一条printf,此时线程中的printf正好也执行完了。两个printf发生了“意料之外的同步”,于是造成了一条接一条的输出顺序,这样线程的序号就正常递增了。

但是依靠printf解决同步问题可不行,我们的目标是使用可靠的线程间通信手段来实现线程的输出序号递增,互斥能解决问题吗?

并不行,因为这里是要确定出顺序

必须用同步方式来解决,让我们使用信号量解决。

运行结果:

达到我们的目的了,但是为什么呢?
过程是这样的。我们初始化了一个信号量,并将其资源设定为0,然后进入for循环,首先开辟了一个线程,然后wait。因为信号量是0,所以for进入等待状态,
传入的i在线程中确定值后,线程释放信号量,即资源加一。此时wait通过,信号量减一又立刻变成0。这样的过程可以确保传入线程的值是从0到9,并且输出顺序也是如此。

信号量也是一个内核对象,可以跨进程使用,方法也是OpenSemaphore。

四、小结

事件和信号量可以解决线程同步问题
信号量由于有资源计数功能,所以比事件的功能更强大
又因为互斥其实是一种特殊的同步问题,所以事件和信号量也能够解决互斥问题
这样说来信号量是所有工具中最强大的

五、读写锁

利用互斥与同步,我们可以解决绝大部分线程间通信问题,然而有一类问题发生频率如此之高以至于专门有一个锁来简化该类问题的处理。
这个锁叫读写锁。为什么叫读写锁呢?来源于一个多线程经典问题:读者写者问题。这问题如下:有一个写者和多位读者。他们共同操作同一个文件。当写者写文件时,读者不能读;当读者读文件时,写者不能写。不过多位读者可以同时读文件。
这个情况非常常见,多个线程操作同一份文件肯定会遇到这个问题,使用信号量和互斥量可以解决,不过代码会比较长。windows专门做了一个读写锁简化代码书写,使用方法见下面的例子

整个程序定义了1个写者线程和9个读者线程,使用读写锁来解决上述问题。
在使用上读写锁跟关键段差不多,都是一头一尾卡住一段代码,不过要注意写者线程和读者线程调用的函数不一样。
写者是exclusive,说明是独占文件
读者是shared,说明是共享文件
运行结果如下

可见是成功地实现了我们的目标。

至此windows多线程介绍到此结束,可以真正进入标准库多线程编程了,见《标准库多线程编程 – 基础》。

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