《操作系统导论》进程调度:条件变量

《操作系统导论》进程调度:条件变量

Tags
OS
Published
2023-02-11
Author
宿愿Cc
到目前为止,我们已经理解了锁的基本概念,学会了如何正确的通过硬件和操作系统的组合来实现锁。然而,单单靠锁并不能构建一个完美并发程序。
在很多情况下,线程会需要检查某一条件(condition)满足后,才会继续执行。常见的场景如:父线程等待子线程是否执行完毕(这种等待一般称为join)。现在我们怎么实现这种等待呢?
我们可以尝试共享一个变量,这种方案一般情况下可以工作,但是效率很低,因为主线程需要一直自旋,会浪费CPU时间。
 

条件变量

前面在学习 “线程API” 的时候已经粗略的学习过条件变量了,这里我们再复习一下条件变量的使用。条件变量是一个显式的队列,当执行状态(即条件,condition)不满足时,线程会将自己加入到队列中,等待这个条件满足。另外在某个线程上,当它改变了上述状态时,就可以唤醒一个或者多个等待线程(通过在该条件上发信号),让它们继续执行。
Dijkstra 最早在“私有信号量”中提出这种思想。Hoare 后来在关于观察者的工作中,将类似的思想称为条件变量。
如果需要使用这样的条件变量,只需要这样:pthread_cond_t c;,这里的c就是一个条件变量(注意:需要初始化)。接着可以通过两种操作来操作这个条件变量:wait()signal()。调用wait()会让线程休眠。当线程想要唤醒等待在某个条件变量上的睡眠线程时,可以调用signal()。如下代码所示
这里值得注意的是,wait()调用一共2个参数,一个是互斥量(锁),一个是条件变量。在调用wait的时候,会假定互斥量是已上锁的状态。wait会在此时将锁释放,同时让调用线程休眠(原子地)。当线程被唤醒时,它会重新获取锁,再返回给调用者。
我们在调用wait的时候一定需要持有锁,这是wait的语言强制要求的。在调用wait和signal时要持有锁(hold the lock when calling signal or wait),这样才能保证代码使安全的。
我们也发现了,父进程使用了一个while循环而不是if语句来判断是否需要等待。虽然逻辑上来说可以不使用循环语句,但是这样做是更好的(后面会加以说明)
 
如下还有一个糟糕的实现,这个实现中,我们在线程发信号和睡眠时都不加锁。那么会发生什么问题呢?
那么请试想一下这种情况:父线程调用thread_join(),然后判断到done等于零,接着准备睡眠。但是在睡眠前发生中断,切换到子线程执行。子线程将done修改成1,然后发出信号,但现在没有等待的线程。随后切换到父线程运行,父线程没有重新检查done,直接睡眠,这样的话父线程就会长睡不醒了。
 

生产者/消费者(有界缓冲区)问题

假设有一个或多个生产者线程和一个或多个消费者线程。生产者生成的数据项放入缓冲区;消费者从缓冲区取走数据项,以某种方式消费。
在实际中有很多这种场景。如在多线程网络服务器中,一个生产者将http请求放入工作队列(即有界缓冲区),消费者线程从队列中取走请求并处理。
我们在使用管道时,也会用到有界缓冲区,例如 grep foo file.txt | wc -l,这个例子就会并发执行俩个进程,grep进程file.txt中查找包含 “foo”的行,写到标准输出;UNIX shell把输出重定向到管道(通过pipe系统调用创建)。管道的另一端是wc进程的标准输入,wc统计完行数后打印结果。这个例子中grep进程就是生产者,wc进程是消费者,他们之间是内核中的有界缓冲区。
 
