Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)

本篇文章涉及到前两篇的知识点,需要的话可以了解下,有什么问题欢迎评论区留言我们互相学习,共同进步。
Linux之多线程第一部分:多线程的概念
Linux之多线程第二部分:线程的控制

为什么要互斥与同步

在介绍互斥之前,先介绍一下为什么要互斥,互斥的作用是什么
1.临界资源:凡是被线程共享访问的资源就是临界资源。比如我们多线程,多进程打印数据到显示器,这里的显示器被多个线程都访问了,因此显示器就是临界资源,再比如说全局变量等。
2.临界区:我的代码中访问临界资源的代码。并不是我所有的代码都进行访问临界资源的,访问临界资源的代码区域我们称之为临界区。
3.对临界区的保护的功能,本质上就是对临界资源的保护,防止多个执行流同时访问乱了套。保护的方式为:互斥与同步

互斥的概念

在任意时刻,只允许一个执行流访问某段代码(某部分资源),这种现象称之为互斥!
比如我们想显示器打印"hello world",在互斥的情况下我们要么完全打印,要么就不打印。也可以称为我们要保持原子性(一个事情要么做就做完,要么就不做)

用抢票案例来说明互斥的作用

说白了,互斥的方法就是在访问临界资源前对当前执行流加锁,只有等到执行流访问完临界资源,才将锁解开。在加锁到解开锁之间,其他的执行流都没有资格访问临界资源。

案例内容:定义10000张票,创建五个线程,让他们一起抢票,直到抢到票为空为止结束抢票

#include
#include
#include
using namespace std;

int tickets = 1000;

void * ThreadRun(void * args)
{
    int id = *(int*)args;
    delete (int*)args;

    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);//1s = 1000ms  1ms = 1000us
            cout<<"我是 "<< id << " 我要抢的票是:"<<tickets<<endl;
            tickets--;
            printf("");
        }
        else
        {
            break;
        }
    }
}
int main(int argc, const char** argv) 
{
    pthread_t tid[5];
    //创建五个线程
    for(int i = 0 ; i < 5 ; i++)
    {
        int *id = new int(i);
        pthread_create(tid + i, nullptr, ThreadRun, (void*)id);
    }
    //接收五个线程
    for(int i = 0 ; i < 5 ; i++)
    {
        pthread_join(tid[i], nullptr);
    }
    return 0;
}

运行结果如下:
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第1张图片
我们发现抢票不仅出现了抢到相同的票,还有竟然抢到了负数的情况,这在现实生活中是绝对不允许的!
下面我先画图说明下为什么会出现这种情况:
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第2张图片
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第3张图片
由上图可知:
比如当前线程A开始进入临界区,访问临界资源,当A执行完第一步(刚把tickets加载到CPU),此时A的时间片到了,A被切走,
但A是带着和自己线程有关的上下文数据一起走的,所以A就带这tickets为1000这个信息走了。
此时线程B进来,当B执行一段时间将tickets减到了10的时候,此时B的时间片到了,B带着自己的上下文数据被切走,A再进来
因为A是带着自己的上下文信息来的,因此A会认为tickets为1000,就会把B原来做的工作全部覆盖掉了,导致一切都乱了套!

这里就到我们的互斥锁派上了用场!

初始化锁:pthread_mutex_init 销毁锁pthread_mutex_destroy

Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第4张图片
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第5张图片

#include
#include
#include
using namespace std;

class Ticket
{
private:
    int tickets;
    //创建锁
    pthread_mutex_t mtx;
public:

    Ticket()
        :tickets(10000)
    {
        //初始化锁
        pthread_mutex_init(&mtx, nullptr);
    }

    bool GetTickets()
    {   
        //res并不是临界资源,他属于变量存于栈区,是每个线程私有的
        bool res = true;
        
        //上锁
        pthread_mutex_lock(&mtx);
        if(tickets > 0)
        {
            usleep(1000);//1s = 1000ms  1ms = 1000us
            cout <<"我是[ "<< pthread_self() << " ]我要抢的票是:"<< tickets << endl;
            tickets--;
        }
        else
        {
            res = false;
            cout<<"票已经被抢空了"<<endl;
        }
        pthread_mutex_unlock(&mtx);
        
        return res;
    }

    ~Ticket()
    {
        //释放锁
        pthread_mutex_destroy(&mtx);
    }
};

void * ThreadRun(void * args)
{
    Ticket * t = (Ticket *)args;
    
    while(true)
    {
        if(!t->GetTickets())
        {
            break;
        }
    }
}

