当前位置: 首页 > news >正文

Linux : 多线程互斥

目录

一  前言

二 线程互斥

  三  Mutex互斥量

1. 定义一个锁(造锁) 

2. 初始化锁

3. 上锁

4. 解锁

5. 摧毁锁

四 锁的使用

五 锁的宏初始化 

六 锁的原理

1.如何看待锁?

2. 如何理解加锁和解锁的本质 

七 c++封装互斥锁

八 可重入与线程安全 

1. 可重入与线程安全联系

2. 可重入与线程安全区别

九 死锁

1.死锁产生的必要条件

2.死锁的避免方法


一  前言

我们在上一章节Linux: 线程控制-CSDN博客学习了什么是多线程,以及多线程的控制和其优点,多线程可以提高程序的并发性和运行效率。但是多线程控制也有一定缺点,例如有些多线程的程序运行结果是有一些问题的,如出现了输出混乱、访问共享资源混乱等特点。所以我们下面提出的这个概念是关于保护共享资源这方面的——线程互斥。


二 线程互斥

在正式认识线程互斥之前,我们先来介绍几个概念:

  • 临界资源:多线程执行流共享的资源(且这个资源是被保护的)就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么不做,要么做完。(原子性针对的是操作)。

接下来我们用一个测试来学习多线程访问共享资源可能带来的问题,以及如何解决。

🚀:系统调用接口大多都是用c接口,我们通过c/c++混编的方式对线程的创建以及等待进行封装

//makefile/
mythread:mythread.cpp
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f mythread
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <functional>
#include <cstdio>
#include <cassert>

///Thread.hpp/头文件///
class Thread; //对类进行声明

//上下文
class Context
{
public:
    Thread* this_;
    void* args_;
public:
    Context()
    :this_(nullptr),args_(nullptr)
    {}
    ~Context()
    {}
};
//对一个线程进行封装
class Thread
{
public:
    typedef std::function<void*(void*)> func_t;
    const int num =1024;
public:
    //1.构造函数,完成对线程的创建
    Thread(func_t func, void* args, int number)
    :func_(func),args_(args)//c++自带类型func_,所以可以用拷贝构造func_(func)
    {
        char buffer[num];//字符数组,char* buffer
        snprintf(buffer, sizeof(buffer), "thread->%d", number);
        name_=buffer;
        Context* ctx=new Context();
        ctx->this_=this;
        ctx->args_=args_;
        int n=pthread_create(&tid_,nullptr,start_routine,ctx);
        assert(n==0);
        (void)n;
    }
    
    static void* start_routine(void* args)
    {
        //静态方法不能调用成员变量
         //return func_(args);
         Context* ctx=static_cast<Context*>(args);
         void* ret=ctx->this_->run(ctx->args_);
         delete ctx;
         return ret;
    }
    //线程等待
    void join()
    {
        int n=pthread_join(tid_,nullptr);
        assert(n==0);
        (void)n;
    }

    void* run(void* args)
    {
        return func_(args);
    }

private:
    std::string name_;
    pthread_t tid_;
    func_t func_;//实现方法
    void* args_;
};
#include <iostream>
#include <unistd.h>
#include <memory>
#include "Thread.hpp"
//测试用例//
int tickets=10000;
void* getTickets(void* args)
{
    std::string username=static_cast<const char*>(args);
    while(true)
    { 
        if(tickets >0)
        {
            //usleep(1244);
            std::cout<< username <<"我正在抢票"<<tickets--<< std::endl;
            usleep(1244);
        }
        else{
            break;
        }
    }
    return nullptr;
}
int main()
{
    std::unique_ptr<Thread> thread1(new Thread(getTickets,(void*)"user1",1));
    std::unique_ptr<Thread> thread2(new Thread(getTickets,(void*)"user2",2));
    std::unique_ptr<Thread> thread3(new Thread(getTickets,(void*)"user3",3));
    std::unique_ptr<Thread> thread4(new Thread(getTickets,(void*)"user4",4));


    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();
}

测试结果

 🚌:接下来我们对代码进行一定改动,再看一下运行结果

 while(true)
    { 
        if(tickets >0)
        {
            usleep(1244);//现在我们在进行tickets--操作之前让线程进行休眠
            //再来看看运行结果会和之前的一样吗?
            std::cout<< username <<"我正在抢票"<<tickets--<< std::endl;
            //usleep(1244);//之前的代码是在这里进行了休眠,
        }
        else{
            break;
        }
    }

