线程互斥与同步

线程互斥

进程之间如果要进行通信我们需要先创建第三方资源(管道,共享内存等),让不同的进程看到同一份资源从而做到通信的目的。进程间通信中的第三方资源就叫做临界资源 ,访问第三方资源的代码就叫做临界区

在多线程中,大部分数据都是共享的(比如全局变量,文件描述符等)。多线程下,为了更好的临界区的保护,让多执行流在任何时刻,都只能有一个线程进入临界区访问临界资源。这种情况就称之为互斥

在多线程下,访问临界资源时如果不加互斥行为(就是多线并发操作共享数据),会带来一些问题。

比如下面这个例子: 主线程一次性创建出了五个线程,这五个线程并发的对全局变量count做--。

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int count = 10000;

void* TouchThread(void* arg)
{
    while(1)
    {
        if(count > 0)
        {
            usleep(1000);
            cout << "thread " <<(long long)arg << ":" << count << endl;
            count--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    const int NUM = 5;
    pthread_t tid[NUM];
    //create thread
    for(size_t  i = 0 ; i < NUM; ++i)
    {
        pthread_create(tid+i,nullptr,TouchThread,(void*)(i+1));
    }

    //join thread
    for(size_t i = 0; i < NUM; ++i)
    {
        pthread_join(tid[i],nullptr);
    }

    return 0;
}

运行结果:

可以看出,多线程确实快。使用timetime 可执行程序可以统计程序运行的时间。我这里分别创建了2个,5个,10个线程分别测试了。结果如下:

但是,程序最终的结果是错误的。全局变量count的值都变为负数了。

为什么会出现负数:

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • count-- 操作本身就不是一个原子操作

原子性:不会被调度机制打断,该操作的状态只有两种,要么成功,要么不成功。

--count这个操作,对应的汇编语言实际上一共要执行三步。(原子性也可以用汇编指令来衡量,只用一条汇编指令就可以完成的操作,则认为他是原子的) count--的汇编指令如下:

count--的操作并不是原子的,对应的三条汇编指令。

  • load:将共享变量count从内存加载到寄存器中。
  • update:更新寄存器里面的值,执行-1操作。
  • store :将新值,从寄存器写回共享变量count的内存地址

count--需要三步才能完成。当线程1将内存中的数据load到寄存器的时候,调度器可能就会线程1切走了,假设此时线程1读到的数据是10000,线程1被切走时,10000属于线程1的上下文信息,CPU会保存线程的上下文信息,然后这个线程就会被挂起。

假设此时线程2被调度器调度,CPU开始执行线程2了。而此时,线程2拿到内存中的count属于全局变量,还是10000。而系统给线程2的时间片可能较多,导致线程2一次性执行了很多次--才被切走,最终将count由1000减到了9000。

此时调度器再把线程1恢复上来,寄存器中保存着线程1的上下文信息,并且要将线程1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的10000,然后线程1继续执行--操作的第二步和第三步,最终将9999写回内存。

上述过程中,同样的数据,对于两个线程拿到的结果却是不一样的。如果这个数据涉及金钱,那么后果不堪设想。

因此对一个变量进行--操作并不是原子的,虽然count--看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编。 要解决这一个问题,需要做到三点:

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,需要给临界区上一把锁。Linux上提供的这把锁叫互斥量。

互斥量 mutex

在临界区访问临界资源的时候加一把锁。做到让临界区的代码只允许一个线程执行,不允许多个线程同时执行。

互斥量接口

互斥量就是phtread库中提供的一个数据类型,叫做pthread_mutex_t。 使用互斥量之前要初始化,使用完之后要销毁互斥量。

  • 初始化

    • 全局或者静态互斥量初始化(使用宏初始化)
    cpp 复制代码
    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
    • 局部互斥量初始化(使用init函数初始化)
    cpp 复制代码
    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
    • 参数一:mutex要初始化的互斥量。
    • 参数二:attr设置互斥量的属性,一般设置为NULL即可。
    • 返回值:成功返回0,失败返回错误码
  • 销毁

    cpp 复制代码
    int pthread_mutex_destroy(pthread_mutex_t *mutex);
    • 参数一:mutex要销毁的互斥量
    • 返回值:成功返回0,失败返回错误码

    销毁互斥量时要注意:

    1. 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
    2. 不能销毁一个已经加锁的互斥量
    3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
  • 互斥量的加锁和解锁

    • 加锁
    cpp 复制代码
    int pthread_mutex_lock(pthread_mutex_t *mutex);
    • 解锁
    cpp 复制代码
    int pthread_mutex_unlock(pthread_mutex_t *mutex);

    参数和返回值与上面的接口都一样。

调用pthread_mutex_lock给互斥量加锁时,可能会遇到下面的情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

对上面的例子进行互斥加锁行为如下:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int count = 10000;
//线程数据
class ThreadData
{
public:
    ThreadData(string &name, pthread_mutex_t *mutex)
        : _name(name), _mutex(mutex)
    {}

public:
    string _name;
    pthread_mutex_t *_mutex;
};

void *TouchThread(void *arg)
{
    ThreadData *td = (ThreadData *)arg;
    while (1)
    {
        pthread_mutex_lock(td->_mutex); // 加锁
        if (count > 0)
        {
            usleep(1000);
            cout << td->_name << ":" << count << endl;
            --count;
            pthread_mutex_unlock(td->_mutex); // 解锁
        }
        else
        {
            pthread_mutex_unlock(td->_mutex); // 解锁
            break;
        }
    }

    delete td;
    return nullptr;
}

int main()
{
    const int NUM = 5;
    pthread_t tid[NUM];
    pthread_mutex_t mutex;            // 互斥量
    pthread_mutex_init(&mutex, NULL); // 初始化互斥量

    // create thread
    for (size_t i = 0; i < NUM; ++i)
    {
        string name = "thread";
        name += to_string(i + 1);
        ThreadData *td = new ThreadData(name, &mutex);//线程的信息,需要名字和互斥量
        pthread_create(tid + i, nullptr, TouchThread, (void *)td);
    }

    // join thread
    for (size_t i = 0; i < NUM; ++i)
    {
        pthread_join(tid[i], nullptr);
    }

    pthread_mutex_destroy(&mutex); // 销毁互斥量
    return 0;
}

这里创建了一个局部互斥量,给每一个线程传参数时不仅要传互斥量还要传线程名。这里使用一个类ThreadData封装了这两个信息传给回调函数。

运行结果:

可以明显看出,加锁之后,速度大不如前。但是最终的结果是正确的,也可以看出,每个线程都是运行一段时间后再让其他线程运行。而不像最之前,五个线程是一起并行执行。

上面的代码在临界区之前进行了对互斥量加锁的行为。加锁之后,只有拿到互斥量的线程才能访问临界资源,没有拿到互斥量的只能阻塞等待。(因为给互斥量加锁时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁)这样就能保证线程互斥,临界资源只允许一个线程访问,不允许多线程并行访问。

一旦访问完临界资源,就应该对互斥量进行解锁,让其他线程竞争到这个互斥量。所以,在对互斥量加锁的时候要保证力度越小越好。也就是临界区的代码越少越好。(上面的代码中,第26行if语句,对临界资源进行判断,这个操作也是要访问临界资源的,所以加锁在if判断之前,当访问完临界资源的时候,就要立即解锁)

通过上面的分析,可以看出,给互斥量加锁之后,执行临界区的代码从并行变为了串行。性能也会损耗不少,只是无法避免的。所以在加锁的时候要在合适的位置进行加锁和解锁,这样尽可能减少性能的损耗。

总结:

对临界资源的保护,是每一执行流都要遵守的,也就是说多线程要访问临界,每一个线程都必须要先申请锁,并且这些线程看到的是同一个互斥量。如果一个线程不申请锁就访问临界资源,这是错误的编码方式。

互斥量加锁的实现原理

在汇编的角度来看,只有一条汇编语句的话,认为该汇编语句是原子的。汇编指令swap或者exchang指令,就可以做到以一条指令的方式,将CPU寄存器和内存的数据交换。

当调用pthread_mutex_init函数时,他的汇编伪码是: 下面以两个线程A和B的场景为例,介绍一下加锁的时候,操作系统做了什么。

  1. 当线程A执行movb $0, %al时,将寄存器%al的值设置为0。
  1. 当线程A执行第二天汇编xchgb时,可能会被调度器调走,而寄存器中的内容是线程A私有的数据,属于线程的上下文信息。切走的时候,将0记录下来。
  2. 线程B被调度器调度,也要加锁,执行movb指令,将寄存器的值设置为0。假设此时没有被调度器切走,然后继续执行xchgb指令,将寄存器的内容和内存中mtuex的值进行交换。
  1. 此时al寄存器的内容为1。大于0,加锁成功。然后执行临界区的代码。(没执行到解锁也被切走了)

  2. 线程B被切走,al寄存器的内容属于线程B的上下文信息,被保存起来。然后执行线程A。

  3. 线程A别执行,恢复线程上下文信息,将寄存器al的值0恢复。

  1. 线程A执行xchgb指令,交换内存和寄存器的值。此时内存中mutex值还是0。换完后,进行判断。不大于0,继续挂起的等待。然后操作系统再让线程B来执行。
  2. 线程B临界区代码执行结束,进行解锁操作。解锁汇编伪码如下:

将寄存器al的值1,和内存中mutex做交换,这样下一个线程进程加锁的时候就可以成功加锁了。

以上就是互斥量加锁的原理。从汇编的角度看。用一条汇编指令,将内存和CPU内寄存器的值做交换,做到了原子性,保证了数据交换的时候线程不会被切走。以加锁的方式保证了临界资源被线程串行访问,保证了临界资源中数据一致性。

线程同步

同步更强调解决访问临界资源合理性的问题,加锁可以解决临界资源数据一致性的问题,但是同时也带来了新的问题,当一个线程带着锁访问临界资源的时候,造成了其他线程阻塞无法访问的情况,带来了线程饥饿问题。解决这一问题,引入了同步。

同步就是在保证数据安全的前提下,按照一定的规则,顺序的去访问临界资源。从而解决饥饿问题。

单纯的加锁,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。当添加一个规则:

  • 当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
  • 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。

例如,现在有两个线程访问一临界资源,一个线程写入数据,另一个线程读取数据,但负责数据读取的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行读操作,而写线程还没有向临界资源写过任何数据。这就再做无用功。引入同步后该问题就能很好的解决。哪怕是读线程先读,没有读到,下次也轮到写线程写了。这样就不会造成一直读不到数据的问题。

条件变量

条件变量是解决线程同步的一种方案,需要配合互斥锁使用。 条件变量的使用和互斥锁差不多,也需要初始化和销毁。 pthread_cond_t是库中提供的条件变量类型。

  • 初始化
    • 全局或静态使用宏PTHREAD_COND_INITIALIZER初始化
    • 局部使用函数初始化pthread_cond_init(使用方法和互斥量一样)
  • 销毁
    • 使用宏初始化的不用销毁
    • 使用函数初始化用pthread_cond_destroy销毁

互斥量的重点在加锁和解锁,而条件变量的重点在等待和唤醒。

  • 一个线程等待条件变量的条件成立而被挂起。

  • 另一个线程使条件成立后唤醒等待的线程。

  • 等待

    • 函数原型
    cpp 复制代码
    int pthread_cond_wait(pthread_cond_t *restrict cond,
              pthread_mutex_t *restrict mutex);
    • 参数一:需要等待的条件变量。
    • 参数二:当前线程所处临界区对应的互斥锁
    • 返回值:函数调用成功返回0,失败返回错误码
  • 唤醒

    • 函数原型:
    cpp 复制代码
    int pthread_cond_broadcast(pthread_cond_t *cond);
    int pthread_cond_signal(pthread_cond_t *cond);
    • 唤醒有两个函数,第一个一次唤醒同一条件变量下的所有线程。
    • 第二个唤醒唤醒等待队列中首个线程。
    • 返回值:成功返回0,失败返回错误码。

使用示例: 创建四个线程,对count进行--,申请成功之后等待,主线程进行唤醒操作

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int count = 10000;

class ThreadData
{
public:
    ThreadData(string &name, pthread_mutex_t *mutex,pthread_cond_t *cond)
        : _name(name), _mutex(mutex),_cond(cond)
    {}

public:
    string _name;
    pthread_mutex_t *_mutex;
    pthread_cond_t *_cond;
};

void *TouchThread(void *arg)
{
    ThreadData *td = (ThreadData *)arg;
    while (1)
    {
        pthread_mutex_lock(td->_mutex); // 加锁
        pthread_cond_wait(td->_cond,td->_mutex);//等待唤醒
        if (count > 0)
        {
            usleep(1000);
            cout << td->_name << ":" << count << endl;
            --count;
            pthread_mutex_unlock(td->_mutex); // 解锁
            
        }
        else
        {
            pthread_mutex_unlock(td->_mutex); // 解锁
            break;
        }
    }

    delete td;
    return nullptr;
}

int main()
{
    const int NUM = 5;
    pthread_t tid[NUM];
    pthread_mutex_t mutex;            // 互斥量
    pthread_cond_t cond;//条件变量

    pthread_mutex_init(&mutex, NULL); // 初始化互斥量
    pthread_cond_init(&cond,NULL);//初始化条件变量

    // create thread
    for (size_t i = 0; i < NUM; ++i)
    {
        string name = "thread";
        name += to_string(i + 1);
        ThreadData *td = new ThreadData(name, &mutex,&cond);//线程的信息,需要名字和互斥量
        pthread_create(tid + i, nullptr, TouchThread, (void *)td);
    }
    sleep(2);

    //唤醒线程
    while(count > 0 )
    {
        cout << "唤醒一批线程ing:" << endl;
        pthread_cond_broadcast(&cond);//每次唤醒一批线程
        sleep(1);
    }
    // join thread
    for (size_t i = 0; i < NUM; ++i)
    {
        pthread_join(tid[i], nullptr);
    }

    pthread_mutex_destroy(&mutex); // 销毁互斥量
    pthread_cond_destroy(&cond); // 销毁条件变量
    return 0;
}

运行结果: 主线程休眠两秒后,开始唤醒线程一次唤醒5个线程。每隔一秒唤醒一次

条件变量为什么需要互斥量

  • 当一个线程访问临界资源的时候,此时临界资源如果还没有准备好,就会让线程去等待,等待的时候,如果不解锁,其他线程来了以后,无法申请到锁,就会被挂起,只有等到带锁的线程解锁之后才可以去竞争锁。所以等待的时候要解锁,让其他线程来了之后也可以申请锁,当线程被唤醒的时候,在哪里等待挂起继续在哪里执行,唤醒的时候会自动加锁。然后再去访问临界资源。
  • 所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
  • 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
  • 条件变量等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有另一个线程通过某些操作,改变临界资源,使原先不满足的条件变得满足,并且通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到临界资源的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改临界资源。

上面的例子不能很好的说明这个问题,多线程的应用场景有一个非常经典的生产者消费者模型,通过生产者消费者模型,能更好的说明上面的问题

生产者消费者模型

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过特定容器(阻塞队列)来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

生产者消费者模型的特点

生产者消费者模型是多线程同步与互斥的一个经典场景,其特点如下:

  • 三种关系
    • 生产者和生产者之间的关系:互斥关系。(这里的阻塞队列就是临界资源,生产者向队列生产数据时,必须是互斥关系,保证队列中数据一致性。)
    • 消费者和消费者之间的关系:互斥关系。(同理,消费者线程访问临界资源也必须是互斥关系)
    • 生产者和消费者之间的关系:互斥与同步关系。(不能让生产者或者消费者一方一直去访问临界资源,这样的话,如果生产者一直生产,会将容器塞满,满了之后,就不能继续生产了,通知消费者消费数据。当容器有容量时再继续生产。)
  • 两种角色
    • 生产者
    • 消费者
  • 一个容器
    • 阻塞队列(生产者和消费者之间数据的交易场所)

基于阻塞队列的生产者消费者模型

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元 素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程 操作时会被阻塞) 先实现一个阻塞队列

cpp 复制代码
#pragma once
#include <iostream>
#include <queue>
#include <unistd.h>
#include <pthread.h>
using namespace std;

const int DEFAULT = 5; // 队列默认容量大小

template <class T>
class BlockQueue
{
public:
    // BlockQueue(){}
    BlockQueue(int capacity = DEFAULT)
        : _capacity(capacity)
    {
        pthread_mutex_init(&_mtx, nullptr);
        pthread_cond_init(&_full, nullptr);
        pthread_cond_init(&_empty, nullptr);
    }
    void push(T &in)
    {
        // 1. 访问临界资源保证线程安全要加锁 这里的阻塞队列就是临界资源
        pthread_mutex_lock(&_mtx);
        // 2. 检查临界资源是否准备完成
        if (IsFull())
        {
            pthread_cond_wait(&_full, &_mtx);
        }
        _bqueue.push(in);
        pthread_mutex_unlock(&_mtx);
        pthread_cond_signal(&_empty);
    }
    void pop(T *out)
    {
        pthread_mutex_lock(&_mtx);
        if (IsEmpty())
        {
            pthread_cond_wait(&_empty, &_mtx);
        }
        *out = _bqueue.front();
        _bqueue.pop();
        pthread_mutex_unlock(&_mtx);
        pthread_cond_signal(&_full);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mtx);
        pthread_cond_destroy(&_full);
        pthread_cond_destroy(&_empty);
    }

private:
    bool IsFull()
    {
        return _bqueue.size() == _capacity;
    }
    bool IsEmpty()
    {
        return _bqueue.size() == 0;
    }

private:
    queue<T> _bqueue;      // 阻塞队列
    size_t _capacity;      // 队列容量
    pthread_mutex_t _mtx;  // 互斥量
    pthread_cond_t _full;  // 判满的条件变量
    pthread_cond_t _empty; // 判空的条件变量
};