int main(int argc, const char** argv) 
{
    //实例化抢票类对象
    Ticket * t = new Ticket();

    pthread_t tid[5];

    //创建五个线程
    for(int i = 0 ; i < 5 ; i++)
    {
        pthread_create(tid + i, nullptr, ThreadRun, (void*)t);
    }
    
    //接收五个线程
    for(int i = 0 ; i < 5 ; i++)
    {
        pthread_join(tid[i], nullptr);
    }
    return 0;
}

运行结果如下,就不会发生抢错与抢到负数的情况啦!!!
但是运行速度会慢些,这是肯定的,因为加了锁,代码是串行执行的,时间就会慢一些,不加锁代码是并行执行就快一些。
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第6张图片
总结一下,加了锁会出现什么情况呢:
首先加锁是每个线程都被加了锁,不是个别加了锁,只有都加锁才能保证公平性,这个很好理解
加了锁之后相当于创建的五个线程一起抢这把锁,比如A抢到了,那么A的优先级就高,CPU就开始调度他,tickets–,当A访问完一遍临界区资源,A解锁,五个线程又开始抢。但是由于A刚刚解完锁,A相比于其他五个线程处于更加活跃的状态,在接下来更容易抢到锁,所以会看到运行的现象是一个线程抢了一批抢票,而不是我抢了一个下一个线程再抢一个。只有当A多次执行后他的时间片到了,A再解锁之后,这时A相对于其他线程的竞争能力变弱,其他四个个线程在同一起跑线,开始重新抢锁!

举例来说:五个孩子抢一台电视的遥控器,A先抢到了,那么A就获得遥控器控制权,它可以看自己想看的电影,当A看完一个电影,A把遥控器放在自己手边,对大家说我看完了,咱们重新抢遥控器吧,由于A离遥控器最近,A抢到遥控器的概率更高,因此A能连续看多部电影。
当A看了好几部之后,家长来了说你A连着看太久电视了,家长把遥控收到自己手里,让A离遥控器最远,其他四个孩子站一排说,你们重新抢。这种情况下其他四个孩子就有机会抢到遥控器了!

根据上面抢票的例子,我们再抛出一个问题,我们是加了锁控制抢票逻辑的合理性,每个执行流只有抢到锁才能抢票,那么我想问一下,我们五个执行流因为都共享这些票,因此票是临界资源,那么锁是临界资源吗?
答:是的!!!和票一样,我们五个执行流都共享这一把锁,因此所也是临界资源!

我们是因为使用了锁才保证了我们抢票具有互斥性,那锁本身是不是也应当具有互斥性呢?
答:是的!!!如果锁本身不是原子的那么他连自己都保证不了,就更不能保证靠他所保护的内容安全!

因此接下来介绍一下互斥锁的原理:

互斥锁的原理

我们先需要理解一个概念:什么样的代码是原子的能(即我只要执行这个代码,就必须把它执行完,要么就不执行)
答**:这个代码在只有一行汇编的情况下他就是原子的**
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第7张图片
上图是对互斥锁汇编原理图,接下来做一下说明:
第一种情况:
加入A先执行第一行,在执行第二行,这时寄存器%al里面就存了A的上下文数据以及与mutex交换过来的数据1。
判断返回,A返回是把寄存器有关A的上下文数据都带走了,包括交换来的数据1。此时mutex被交换变成了0。
这时B进来执行第一行,执行第二行,交换玩发现还是0,因为唯一的数据1被A带走了,B申请锁失败!判断被挂起等待。
A再回来解锁,移动数据1覆盖掉mutex的数据,这时mutex就被修改为1了,然后唤醒其他被挂起等待的线程,重新开始抢锁。
第二种情况:
A执行完前两行被B挤掉,B进来开始执行前两行后发现,原来的数据1在A手里呢,因此即便B挤进来了,B还是逃不掉被挂起等待的宿命,只有A再回来把锁unlock掉,B才有可能抢到锁。对于B来说

基于上两种情况得出结论,mutex的本质:其实是通过一条汇编,将锁的数据交换到自己的上下文中!!保证了申请锁的原子性

大白话总结:申请锁只需要一行汇编,因此我申请过程不会被别人挤掉,我申请成功了,别人再我执行时把我挤掉了,他也没办法申请到锁,因为锁还在我身上,只有等我把锁还回去,才有可能被别人抢到锁,但我换锁之前已经把我想做的事情都做完了,因此保证了我访问临界资源的安全!