从结果来看,我们放票了10000张照片,而竟然抢到了-2张票,明显不合理。接下来我们回答一下为什么会出现这种现象? 

 代码中凡是关于算数计算的问题,实际上都是交给CPU进行执行的,这里面包括了加减乘除、逻辑运算、逻辑判断,最终都由CPU来解决的。对变量tickets进行--,看起来只有一条语句,但是汇编至少是三条语句,即cpu 会对 tickets-- 的操作会分成三步来执行 

  1. 从内存读取数据到cpu寄存器中
  2. 在寄存器中让cpu进行对应的逻辑判断和运算
  3. 将新的结果写到内存中变量的位置

接下来我们用下面图进行说明,为了方便,假设我们只有两个线程。

 

上面就是该程序出错的原因,其主要原因是在判断 tickets>0 由于会调用其他线程,从而使得错误发生在 tickets-- 操作上,对票的数量修改产生混乱。 

 🚴造成这种结果的原因是什么呢?

  1. 我们对共享资源的访问和修改都不是原子的(即没有做完),这两个操作都会存在中间态,即CPU在计算的过程中需要读取、计算、返回等多个操作,一旦CPU执行某个线程处在某个中间状态的时候暂停了,其他线程可能会“趁虚而入”。
  2. 存在多个线程同时访问共享资源的情况。

  三  Mutex互斥量

 了解了程序出现问题的原因,下来我们就讨论如何解决它:我们先从如何防止多个线程同时访问共享资源开始

       代码必须要有互斥行为:当一个线程访问并执行共享资源的代码时,其他线程不能进入

要想使线程具有互斥行为,我们要引出一个关键工具——,通过给执行共享资源区上一把锁,从而阻止其他线程进入,这种锁被称为互斥锁,给予代码互斥的效果。

 锁的接口及其使用

pthread 库为我们提供了 “定义一个锁”、“初始化一个锁   “上锁”、“解锁”、“销毁一个锁” 的接口:

1. 定义一个锁(造锁) 

pthread_mutex_t  是一个类型,可以来定义一个互斥锁。就像定义一个变量一样使用它定义互斥锁的时候,锁名可以随便设置。互斥锁的类型 pthread_mutex_t 是一个联合体。 

2. 初始化锁

pthread_mutex_init( ) 是pthread库提供的一个初始化锁的一个接口,第一个参数传入的就是需要初始化的锁的地址。 第二个参数需要传入锁初始化的属性,在接下来的使用中暂时不考虑,使用默认属性即传入nullptr 。成功返回0,否则返回错误码。 

3. 上锁

pthread_mutex_lock() ,阻塞式上锁,即 线程执行此接口,指定的锁已经被锁上了,那么线程就进入阻塞状态,直到解锁之后,此线程再上锁。当上锁成功,则返回0,否则返回一个错误码。

4. 解锁

pthread_mutex_unlock() ,作用是解锁接口,一般用于出了执行共享资源区的时候。当解锁成功,返回0,否则返回一个错误码。

5. 摧毁锁

pthread_mutex_destroy 是用来摧毁定义的锁,参数需要传入的是需要摧毁的锁的指针。成功则返回0,否则返回错误码。


四 锁的使用

#include <iostream>
#include <unistd.h>
#include <memory>
#include <vector>
#include "Thread.hpp"

class ThreadData
{
public:
    ThreadData(const std::string & threadname,pthread_mutex_t* mutex_p)
    :threadname_(threadname), mutex_p_(mutex_p)
    {}
    ~ThreadData()
    {}
public:
    std::string threadname_;
    pthread_mutex_t* mutex_p_;//锁的指针
};
int tickets=10000;
void* getTickets(void* args)
{

    //std::string username=static_cast<const char*>(args);
    //加锁和解锁是多个线程串行执行的,程序变慢了。
    ThreadData* td=static_cast<ThreadData*>(args);
    while(true)
    { 
        //加锁
        pthread_mutex_lock(td->mutex_p_);
        if(tickets >0)
        {
            usleep(1244);
            std::cout<< td->threadname_ <<"我正在抢票"<<tickets<< std::endl;
            tickets--;   
            pthread_mutex_unlock(td->mutex_p_);//解锁
           // usleep(1244);
        }
        else{  
            pthread_mutex_unlock(td->mutex_p_);//解锁
            break;
        }
    }
    return nullptr;
}

int main()
{
    #define NUM 4
    pthread_mutex_t lock;//定义一个锁
    pthread_mutex_init(&lock,nullptr);//初始化一个锁
    //接下来我们如何把这个锁以及一些参数传递给线程呢?我们创建一个类ThreadData
    std::vector<pthread_t> tids(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(&tids[i],nullptr,getTickets,td);
    }
    for( const auto &tid:tids)
    {
        pthread_join(tid,nullptr);
    }
    pthread_mutex_destroy(&lock);//销毁锁
    return 0;
    
}

