【Linux】线程中的互斥锁、条件变量、信号量(数据安全问题、生产消费模型、阻塞队列和环形队列的实现)

1、线程互斥

1.1 线程间频繁切换导致的问题

下面进行一个"售票"小实验,通过让四个线程并发的对一个全局变量进行tickets--操作,看最后的结果,好让我们意识到多线程并发的问题。

做法就是多个线程并发的执行同一段代码,同时调度器尽可能频繁发生线程调度和切换。

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//定义一个全局变量,这个变量可以被所有线程访问
//这个数据越大,出现问题的概率越高
int tickets = 10000000;

void* GetTicks(void* args)
{
    while(true)
    {
        if(tickets > 0) //1、读取数据到寄存器中 2、进程判断
        {
            tickets--; //1、读取数据 2、更改数据 3、写回数据
        }
        else
        {
        	//tickets到0退出
            break;
        }
        //后续可能还有其它动作,假设用了500微秒
        usleep(500);
    }
}

int main()
{
#define NUM 4
    vector<pthread_t> threads(NUM);

    for(int i = 0; i < NUM; ++i)
    {
        pthread_create(&threads[i], nullptr, GetTicks, nullptr);
    }

    for(const auto& tid : threads)
    {
        pthread_join(tid, nullptr);
    }

    cout << "最终剩余:" << tickets << endl;
    
    return 0;
}

结果出现减到负数。
值得注意的是,线程的调度顺序是随机的,也就是说每次运行结果都是随机的。
下面探讨就是为什么会出现随机结果

(值得注意的是,共享数据越大,出现问题的概率越高)
在这里插入图片描述

那么为什么会发生上面这个现象呢?

首先值得说明的是,tickets–这个操作在汇编中是有三步的。
在这里插入图片描述

其次,一个线程在运行一个时间片到后,线程切换调度另一个线程。线程从内核态切换回用户态前,线程要对调度状态进行检测,如果可以就进行切换。(其实就是在调用系统接口前判断状态,然后是内核态就切换)

可能当一个线程执行到tickets对应的代码时,比如进入tickets>0判断并且还未减一操作,这时该线程被切换走了,上下文数据被保存,线程切换换成了下一个线程,而下一个线程减一操作让tickets减为了0;当原来线程被切回来时,读取内存中tickets为0的数据,继续调用还未减一的操作,那么执行完操作后就给内存返回了一个负数。

这种就是典型的由于线程执行顺序不确定导致的竞态条件问题。


通过以上还可以直观的理解一些概念:

  1. 上述tickets对所有线程来说就可以称为一种共享资源,而如果通过某种手段让多个执行流安全的访问共享资源:此时这个共享资源就成为临界资源 (比如上面的tickets全局变量,但是目前暂时还没有通过一些手段让执行流安全访问它,所以只算是共享资源,假设它已经能被安全访问了,就有以下概念)
  2. 把多个执行流中,访问临界资源的代码,叫做临界区(很小部分,比如上面线程执行函数中访问tickets的两句代码),而其它部分就是非临界区
  3. 对一个资源访问的时候,要么不做,要么做完就是原子性的体现。目前可以理解成一个对资源进行的操作,如果只用一条汇编就能完成,那么这个操作就是原子性的。(可惜上面tickets减1操作由三条汇编完成,不是原子的。)


当然上述代码不仅可能因为tickets>0出问题,还可能在tickets减1操作上出问题

在这里插入图片描述

1.2 使用互斥锁

为了解决上面出现的线程频繁切换导致共享资源数据异常问题,可以通过一个互斥锁来初步解决

  1. 当代码执行临界区后,不允许其它线程切换。
  2. 如果多个线程在执行,并且目前没有线程进入临界区,那么只能让一个线程进入临界区。
  3. 如果线程不在临界区执行,那么不能阻止其它进入临界区的线程。

形象点来说,就是一个带锁的空间,一次只能一个线程访问,只有持有钥匙的线程才能进入,并且在未归还钥匙前,其它线程都不能进入这个空间,只能在外面等待。

这种多线程串行访问临界资源,就称为互斥。

做到以上三点就构成了一把互斥锁,在Linux中也称为互斥量

互斥量接口:

在这里插入图片描述

init初始化一把锁,destroy销毁一把锁。

其中mutex代表一个锁,如果在局部定义,就需要初始化和销毁;如果在全局定义,就直接赋值成PTHREAD_MUTEX_INITIALIZER

上面两个函数都是成功返回0,失败返回错误码。

