条件变量
条件变量是用来等待线程而不是上锁的,条件变量通常和互斥锁一起使用,这因为互斥锁的一个明显的特点就是它只有两种状态:锁定和非锁定,而条件变量可以通过允许线程阻塞和等待另一个线程发送信号来弥补互斥锁的不足,所以互斥锁和条件变量通常一起使用。
一般条件变量有两个状态:
- 一个/多个线程为等待“条件变量的条件成立“而挂起(wait)
- 另一个线程在“条件变量条件成立时”通知其他线程(signal)
相关函数
操作 | 函数 |
---|---|
条件等待 | int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) |
计时等待 | int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime) |
单个激活 | int pthread_cond_signal(pthread_cond_t *cond) |
全部激活 | int pthread_cond_broadcast(pthread_cond_t *cond) |
其中参数的意义:
- cond:条件变量
- mutex:互斥锁
- abstime:等待时间
返回值意义:单个激活和多个激活函数的返回值如果是0则表示成功,如果是正数则表示失败。超时等待返回ETIMEDOUT则表示超时结束等待。
实现场景
两个生产者拥有一个箱子,要求生产的目标产品必须放进箱子里,当生产的产品达到100个时,就用箱子将产品送至消费者处理,本博文中消费者的操作是对其进行输出。
代码详解
思路:
- 需要三个线程,分别模拟两个生产者和一个消费者
- 创建一个函数模拟生产者动作
- 当产品数目达到100个时通知消费者,消费者对其进行处理
- 当生产者线程生产的产品达到100个时,应对其进行阻塞,相应的唤醒消费者线程
这里需要特别注意的是生产数目达到预期值如何阻塞生产者同时唤醒消费者,因为有唤醒操作,所以我们需要使用条件变量,条件变量的wait的重要作用是,当该线程执行条件不满足(即产品数目是否达到100),若是不满足该函数就会让出互斥锁,阻塞当前线程。
具体代码如下:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; // 互斥锁
pthread_cond_t cv = PTHREAD_COND_INITIALIZER; // 信号
void* product(void* arg){
int& count = *static_cast<int*>(arg);
for(;;){
pthread_mutex_lock(&m);
if(count < 100){
// 防止某一个生产者线程和主线程抢锁多生产
// 所以增加判断条件,让生产线程只在产品数小于100时生产。
++count;
cout << pthread_self() << ":" << count << endl;
}
pthread_mutex_unlock(&m);
if(count >= 100){
pthread_cond_signal(&cv); // 唤醒,通知主线程
usleep(200); // 交出CPU
}
}
}
int main(){
int count = 0;
pthread_t tid[2];
for(int i=0; i<2; ++i)
pthread_create(&tid[i], NULL, product, &count);
for(;;){
pthread_mutex_lock(&m);
while(count<100){
// 更习惯使用while,因为可以判断两次
pthread_cond_wait(&cv, &m); // 阻塞,交出锁
// 唤醒操作+抢锁操作
}
count -= 100;
cout << pthread_self() << "substract 100, left:" << count << endl;
pthread_mutex_unlock(&m);
usleep(200);
}
}
实现结果如下:
条件变量往往与互斥锁结合使用,具体的唤醒阻塞上锁解锁流程如下图:
我们除了在生产者线程中使用互斥锁之外,还在条件变量的部分进行了加锁,这是因为需要在条件变量进行判断时,将变量锁住,让其他线程不能修改此变量,这样就可以保证在判断的时候条件的变量的值是正确的。即互斥锁的作用不是为了保护条件变量,而是为了进行条件判断时保护共享变量的值不会被修改。
我们在主线程中用的while循环,主要目的是可以判断两次,保证的确该线程后续执行条件不满足,防止误唤醒,造成惊群效应。
其中惊群效应指的是多个进程或者线程在等待同一个事件,当事件发生时,所有线程和进程都会被内核唤醒。唤醒后通常只有一个进程获得了该事件并进行处理,其他进程发现获取事件失败后又继续进入了等待状态,在一定程度上降低了系统性能。
其中降低系统性能的原应主要是上下文切换问题,频繁的上下文切换带来的一个问题是数据将频繁的在寄存器与运行队列中流转。极端情况下,时间更多的消耗在进程/线程的调度上,而不是执行。
除此以外,可以看出pthread_cond_signal的功能只是唤醒一个被条件变量阻塞的线程,但该函数不会修改锁的状态。而pthread_cond_wait会修改互斥锁的状态。那么先解锁再唤醒还是先唤醒再解锁呢?
(1)先解锁互斥锁,再唤醒睡眠的线程。
优点:减少了线程再内核态了用户态切换的次数,减少了资源的消耗。因为唤醒线程和解锁,都是需要再内核态完成的,而先解锁,再唤醒,内核会一次将这两个操作完成,这样就减少了用户态和内核态切换的次数,从而节省了资源。
缺点:如果此时存在一个低优先级的线程在等待锁,那么一旦锁被释放,那么这个锁就会被低优先级的线程争抢去,而不会被wait的线程得到,导致wait线程阻塞,无法返回。
(2)先唤醒睡眠的线程,再解锁互斥锁。
优点:唤醒后的线程在等待为该互斥锁加锁,一旦锁被释放,wait线程就会立即加锁,而不会发生上述,锁被抢占额度情况。
缺点:会增加用户态到内核态切换的次数,增加资源的消耗。
虽然在语法这两个都可以,但一般在程序使用先唤醒,再解锁的方式。
使用C++11标准库中的函数实现
具体思路同上,只是将生产者进行生产的函数作为一个lamda表达式实现,除此以外只是使用C++标准库中的函数实现。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
using namespace std;
int main(){
int count = 0;
mutex m;
condition_variable cv;
const int protuct_count = 100;
thread t[2];
for(int i=0; i<2; ++i){
t[i] = thread([&](){
for(;;){
if(count < protuct_count){
++count;
cout << this_thread::get_id() << ":" << count << endl;
}
m.unlock();
if(count >= protuct_count){
cv.notify_one(); // 唤醒一个线程
this_thread::sleep_for(100ms);
}
}
});
}
for(;;){
unique_lock<mutex> lock(m); // 将互斥量封装成一个独立锁来使用,因为条件变量wait函数传入的参数必须是一个锁
while(count < protuct_count){
cv.wait(lock);
}
count-=protuct_count;
}
}