注意:

  • 上面的阻塞队列代码,在检查临界资源是否准备完成时,使用的时if进行判断的,但是,pthread_cond_wait是一个函数,这就意味着这个函数有可能会被调用失败,一旦调用失败,就会继续执行后面的代码,会去访问临界资源。
  • 拿生产者线程来说,如果阻塞队列已经满了,但是等待函数调用失败,继续会去向阻塞队列中生产数据,这样会造成阻塞队列实际的容量和自己设定的容量不一样,后续在进行判空判满的话就会出错。
  • 其次,如果在多消费者的情况下,当生产者生产了一个数据后如果使用pthread_cond_broadcast函数唤醒消费者,就会一次性唤醒多个消费者,但待消费的数据只有一个,此时其他消费者就被伪唤醒了。
  • 为了避免这种特殊情况的出现, 应该让线程在被唤醒后,再次进行判断,确认是否真的满足生产消费条件,因此这里必须要用while进行判断。

修改后的阻塞队列:

cpp 复制代码
// 基于阻塞队列的生产者消费者模型
#pragma once
#include <iostream>
#include <queue>
#include <unistd.h>
#include <pthread.h>
using namespace std;

const int DEFAULT = 5; // 队列默认容量大小

template <class T>
class BlockQueue
{
public:
    BlockQueue(int capacity = DEFAULT)
        : _capacity(capacity)
    {
        pthread_mutex_init(&_mtx, nullptr);  // 初始化互斥量
        pthread_cond_init(&_full, nullptr);  // 初始化条件变量
        pthread_cond_init(&_empty, nullptr); // 初始化条件变量
    }
    void push(T &in)
    {
        // 1. 访问临界资源保证线程安全要加锁 这里的阻塞队列就是临界资源
        pthread_mutex_lock(&_mtx);
        // 2. 检查临界资源是否准备完成(如果当前阻塞队列满了,生产者线程就不想继续向队列中push数据。就在满的条件变量下进行等待)
        while (IsFull())//必须使用while进行判断
        {
            pthread_cond_wait(&_full, &_mtx);
        }
        _bqueue.push(in);
        pthread_mutex_unlock(&_mtx);
        pthread_cond_signal(&_empty);
    }
    void pop(T *out)
    {
        // 1. 访问临界资源保证线程安全要加锁 这里的阻塞队列就是临界资源
        pthread_mutex_lock(&_mtx);
        // 2. 检查临界资源是否准备完成(如果当前阻塞队列为空,消费者线程就不能继续从队列中pop数据。在空的条件变量下进行等待)
        while (IsEmpty())//必须使用while进行判断
        {
            pthread_cond_wait(&_empty, &_mtx);
        }
        *out = _bqueue.front();
        _bqueue.pop();
        pthread_mutex_unlock(&_mtx);
        pthread_cond_signal(&_full);
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mtx);  // 销毁互斥量
        pthread_cond_destroy(&_full);  // 销毁条件变量
        pthread_cond_destroy(&_empty); // 销毁条件变量
    }