加锁和解锁
在这里插入图片描述

lock加锁
unlock解锁
trylock,如果有锁就不加并且里面返回错误信息,如果没有就加锁。

下面直接通过简单代码演示其功能

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;

int tickets = 10000000;
//定义全局锁的话,不用在局部初始化和销毁,线程共享这个锁
//pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; 

//保存每个线程的属性
struct ThreadData
{
    string _name;
    pthread_mutex_t* _mutex_p;
    ThreadData(const string& name, pthread_mutex_t* mutex_p)
        :_name(name)
        ,_mutex_p(mutex_p)
    {}

    ~ThreadData()
    {}
};

void* GetTicks(void* args)
{
    ThreadData* td = static_cast<ThreadData*>(args);

    while(true)
    {
    	//在临界区加锁
        pthread_mutex_lock(td->_mutex_p);
        if(tickets > 0) //1、读取数据到寄存器中 2、进程判断
        {
        	//cout << "当前线程:" << td->_name << endl;
            tickets--; //1、读取数据 2、更改数据 3、写回数据
            //退出解锁
            pthread_mutex_unlock(td->_mutex_p);
        }
        else
        {
        	//退出前解锁
            pthread_mutex_unlock(td->_mutex_p);
            break;
        }
        //后续可能还有其它动作,假设用了500微秒
        usleep(500);
    }
}


int main()
{
#define NUM 4
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    vector<pthread_t> threads(NUM);

    for(int i = 0; i < NUM; ++i)
    {
    	//为每个线程创建一个名称
        char buffer[64];
        snprintf(buffer, sizeof buffer, "Thread %d", i+1);
        
        ThreadData* td = new ThreadData(buffer, &lock);
		
        pthread_create(&threads[i], nullptr, GetTicks, (void*)td);
    }

    for(const auto& tid : threads)
    {
        pthread_join(tid, nullptr);
    }

    pthread_mutex_destroy(&lock);
    cout << "最终剩余:" << tickets << endl;
    return 0;
}

结果就是还是同一1000w量级下,加锁后怎么运行都是0,这解决了问题

在这里插入图片描述

不过有一点可以明显感觉到,程序运行变慢了。
这是因为加锁和解锁的频繁切换。

问题并没有完全解决,因为上面代码还有线程饥饿问题:

void* GetTicks(void* args)
{
    ThreadData* td = static_cast<ThreadData*>(args);
    while(true)
    {
        pthread_mutex_lock(td->_mutex_p);
        if(tickets > 0)
        {
        	cout << "当前线程:" << td->_name << endl;
            tickets--;
            pthread_mutex_unlock(td->_mutex_p);
        }
        else
        {
            pthread_mutex_unlock(td->_mutex_p);
            break;
        }
        //usleep(1); //休眠1微秒
    }
}

再为代码添加一个打印线程,会发现在线程调度函数时,一个优先级高的线程会一直抢占临界区。这就使得其它线程一直等待,出现线程饥饿问题

在这里插入图片描述

这也是互斥锁的问题。
不过可以在后面添加一个usleep(1);让当前线程阻塞,进行线程切换,以保证线程依次运行。

在这里插入图片描述


那么如何看待锁呢?

首先可以理解一个线程持有锁进入临界区,在其它线程看来,就是原子的(是否持有锁),因此通过锁构建的原子性安全的访问了共享资源。

其次可以从上面代码中看到,lock变量只在主线程定义了一个,但是却可以让四个线程共享。因此其实锁本身就是一个共享资源,而全局的变量是被锁保护的,那么锁的安全怎么被保护呢?


其实pthread_mutex_lock这个加锁操作具有原子性,因此保证了其安全。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。

1.3 互斥锁的原理

先来看这几个问题
当一个线程,申请锁成功,进入临界资源,正在访问临界资源期间,其它线程在做什么呢?
其它线程在执行临界区以外的代码后如果需要访问临界区就会等待。

当一个线程,申请锁成功,进入临界资源,正在访问临界资源期间,是否可以被其它线程切换呢?
是可以的,只不过当其它线程切换后,由于之前的线程在被切换的同时也带走了其上下文,对应的锁也被其带走了。因此当其它线程访问临界区会等待,再次进行线程切换,只有再次切换到拥有锁的那个线程才能访问临界区。


因此在理解上,在其它线程看来,当前线程持有锁的过程是原子的(要么有,要么没有)。

下面这张图就解释了实现互斥锁操作的安全性实现问题。

在这里插入图片描述

