一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第23天,点击查看活动详情。
条件变量
之前我们解释了锁和基于的锁的并发数据结构,已经可以构建一个小型的并发程序。但是我们只注意于对线程操作的数据的保护,而忽视了线程与线程之间的关系处理。事实上,有很多情况是线程需要确认条件允许,再进行下一步的操作,这种条件在线程之间如何共享,如何管理是我们所需要关心的。举例来说,当父线程运行到某个特定节点时,它要确认所有子线程已经结束再进行下一步的操作,这时候可以调用join()。一种用于线程同步的技术。
我们可以创建一个全局变量,用于标识父进程是否能够继续执行,当条件不允许时,父进程一直等待并检查条件变量。这样功能完成了,但是明显是低效的,因为父进程一直在浪费CPU资源。因此,我们需要一种能够让线程进入睡眠状态,直到条件允许的时候再唤醒的方法。
为了构建这么一个系统,前人提出了条件变量(Condition Variable) 这一概念,具体的说,条件变量是一个显式的队列,当某些执行状态(即某些条件)不符合时,线程可以将自己放在这个队列中;当其他线程改变状态时,可以唤醒一个(或多个)正在等待的线程,使它们继续执行。所以这里条件变量并不是某一个数值,而是一种数据结构。
条件变量有两个操作,wait()和signal()。当线程希望自己进入睡眠状态时,就会调用wait();当一个线程执行完了某些操作,因此想要唤醒一个正在等待中的线程时,就会执行signal()。当然,为了确保同步性,这两个调用都会用到锁。线程在调用wait()之后,将锁作为参数输入,线程进入睡眠状态并释放锁。在调用signal后,某一个符合条件的线程被唤醒并立即得到锁。下图是一个双线程的示例
在上面的例子中,变量done以及锁变量m都是必须的,因为这两个相互配合,使得父线程和子线程的操作有原子性,而不是在某个时间点卡死。用while不用if是因为这样程序才能够能够处理多个子线程或者父线程。
条件变量的原理我们知道了,但是似乎还不够完善。我们再看条件变量在Producer/Consumer问题(生产者/消费者问题)上发挥的作用。Producers指那些生成数据并且放置在有界缓冲区(Bounded Buffer) 的线程,Consumers指那些从有界缓冲区取出数据并使用的线程。例如,在一个多线程的web服务器中,Producers将HTTP请求放入一个工作队列(即有界缓冲区);Consumers将请求从队列中取出并处理它们。因为有界缓冲区是共享资源,我们当然需要对它进行同步访问,以免出现race condition。
我们定义一个简单的有界缓冲区,即一个只存一个数字的内存位,用get()和put()调用来表示对这个内存位的拿和放的操作。结合上面的知识来设计并发程序对这个内存位进行读写。
上图左边是最初的一版设计,而右边第二、三版对应解决的问题是
- 同类线程的竞争问题
- 线程的有向选择问题
经过设计,现在的程序能够在多个生产者/消费者线程同时执行的情况下保持稳定读写。如果再将有界缓冲区扩展为多个位置,则可模拟大多数Producer/Consumer问题下的通用解决方案,代码如下
条件变量不只能用来解决Producer/Consumer问题,具体的说,假如有一段在多线程内存分配库中的程序,功能为分配与释放内存。假设进行Free的线程为T1,进行alloc的线程为T2和T3。对于T1来说,释放空间后就要唤醒分配空间的线程。这时问题是:该唤醒哪一个线程,这种情况被称为covering condition。
解决方案很简单,将代码中的signal()调用替换为broadcast(),这样可以唤醒所有等待的线程,这样的话我们能保证任何应该被唤醒的线程都是被唤醒的。当然,这样做的缺点可能是对性能的负面影响,因为我们可能会不必要地唤醒许多其他本应(还)处于等待状态的线程。这些线程将简单地唤醒,重新检查条件,然后立即回到睡眠状态。
covering condition问题提醒我们,对条件变量的操作是要根据实际情况来调整的,不是盲目的使用signal和wait。