线程安全和可重入
概念
- 线程安全:多个线程并发访问同一段代码时,不会出现不同的结果。常见的是对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
常见的线程不安全的情况
- 使用不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是调用了可重入的,而只调用了可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生
- 死锁,因此是不可重入的。
死锁
概念
死锁是指在一组进程/线程 中的各个进程/线程 均占有不会释放的资源,但因互相申请被其他进程/线程所占用的,不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用(前提)
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放(原则)
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺(原则)
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系(重要条件)
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法
- 死锁检测算法
- 银行家算法
线程同步
条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量,避免一直等待,浪费资源。
同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
条件变量函数
pthread_cond_init 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
参数:
cond:要初始化的条件变量
attr:NULL
pthread_cond_destroy 销毁
int pthread_cond_destroy(pthread_cond_t *cond)
参数:
cond:需要销毁的条件变量
pthread_cond_wait 等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒函数有两个
int pthread_cond_signal(pthread_cond_t * cond);(单个唤醒)
int pthread_cond_boardcast(pthread_cond_t cond);(全部唤醒)
参数:
cond:需要唤醒的条件变量
为什么在条件变量等待的时候需要pthread_mutex_t 的锁呢?
因为在我们利用条件变量的时候,我首先是需要加锁去判断我们的共享资源是否处于就绪状态的,如果共享资源不属于就绪状态,我们就需要将该线程放入条件队列里面去等待,但是我们判断共享资源也属于访问共享资源,因此我们在条件变量之前肯定是需要等待的,当我们的线程进入条件变量,**注意他是持锁进入了,**所以我们需要去释放锁,这就是我们为什么在wait时,需要传入一个锁类型了。
生产者消费者模型
为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列 来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
在生产消费模型中,我们有生产者,消费者,和交易场所,我们的生产者和消费者都是在我们的交易场所内进行交易的。所以这个交易场所就是我们的阻塞队列。我们利用俩个不同的线程不就可以充当我们的生产者和消费者。
接下来我们分析一下生产者和消费者之间的关系:生产者和生产者之间,我们可以明显的知道属于是互斥关系 ,消费者和消费者也是属于互斥关系 ,在生产者和消费者之间时,我们在生产的时候,还没有送入交易场所就不可以消费,我在交易场所消费的时候,你也不可以进来生产,但是我们又希望我们刚刚生产完就有用户来消费,所以我们的生产者和消费者之间是既同步又共享的
我们可以利用321原则来记忆生产消费者模型:3种关系,2种角色,1个交易场所
生产者消费者模型优点
- 解耦
- 支持并发
- 支持忙闲不均
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞。
接下来我们基于C++中的queue来模拟实现
重点介绍交易场所即可(代码种详解)
cpp
template<class T>
class BlockQueue
{
public:
BlockQueue(int capcity = defaultnum) : maxcap_(defaultnum)//构造时初始化我们的锁和条件
//变量
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&c_, nullptr);
pthread_cond_init(&p_, nullptr);
}
T Pop()//用于消费
{
pthread_mutex_lock(&mutex_); //消费时需要加锁,防止生产者打扰
if (q_.size() == 0)//如果此时没有资源,消费者要去消费者的条件变量等待,直到有人唤醒
{
pthread_cond_wait(&c_, &mutex_);
}
T data = q_.front();//获取资源
q_.pop();//消费掉资源
pthread_cond_signal(&p_);//因为此时我们已经消费了一个数据,所以肯定有位置让生产者来消
//费
pthread_mutex_unlock(&mutex_);//解锁
return data;//返回数据,方便后续数据处理
}
void Push(const T &data)//用于生产
{
pthread_mutex_lock(&mutex_);//加锁,防止生产者来打扰
while (q_.size() == maxcap_)//如果此时队列到达最大容量,没有位置生产数据,则进入生产者
//的条件变量进行等待,直到有人唤醒
{
pthread_cond_wait(&p_,&mutex_);
}
q_.push(data);//生产数据
pthread_cond_signal(&c_);//既然生产了数据,那么一定就有数据让消费者来消费了,唤醒消费
//者
pthread_mutex_unlock(&mutex_);//解锁
}
~BlockQueue()//在析构时摧毁所有条件变量和锁
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_);
pthread_cond_destroy(&p_);
}
private:
queue<T> q_;//利用STL种的队列
int maxcap_;//定义最大容量
pthread_mutex_t mutex_;//利用一把锁来保持互斥
pthread_cond_t c_;//消费者的条件变量
pthread_cond_t p_;//生产者的条件变量
};