1.4 线程中的数据安全问题

线程中的数据安全问题有以下几种:

竞态条件:多个线程同时竞争同一资源,导致结果依赖于运行时的随机性。(就是第一套代码的问题)

非原子操作:一个操作被多个线程同时执行,导致其中某些操作未能按照期望的顺序执行。(比如第一套代码的tickets减1操作)

死锁问题:多个线程等待彼此释放锁,导致程序无法继续执行。(更专业谈:多个进程循环等待它方占用的资源而僵持下去的问题。当两个或以上的进程同时对多个互斥资源请求使用时,就可能导致死锁。)

举个例子:两个小朋友分别有5毛钱,但只能凑齐1块才能买一个棒棒糖,其中一个小朋友想向另一个小朋友索要他的5毛钱,而另一个小朋友又想要他的5毛钱,这就出现了一个互相等待的状态。

在未来需要使用多把锁的情况,我们持有自己的锁不释放,同时也要对方的锁,这就容易造成死锁问题。

一把锁也是可以造成死锁问题的:一把锁加锁两次!


死锁的四个必要条件

1、构成互斥:如用了锁就构成了互斥。(不能被2个或以上进程同时占用的这种资源,如果被使用那么就构成互斥。)
2、请求与保持:线程本身拥有锁,不释放,还要其它线程的锁。(要破坏这个条件,可以通过trylock调用,当有锁的时候加锁就会报错)
3、不剥夺:当前线程不能直接拿到向另一个线程申请的锁。(而剥夺了就可以破坏死锁,所以可以通过一个竞争策略,当前线程假设已经申请到A锁,接下来又要申请B锁,B锁被其它线程拿到了,这时可以通过两个线程的优先级,让其中优先级低的主动解锁)
4、环路等待条件:当前线程向另一个线程申请锁,另一个线程也同时向当前线程申请锁。(让一个线程同时申请A,B两把锁,不要先一个申请A和B,另一个右申请B和A。)

所以避免死锁就得破坏上面这些条件。
但是其实能不用锁就不用锁

2、线程同步之条件变量