现在我们应该知道,有界缓冲区的资源应该是共享的,既然是共享的,我们就必须通过一些同步机制来访问它,以避免竞态问题。
通过一个小例子,我们来理解这个问题。我们用一个整数来作为共享缓冲区(当然可以用其它更复杂的数据结构来代替)。然后通过两个内部函数来往缓冲区插入和取出数据。如下代码
我们再来看下生产者和消费者线程是怎么使用这两个api的。生产者会不断的往缓冲区放入数据,而消费者会不断的从缓冲区取出数据。
 

加锁的方案

我们再来给消费者和生产者添加锁和条件变量。在只有一个生产者和消费者的时候,是不会发生问题的。
信号的这种释义常称为 Mesa 语义(Mesa semantic),为了纪念以这种方式建立条件变量的首次研究。另一种释义是 Hoare 语义(Hoare semantic),虽然实现难度大,但是会保证被唤醒线程立刻执行。实际上,几乎所有系统都采用了 Mesa 语义。
 
我们加上锁和条件变量后依旧还有问题,我们可以想一想:假设一共有两个消费者(T1和T2)和一个生产者。两个消费者先执行,发现缓冲区没有数据,然后俩个消费者都睡眠了。接着生产者开始运行,往缓冲区放入数据,然后唤醒消费者(假定是T1)。随后生产者进入睡眠。
T1醒过来并从Pthread_cond_wait() 调用返回,重新检查(while循环会重新检查,如果不重新检查,写的是if,在下面调用get的时候可能会触发断言)条件。发现缓冲区是有数据的,随后并消费了它。然后它又在这个条件上发信号,准备唤醒睡眠的线程。问题来了,它该唤醒谁?
目前来说,消费者线程T2和生产者线程都在睡眠。此时因为缓冲区空了,其实应该唤醒生产者。但是,如果它唤醒了消费者T2(这是有可能发生的,取决于等待队列是如何管理的),那么问题就出现了。T2醒来会发现缓冲区是空的,接着又睡去。这下好了,两个消费者线程和生产者线程都睡眠了。显然这是一个bug。
虽然条件变量是必不可少的,但是需要更精准的使用的,使它更具有指向性。消费者不应该唤醒消费者,而是应该唤醒生产者。反之亦然。
 

双条件变量

为了避免上述的情况,我们可以使用两个条件变量,而不是一个。生产者等待条件变量 empty 信号,发信号给变量 fill,而消费者等待条件变量 fill,发信号给 empty。代码如下:
 

最终方案

 

覆盖条件

我们先来看一个例子,这个例子会在线程在分配内存时,因为内存不足而等待。相应的,线程在释放内存时,会发出信号说有更多的内存可以使用了。问题来了:如果有多个线程在等待,应该唤醒哪个线程?
考虑以下场景,假设现在内存已满,线程调用allocate(100),随后线程调用allocate(10)。现在两个线程都在睡眠,等待有足够的内存。
这时第三个线程调用free(50)。这时候它发信号唤醒等待线程,它现在应该唤醒线程才是对的(因为内存依旧不满足的需求),但他有可能会唤醒醒了后发现内存不够,又进入睡眠等待。
Lampson和Redelltichu 提出了一种方案,即在唤醒线程时用pthread_cond_broadcast()代替 pthread_cond_signal(),这是广播的方式,会唤醒所有等待的线程。这样就可以保证所有应该被唤醒的线程都唤醒。但他也有副作用,即把不需要唤醒的线程也唤醒了,这些线程醒了后,重新检查条件,又马上进入睡眠,这可能会影响一些性能。
Lampson和Redelltichu 给这种条件变量叫做覆盖条件(covering condition),因为它可以覆盖到所有需要唤醒线程的场景(保守策略)。但是需要注意它会唤醒所有的线程。这个方案其实也可以用在单个条件变量的生产者/消费者问题中。但我们需要注意具体的场景,一般来说,如果我们发现程序中只有采用广播的方式才能正常工作(但你认为不需要),可能是程序有缺陷,代码的写的有问题,应该修复它!但是在上述内存分配的例子中,广播可能是最直接有效的方案。