private:
    //  检查当前阻塞队列是否为满
    bool IsFull()
    {
        return _bqueue.size() == _capacity;
    }
    // 检查当前阻塞队列是否为空
    bool IsEmpty()
    {
        return _bqueue.size() == 0;
    }

private:
    queue<T> _bqueue;      // 阻塞队列
    size_t _capacity;      // 队列容量
    pthread_mutex_t _mtx;  // 互斥量
    pthread_cond_t _full;  // 判满的条件变量
    pthread_cond_t _empty; // 判空的条件变量
};

条件变量使用规范

  • 等待条件代码
cpp 复制代码
pthread_mutex_lock(&mutex);
while (条件为假)
	pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
  • 等待一定是在加锁和解锁之间进行等待的。
  • 给条件发送信号代码
cpp 复制代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

这里可以使用RAII机制对锁进行设计。如下:

cpp 复制代码
// 使用RAII机制对锁进行设计
#pragma once
#include <iostream>
#include <pthread.h>
using namespace std;

class MUTX
{
public:
    MUTX(pthread_mutex_t *pmtx)
        : _pmtx(pmtx)
    {
    }
    void lock()
    {
        pthread_mutex_lock(_pmtx);
    }
    void unlock()
    {
        pthread_mutex_unlock(_pmtx);
    }
    ~MUTX()
    {
    }

private:
    pthread_mutex_t *_pmtx;
};
// RAII风格的加锁方式
class LockGroud
{
public:
    LockGroud(pthread_mutex_t *pmtx) : _pmtx(pmtx)
    {
        _pmtx.lock();
    }
    ~LockGroud()
    {
        _pmtx.unlock();
    }

private:
    MUTX _pmtx;
};