前面说,互斥锁能够通过限制单一线程访问临界区从而解决数据安全的问题。但是互斥锁仅仅只是限制了一个线程的访问,如果单个线程在一定条件下一直持有锁不释放,这就会频繁占用CPU资源,导致其它线程总是等待资源,造成线程饥饿问题,这非常不合理。(也就是互斥锁限定了一个线程访问临界资源,但是其没有规定哪个线程,这就可能造成一个线程一直抢占的问题,这是对的但不合理

为了解决这个问题,就需要线程同步,让访问完临界区的线程到后面排队,依次顺序访问临界区。

在访问临界资源安全的前提下,让线程访问某种资源,且具有一定的顺序性(防止饥饿,线程协同),这就是同步。

首先条件变量是一种Linux中最常见的同步机制,它通过一种等待通知机制维持了线程间的同步关系,下面先通过了解一个场景便于更好的理解条件变量。

2.1 生产消费模型

先通过了解生产消费模型能让我们更好的了解条件变量。

在生产消费模型可以通过一个例子理解存在三种个体,分别是生产者、消费者、超市(对应系统中的两个线程和一个缓冲区)。其中顾名思义,就是简单的消费者从超市消费,生产者生产放到超市。

生产消费模型的好处:

消除生产者和消费者的强耦合:超市的出现,使得生产者和消费者之间虽然彼此依赖但又不强加关系。生产者在生产的同时,消费者可以做其它事,而不是等待。(对应在系统上,生产线程在加载到数据到缓冲区的同时,消费线程可以取其它数据)

支持生产和消费忙闲不均问题:生产者和消费者彼此的生产速度和消费速度没有依赖关系,可以随意控制。

提高效率:首先正因为生产和消费可以分离开来,生产者和消费者可以分别以不同的速度和缓存器进行交互(放和拿)。其次,多个生产者在放入缓冲区前可以并行生产数据,多个消费者在拿取后也可以并行消费数据。彼此不影响。(因为生产数据和消费数据可能会用很长时间,因此如果消费数据和生产能并行,就能提高效率)


在这里插入图片描述

生产消费模型中个体的彼此关系:

生产者和生产者之间:在生产的过程彼此都是独立不影响的,但是在生产后放在缓冲区的时候是需要互斥的!(其实生活中超市是有很多个货架,所以放货可以一起,但是计算机中数据是会被覆盖的,所以需要互斥!

消费者和消费者之间:在获取数据后的处理彼此不影响,但是在获取数据的时候,是需要互斥的(不然会出现一个消费者抢另一个消费者的数据)

消费者和生产者之间:首先是需要互斥的,以保证数据安全(因为可能在读取数据的同时,生产者将数据覆盖了)。其次需要一种同步机制,如条件变量。(比如当一种货物没有的时候,如果消费者一直频繁向超市进行询问,那么这会浪费消费者和超市柜员的时间。在计算机中,如果消费线程在加锁访问临界资源时,由于没有资源可以被消费,消费线程就会一直在临界区判断是否有资源可以消费,这就导致其频繁占用CPU资源,造成生产线程的饥饿问题


那么生产消费模型所需要的同步就很清楚:

1、如果生产线程互斥的将缓冲区资源写满了,那么在缓冲区满的时候,就需要一种通知与等待机制让该线程进入一个等待队列,直到其它线程通知它缓冲区还有空位,就可以再次运行它。

2、反之,如果消费线程将缓冲区资源读完了,那么在缓冲区空的时候,就需要一种通知与等待机制让该线程进入一个等待队列,直到其它线程通知它缓冲区还有数据,就可以再次运行它。

这种通知与等待机制就需要同步机制中的条件变量。

2.2 条件变量概念和调用函数

条件变量的使用,能够让一个线程在满足某些条件后等待,直到其它线程通知它条件已满足。
通过条件变量可以实现等待和通知机制。

条件变量也是pthread库中的一个类型,用关键字cond表示。

条件变量的创建和销毁
在这里插入图片描述

cond在创建和销毁上用法和mutex基本一致。

如果在局部需要先创建一个临时变量(比如pthread_cond_t cond),再通过 init 初始化。
init函数调用cond参数对应创建的临时变量,attr参数访问其属性(不需要修改就置为nullptr)

如果要销毁临时变量,就通过 destroy 调用。

如果在全局创建就可以直接写pthread_cond_t cond = PTHREAD_COND_INITIALIZER


条件变量中等待和唤醒
让当前线程在指定的cond上等待
在这里插入图片描述

pthread_cond_wait:

该函数的目的就是当线程在临界区中运行时,如果满足了某种条件,就将当前线程在指定的cond上进行等待。当满足条件后由其它线程通过信号将其唤醒,如果没有会一直等待,直到有信号或超时终止。

函数实现的细节: 等待时自动释放锁,好让其它线程访问共享资源,之后加入一个等待队列,当满足条件后,函数会重新获取锁。
因此:
cond参数需要指定线程在哪一个条件变量上等待。
mutex参数需要指定当前线程被上的锁。

timedwait是指定了等待时间其它都一样,一般用wait控制好就行。


向在cond上等待的线程发送一个信号,使其退出等待队列,正常运行。
在这里插入图片描述

其中:
broadcast:可以让所有在cond对应等待队列的线程收到信号。
signal:让其中一个在cond对应等待队列最前的线程收到信号。

理解条件变量

条件变量其实就是一个类型。有着一个自己的等待队列,并且有着多种属性(如状态)。
一个例子:
假设有一个面试现场(临界区),在面试现场的一块区域中有一个面试官(共享资源)正在面试一个面试者(线程),在这个区域外,有很多其它面试者(线程)正在无序地等待面试。无序就造成了当一个面试者面试完成出来后,其它面试者就争着看谁声音大寻求面试,这也就容易造成一些面试者抢不到面试机会(饥饿问题)。因此如果再让一个公司的人(条件变量)在门口建立一个等待队列,让面试的人依次排队,后来的人在队尾,这样就是一个合理有效的方案。

在这里插入图片描述

2.3 阻塞队列的实现

下面通过代码,将生产消费模型和条件变量的使用进行融合。

阻塞队列的思路
在这里插入图片描述

  1. 建立一个阻塞队列作为一个缓冲区,当生产者生产时就是入队操作,当消费者消费时就是出队操作。
  2. 生产的具体过程中,由于需要互斥生产,所以在入队前需要加锁,入队后需要解锁。并且在入队前需要判断队列是否已满,如果满了需要让线程进入等待。如果入队成功说明必不为空可以向空的条件变量线程发送信号。
  3. 消费的具体过程中,由于需要互斥消费,所以在出队前需要加锁,出队后需要解锁。并且在出队前需要判断队列是否空,如果空了需要让线程进入等待。如果出队成功说明必不为满可以向满的条件变量线程发送信号。
  4. 生产和消费的关系上,需要维持同步关系,当队列为满时,需要让生产线程进入等待,并且唤醒等待的消费线程,反之队列为空消费线程进入等待,并唤醒等待的生产线程。
  5. 生产的数据可以通过随机生成几个数字,消费可以通过算几个简单加减法演示。
  6. 再建立一个阻塞队列,利用其存储处理完的数据,再写入文件。

阻塞队列类的实现

#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
using std::cout;
using std::endl;
using std::queue;

//定义阻塞队列最大容量
const int maxcap = 500;

template<class T>
class BlockQueue
{
private:
    queue<T> _q;
    int _capacity;
    pthread_cond_t not_full_cond;//没有满的条件
    pthread_cond_t not_empty_cond;//没有空的条件
    pthread_mutex_t _mutex;
public:
    BlockQueue(const int& capacity = maxcap)
        :_capacity(capacity)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&not_full_cond, nullptr);
        pthread_cond_init(&not_empty_cond, nullptr);
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&not_full_cond);
        pthread_cond_destroy(&not_empty_cond);
    }

    void push(const T& in) //输入型参数 const T&
    {
    	//访问共享资源必须是互斥访问,所以加锁
        pthread_mutex_lock(&_mutex);
        while(isfull())
        {
        	//当队列满,就在没有满的条件进行等待。
            pthread_cond_wait(&not_full_cond, &_mutex);
        }

        _q.push(in);
        //入队成功说明没有空的条件成立,向等待这个条件的线程发送信号
        pthread_cond_signal(&not_empty_cond);
        pthread_mutex_unlock(&_mutex);
        usleep(1);
    }

    void pop(T* out)  //输出型参数 T*   输入输出型参数T&
    {
        pthread_mutex_lock(&_mutex);
        while(isempty())
        {
            pthread_cond_wait(&not_empty_cond, &_mutex);
        }

        *out = _q.front();
        _q.pop();
        pthread_cond_signal(&not_full_cond);
        pthread_mutex_unlock(&_mutex);
        usleep(1);
    }
private:
    bool isfull()
    {
        return _q.size() == _capacity;
    }

    bool isempty()
    {
        return _q.empty();
    }
};

