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

【LINUX操作系统】线程同步与互斥

目录

前置回顾

1. 互斥

1.1 抢票代码

1.2 Mutex-加锁

1.3 mutex相关接口的操作 ————基于抢票代码的实现

2. 互斥原理的探究————笔者自己的理解方法

3. 封装锁

4. 同步

4.1 初识条件变量 

4.2 在抢票模型中使用条件变量


前置回顾

在学习信号部分时,我们提及到了以下概念:

共享资源:在多线程或多进程环境中,被多个执行单元(线程或进程)同时访问和修改的公共资源。

临界资源:被保护的共享资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区( 因此,代码都可以分成非临界区和临界区,保护临界资源其实就是对于临界代码的限制
互斥( Mutex ):任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤
同步:在互斥的基础上提升效率。(后文会有详细介绍)
原⼦性:不会被任何调度机制打断的操作,该操作只有两态,要么完成, 要么未完成

本节主要讨论如何在多线程代码中如何通过互斥和同步提升代码的安全性和效率。

1. 互斥

1.1 抢票代码

在基于上一文中【LINUX操作系统】线程库与线程库封装-CSDN博客

我们封装的线程,我们来看一下不被保护的共享资源会有什么问题。

        

再在全局定义一个ticketnum = 1000,作为被争抢的资源,ticket函数(还未实现)就是临界区

#include "Pthread.hpp"
#include <vector>using namespace ThreadModule;//假设一共有4个线程进行抢票
#define NUM 4//总票数
int ticketnum = 1000;void ticket()
{while(true){if(ticketnum>0){//1.抢票printf("抢的是第%d号票\n",ticketnum--);//2.入库模拟usleep(1000);}else{break;}}return;
}int main()
{std::vector<Thread> threads;//1.构建线程对象for(int i = 0;i<NUM;i++){threads.emplace_back(ticket);}//2.启动线程for(auto& e:threads){e.Start();}//3.线程等待for(auto& e:threads){e.Join();}return 0;
}

          这就是临界区部分:            

                  

结合上一篇封装的库内容我们完成了以上的主程序,发现以下的有趣现象。

                                                

如果改变usleep的位置,还可能出现以下情况:

我们来说明一下具体是怎么回事(假设10000张票):

        1.为什么会出现减过了,又加回去的情况?(992->989->991)

首先要明白,ticketnum--不是原子性操作,大概会分成三步:ticketnum的值放进寄存器;在寄存器中执行自减;通过寄存器写回内存。这三步是可以被中断的,比如当前有两个进程,t1和t2,t1刚把数据放进寄存器并自减完,准备从寄存器拿回内存,发生时间中断(暂时不考虑虚拟地址等内容),那么t1会将现在CPU中上下文的内容带走到libthread.so中存储。

现在轮到t2使用这个cpu,假设t2运气很好,执行了9999次--,将ticketnum减到了1,t2又被叫停了,t2就只能退出cpu,t1再加载回来数据,就会把原来寄存器中的9999给覆盖上去。

2.为什么usleep换了位置之后,出现了减到负数的情况:

        在上面的逻辑中,明明当票数小于等于0时就会走break。

        当多线程被创建后启动时,所有的线程都会看到tickets这个变量,当tickets变量值为1时,满足if条件进入到usleep()接口,当前线程就算已经进入了这个分支语句,并且即将usleep。当前线程休眠,其他线程进入,由于ticketnum还没有--,所以其他线程也能进入这个分支。。。。。。

        这样一来,进入该分支语句的线程都会执行一次ticketnum--,导致变成负数。        

从usleep回来的时候会从内核空间回到用户空间,会检查一次时间片,因此usleep在前面比后面更容易导致减成负数——————放到前面增加了各个线程被调度的频率。因此,usleep放在后面也可能导致这个问题,只是概率没有放在前面大。

补充:一行汇编一定是原子的,但是原子操作不一定只是一行汇编 

以上就是多线程并发切换导致的线程安全问题

1.2 Mutex-加锁

如何解决以上问题?

代码必须要有互斥⾏为:当代码进⼊临界区执⾏时,不允许其他线程进⼊该临界区。
如果多个线程同时要求执⾏临界区的代码,并且临界区没有线程在执⾏,那么只能允许⼀个线程
进⼊该临界区。
如果线程不在临界区中执⾏,那么该线程不能阻⽌其他线程进⼊临界区。
要做到这三点,本质上就是需要⼀把锁。Linux上提供的这把锁叫 互斥量

1.3 mutex相关接口的操作 ————基于抢票代码的实现

在Linux下实现互斥需要利用到用户级线程库中的操作:

创建互斥锁,使用pthread_mutex_t类型创建互斥锁变量
初始化互斥锁,使用PTHREAD_MUTEX_INITIALIZER初始化互斥锁变量或者使用pthread_mutex_init接口进行初始化
进入临界区之前加锁,使用pthread_mutex_lock接口
出临界区进行解锁,使用pthread_mutex_unlock接口
销毁互斥锁,使用pthread_mutex_destroy接口

全局初始化方法,右端的宏必须在初始化时就使用。

//初始化全局锁
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

局部锁的初始化方法:

pthread_mutex_t lock;
pthread_mutex_init(&lock,nullptr);
对于 int pthread_mutex_init ( pthread_mutex_t * restrict mutex, const
pthread_mutexattr_t * restrict attr); 的各个参数
参数:
mutex :要初始化的互斥量的指针
attr NULL
        对于创建互斥锁的两种方式:如果使用PTHREAD_MUTEX_INITIALIZER直接初始化互斥锁变量,那么就不需要使用pthread_mutex_init对互斥锁变量进行初始化以及不需要使用pthread_mutex_destroy对互斥锁进行销毁。相反,如果是局部锁的初始化,不仅要解锁,还需要调destroy在使用完锁之后进行销毁。
加锁的过程引入了一个新问题 

        

1.锁保护了临界资源,谁来保护锁?

         pthread_mutex:加锁和解锁被设计成为原子的了,没有中断的风险

2. 如何看待锁呢?二元信号量就是锁!(此点可暂时不用理解)
        2.1 加锁本质就是对资源展开预订
        2.2 整体使用资源!!

3. 如果申请锁的时候,锁被别人已经拿走了,怎么办?

        其他线程要进行阻塞等待(这样等待一定科学吗)
4. 线程在访问临界区代码的时候,可以不可以切换??可以切换!!
         4.1 线程被切走的时候,别人能进来吗??不能!我是抱着锁被切换的!!

        因此,这说明单纯的给临界区加锁的话,临界区代码对于各个线程来说就变成串行的              了,效率极低。临界区也变的具有“原子性”,因为对于各个线程来说,访问临界区不会            出现访问一半被其他线程抢过来访问的情况。
5. 不遵守这个约定??

        程序员似乎有能力跨过这个锁?当然,代码都是你写的。

        但是这是一种bug写法!

我们用全局初始化的方法对刚刚写的代码加锁:

while(true){pthread_mutex_lock(&glock);if(ticketnum>0){//初始化局部锁// pthread_mutex_t lock;// pthread_mutex_init(&lock,nullptr);usleep(1000);//1.抢票printf("抢的是第%d号票\n",ticketnum--);//2.入库模拟// usleep(1000);pthread_mutex_unlock(&glock);}else{pthread_mutex_unlock(&glock);break;}}

到底该在什么地方解锁呢?以下两种写法有什么区别

        

第一种写法中,ticketnum==0的时候,被直接break了,而没有经过unlock的过程,线程可能因此发生阻塞,导致整个主线程都在阻塞。

局部锁

刚刚提到了,使用局部锁必须自己清除这个锁(new了之后需要手动delete)

int main(){pthread_mutex_t mutex;
pthread_mutex_init(mutex,nullptr);//....pthread_mutex_destroy;
}

在主程序中创建的变量,如何让每一个线程都拿到这一把锁呢?

线程资源以传参的形式传给线程即可,也就是让ticket函数有一个接受锁的传参的机会。

并且还希望在ticket中传一个当前正在抢票的线程的名字,能够让我们看到是谁在抢票

但是我们并不是简单的传一个pthread_mutex_t的参数,而是传一个ThreadData结构体

class ThreadData
{
public:
private:std::string _name;pthread_mutex_t* _mutex;
};

然后把我们的thread.hpp改成带模板的可传参版本

template<typename T>class Thread{using func_t = std::function<void(T&)>;static int num_of_name;public:Thread(func_t func,T& data): _IsJoined(true), _pid(getpid()), _func(func),_STATUS(TSTATUS::NEW),_data(data){_name = "THREAD-" + std::to_string(num_of_name++); // THREAD-1 THREAD-2 THREAD -3........}static void *Routine(void *arg){Thread<T>* p_thread = static_cast<Thread<T>*>(arg); p_thread->_func(p_thread->_data);p_thread->_STATUS=TSTATUS::RUNNING;return nullptr;}bool Start(){if (_STATUS != TSTATUS::RUNNING) {int n = pthread_create(&(this->_tid), nullptr, Routine, (void*)this);if (n != 0) // 构建代码的健壮性{std::cerr << "pthread ERROR " << strerror(errno) << std::endl;return false;}_STATUS = TSTATUS::RUNNING;return true;}return false;}bool Stop(){if (_STATUS == TSTATUS::RUNNING){int n = pthread_cancel(_tid);if (n != 0)return false;_STATUS = TSTATUS::STOP;return true;}return false;}bool Join(){if(_STATUS==TSTATUS::RUNNING){if(_IsJoined){int n = pthread_join(_tid,nullptr);if(n!=0) return false;_STATUS = TSTATUS::STOP;return true;}return false;}return false;}void EnableDetach(){_IsJoined=false;}bool Detach(){EnableDetach();pthread_detach(_tid);return true;}std::string& Name(){return _name;}private:std::string _name;pthread_t _tid;pid_t _pid;bool _IsJoined;func_t _func;TSTATUS _STATUS;T& _data;};template<typename T>int Thread<T>::num_of_name = 1;

而主程序中,尤其是注意main里构造线程的部分,我们是要传一把相同的锁给所有不同线程的ticket函数

class ThreadData
{
public:std::string _name;pthread_mutex_t* _pmutex;
};void ticket(ThreadData& arg)
{while(true){//pthread_mutex_lock(&glock);pthread_mutex_lock(arg._pmutex);if(ticketnum>0){//初始化局部锁// pthread_mutex_t lock;// pthread_mutex_init(&lock,nullptr);usleep(1000);//1.抢票printf("我是%s, ",arg._name.c_str());printf("抢的是第%d号票\n",ticketnum--);//2.入库模拟// usleep(1000);//pthread_mutex_unlock(&glock);pthread_mutex_unlock(arg._pmutex);}else{//pthread_mutex_unlock(&glock);pthread_mutex_unlock(arg._pmutex);break;}//pthread_mutex_unlock(&glock);}return;
}int main()
{std::vector<Thread<ThreadData>> threads;pthread_mutex_t mutex;pthread_mutex_init(&mutex,nullptr);//1.构建线程对象for(int i = 0;i<NUM;i++){ThreadData* p_data = new ThreadData();threads.emplace_back(ticket,*p_data);p_data->_pmutex=&mutex;p_data->_name=threads.back().Name();}//2.启动线程for(auto& e:threads){e.Start();}//3.线程等待for(auto& e:threads){e.Join();}pthread_mutex_destroy(&mutex);return 0;
}


2. 互斥原理的探究————笔者自己的理解方法

     笔者的理解方法:可以认为,临界区资源在一开始都被挂上了锁,现在每一个线程都需要钥匙才能去访问临界区,但是钥匙只有一把,并且放在内存中,钥匙的名字是mutex,线程有一个寄存器,里面值是0,内存中mutex的值是1

当代码执行到临界区,即将,线程们都要去找钥匙,假设当前时间片是thread1先去找钥匙:

互换两部分的数据,这样thread就去访问临界区了。

如果此时又切换到thread2,再想访问临界区代码,就会检测内存中mutex的值,但是因为mutex已经是0了,就需要等thread1用完之后把钥匙换回来,也称之为“等锁”

伪码如下:

        

         为了实现互斥锁操作,⼤多数体系结构都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性。 即使是多处理器平台,访问内存的总线周期也有先后,⼀个处理器上的交换指令执⾏时另⼀个处理器的交换指令只能等待总线周期。

除了软件加锁机制,还可以适当了解一下硬件加锁: 

硬件加锁的大致思路:

中断分为可屏蔽中断和不可屏蔽中断,时钟中断是一种可屏蔽中断。访问临界资源的时候屏蔽掉时钟中断,此时线程就不会切换.........自然也就不存在大部分资源共享问题


 在c++中,有将mutex和thread都封装好的模块,非常好用

#include <iostream>
#include <thread>
#include <mutex>// 共享资源
int shared_counter = 0;
// 互斥锁对象
std::mutex mtx;// 线程函数,用于增加共享计数器
void increment_counter() {for (int i = 0; i < 1000; ++i) {// 加锁mtx.lock();// 访问和修改共享资源++shared_counter;// 解锁mtx.unlock();}
}int main() {// 创建多个线程std::thread t1(increment_counter);std::thread t2(increment_counter);// 等待线程完成t1.join();t2.join();// 输出最终的共享计数器值std::cout << "Final shared counter value: " << shared_counter << std::endl;return 0;
}

即: 

#inlcude<mutex>std::mutex gmtx;gmtx.lock();///gmtx.unlock();

3. 封装锁

老规矩,锁的使用是面向过程的,我们按照C++的模块方法改成面向对象的方法:

首先,禁用拷贝构造和赋值。锁本来不能被拷贝!!拷贝锁是一种bug

很简单的封装即可:

#ifndef _MUTEX__HPP
#define _MUHTEX_HPP#include<pthread.h>
#include<iostream>namespace LockMoudle
{class Mutex{public:Mutex(const Mutex& mutex)=delete;const Mutex& operator= (Mutex& mutex)=delete;Mutex(){int n = pthread_mutex_init(&_lock,nullptr);if(n!=0){std::cerr<<"inii error"<<std::endl;}}~Mutex(){int n = pthread_mutex_destroy(&_lock);}void lock(){pthread_mutex_lock(&_lock);}void unlock(){pthread_mutex_unlock(&_lock);}private:pthread_mutex_t _lock;};
}

进一步使用RALL思想:将资源的获取和释放与对象的生命周期绑定

而此时的控制资源的获取和释放就是lock与unlock两个接口。

尝试使用:

#include <iostream>
#include <vector>
#include "thread.hpp"
#include "mutex.hpp"using namespace ThreadModule;
using namespace MutexModule;
#define NUM 4int tickets = 1000;// 创建锁对象
Mutex lock;void getTicket()
{while (true){// 加锁MutexGuard lockGuard(lock);// 有票时,抢票,否则直接跳出循环if (tickets > 0){usleep(1000);std::cout << "当前线程获取到一张票" << tickets-- << std::endl;}else{break;}}
}int main()
{// 创建多个线程std::vector<Thread> threads;for (int i = 0; i < NUM; i++)threads.emplace_back(getTicket);// 启动多个线程for (int i = 0; i < NUM; i++)threads[i].start();// 等待多个线程for (int i = 0; i < NUM; i++)threads[i].join();return 0;
}

4. 同步

在之前的代码中,如果多尝试几次抢票的代码,就会发现由于没有同步而导致的饥饿问题:

        如果一个线程抢到了票,其执行完了抢票逻辑,就需要释放锁并且唤醒其他进程一起再抢锁:所有线程都重新竞争同一把锁,但是刚刚释放锁的线程不存在被唤醒的过程,一定会更容易比其他等待被唤醒的线程先抢到锁

这种情况下导致这个线程抢到锁的概率很大,其他线程就只能一直处于等待->唤醒->等待,循环往复始终无法拿到锁从而产生线程饥饿问题。所以为了解决这个问题,就需要保证某一个线程释放锁时不能再次立即拿锁,而是需要和其他线程一起抢锁,并且为了保证所有线程都可以拿到锁,还需要保证这些线程是有顺序拿到锁,这种机制就是同步。

可以如下实验,如果不退出,此时再开一个客户端监视,发现大部分时候都是一个进程从检测if到进入新的while

        

互斥解决线程安全问题,同步解决效率问题:

        同步就是以上问题的解决方法,每当一个线程访问资源完毕,不能立即再次申请资源,而是排到一个队列的尾部,让所有线程更有顺序性的去使用资源,在保证线程安全的情况下让系统更高效运行。

4.1 初识条件变量 

        当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
        例如⼀个线程访问队列时,发现队列为空,它只能等待,直到其它线程将⼀个节点添加到队列中。这种情况就需要⽤到条件变量。
条件变量是同步的重要手段。

引入条件变量相关接口(是不是和mutex的接口很类似?):

简单理解下条件变量:

        

  1. 配合(Mutex)使用
    • 条件变量通常与互斥锁(如 pthread_mutex_t)结合使用。线程在检查条件前需先获取锁,确保对共享数据的访问是线程安全的。
    • 条件变量本身不存储条件状态(换句话说,现在是否需要阻塞或者唤醒需要我们自行通过if或者while来判断),仅提供阻塞和唤醒的机制。
  2. 基本操作
    • 等待(pthread_cond_wait:线程释放锁并阻塞,直到被其他线程唤醒。被唤醒后,它会重新获取锁。
    • 唤醒(pthread_cond_signal 或 pthread_cond_broadcast
      • signal:唤醒至少一个阻塞的线程。
      • broadcast:唤醒所有阻塞的线程。
  3. 典型使用场景
    • 生产者-消费者模型:消费者线程等待队列非空时被唤醒。
    • 任务调度:工作线程在任务队列为空时休眠,新任务到达时被唤醒。
    • 管道

以一个放苹果的例子来说明以上三个接口:

        今天有一个人放苹果(数据),想拿苹果必须先通过一把锁(mutex),现在有很多拿苹果的人。为了规范管理,我们让拿苹果的人形成一个队列,每次想通过锁拿苹果的人,不管拿没拿到,访问完这个苹果就得回到队列的尾巴上开始排队。

        因此,拿完苹果或者当下没有苹果,不允许一个人(线程)刚放下锁又拿起锁,而是必须进入条件变量的队列去等待,这就是pthread_cond_wait(&_cond,&_mutex)。_cond指的就是设计给当前锁的条件变量,使用条件变量可能需要搭配:if(applenums==0){pthread_cond_wait(........)}。

       现在,想拿苹果的人都在队列中等待,何时去拿苹果呢? 需要放苹果的人(另外一个执行不同任务的线程)放下苹果,然后敲钟通知队列中的第一个人去拿(pthread_cond_signal,唤醒这个线程),如果今天放苹果的人想通知一群人都去拿,就使用pthread_cond_broadcast

4.2 在抢票模型中使用条件变量

        

也就是,当票数为0,就不再去抢票,而是去等待队列中挂起

4.3 pthread_cond_wait释放锁

another test code:

主函数:

int main()
{pthread_t tid1,tid2,tid3;pthread_create(&tid1,nullptr,active,(void*)"thread_1");pthread_create(&tid2,nullptr,active,(void*)"thread_2");pthread_create(&tid3,nullptr,active,(void*)"thread_3");pthread_join(tid1,nullptr);pthread_join(tid2,nullptr);pthread_join(tid3,nullptr);return 0;
}

因为进入了条件变量的等待队列,所以那一条语句没有打印出来。

我们加一点主函数里用来唤醒队列的pthread_cond_signal:

 

运行结果:

每一次先进行等待的队列不一样,比如第一次是thread_3先等待,第二次是2先开始等....

pthread_cond_signal换成pthread_cond_broadcast

由于在sleep的2秒中内足够让三个线程都按照顺序去拿一次锁,然后再次等待,直到2s之后再发广播通知。

        这也说明了,pthread_cond_wait会让该线程放下锁去等待,回来的时候还要重新获得锁。

如果不放下锁,一个线程抱着锁就去条件变量下等待,其他线程就不能再进入临界区,就不能三个变量一起在条件变量的队列中等待siganl或者broadcast了

        所以pthread_cond_wait的功能大大概可以分为:先让线程去cond下等待,回来的时候再去获得锁,并且该函数会保证线程获得锁。

条件变量的更多理解,请在等待下一篇讲生产消费模型之后进一步学习。 

相关文章:

  • Selenium-Java版(操作元素)
  • 毕业论文,如何区分研究内容和研究方法?
  • 级联与端到端对话系统架构解析:以Moshi为例
  • 二叉树前中后序遍历统一迭代法详解:空标记法与栈操作的艺术
  • LIO-SAM框架理解
  • 鸿蒙OSUniApp 实现精美的用户登录和注册页面#三方框架 #Uniapp
  • html5+css3实现傅里叶变换的动态展示效果(仅供参考)
  • Pytorch的Dataloader使用详解
  • 【USRP】在linux下安装python API调用
  • Oracle 中的虚拟列Virtual Columns和PostgreSQL Generated Columns生成列
  • 一分钟了解大语言模型(LLMs)
  • 基于ssm+mysql的高校设备管理系统(含LW+PPT+源码+系统演示视频+安装说明)
  • 音频分类的学习
  • De-biased Attention Supervision for Text Classifcation with Causality
  • 学习51单片机01(安装开发环境)
  • 基于Matlab的非线性Newmark法用于计算结构动力响应
  • STM32 之网口资源
  • 当 DeepSeek 遇见区块链:一场颠覆式的应用革命
  • 学习黑客蓝牙技术详解
  • SAP Fiori Elements Object Page
  • 普京批准俄方与乌克兰谈判代表团人员名单
  • 陕西宁强县委书记李宽任汉中市副市长
  • 中哥两国元首共同见证签署《中华人民共和国政府与哥伦比亚共和国政府关于共同推进丝绸之路经济带和21世纪海上丝绸之路建设的合作规划》
  • 茅台1935今年动销达到预期,暂无赴港上市计划!茅台业绩会回应多个热点
  • 93岁南开退休教授陈生玺逝世,代表作《明清易代史独见》多次再版
  • 网信部门曝光网络谣言典型案例,“AI预测彩票号码百分百中奖”等在列