这样在阻塞队列中使用锁如下(以push为例):

cpp 复制代码
 void push(T &in)
{
    // RAII机制的加锁方式
    LockGroud lockgroud(&_mtx);
    while (IsFull()) // 必须使用while进行判断
    {
        pthread_cond_wait(&_full, &_mtx);
    }
    _bqueue.push(in);
    pthread_cond_signal(&_empty);
    // 函数调用结束,lockgrou局部对象生命周期结束自动调用析构函数进行解锁
}

有关线程的其他概念

可重入&&线程安全

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 如果没有锁保护的情况下,会出现线程安全问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。(上面demo代码中TouchThread函数就是可重入函数)。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。

常见的线程不安全情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数。
  • 调用线程不安全函数的函数

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

常见的不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
  • 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
  • 可重入函数体内使用了静态的数据结构。

常见可重入情况

  • 不使用全局变量或静态变量。
  • 不使用malloc或者new开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都由函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

可重入与线程安全的联系

  • 函数是可重入的,那就是线程安全的。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全的区别

  • 可重入函数是线程安全函数的一种。
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。

死锁

死锁是锁常见的一个概念。 死锁是指在一组进程或线程中的各个进程或线程均占有不会释放的资源,但因互相申请被其他进程或线程所占用不会释放的资 源而处于的一种永久等待状态。

发生死锁的必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用。
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配
相关推荐
水木的编程那些事儿20 分钟前
awk那些事儿:在awk中使用shell变量的两种方式
linux·awk
aspirestro三水哥1 小时前
4.4.5 timer中断流向Linux(从interrupt log回放)
linux·运维·服务器·arm64·xenomai
头真的要秃啦1 小时前
Linux TCP服务器客户端
linux·服务器·tcp/ip
Crossoads2 小时前
【汇编语言】更灵活的定位内存地址的方法(一)—— 字符操作:and与or指令、ASCII码及大小写转换
android·linux·运维·服务器·汇编·机器学习·数据挖掘
hong1616882 小时前
Conda环境与Ubuntu环境移植详解
linux·ubuntu·conda
闲晨3 小时前
Linux开发工具:Vim 与 gcc,打造高效编程的魔法双剑
linux·运维·vim
我是唐青枫4 小时前
Linux Debian发行版系统包管理工具使用教程
linux·debian
vickycheung37 小时前
基于RK3588的移动充电机器人应用解决方案
linux·机器人·arm 嵌入式开发
胡西风_foxww9 小时前
Linux下编译安装Nginx
linux·运维·nginx·编译·安装·openssl·pcre