可重入与线程安全联系与区别

函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

大白话总结:可重入就是我多个执行流访问你不会出问题,不可重入就是出问题了。
这段只要记住一句话:线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配
大白话总结,死锁就是我手里有锁了,我还一直在要锁,导致我带着锁被挂起,其他执行流也因为没有锁无法执行,这个进程卡在这里

同步的概念

在互斥的前提下,让访问临界资源在安全的前提下,具有一定的顺序性与合理性!
说白了就是,我有多个执行流,你保证了互斥,但别总让某一个执行流一直访问,导致其他执行流都饿死了。让每个执行流都能有顺序的访问到临界资源!因此接下来介绍下控制同步的函数。

条件变量函数:初始化pthread_cond_init 销毁pthread_cond_destroy

Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第8张图片
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第9张图片
可以看出,创建条件变量与创建锁的方式几乎完全相同!

唤醒与等待条件变量函数:唤醒 pthread_cond_signal 等待 pthread_cond_wait

Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第10张图片
在这里插入图片描述

大白话总结:等待函数就是执行流执行到这里挂起,等待唤醒函数唤醒他继续工作

下面写几行代码来介绍条件变量函数如何使用

这个是.hpp文件

#include
#include
#include
#include
using namespace std;


//创建全局的锁与条件变量,因为是全局的所以每个线程都能使用
pthread_mutex_t mtx;
pthread_cond_t cnd;


void * Ctrl(void * args)
{
    string name = (char *)args;

    while(true)
    {
        //唤醒  一个  挂起等待的线程
        cout<<"master 即将唤醒线程"<<endl;
        pthread_cond_signal(&cnd);
        sleep(1);
    }
}

void * Work(void * args)
{
    int number = *(int *)args;

    while(true)
    {
        //等待条件变量
        pthread_cond_wait(&cnd, &mtx);
        cout<<"Worker "<< number <<" is working..."<<endl;
        sleep(1);
    }
}

#define NUM 3
int main()
{
    //创建三个线程ID和一个控制线程ID
    pthread_t worker[NUM];
    pthread_t master;

    //初始化锁与条件变量
    pthread_mutex_init(&mtx, nullptr);
    pthread_cond_init(&cnd, nullptr);
    
    //创建控制线程
    pthread_create(&master, nullptr, Ctrl, (void *)"master");
    for(int i = 0 ; i < NUM ; i++)
    {
        int * number = new int(i);
        //创建线程
        pthread_create(worker+i, nullptr, Work, (void *)number);
    }

    for(int i = 0 ; i < NUM ; i++)
    {
        //创建线程
        pthread_join(worker[i], nullptr);
    }
    return 0;
}

运行结果如下:
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第11张图片
可以看出在条件变量函数控制下,每个线程被按顺序唤醒执行任务,理解起来很简单。
大白话总结:条件变量函数控制着每个线程,pthread_cond_wait函数相当于把每个线程都放到队列中,队列中的任务都被挂起,pthread_cond_signal表示一次只让一个线程出队列执行任务

生产者消费者模型

下面我将用生产者消费者的案例来说明互斥与同步共同使用的场景

#pragma once

#include
#include
#include
#include
using namespace std;


namespace ns_block_queue
{
    //全局
    const int default_cap = 5;

    template<class T>
    class BlockQueue
    {
    private:
        queue<T>_bq;
        int _cap;
        //创建锁
        pthread_mutex_t _mtx;
        //创建队列满了的条件变量
        pthread_cond_t _is_full;
        //创建队列空了的条件变量
        pthread_cond_t _is_empty;

    public:
        BlockQueue(int cap = default_cap)
            :_cap(cap)
        {
            //初始化
            pthread_mutex_init(&_mtx,nullptr);
            pthread_cond_init(&_is_full, nullptr);
            pthread_cond_init(&_is_empty, nullptr);    
        }

        ~BlockQueue()
        {
            //销毁锁
            pthread_mutex_destroy(&_mtx);
            pthread_cond_destroy(&_is_full);
            pthread_cond_destroy(&_is_empty);
        }
    private:
        bool isFull()
        {
            return _bq.size() == _cap;
        }

        bool isEmpty()
        {
            return _bq.empty();
        }

        void ProducterWait()
        {
            pthread_cond_wait(&_is_full, &_mtx);        
        }

        void ConsumerWait()
        {
            pthread_cond_wait(&_is_empty, &_mtx);
        }

        void Lock()
        {
            pthread_mutex_lock(&_mtx);
        }

