Linux之【多线程】线程互斥(锁)&线程同步(条件变量)

文章篇幅较长,请耐心阅读😀😀😀😀😀😀

一、引入:线程安全问题

全局变量可以被多个线程同时访问,多个线程对其进行操作,可能会出现数据不一致问题。

下面以一个购票池为例:

int tickets = 1000;//共享资源
void* get_ticket(void* args)
{
    std::string name = static_cast<const char *>(args);
    while(true)
    {
        if(tickets>0)
        {
            usleep(1111);//引起线程阻塞,挂起,切换其他线程
            cout<<name<<"正在抢票 : "<<tickets<<endl;
            tickets--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main()
{
    std::unique_ptr<Thread> thread1(new Thread(get_ticket,(void*)"User1",1));
    std::unique_ptr<Thread> thread2(new Thread(get_ticket,(void*)"User2",2));
    std::unique_ptr<Thread> thread3(new Thread(get_ticket,(void*)"User3",3));
    std::unique_ptr<Thread> thread4(new Thread(get_ticket,(void*)"User4",4));

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

在这里插入图片描述
观察图片可以发现,票数有负数!!!

  • 多个线程交叉运行,即让调度器频繁地发生线程调度与切换 ,线程一般在时间片结束、来了更高级别的线程、线程等待的时候发生切换

  • 线程等待:当从内核态返回用户态的实施,线程要对调度状态监测,如果可以,就发生切换;检测工作是由OS来做的,但是线程共享地址空间,执行OS的代码本来就是在线程上下文执行,3–4G是内核代码,当线程检测,只不过执行OS代码,实际上就是OS在检测

  • 上述代码极端情况在tickets==1时,假设所有线程都进去,然后第一个线程在判断:(1.读取内存数据cpu内的寄存器中2.进行判断),为真进入代码块,这个时候发生线程切换并带走上下文数据,其余线程依次进行判断并执行和第一个线程一样的动作,直到第一个线程被唤醒,执行减1并写回内存,这个时候tickets已经为0,但是其余线程还没有结束,也会执行减减并修改数据,导致出现负数的情况

  • 减减的本质就是1.读取数据2.更改数据3.写回数据

二、浅谈"++“和”- -"非原子性操作

对变量进行++或者–,在C、C++上看起来只有一条语句,但是汇编之后至少是三条语句:
1.从内存读取数据到CPU寄存器中
2.在寄存器中让CPU进行对应的算逻运算
3.写回新的结果到内存中变量的位置
在这里插入图片描述

  • 现在线程A把数据加载到寄存器中,做减减,成为99,到第三步的时候写回到内存的时候被切走了,顺便把寄存器中的上下文也拿走了:

在这里插入图片描述

  • 此时调度线程B,一直在减减,当tickets变为10的时候,内存中变量的也变为了10,但是当它想继续减减的时候,线程B被切走了,带着自己的上下文走了

在这里插入图片描述

  • 现在线程A回来了:恢复寄存器上下文,继续之前的第三步,线程B已经把tickets变为10,但是被线程A改为了99!!!

在这里插入图片描述
由此可知我们定义的全局变量在没有保护的时候,往往是不安全的,像上面的例子,多个线程交替执行时造成数据安全问题,发生了数据不一致问题。

而解决这种问题的办法就是加锁

三、Linux线程互斥

临界资源:多个执行流进行安全访问的共享资源就叫临界资源

临界区:多个执行流进行访问临界资源的代码就是临界区

互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。

原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么不做,要么做完,这就是原子性。

现在结合上文,先"简单"理解原子性:一个资源进行的操作如果只用一条汇编语句就能完成,就是原子性的,反之不是原子的。(++ --就不是原子性的),文章后面会再详解

3.1 互斥量–>mutex⚠️

3.1.1 互斥锁的理解

#include <pthread.h>
// 局部锁初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
// 销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
// 全局锁初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//成功返回0,失败返回错误码


#include <pthread.h>
//加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);

//如果加锁成功,直接持有锁,加锁不成功,此时立马出错返回(试着加锁,非阻塞获取方式)
int pthread_mutex_trylock(pthread_mutex_t *mutex);

//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 成功返回0,失败返回错误码


加上局部互斥锁的售票池

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 = 100; // 共享资源--临界资源
void *get_ticket(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    while (true)
    {
        pthread_mutex_lock(td->mutex_p_);
        /* */ if (tickets > 0)
        /*临*/ {
        /*  */      usleep(1111); // 引起线程阻塞,挂起,切换其他线程
        /*界*/      cout << td->threadname_ << "正在抢票 : " << tickets << endl;
        /*  */      tickets--;
        /*区*/      pthread_mutex_unlock(td->mutex_p_);
                }
        else
        {
            pthread_mutex_unlock(td->mutex_p_);
            break;
        }
         //usleep(1000);//休息一会,让别的线程申请锁
    }
    return nullptr;
}
int main()
{
#define NUM 4
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    vector<pthread_t> tids(NUM);
    for (int i = 0; i < NUM; ++i)
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "thread %d", i + 1);
        ThreadData *td = new ThreadData(buffer, &lock);
        pthread_create(&tids[i], nullptr, get_ticket, td);
    }
    for(const auto& tid:tids)
    {
        pthread_join(tid,nullptr);
    }
    return 0;
}    

在这里插入图片描述
此时的运行结果每次都是能够减到1,且不是负数,但是运行的速度也变慢了。这是因为加锁和解锁的过程是多个线程串行执行的,程序变慢了

同时这里看到每次都是只有一个线程在抢票,这是因为锁只规定互斥访问,并没有规定谁来优先执行,所以谁的竞争力强就谁来持有锁

只需要取消//usleep(1000)注释即可
在这里插入图片描述
全局锁的使用
比局部简单,只需要在全局内初始化,不需要init、destroy就可以直接使用

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

小结:锁的概念⚠️

  1. 锁本身就是一个共享资源!全局的变量是要被保护的,锁是用来保护全局的资源的,锁本身也是全局资源
  2. pthread_mutex_lock、pthread_mutex_unlock:加锁和解锁的过程必须是安全的!加锁的过程其实是原子的
  3. 如果申请锁暂时没有成功,执行流暂时阻塞,直到有人释放锁
  4. 谁先拿到锁,谁先进入临界区

3.1.2 深入了解锁的原子性⚠️

针对锁的原子性概念
在这里插入图片描述
锁是原子性的原理
从汇编谈加锁:为了实现互斥锁操作,大多数体系结构提供了swap和exchange指令,作用是把寄存器和内存单元的数据直接做交换,由于只用一条指令,就可以保证原子性
在这里插入图片描述

  1. 线程A申请锁:把0move到寄存器中,然后交换数据,%al里面变成1,内存里面变成0,之后,被切走,需要携带自己的上下文数据一起跑路!!!
    在这里插入图片描述

  2. 线程B前来申请锁资源,把0写进%al里面,也是要交换数据,但是执行判断条件的时候为假,需要挂起等待。

  3. 这个时候,线程A结束阻塞,恢复上下文数据并接着执行上次未执行完的代码,判断为真,return 0,申请锁成功。

解锁:过程很简单,把寄存器的内容1移动到内存中,直接return,解锁完成

3.2 线程安全与可重入函数

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
可重入与线程安全区别

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

四、死锁

死锁概念:一组执行流(不管进程还是线程)持有自己锁资源的同时,还想要申请对方的锁,锁是不可抢占的(除非自己主动归还),会导致多个执行流互相等待对方的资源,而导致代码无法推进。这就是死锁
注:一把锁可以造成死锁,先申请一把锁,未释放再申请一把锁
死锁四个必要条件:
1.互斥:一个共享资源每次被一个执行流使用

2.请求与保持:一个执行流因请求资源而阻塞,对已有资源保持不放

3.不剥夺:一个执行流获得的资源在未使用完之前,不能强行剥夺

4.环路等待条件:执行流间形成环路问题,循环等待资源

避免死锁
1.破坏死锁的四个必要条件
2.加锁顺序一致
3.避免锁未释放的场景
4.资源一次性分配

产生死锁的必要条件:

互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。

请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。

不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。

环路等待条件:在发生死锁时,必然存在一个进程——资源的环形链。

预防死锁的方法:

资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)

只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请保持条件)