细节一:这里使用while而不是if的原因是,当多个线程收到信号退出等待队列,可能在其中一个线程生产后队列又满了,所以其它线程还需要再判断条件进入等待队列,不然会出现虚假唤醒。
并且,在wait介绍那里也说了,等待时自动释放锁,好让其它线程访问共享资源,之后加入一个等待队列,当满足条件后,函数会重新获取锁。

while(isfull())
{
    pthread_cond_wait(&not_full_cond, &_mutex);
}

细节二:这两个函数谁在前谁在后都无所谓,因为发信号和该线程无关。

pthread_cond_signal(&not_empty_cond);
pthread_mutex_unlock(&_mutex);


任务类

#pragma once
#include<iostream>
#include<functional>
#include<string>
#include<unordered_map>
#include<string.h>

//传输任务
class CalTask
{
	//定义一个函数类型
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask()
    {}

    CalTask(const int& x, const int& y, const char& op, func_t func)
        :_x(x) ,_y(y) ,_op(op) ,_callback(func)
    {}
	
	//消费者返回的一个处理结果
    std::string operator()()
    {
        char buffer[64];
        snprintf(buffer, sizeof buffer, "%d %c %d=%d", _x, _op, _y, _callback(_x, _y, _op));
        return buffer;
    }
	
	//生产者生产的一个处理任务
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
        return buffer;
    }
private:
    int _x;
    int _y;
    char _op;
    func_t _callback;
};

//一共有以下operation
const std::string oper = "+-*/%";

int mymath(int x, int y, char op)
{
    std::unordered_map<char, std::function<int(int, int)>> dict =
    {
        {'+', [](int x, int y){ return x + y; }},
        {'-', [](int x, int y){ return x - y; }},
        {'*', [](int x, int y){ return x * y; }},
        {'/', [](int x, int y){ if(y == 0) {std::cerr << "div zero error!" << std::endl; return -1;} else return x * y; }},
        {'%', [](int x, int y){ if(y == 0) {std::cerr << "mod zero error!" << std::endl; return -1;} else return x % y; }}
    };

    int ret = dict[op](x, y);
    return ret;
}

//存储任务
class SaveTask
{
    using func_t = std::function<void(const std::string&)>;
public:
    SaveTask()
    {}