        void Unlock()
        {
            pthread_mutex_unlock(&_mtx);
        }

        void WakeUpConsumer()
        {
            //唤醒当前在条件队列挂起等待的消费者
            pthread_cond_signal(&_is_empty);
        }

        void WakeUpProducter()
        {
            pthread_cond_signal(&_is_full);
        }

    public:
        //生产者向队列中生产数据
        void Push(const int & val)
        {
            Lock();
          	//不用if判断而是用while循环判断之为了防止线程意外被唤醒
            while(isFull())
            {
                //如果满了则生产者在条件队列中挂起等待
                ProducterWait();
            }
            _bq.push(val);
            Unlock();
            //当生产者产生数据后唤醒消费者
            WakeUpConsumer();
        }

        //消费者从队列中拿走数据
        void Pop(T * out)
        {
            Lock();
            while(isEmpty())
            {
                //如果空了则消费者在条件队列中挂机等待
                ConsumerWait();
            }
            *out = _bq.front();
            _bq.pop();
            Unlock();
            //当消费者产生数据后唤醒生产者
            WakeUpProducter();
        }
    };
}

.cpp文件

#include 
#include 
using namespace std;
#include "BlockQueue.hpp"
using namespace ns_block_queue;

//消费者从队列中拿走数据
void *ConsumerWork(void *args)
{
    BlockQueue<int> *bq = (BlockQueue<int> *)args;
    while (true)
    {
        sleep(2);
        int data = 0;
        bq->Pop(&data);
        cout << "消费者消费了一个数据" << data << endl;
    }
}
//生产者向队列中存数据
void *ProducterWork(void *args)
{
    BlockQueue<int> *bq = (BlockQueue<int> *)args;
    while (true)
    {
        //放入随机数据1到20
        int data = rand() % 20 + 1;
        cout << "生产者生产数据 " << data << endl;
        bq->Push(data);
    }
}

int main()
{
    srand((long long)time(nullptr));
    BlockQueue<int> *bq = new BlockQueue<int>();
    //消费者线程
    pthread_t c;
    //生产者线程
    pthread_t p;

    //创建线程
    pthread_create(&c, nullptr, ConsumerWork, (void *)bq);
    pthread_create(&p, nullptr, ProducterWork, (void *)bq);

    //接收线程
    pthread_join(c, nullptr);
    pthread_join(p, nullptr);

    return 0;
}

运行结果如下:
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第12张图片
我把代码的逻辑总结下:
1.生产者负责向队列里生产数据,消费者负责向队列中拿走数据
2.当队列满了,生产者被挂机等待;当队列为空消费挂起等待。
3.因为容器的容量为6,所以一开始生产者会一下子装满容器然后被挂起,唤醒消费者来拿数据,消费者每拿一个证明队列不为满,挂起消费者,唤醒生产者生产,生产者在生产1个,又满了,这样循环!
4.因为容器是队列,因此消费者会先拿走对头的数据

这里必须要重点说明下pthread_cond_wait()函数

pthread_cond_wait()函数

Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第13张图片
生产消费模型并不是简单的生产者放数据,消费者拿数据,放到真正的应用场景应该是生产者产生任务,消费者处理任务
因此我增加了一个简单的任务:生产者产生数据,消费者对数据进行±*/%计算,代码如下:


#pragma once

#include 
#include 
#include 
#include 
using namespace std;

namespace ns_block_queue
{
    //全局
    const int default_cap = 4;

    template <class T>
    class BlockQueue
    {
    private:
        queue<T> _bq;
        int _cap;
        //创建锁
        pthread_mutex_t _mtx;
        //创建队列满了的条件变量
        pthread_cond_t _is_full;
        //创建队列空了的条件变量
        pthread_cond_t _is_empty;

    public:
        BlockQueue(int cap = default_cap)
            : _cap(cap)
        {
            //初始化
            pthread_mutex_init(&_mtx, nullptr);
            pthread_cond_init(&_is_full, nullptr);
            pthread_cond_init(&_is_empty, nullptr);
        }

        ~BlockQueue()
        {
            //销毁锁
            pthread_mutex_destroy(&_mtx);
            pthread_cond_destroy(&_is_full);
            pthread_cond_destroy(&_is_empty);
        }

    private:
        bool isFull()
        {
            return _bq.size() == _cap;
        }

        bool isEmpty()
        {
            return _bq.empty();
        }

        void ProducterWait()
        {
            pthread_cond_wait(&_is_full, &_mtx);
        }