测试结果 

可以看到同过加锁的操作对共享资源的代码进行加锁保护之后,程序已经能正常的进行抢票了。但是我们又发现一个问题,那就是都是线程4进行抢票,这是为什么呢? 

🚩:锁只规定互斥访问,没有规定谁优先执行 ,接下里我们通过修改一下代码,来进行测试。
 通过usleep(1000),线程在访问加锁的资源之后,进行休眠即阻塞状态,这个时候cpu会对其他线程进行随机调度,从而实现了多个进程对保护的共享资源进行抢票的过程 。

while(true)
    { 
        //加锁
        pthread_mutex_lock(td->mutex_p_);
        if(tickets >0)
        {
            usleep(1244);
            std::cout<< td->threadname_ <<"我正在抢票"<<tickets<< std::endl;
            tickets--;   
            pthread_mutex_unlock(td->mutex_p_);//解锁
           // usleep(1244);
        }
        else{  
            pthread_mutex_unlock(td->mutex_p_);//解锁
            break;
        }
        //抢完票之后,我们设置一个任务。例如形成订单
        usleep(1000);//形成一个订单给用户
        //通过usleep(1000),线程在访问加锁的资源之后,进行休眠即阻塞状态,这个时候cpu会对其他线程进行随机调度
        //从而实现了多个进程对保护的共享资源进行抢票的过程
    }


五 锁的宏初始化 

在上面我们已经简单学习了锁的使用,关于锁的初始化上面用到的是pthread库提供的接口:pthread_mutex_init() ,但是在系统中还存在另一种初始化锁的方法,还方法只针对全局锁进行初始化使用该宏初始化的锁是不需要手动销毁的,即不需要我们调用 pthread_mutex_destroy() 接口

下面演示该宏定义的全局锁的使用:

int tickets=10000;
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;//宏定义的全局锁
void* getTickets(void* args)
{

    //加锁和解锁是多个线程串行执行的,程序变慢了。
    std::string username=static_cast<const char*>(args);
    //ThreadData* td=static_cast<ThreadData*>(args);
    while(true)
    { 
        //加锁
        pthread_mutex_lock(&lock);//加锁直接取地址&lock
        if(tickets >0)
        {
            usleep(1244);
            std::cout<< username<<"我正在抢票"<<tickets<< std::endl;
            tickets--;   
            pthread_mutex_unlock(&lock);//解锁
           // usleep(1244);
        }
        else{  
            pthread_mutex_unlock(&lock);//解锁
            break;
        }
        //抢完票之后,我们设置一个任务。例如形成订单
        usleep(1000);//形成一个订单给用户
    return nullptr;
}

int main()
{
   
     //创建四个线程
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1,nullptr,getTickets,(void*)"thread 1");
    pthread_create(&t2,nullptr,getTickets,(void*)"thread 2");
    pthread_create(&t3,nullptr,getTickets,(void*)"thread 3");
    pthread_create(&t4,nullptr,getTickets,(void*)"thread 4");

    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);
    pthread_join(t3,nullptr);
    pthread_join(t4,nullptr);
    //不需要手动销毁锁了即pthread_mutex_destroy(&lock,nullptr) 

}

测试结果:

接下来我们对几个概念进行再次说明一下:

  • 临界资源:多线程执行流共享的资源(且这个资源是被保护的)就叫做临界资源,例如上文代码的共享资源tickets通过加锁被保护,叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区,例如上文加锁和解锁的中间部分即对tickets进行--的代码区叫做临界区

六 锁的原理

前边说了这么多有关于锁的介绍,那么我们该如何看待锁呢?

1.如何看待锁?

  •  a.  多个线程都能利用锁,即:锁本身就是一个共享资源,既然是共享资源,那么共享资源就要被保护?锁的安全谁来保护呢
  •  b.  锁是共享资源需要被保护,那么加锁这个操作就是原子性的(即要么加锁成功,要么加锁不成功)
  •  c. 加锁如果申请成功,就继续向后执行,如果申请暂时没有成功,执行流会进行阻塞。
  • d. 谁持有锁,谁进入临界区。

如果线程1,申请成功,进入临界资源,正在访问临界资源期间,其他线程只能阻塞等待。

如果线程1,申请成功,进入临界资源,正在访问临界资源期间,那么线程1可以被cpu进行切换吗?答案是可以的,但是当持有锁的线程被切走的时候,即使自己被切走了,其他线程