    SaveTask(const std::string& message, func_t func)
        :_message(message)
        ,_func(func)
    {}

    void operator()()
    {
        _func(_message);
    }

private:
    std::string _message;
    func_t _func;
};

void Save(const std::string& message)
{
    FILE* fp = fopen("./log.txt", "a+");
    if(fp == nullptr)
    {
        std::cout << strerror(errno) << std::endl;
        return;
    }

    fputs(message.c_str(), fp);
    fputs("\n", fp);

    fclose(fp);
}

其中有一个传输任务和一个存储任务。
传输任务就是收到数据后进行简单的加减乘除。
存储任务就是将结果放在一个log.txt文件中

主执行

#include "BlockQueue.hpp"
#include "Task.hpp"

//定义一个类,包括传输的队列和存储的队列
template<class C, class S>
class BlockQueues
{
public:
    BlockQueue<C>* _cal_bq;
    BlockQueue<S>* _save_bq;
};

//生产者
void* productor(void* args)
{
	//类型安全转换
    BlockQueues<CalTask, SaveTask>* bq = static_cast<BlockQueues<CalTask, SaveTask>*>(args);
    
    while(true)
    {
        int x = rand()%100;
        int y = rand()%10;
        int op_count = rand()%oper.size();
        CalTask in(x, y, oper[op_count], mymath);
        bq->_cal_bq->push(in);
        cout << "生产者:" << in.toTaskString() << endl;
    }

    return nullptr;
}

void* consumer(void* args)
{
    BlockQueues<CalTask, SaveTask>* bq = static_cast<BlockQueues<CalTask, SaveTask>*>(args);
    while(true)
    {
        CalTask out;
        bq->_cal_bq->pop(&out);
        cout << "消费者:" << out() << endl;

        SaveTask sv(out(), Save);
        bq->_save_bq->push(sv);
        cout << "推送储存任务完成....." << endl;
		
		//可以控制消费者速度
        //sleep(1);
    }

    return nullptr;
}

void* saver(void* args)
{
    BlockQueue<SaveTask>* bq = (static_cast<BlockQueues<CalTask, SaveTask>*>(args))->_save_bq;
    while(true)
    {
        SaveTask t;
        bq->pop(&t);
        t();
        cout << "保存任务完成..." << endl;
    }

    return nullptr;
}

int main()
{
    srand((unsigned long)time(nullptr) * 0x123543);
    BlockQueues<CalTask, SaveTask> bq;
    bq._cal_bq = new BlockQueue<CalTask>();
    bq._save_bq = new BlockQueue<SaveTask>();
 

    pthread_t p[3], c[2], s;
    pthread_create(&p[0], nullptr, productor, (void*)&bq);
    pthread_create(&p[1], nullptr, productor, (void*)&bq);
    pthread_create(&p[2], nullptr, productor, (void*)&bq);
    pthread_create(&c[0], nullptr, consumer, (void*)&bq);
    pthread_create(&c[1], nullptr, consumer, (void*)&bq);
    pthread_create(&s, nullptr, saver, (void*)&bq);

    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);
    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(s, nullptr);

    delete bq._cal_bq;
    delete bq._save_bq;
    return 0;
}

运行结果:
在这里插入图片描述

3、线程同步之信号量

3.1 理解信号量

在前面条件变量参与的阻塞队列中,存在一些问题。

1、外部不能确定临界资源的情况,如果想要确认临界资源的情况只能通过先加锁再判断确定队列的情况。
2、多线程在并发访问阻塞队列时,是以一个整体资源去访问,并且是对确定位置的操作(固定的队尾入数据,队头出数据)。如果需要多线程并发访问共享资源的不同区域,这就不太行了。

接下来就需要引入信号量

首先,信号量可以理解为一个计数器,用来衡量临界资源中资源的多少。

其次,如果在访问临界资源前申请了一个信号量,就可以提前对临界资源的情况进行确认。(就不再需要像阻塞队列中那样先加锁再判断了)

那么所有线程就必须看到信号量,信号量本身就是一个共享资源。
共享资源需要被安全访问,那么信号量是如何保证本身安全的呢?

信号量作为一个计数器,自然就有两种功能:
1、sem减1,对应申请资源,这个操作是一种原子操作,因此保证了线程访问其是安全的。其也简称P操作
2、sem加1,对应归还资源,这个操作是一种原子操作,因此保证了线程访问其是安全的。其也简称V操作
两个PV原子操作语句,也称为PV原语。