可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)

资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

五、Linux线程同步

5.1 初步认识

引入情景:上面的抢票系统我们看到一个线程一直连续抢票,造成了其他线程的饥饿,为了解决这个问题:我们在数据安全的情况下让这些线程按照一定的顺序进行访问,这就是线程同步

饥饿状态:得不到锁资源而无法访问公共资源的线程处于饥饿状态。但是并没有错,但是不合理

竞态条件:因为时序问题,而导致程序异常,我们称为竞态条件。

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

条件变量通常需要配合互斥锁一起使用。

5.2 条件变量⚠️

  1. 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  2. 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量

函数接口认识

#include <pthread.h>
//初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
//全局初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
//销毁
int pthread_cond_destroy(pthread_cond_t *cond);
------------------------------------------------------
//阻塞等待
int pthread_cond_wait(pthread_cond_t *restrict cond,
       pthread_mutex_t *restrict mutex);
---------------------------------------------------------------
// 唤醒一批线程
int pthread_cond_broadcast(pthread_cond_t *cond);
// 唤醒一个线程
int pthread_cond_signal(pthread_cond_t *cond);
   

5.3 结合生活理解条件变量

应聘者要面试,不能同时进入房间进行面试,但是没有由于没有组织,上一个人面试完之后,面试官打开门准备面试下一个,一群人在外面等待面试,但是有人抢不过别人,面试官存在记不住谁面试过了,所以有可能一个人面试完之后又去面试了,造成其他人饥饿问题,这时候效率很低