依然无法继续申锁成功,也便无法继续向后执行,直到线程1释放了锁,其他线程才能申请锁成功,继续往后执行。

2. 如何理解加锁和解锁的本质 

  • 经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,所以保证了原子性,

针对上面的伪代码我们对加锁和解锁进行分析 

首先, al 表示寄存器, mutex 则表示在内存中的锁   (mutex互斥量可以理解为就是一个锁)

movb $0, %al, 把 0 存入 al 寄存器中

xchgb %al, mutex, 交换 al寄存器 和 内存中mutex 的数据

if(al > 0) { return 0; }, 如果 al 寄存器中的数据 大于 0, 则 申请锁成功, 返回 0. 否则, 就阻塞等待.

整个上锁函数执行的语句可以看作这几个过程.  其中, xchgb %al, mutex 操作 是实际上锁的操作.

我们用图来描述, 如果线程1 在执行上锁的操作。

🚲如果没有上锁时, 锁的值是1.那么 执行 xchgb %al, mutex 将 al 中的0 与 mutex 的值交换,

此时 al中的值变为1,这个时候其实以及申锁成功了,如果线程没有被cpu切走,那么

if(al>0)满足,线程就执行后续语句。

al中的值变为1,这个时候表示申锁成功了,但是线程还没往下执行就被cpu切走了,那么后面的线程也不可能申锁成功执行后续代码,这是因为cpu中寄存器对线程进行切换的时候,会把寄存器中关于线程的上下文切走。所以当下一个线程来的时候,寄存器会把新线程的al=0与内存中的mutex=0交换,al的值还是0,不会继续执行,进入阻塞状态,所以此时cpu又会对

上一个线程调度,cpu对线程加载的时候,会把线程上下文重新加载过来,即al=1,所以执行后续代码,当执行完相应的临界区的时候,寄存器再将al=1 与mutex交换,这就是解锁过程,此时mutex=1,后面的线程才有可能申请锁成功,上面的分析说明了加锁和解锁是个二原性行为,保证了共享资源不会被多个线程同时执行,即只能串行运行。


七 c++封装互斥锁

系统调用接口大多采样c接口,c语言是面向过程的,而c++面向对象的,为了更好使用加锁解锁。我们使用c++对互斥锁进行封装。

//Mutex.hpp/
#pragma once 
#include <iostream>
#include <pthread.h>

//对锁进行封装,类似undersort_map一样先定义一个结点
//结点成员包含锁,以及加锁解锁,然后在定义了一个类
//类中成员变量是结点,然会类的加锁解锁分别调用结点的成员函数
class Mutex
{
public:
    Mutex(pthread_mutex_t* lock_p=nullptr)
    :lock_p_(lock_p)
    {}
    void lock()
    {
        if(lock_p_) pthread_mutex_lock(lock_p_);
    }
    void unlock()
    {
        if(lock_p_) pthread_mutex_unlock(lock_p_);
    }
    ~Mutex()
    {}
private:
    pthread_mutex_t* lock_p_;
};

//这才是我们最终想要的封装
class LockGuard
{
public:
    LockGuard(pthread_mutex_t* mutex):mutex_(mutex)
    {
        mutex_.lock();//在构造函数中进行加锁
    }
    ~LockGuard()
    {
        mutex_.unlock();//在析构函数中进行解锁
    }
private:
    Mutex mutex_;
};

然后我们对测试代码做一下改动

int tickets=10000;
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;//定义一个锁
void* getTickets(void* args)
{

    //加锁和解锁是多个线程串行执行的,程序变慢了。
    std::string username=static_cast<const char*>(args);
    while(true)
    { 
        {//加了这个{}是相当于加了个作用域,当出了{}解锁成功,后面的usleep()代码没有加锁
            LockGuard lockguard(&lock);//构造并且加锁,处理作用域自带析构调用解锁函数
            if(tickets >0)
            {
                usleep(1244);
                std::cout<< username<<"我正在抢票"<<tickets<< std::endl;
                tickets--;   
            }
            else{  
                break;
            }
        }
       
        //抢完票之后,我们设置一个任务。例如形成订单
        usleep(1000);//形成一个订单给用户
       
    }
    return nullptr;
}


八 可重入与线程安全 

  •  线程安全:多线程并发运行同一段代码时,并不会影响到整个进程的运行结果,就成为线程安全
  • 可重入同一个函数被不同执行流调用, 在一个执行流执行没结束时, 有其他执行流再次执行此函数, 这个现象叫 重入

