文章目录
- 线程饥饿
- 条件变量接口的使用
- 生产者和消费者模型
- 使用阻塞队列实现生产消费模型
- 代码中存在的问题
- 关于pthread_cond_wait的原子性
- 生产消费模型中的并发体现
线程饥饿
在多线程并发执行的场景中,会不会出现这样的情况,一些线程由于优先级更高,或者调度成本较低,cpu会不断地调度这些线程,由于线程访问临界资源是互斥的,有一些线程可以频繁的访问临界资源,肯定也就有一些线程几乎没有访问临界资源,我们将这个现象称为线程饥饿,线程饥饿发生时,一些线程总是无法访问临界资源
对于临界资源来说,线程互斥可以保证线程安全,所以互斥是没有问题的,甚至是必须的。但是线程互斥一定是合理的吗?很明显,由于线程饥饿问题的存在,线程互斥不是合理的。我们希望所有的线程访问临界资源的机会是平等的,我们不能因为线程互斥而剥夺线程平等访问临界资源的权利。
所以我们需要通过规则的限制,使线程访问临界资源有序,使之合理的访问,赋予线程平等访问临界资源的权利,我们将能够使线程平等的访问临界资源的机制叫做同步机制。同步机制建立在线程互斥的前提下,我们需要先保证线程的互斥,然后使线程按照一定的顺序互斥地访问临界资源。
同步的‘同’,不是一起工作的意思,而是协同,互相配合的意思,也有协同步调的意思。每个线程协同步调,按一定的先后次序访问临界资源,就叫做同步!
条件变量接口的使用
条件变量是最经常被用来使用的实现线程同步的一个机制,当条件(由程序员设置的条件)不满足时,线程会在条件变量下休眠,当条件满足时,我们就可以唤醒在该条件变量下休眠的线程,接着该线程就能访问临界资源。也就是说,条件变量是一个为线程提供休眠的场所,并且条件变量唤醒线程是按照一定顺序的,所以使用条件变量可以实现线程的同步。
条件变量的类型:pthread_cond_t
经常使用的条件变量接口一共有5个,其中pthread_cond_init和pthread_cond_destroy的使用和互斥锁的接口一样,这里不再赘述。
pthread_cond_wait:使线程陷入条件变量下的休眠,参数是一个条件变量和一把锁的地址,线程会陷入你传入的条件变量下的休眠,并且wait接口的使用要配合一把锁,当线程陷入休眠时,如果占用锁资源,wait会将锁释放,防止死锁的发生。
如果线程的锁被释放了,那么当线程被唤醒时,wait会为当前线程加锁,只有当前线程加了锁,wait函数才算调用完
pthread_cond_signal,pthread_cond_broadcast:两个接口都是用来唤醒在条件变量下等待的线程,需要传入条件变量的地址。他们的区别就是pthread_cond_signal会唤醒在条件变量下休眠的一个线程,而pthread_cond_broadcast会唤醒在条件变量下休眠的所有线程
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <functional>
using namespace std;
pthread_cond_t g_cond;
pthread_mutex_t g_mutex;
vector<function<void()> > functions;
void world() { cout << "hello world!" << endl;}
void linux() { cout << "hello linux!" << endl;}
void *start_routine(void *arg)
{
while (1)
{
// 使线程无条件的陷入条件变量下的休眠
pthread_cond_wait(&g_cond, &g_mutex);
// 当线程被唤醒时,执行方法集中的所有方法
for (auto f : functions)
{
f();
}
}
}
int main()
{
// 方法集的加载
functions.push_back([](){
cout << "hello conditon!" << endl;
});
functions.push_back(world);
functions.push_back(linux);
// 条件变量的初始化
pthread_cond_init(&g_cond, nullptr);
pthread_mutex_init(&g_mutex, nullptr);
// 线程的创建
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
// 线程的运行
pthread_create(&tid1, nullptr, start_routine, (void*)"thread1");
pthread_create(&tid2, nullptr, start_routine, (void*)"thread2");
pthread_create(&tid3, nullptr, start_routine, (void*)"thread3");
// 主线程每隔一秒唤醒一次在条件变量下休眠的线程
while (1)
{
sleep(1);
pthread_cond_signal(&g_cond);
}
// 线程资源的回收
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
// 条件变量的释放
pthread_cond_destroy(&g_cond);
pthread_mutex_destroy(&g_mutex);
return 0;
}
这段demo中的线程直接无条件的陷入条件变量下的休眠,主线程每隔一秒钟会唤醒该条件变量下的一个线程,使其执行方法集functions中的方法。
生产者和消费者模型
生产消费模型是最典型的同步与互斥的应用场景。当消费者要进行消费时,会进入超市购买商品,而不是直接找生产者购买。当生产者需要销售商品时,会将商品放入超市售卖,而不是直接找消费者销售。
超市这个场所对消费者和生产者进行了解耦,消费者无需关心生产者是否将商品生产出来,若没有生产商品,什么时候才能生产出商品…生产者也无需关心消费者是否需要他的商品,消费者在哪…他们只需要关心超市,生产者只关心超市是否还有剩余的货架摆放他们的商品,自己的商品是否被卖完…消费者只关心超市是否有他所需要的商品。强耦合的生产者与消费者就这样被超市降低了耦合度,超市不仅提高了双方的效率,还降低了彼此资源的无用消耗。
在这个消费的过程中,有两个角色,消费者和生产者,分别由线程承担这两种角色,可以由很多线程构成消费者或生产者,也可以由一个线程构成消费者或生产者。消费者在超市中消费,生产者通过超市销售商品,所以超市就是消费过程中的一个交易场所。
而对于生产者来说,生产者之间需要竞争超市的空间,以摆放更多的商品销售,当处于极端情况,即超市只能摆放一件商品时,一个生产者摆放了商品,其他生产者就不能摆放商品,所以生产者之间就形成了一种互斥的关系
对于消费者来说,在极端情况下,当超市的商品只有一件时,消费者之间就需要互相竞争,一个消费者购买了商品,其他消费者不能购买这个商品,所以从这个角度来说,消费者之间的关系也是互斥
对于消费者和生产者来说,在生产者将商品摆放到超市的过程中,消费者是否可以购买这个商品?在生活中,这个例子很简单,摆放商品的操作是原子性的,商品只有两种状态,被摆放到货架与未被摆放,只要商品被摆放到货架上,消费者就可以购买。但是在计算机中,摆放商品的操作可能并不是原子的,即除了未摆放和已经摆放,商品还存在中间状态,当生产者将商品摆放到一半时,消费者就不能获取该商品,否则将导致购买的商品有问题。所以生产者和消费者之间存在一种互斥关系,当生产者访问货架,摆放商品时,消费者无法访问货架,购买其摆放的商品,只有生产者将商品摆放到货架上,消费者的购买才是有效的。
当超市的货架上没有商品时,消费者进入超市无法购买商品,但是由于消费者急需这件商品,于是消费者不断的进入超市,访问该货架,但是货架上依然没有他需要的商品。
当超市的货架无法摆放更多的商品,但是生产者由于库存压力需要将商品摆放到货架上销售,于是生产者频繁的访问超市的货架,但是结果却是货架依然无法摆放更多的商品。
在这两个从场景中,无论是消费者还是生产者,他们频繁访问超市都将消耗大量的时间,但是因为条件没有就绪,他们消耗的时间都是无意义的,为了减少这样无意义的时间消耗,生产者和消费者之间应该具有某种特定的访问顺序,这个顺序就是:当商品被消费者消费,货架有了空位,生产者才可以生产商品并摆放到货架上,当生产者生产了商品并摆放到货架上,消费者才可以消费。即消费完了再生产,生产完了再消费。所以生产者和消费之间除了互斥关系,还应该具有同步关系,他们对于超市的访问需要具有一定顺序。
所以在这个模型中,两种角色产生了三种关系,它们分别是
消费者与消费者之间的互斥
生产者与生产者之间的互斥
消费者与生产者之间的互斥与同步
使用阻塞队列实现生产消费模型
将阻塞队列作为生产消费模型中的超市,生产者和消费者分别用一个线程模拟,一个线程向阻塞队列中写入数据,另一个线程从阻塞队列中读取数据。
要实现一个阻塞队列,首先肯定要有一个queue结构,并且需要设置一个变量规定queue的大小。当队列为空时,消费者就不能消费,将消费线程陷入阻塞,当队列中有数据时再唤醒消费线程。当队列满时,生产者就不能生产,将生产线程陷入阻塞,当队列有位置写入数据时再将其唤醒。生产者与消费者两者之间的等待条件不同,并且两者需要从不同的地方唤醒,所以需要有两个条件变量,分别作为两者休眠的场所,用来实现阻塞队列的有序访问
在这个模型中,无论是消费者与消费者,生产者与生产者还是生产者与消费者,他们之间都具有互斥关系,也就是说,同一时间,只允许一个线程访问阻塞队列,所以对于阻塞队列的访问要实现互斥,因此需要用到一把互斥锁
总结一下,阻塞队列中需要有一个queue结构,一个保存队列大小的变量,一把锁以及两个条件变量
template <class T>
class block_queue
{
private:
int _cap; // 容量
queue<T> _bq; // 队列
pthread_mutex_t _mutex; // 互斥锁
pthread_cond_t _concond; // 给消费者等待的条件变量
pthread_cond_t _procond; // 给生产者等待的条件变量
};
然后是阻塞队列的构造与析构,分别用来初始化队列的容量,锁,条件变量,以及析构锁,条件变量
block_queue(uint32_t cap = 10) :_cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_concond, nullptr);
pthread_cond_init(&_procond, nullptr);
}
~block_queue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_concond);
pthread_cond_destroy(&_procond);
}
接着是生产与消费的逻辑,先用伪代码编写
// 生产者生产数据
void push(const T& in)
{
// 上锁
// 条件判断,如果队列满了,生产线程陷入阻塞
// 队列没满,生产数据
// 解锁
// 因为数据已经写入了队列,所以唤醒消费线程,使之消费数据
lock();
if (is_full())
{
// 生产者陷入等待
pro_wait();
}
// 满足队列不满的条件
push(in);
unlock();
wakeup_con();
}
// 消费者消费数据
T pop()
{
// 上锁
// 条件判断,如果队列为空,消费线程陷入阻塞
// 如果队列不为空,消费数据
// 解锁
// 此时的队列肯定不为空,所以唤醒生产线程,使之生产数据
lock();
if (is_empty())
{
// 消费者陷入等待
con_wait();
}
// 满足队列有数据的情况
T ret = pop();
unlock();
wakeup_pro();
return T;
}
伪代码中,所有的接口都进行了封装,接下来就需要一个个封装这些接口
#include <iostream>
#include <pthread.h>
#include <queue>
using namespace std;
template <class T>
class block_queue
{
public:
block_queue(uint32_t cap = 10) :_cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_concond, nullptr);
pthread_cond_init(&_procond, nullptr);
}
~block_queue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_concond);
pthread_cond_destroy(&_procond);
}
// 生产者生产数据
void push(const T& in)
{
// 上锁
// 条件判断,如果队列满了,生产线程陷入阻塞
// 队列没满,生产数据
// 解锁
// 因为数据已经写入了队列,可以唤醒消费线程,消费数据
lock();
if (is_full())
{
// 生产者陷入等待
pro_wait();
}
// 满足队列不满的条件
push_data(in);
unlock();
wakeup_con();
}
// 消费者消费数据
T pop()
{
// 上锁
// 条件判断,如果队列为空,消费线程陷入阻塞
// 如果队列不为空,消费数据
// 解锁
// 此时的队列肯定不为空,可以唤醒生产线程,生产数据
lock();
if (is_empty())
{
// 消费者陷入等待
con_wait();
}
// 满足队列有数据的情况
T ret = pop_data();
unlock();
wakeup_pro();
return ret;
}
private:
// 上锁与解锁
void lock()
{
pthread_mutex_lock(&_mutex);
}
void unlock()
{
pthread_mutex_unlock(&_mutex);
}
// 判断队列是否为空
bool is_full()
{
return _bq.size() == _cap; // 返回当前队列的大小是否等于队列容量
}
bool is_empty()
{
return _bq.empty();
}
// 数据的生产与消费
// 就是入队和出队接口的封装
void push_data(const T& in)
{
_bq.push(in);
}
// 出队时返回队头元素
T pop_data()
{
T ret = _bq.front();
_bq.pop();
return ret;
}
// 生产者与消费者的阻塞等待
void pro_wait()
{
pthread_cond_wait(&_procond, &_mutex);
}
void con_wait()
{
pthread_cond_wait(&_concond, &_mutex);
}
// 唤醒生产者与消费者
void wakeup_con()
{
pthread_cond_signal(&_concond);
}
void wakeup_pro()
{
pthread_cond_signal(&_procond);
}
int _cap; // 容量
queue<T> _bq; // 队列
pthread_mutex_t _mutex; // 互斥锁
pthread_cond_t _concond; // 给消费者等待的条件变量
pthread_cond_t _procond; // 给生产者等待的条件变量
};
void* consumer(void* arg)
{
block_queue<int>* bqp = static_cast<block_queue<int>*>(arg);
while (1)
{
int ret = bqp->pop();
cout << "线程[" << pthread_self() << "]" << "消费了一个数据" << ret << endl;
}
}
void* producer(void* arg)
{
block_queue<int>* bqp = static_cast<block_queue<int>*>(arg);
while (1)
{
sleep(1);
int data = rand() % 10;
bqp->push(data);
cout << "线程[" << pthread_self() << "]" << "生产了一个数据" << data << endl;
}
}
int main()
{
srand((unsigned long)time(nullptr));
block_queue<int> bq;
pthread_t tid1, tid2;
pthread_create(&tid1, nullptr, consumer, &bq);
pthread_create(&tid2, nullptr, producer, &bq);
while (1)
{
sleep(1);
}
return 0;
}
这段demo的生产线程先休眠一秒,然后每隔1秒生产一个数据,消费者线程会不断的陷入条件变量下的休眠,当生产者线程生产了数据,它才会被唤醒,然后读取数据
代码中存在的问题
当队列满了后,生产线程会陷入等待,而如果你的代码逻辑比较复杂,或者其他什么原因,导致了操作系统错误的唤醒了在条件变量下等待的生产线程,该线程向后执行,将往满了的队列中生产数据,这可能会导致程序的运行错误
所以由于伪唤醒的存在,我们不仅需要唤醒线程,还需要再次对队列进行判断。使用if不能实现这样的操作,而使用while就能实现这样的操作,线程被唤醒后,会回到前面,判断while的条件是否成立,这就是一次条件的再判断,当条件不成立(线程可以访问临界资源了),while退出,线程执行后面的代码。所以这里用while代替if更合适
在解锁前还是解锁后唤醒线程呢,两者有差别吗?
以生产者生产为例,如果唤醒消费者线程的操作在解锁之前,那么生产者向队列生产完数据,就会唤醒正在等待的消费者线程,虽然唤醒消费者线程的条件满足了(队列中有数据),但是唤醒消费者线程还需要对其加锁(因为此时的消费者线程位于临界区,pthread_cond_wait会为线程自动加锁),由于生产者线程没有解锁,消费者线程加锁失败(此时的线程还卡在pthread_cond_wait函数中),消费者线程的等待就从等待条件的就绪变为了等待占有锁的线程解锁。当生产者线程解锁,消费者线程或者其他没有在等待的消费者线程就可以拿到这把锁,进入阻塞队列消费。这个过程不会产生什么错误
还是这个情况,如果其他消费者线程先于被唤醒的消费者线程拿到所,进入了队列消费,也不会导致程序崩溃,因为当被唤醒的线程拿到锁后会再次进行对队列的条件进行判断(while的作用),如果队列的条件不允许消费,被唤醒的线程会再次陷入条件的等待。
如果生产者线程在解锁之后才唤醒消费者线程,那么在消费者线程被唤醒之前,可能生产者线程被切换,cpu运行其他没有在等待的消费者线程,这些消费者线程看到队列中有数据,并且可以申请到锁进入队列,所以它们就进入队列消费数据。当生产者线程被切回重新执行,接着唤醒消费者线程,该消费者线程在拿到锁后,由于while的条件判断,会再次判断队列中的条件是否适合进行消费,如果条件不适合,就继续陷入等待,如果条件适合就进入阻塞队列消费。所以这个过程也不会出错
分析完两种情况,可以看出无论唤醒线程的操作是在解锁前还是在解锁后,对于最后的结果都是没有影响的并不会出错的。
关于pthread_cond_wait的原子性
执行pthread_cond_wait时,线程如果持有锁,pthread_cond_wait会解锁(不能让线程带着锁陷入等待),再陷入等待,解锁和等待必须是一个原子操作,也就是说,pthread_cond_wait要么被执行完,要么没有被执行,如果它不是一个原子操作,假如线程A被解锁之后,陷入等待之前,有其他线程申请到了这把锁,然后修改了阻塞队列,此时队列的条件满足线程A的唤醒条件,线程A会被唤醒,但是由于线程A只是解锁了,还没有陷入等待,所以唤醒线程A失败。
接着cpu会继续运行线程A,线程A陷入了等待,但是有没有可能该队列永远不会出现让线程A唤醒的条件呢?如果是这样,线程A会陷入永久的等待,资源也就泄漏了,所以pthread_cond_wait的解锁必须是原子操作
生产消费模型中的并发体现
上面我实现的阻塞队列有体现线程的并发吗?生产者线程进入队列时,其他线程包括消费者线程都不能进入队列,因为要保证它们之间的互斥,所以线程在访问队列时,总是串行式的访问,必须等待其他线程访问完队列,当前线程才能进入队列。可能有人就会问了,这样访问临界资源的效率不是很低吗?这个模型中的并发体现在哪?
虽然互斥的访问队列效率比较低,但是访问队列的时间相对其他时间是比较少的,也就是访问队列的速度很快,线程访问队列的操作只有取出数据与写入数据。在线程写入数据前与取出数据后,线程要生产数据和处理数据,在这个模型中,重要的并不是取出与写入数据,阻塞队列只是一个通信的桥梁,这个模型中最重要的是数据的生产与处理,因为它们消耗的时间很显然比取出与写入数据所消耗的时间多。
所以生产消费模型中的并发体现在:生产数据与处理数据的并发,队列只是线程间通信的桥梁,线程通信前与线程通信后的线程是并发运行的。