后来重新进行管理:设立一个等待区,所有人都在这里等待并由面试官安排进入,等待区+面试官就组成了条件变量;如果一个人想面试,先得去排队并在等待区等待,未来所有应聘者都要等

在这里插入图片描述

条件变量(struct cond)里面包含状态,队列,而我们定义好的条件变量包含一个队列,不满足条件的线程就链接在这个队列上进行等待
在这里插入图片描述

5.4 结合代码简单理解条件变量

每次唤醒一个线程

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


int tickets = 1000;
//初始化全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//初始化全局变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* start_routine(void* args)
{
    string name = static_cast<const char*>(args);
    while(true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond,&mutex);//线程阻塞挂起
        //判断暂时省略
        cout<<name<<" -> "<<tickets<<endl;
        tickets--;
        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
    pthread_t t1,t2;
    pthread_create(&t1,nullptr,start_routine,(void*)"thread 1");
    pthread_create(&t1,nullptr,start_routine,(void*)"thread 2");

    while(true)
    {
        sleep(1);
        pthread_cond_signal(&cond);//随机唤醒一个等待的线程
        cout<<"main thread wakeup one thread..."<<endl;
    }
    pthread_join(t1,nullptr);
    pthread_join(t2,nullptr);

    return 0;
}

在这里插入图片描述
一次唤醒全部线程

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


int tickets = 1000;
//初始化全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
//初始化全局变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* start_routine(void* args)
{
    string name = static_cast<const char*>(args);
    while(true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond,&mutex);//线程阻塞挂起
        //判断暂时省略
        cout<<name<<" -> "<<tickets<<endl;
        tickets--;
        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
#define NUM 5
    vector<pthread_t> tids(NUM);
    for(int i=0;i<NUM;++i)
    {
        char* namebuffer=new char[1024];
        snprintf(namebuffer,1024,"thread->%d",i+1);
        pthread_create(&tids[i],nullptr,start_routine,namebuffer);
    }
    while(true)
    {
        sleep(1);
        pthread_cond_broadcast(&cond);//唤醒全部等待的线程
        cout<<"main thread wakeup all thread..."<<endl;
    }
    for(const auto& tid:tids)
    {
        pthread_join(tid,nullptr);
    }
    return 0;
}

在这里插入图片描述

关于线程同步的暂时讲到这里,后面会结合生产者消费者模型细细讲解一番!!!
附:本文Thread.hpp和Mutex.hpp皆在我的码云

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值