        void ConsumerWait()
        {
            pthread_cond_wait(&_is_empty, &_mtx);
        }

        void Lock()
        {
            pthread_mutex_lock(&_mtx);
        }

        void Unlock()
        {
            pthread_mutex_unlock(&_mtx);
        }

        void WakeUpConsumer()
        {
            //唤醒当前在条件队列挂起等待的消费者
            pthread_cond_signal(&_is_empty);
        }

        void WakeUpProducter()
        {
            pthread_cond_signal(&_is_full);
        }

    public:
        //生产者向队列中生产数据
        void Push(const T &val)
        {
            Lock();

            while (isFull())
            {
                //如果满了则生产者在条件队列中挂起等待
                ProducterWait();
            }
            _bq.push(val);
            Unlock();
            //当生产者产生数据后唤醒消费者
            WakeUpConsumer();
        }

        //消费者从队列中拿走数据
        void Pop(T *out)
        {
            Lock();
            while (isEmpty())
            {
                //如果空了则消费者在条件队列中挂机等待
                ConsumerWait();
            }
            *out = _bq.front();
            _bq.pop();
            Unlock();
            //当消费者产生数据后唤醒生产者
            WakeUpProducter();
        }
    };
}
#include 
#include 
using namespace std;
#include "BlockQueue.hpp"
#include "task.hpp"
#include 
using namespace ns_block_queue;
using namespace ns_task;

//消费者从队列中拿走数据并处理
void *ConsumerWork(void *args)
{
    BlockQueue<Task> *bq = (BlockQueue<Task> *)args;
    while (true)
    {
        Task t;
        bq->Pop(&t); //这里完成任务处理第一步
        t.Run();
        // int data = 0;
        // bq->Pop(&data);
        // cout <
    }
}
//生产者向队列中存数据
void *ProducterWork(void *args)
{
    BlockQueue<Task> *bq = (BlockQueue<Task> *)args;
    string str = "+-*/%";
    while (true)
    {
        int x = rand() % 20 + 1; //[1,20]
        int y = rand() % 10 + 1;
        char c = str[rand() % 5]; //[0,4]

        Task t(x, y, c);
        cout << "生产者产生了数据:" << x << c << y << " =?" << endl;
        bq->Push(t);
        sleep(1);

        // sleep(2);
        // //放入随机数据1到20
        // int data = rand() % 20 + 1;
        // cout << "生产者生产数据 " << data << endl;
        // bq->Push(data);
    }
}

int main()
{
    srand((long long)time(nullptr));
    BlockQueue<Task> *bq = new BlockQueue<Task>();
    //消费者线程
    pthread_t c, c1, c2, c3;
    //生产者线程
    pthread_t p;

    //创建线程
    pthread_create(&c, nullptr, ConsumerWork, (void *)bq);
    pthread_create(&c1, nullptr, ConsumerWork, (void *)bq);
    pthread_create(&c2, nullptr, ConsumerWork, (void *)bq);
    pthread_create(&c3, nullptr, ConsumerWork, (void *)bq);
    pthread_create(&p, nullptr, ProducterWork, (void *)bq);

    //接收线程
    pthread_join(c, nullptr);
    pthread_join(c1, nullptr);
    pthread_join(c2, nullptr);
    pthread_join(c3, nullptr);
    pthread_join(p, nullptr);

    return 0;
}

这里是任务,封装成仿函数:

#pragma once
#include
using namespace std;
#include
//实现+-*/%
namespace ns_task
{
    class Task
    {
    private:
        int _x;
        int _y;
        char _c;

    public:
        Task(){};
        Task(int x, int y, char c)
            : _x(x), _y(y), _c(c)
        {
        }
        ~Task(){};

        void Run()
        {
            int res = 0;
            switch (_c)
            {
            case '+':
                res = _x + _y;
                break;
            case '-':
                res = _x - _y;
                break;
            case '*':
                res = _x * _y;
                break;
            case '/':
                res = _x / _y;
                break;
            case '%':
                res = _x % _y;
                break;
            default:
                cout<<"bug??"<<endl;
                break;
            }

            cout<<"当前消费者"<< pthread_self()<<"完成了任务:"<<_x<<_c<<_y<<" = "<<res<<endl;
        }
    };
}

运行结果如下:
Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第14张图片
因此,以后可以模拟生产消费模型完成各种任务的处理。

读者写者模型(了解)

Linux之多线程第三部分:线程的互斥与同步(可以直接看总结,通俗易懂)_第15张图片

你可能感兴趣的:(c++,linux)