信号量一旦申请成功,就意味着能保证申请的线程一定拥有一部分临界资源。(资源预订)
这也就意味着能使多个线程并发的访问到同一共享资源的不同区域。

下面先通过介绍接口,再通过一个结构的实现来体会信号量。

3.2 信号量接口

在Linux中信号量用sem关键字表示。
其调用的接口都是在<semaphore.h>头文件下。

信号量的初始化和销毁
初始化一个信号量
在这里插入图片描述

信号量也是一开始要定义的。
sem参数就是传定义的变量。
pshared:0表示线程间共享,非0表示进程间共享。
value:表示信号量初始值。(这个就设置为其代表共享资源的意义的值,比如设置为共享资源空间一开始有多大)
返回值成功返回0,错误返回错误码

销毁一个信号量,这个很简单,直接传定义的地址。
在这里插入图片描述

等待和发表信号量
这两个就是对应PV操作。
等待信号量,会将信号量的值减1,参数传定义的地址。
在这里插入图片描述

发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
在这里插入图片描述
下面直接通过代码感悟

3.3 环形队列的实现

首先这个环形队列也是基于生产消费模型来实现。

环形队列在数据结构上,本质是一个定长数组,通过两个下标指向入队和出队的位置(front,end),在环形队列为空时,两个下标指向同一位置,在环形队列为满时,两个指针也指向同一位置(这里不考虑空结点实现),两个下标通过取模运算控制范围。

在这里插入图片描述
具体实现细节:

1、环形队列通过一个固定大小的数组来存储数据。
2、在生产者访问临界区前,需要通过P操作来判断是否还有剩余空间,如果没有就需要等待。
3、反之消费者访问临界区前,也需要通过V操作来判断是否还有剩余资源,没有就等待。这样就可以维护生产者和消费者之间的同步关系。(不然会出现生产者和消费者对同一区域进行读写)
4、比如多个生产者在访问临界区时,肯定也是需要保持互斥的,那么就需要互斥锁。
5、生产的数据可以通过随机生成几个数字,消费可以通过算几个简单加减法演示。

环形队列类

//"RingQueue.hpp"
#pragma once

#include <iostream>
#include <vector>
#include <cassert>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

const int& mxcap = 500;

template<class T>
class RingQueue
{
private:
    //线程要访问临界资源中的某一块区域 申请信号量 所有人都要看到信号量,信号量因此也是个共享资源
    //访问共享资源要被保护,那么其操作就应该是原子的 PV原语
    void P(sem_t& sem)
    {
        //wait使信号量减一
        int n = sem_wait(&sem);
        assert(n == 0);
        (void)n;
    }

    void V(sem_t& sem)
    {
        //post使信号量加一
        int n = sem_post(&sem);
        assert(n == 0);
        (void)n;
    }
public:
    RingQueue(const int& cap = mxcap) :_cap(cap), _queue(cap)
    {
        sem_init(&_spaceSem, 0, _cap);
        sem_init(&_dataSem, 0, 0);
        pthread_mutex_init(&_pmutex, nullptr);
        pthread_mutex_init(&_cmutex, nullptr);
        _ProductorIdx = _ConsumerIdx = 0;
    }

    void Push(const T& in)
    {
        //生产对应剩余空间减一
        P(_spaceSem);
        pthread_mutex_lock(&_pmutex);
        _queue[_ProductorIdx++] = in;
        _ProductorIdx = _ProductorIdx % _cap;
        pthread_mutex_unlock(&_pmutex);
        V(_dataSem);
        usleep(1);
    }

    void Pop(T* out)
    {
        //消费对应空间数据减一
        P(_dataSem);
        pthread_mutex_lock(&_cmutex);
        *out = _queue[_ConsumerIdx++];
        _ConsumerIdx = _ConsumerIdx % _cap;
        pthread_mutex_unlock(&_cmutex);
        V(_spaceSem);
        usleep(1);
    }


    ~RingQueue()
    {
        sem_destroy(&_spaceSem);
        sem_destroy(&_dataSem);
        pthread_mutex_destroy(&_pmutex);
        pthread_mutex_destroy(&_cmutex);
    }
private:
    std::vector<T> _queue;
    int _cap;
    sem_t _spaceSem; //生产者看到的是剩余的空间
    sem_t _dataSem; //消费者需要看到的是数据
    int _ProductorIdx; //用下标确实生产消费者的位置
    int _ConsumerIdx;
    pthread_mutex_t _pmutex;
    pthread_mutex_t _cmutex;
};


任务类:

//"Task.hpp"
#pragma once
#include<iostream>
#include<functional>
#include<string>
#include<unordered_map>
#include<string.h>

using std::cout;
using std::endl;

class CalTask
{
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask()
    {}

    CalTask(const int& x, const int& y, const char& op, func_t func)
        :_x(x) ,_y(y) ,_op(op) ,_callback(func)
    {}

    std::string operator()()
    {
        char buffer[64];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", _x, _op, _y, _callback(_x, _y, _op));
        return buffer;
    }

    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", _x, _op, _y);
        return buffer;
    }
private:
    int _x;
    int _y;
    char _op;
    func_t _callback;
};

const std::string oper = "+-*/%";

int mymath(int x, int y, char op)
{
    std::unordered_map<char, std::function<int(int, int)>> dict =
    {
        {'+', [](int x, int y){ return x + y; }},
        {'-', [](int x, int y){ return x - y; }},
        {'*', [](int x, int y){ return x * y; }},
        {'/', [](int x, int y){ if(y == 0) {std::cerr << "div zero error!" << std::endl; return -1;} else return x / y; }},
        {'%', [](int x, int y){ if(y == 0) {std::cerr << "mod zero error!" << std::endl; return -1;} else return x % y; }}
    };

    int ret = dict[op](x, y);
    return ret;
}


主执行:

#include "RingQueue.hpp"
#include "Task.hpp"

void* ProductorRoutine(void* rq)
{
    RingQueue<CalTask>* _rq = static_cast<RingQueue<CalTask>*>(rq);
    while(true)
    {
        //生产
        int x = rand()%10;
        int y = rand()%20;
        int op_rand = rand()%oper.size();
        CalTask ct(x, y, oper[op_rand], mymath);
        _rq->Push(ct);
        cout << "生产任务:" << ct.toTaskString() << endl;
        sleep(1); //控制速度
    }
}

void* ConsumerRoutine(void* rq)
{
    RingQueue<CalTask>* _rq = static_cast<RingQueue<CalTask>*>(rq);
    while(true)
    {
        //消费
        CalTask ct;
        _rq->Pop(&ct);
        cout << "消费任务:" << ct() << endl;
       sleep(2);
    }
}

int main()
{
    srand((unsigned long)time(nullptr) ^ 0x12345678);
    pthread_t p[3], c[6];
    RingQueue<CalTask>* rq = new RingQueue<CalTask>();
    for(int i = 0; i < 3; ++i) pthread_create(p+i, nullptr, ProductorRoutine, rq);
    for(int i = 0; i < 6; ++i) pthread_create(c+i, nullptr, ConsumerRoutine, rq);

    for(int i = 0; i < 3; ++i) pthread_join(p[i], nullptr);
    for(int i = 0; i < 6; ++i) pthread_join(c[i], nullptr);
    delete rq;
    return 0;
}

运行结果
在这里插入图片描述

4、条件变量和信号量的对比

在功能上: 信号量主要用于线程之间同步资源的分配和释放,比如限制资源的数量或控制并发访问。而条件变量则用于在线程之间进行等待和通知,允许线程在满足特定条件之前等待,并在条件满足时被通知继续执行。
通知方式不同: 在使用条件变量时,通过signal或broadcast函数通知等待线程继续执行。而信号量没有直接的通知功能,只有sem_post函数释放资源时,如果有线程在等待信号量的资源,就将其唤醒。
应用场景不同: 信号量常用于限制并发访问或资源管理,比如限制线程的数量或控制访问共享资源的线程数量。条件变量常用于多个线程之间的条件等待和通知,例如线程A等待线程B完成某个任务后才能继续执行。

5、小结

总结一下:
多线程在并发没有安全访问共享资源时是会由于执行顺序不确定出现数据异常问题的,这也是竞态问题。因此就需要互斥锁,以保证其安全访问共享资源。接着互斥锁也是一种共享资源,因此通过了解了它的原理后就明白,互斥锁的实现原理本质也是一种原子操作,这就保证了其安全性。

互斥锁虽然能解决数据安全问题,但是因为其不合理性,引入了线程同步。
线程同步常用的就是条件变量,保证了多线程访问临界资源的顺序性,而通过生产消费模型和阻塞队列就能很好的认识条件变量。

信号量的引入,能让线程提前知道共享资源的情况,并且事先预定资源,再通过程序员自己的实现就可以使得多个线程能并发访问临界资源的不同区域。环形队列的实现也很好的体现了这些。

本章完~

  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值