1. 可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

2. 可重入与线程安全区别

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

九 死锁

在多把锁的场景下,我们持有自己的锁不释放,还要对方的锁,对方也是如此,此时就容易造成死锁。自己同时申请多把锁也可能造成死锁。

我们用一个例子进行说明

pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;//定义一个锁
void* getTickets(void* args)
{

    //加锁和解锁是多个线程串行执行的,程序变慢了。
    std::string username=static_cast<const char*>(args);
    //ThreadData* td=static_cast<ThreadData*>(args);
    while(true)
    { 
        // //加锁
//**********************************************************************
         pthread_mutex_lock(&lock);//在这里我们申请了两把锁
         pthread_mutex_lock(&lock);//在这里我们申请了两把锁
//**********************************************************************
            if(tickets >0)
            {
                usleep(1244);
                std::cout<< username<<"我正在抢票"<<tickets<< std::endl;
                tickets--;   
                pthread_mutex_unlock(&lock);//解锁
               // usleep(1244);
            }
            else{  
                pthread_mutex_unlock(&lock);//解锁
                break;
            }
        
       
        //抢完票之后,我们设置一个任务。例如形成订单
        usleep(1000);//形成一个订单给用户
        //通过usleep(1000),线程在访问加锁的资源之后,进行休眠即阻塞状态,这个时候cpu会对其他线 
         程进行随机调度
        //从而实现了多个进程对保护的共享资源进行抢票的过程
    }
    return nullptr;
}

运行结果

🚢:为什么会造成进程卡住的情况呢?首先前面我们说明了锁的原理。 当我们申请了一把锁的时候 pthread_mutex_lock(&lock); 寄存器al 变成1,内存中mutex=0.此时al=1, 满足条件,执行后续语句,然后下一个语句又是申请一把锁  pthread_mutex_lock(&lock),前面的锁没有释放,那么后面的 pthread_mutex_lock(&lock)就会阻塞等待,当cpu切换线程执行其他线程也是会遇到这种情况,那么整个多线程就一直处于阻塞状态,从而不会执行后续cout,和tickets--等语句。导致的结果就是线程一直阻塞,显示器上什么也不显示。

1.死锁产生的必要条件

  1. 互斥条件:: 一个资源每次只能被一个执行流使用
  2. 请求与保持条件: 一个执行流因请求资源(索要锁时)而阻塞时,对已获得的资源(锁)保持不放(锁不释放)
  3. 不剥夺条件: 一个执行流已获得的锁资源,在末使用完之前,不能强行剥夺
  4. 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系(我向你要锁你向我要锁)

2.死锁的避免方法

最直接有效的避免方法是不使用锁. 虽然锁可以解决一些多线程的问题, 但是可能会造成死锁。

如果非要使用锁, 那就得考虑避免死锁,破坏死锁的四个必要条件。

相关文章:

  • 智享 AI直播3.0时代:无人智能系统如何颠覆传统拓客模式?‌‌
  • 计算机组成原理-指令系统
  • 集合框架二三事
  • 供应链业务-供应链全局观(三)- 供应链三流的集成
  • Transformer模型中的两种掩码
  • RK3588上Linux系统编译C/C++ Demo时出现BUG:The C/CXX compiler identification is unknown
  • 双向链表专题(C语言)
  • RK3576 GPIO 配置与使用
  • 【Docker】离线安装Docker
  • 【土堆 PyTorch 教程总结】PyTorch入门
  • 【频域分析】功率谱
  • Conda与Pip:Python包管理工具的对比与选型
  • Day15:关于MySQL的编程技术——基础知识
  • MDP最优控制问题转化为可求解的线性规划
  • dify应用例子
  • 一、springboot 整合 langchain4j 实现简单的问答功能
  • FreeRTOS(消息队列信号量队列集事件标志组)
  • Emu: Enhancing Image Generation Models Using Photogenic Needles in a Haystack
  • Windows笔记本怎样删除已保存的Wifi
  • 0413-多态、Object类方法、访问权限修饰符、装箱拆箱、128陷阱
  • 海昏侯博物馆展览上新,“西汉帝陵文化展”将持续展出3个月
  • 上海黄浦江挡潮闸工程建设指挥部成立,组成人员名单公布
  • 商务部回应稀土出口管制问题
  • 女孩患异食癖爱吃头发,一年后腹痛入院体内惊现“头发巨石”
  • 王毅集体会见加勒比建交国外长及代表
  • 女高音吴睿睿“古词新唱”,穿着汉服唱唐诗宋词