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

【Linux手册】解决多线程共享资源访问冲突:互斥锁与条件变量的使用及底层机制


请添加图片描述


半桔:个人主页

 🔥 个人专栏: 《Linux手册》《手撕面试算法》《C++从入门到入土》

🔖时间能治愈一切,请给时间一点时间。 -丁立梅-

文章目录

  • 前言
  • 一. 多线程访问出错
  • 二. 访问共享资源出错的原因
  • 三. 互斥锁
  • 四. 互斥锁的原理
  • 五. 线程同步互斥周边概念
    • 5.1 重入与线程安全
    • 5.2 死锁
  • 六. 线程同步

前言

在现代计算机系统中,并发执行已成为提升资源利用率与系统吞吐量的核心机制。当多个进程/线程共享有限的系统资源(如处理器、内存、I/O 设备等)时,如何协调它们的执行顺序以避免冲突、保证数据一致性,成为操作系统设计的关键挑战 —— 这正是进程同步与互斥问题的核心议题。

本文将系统梳理进程同步与互斥的理论基础,深入分析典型问题的内在逻辑,探讨各类解决方案的适用场景与局限性。

本文分为6个部分:

  1. 模拟多线程访问共享资源出错现象;
  2. 访问共享资源出错原因;
  3. 互斥锁解决共享资源访问问题;
  4. 互斥锁的原理;
  5. 线程同步互斥周边概念;
  6. 线程同步如何实现。

一. 多线程访问出错

下面先通过一个实验看一下什么是多线程访问错误:

模拟抢票的逻辑,使用5个线程模拟用户,用户要抢500张票。

  • 准备工作:设置一个结构体,存储线程的ID和线程的名字,用来打印抢票数据和回收线程:
int tickets = 1000;
struct ThreadData
{std::string thread_name_;pthread_t thread_id_;
};
  • 编写抢票逻辑
void* Get_Ticket(void* args)
{ThreadData* pdata = static_cast<ThreadData*>(args);// 进行抢票while(tickets > 0)   // 还有票,可以抢{// 打印出线程名以及抢到的票编号std::cout << pdata->thread_name_ << " is getting  a ticket , the numberr of tickets is " << tickets << std::endl;--tickets;usleep(rand() % 5);  // 模拟延时}return nullptr;
}
  • 编写主线程,创建新线程和回收新线程:
int main()
{// 模拟10个人抢票的逻辑// 即10个线程一起抢票, --tickets表示抢票std::vector<ThreadData*> threads;for(int i = 0 ; i < 5  ; i++){ThreadData* pdata = new ThreadData();pdata->thread_name_ = "Thread-" + std::to_string(i + 1);pthread_create(&pdata->thread_id_, NULL, Get_Ticket, pdata);threads.push_back(pdata);}// 等待所有线程结束for(int i = 0 ; i < threads.size() ; i++){pthread_join(threads[i]->thread_id_, NULL);delete threads[i];}return 0;
}

输出现象:因为输出太长,此处直接去最后几行:
请添加图片描述

根据上面的现象可以看到,有线程抢到了同一编号的票,并且有线程抢到了标号为负数的票。这就是多线程并发访问导致的数据不一致问题,下面围绕这一现象进行详细解释。

二. 访问共享资源出错的原因

对于全局变量的++/–操作是否是安全的???

在上面代码中多个线程都会对同一个全局变量进行–操作,那么这一过程是否安全。
此处需要了解以下,CPU是如何处理加减操作的,具体过程如下:

  1. 先将tickets的数据存放到CPU的寄存器中;
  2. 将寄存器中的值-1;
  3. 将寄存器中的数据拷贝回内存中。

具体实现如下:

mov eax,tickets // 将tickets加载到eax寄存器
dec eax // 将eax寄存器的值减1
mov tickets,eax // 将结果存储到变量tickets中

我们知道操作系统上的线程是并发运行的,随时有可能被操作系统切换;

所以有没有一种情况:

  • 线程A执行到dec eax后,突然被切换了,然后将这些上下文数据存储到线程中;线程B在CPU上多跑一会,抢到了很多票,执行了多次--tickets操作,后也被切换了;此时A又被调度了,它下一步要执行mov tickets,eax,但是寄存器eax中的值是多少,是线程B前往票后的值吗?
  • 并不是线程A中的值,是线程B还没抢之前的值,所以此时将这个值返回tickets中就会导致,tickets值变大,从而导致多个线程抢到同一张票
  • 同理如果一个线程C在while(tickets > 0)判断完后被切换,其他线程执行将票抢完了,已经是0了,没有票了;此时线程C再次被切换进来,因为之前判断是允许抢票的,所以其已经在循环内了,从而导致线程抢到负数的票

此处引入一个新的概念:

原子性(Atomicity): 是指一个操作或一组操作具有 “不可分割” 的特性 —— 要么完整地执行完毕,要么完全不执行,在执行过程中不会被任何外部因素(如其他进程、线程、中断等)打断,也不会向外界暴露中间状态。

一般只有一条汇编语句的代码就是具有原子性的。

所以也就是说:

  1. 对tickets的加减是不原子的,所以导致抢到了相同的票
  2. 进入循环执行代码也是不原子的,所以导致抢到了负票

如何解决这种数据不一致问题?如何保证对共享数据的访问,任何时候只有一个执行流在访问共享资源???

下面介绍互斥锁来解决数据不一致问题。

三. 互斥锁

在介绍互斥锁之前,先介绍几个概念:

  • 临界资源:多线程执行流共享的资源就叫做临界资源;
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区;
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。

锁是对临界区资源访问的一种限制操作,其保证在任何一个时间点上只有一个线程在访问临界资源。

示意图如下:

请添加图片描述

锁一共有4个操作,初始化锁,销毁锁,加锁和解锁。

初始化锁:

int pthread_mutex_init(pthread_mutex_t *restrict mutex , const pthread_mutexattr_t *restrict attr)

  1. 参数1:需要进行初始化的锁;
  2. 参数2:定制互斥锁的行为特性,一般不进行定制,使用NULL;
  3. 返回值:0表示成功,失败返回错误码。

销毁锁:

int pthread_mutex_destory(pthread_mutex_t *mutex):参数与返回值与上面类似。

加锁:int pthread_mutex_lock(pthread_mutex_t* mutex)
解锁:int pthread_mutex_unlock(pthread_mutex_t* mutex)

此时就可以对上面抢票代码进行重写,此时因为不能在循环外进行加锁,因此要把判断能否抢票的逻辑放在循环里面:

pthread_mutex_t lock;void *Get_Ticket(void *args)
{ThreadData *pdata = static_cast<ThreadData *>(args);// 进行抢票while (1) {         // 先上锁pthread_mutex_lock(&lock);if (tickets > 0)               // 还有票,可以抢{// 打印出线程名以及抢到的票编号std::cout << pdata->thread_name_ << " is getting  a ticket , the numberr of tickets is " << tickets << std::endl;--tickets;pthread_mutex_unlock(&lock);    // 解锁}else{pthread_mutex_unlock(&lock);    // 解锁break;}// 解锁usleep(getpid() % 100);  // 让线程休眠一会,防止一个线程申请锁的能力太强}return nullptr;
}

以上代码中解锁后如果没有代码,就会导致刚解锁的进程又会立即拿到锁,就会导致锁的分配不合理,容易导致进程饥饿问题;上面代码逻辑本身也是不对了,在解锁后,肯定还有其他操作要进行,比如保存用户信息,将票号从总票数中移除等,一次上面使用usleep()来模拟该操作。

锁本身也是共享资源,所以申请锁和释放锁本身在设计的时候就是原子的。

在临界区中,线程可以被切换吗???

可以被切换,只不过在切换时会将锁也带走,在此期间其他线程依旧没有办法访问临界区。

补充:还有一种初始化锁的方式:pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER,通过该方法进行初始化的锁,必须定义成全局的,并且不需要销毁。

四. 互斥锁的原理

为了实现互斥锁操作,大多数的CPU体系结构都提供了swapexchange指令,该指令的作用是把寄存器和内存单元的数据相符交换,由于只有一条指令,保证了原子性。

加锁&&解锁示意图如下:
请添加图片描述

在加锁的时候,将寄存器中的al先进行初始化,再将mutex中的值交换过去,相当于将锁给线程,如果mutex中的锁已经被拿走了,就会将线程挂起,直到锁归还。
解锁是,再见mmutex置为1,表示规范锁。

所以,交换锁的本质就是:把内存中的数据交换到CPU寄存器中,把数据交换到线程的上下文中。

mutex只有一个,其值子啊内存和寄存器之间进行交换;将共享的锁,以一条汇编的方式交换到线程的上下文,代表着该线程拿到了锁,其他线程就拿不到了。

五. 线程同步互斥周边概念

5.1 重入与线程安全

重入:对于一个函数来说,当有多个执行流同时在函数内执行,就成该函数为重入函数;
重入函数又可以分为:可重入函数和不可重入函数。

线程安全:当多个线程并发式的访问同一段代码的时候,不会出现不同的结果,就称为线程安全。

我们经常调用的库函数/系统调用基本上都是不可重入的。

重入与线程安全的关联:

  1. 一个函数是可重入的,那么一定是线程安全的;
  2. 一个函数是不可重入的,可能会出现线程不安全。

5.2 死锁

死锁:当一个线程占有一把锁A,正在获取另一把锁B时,另一个线程持有锁B,但是同时也在请求锁A,造成两个进程都在阻塞是等待的情况。

死锁的必要条件:

  1. 互斥条件,一个资源每次只能有一个进程访问;
  2. 请求与保持:执行流因为请求资源——锁而阻塞,对已有资源——锁不进行释放;
  3. 不掠夺条件:资源——锁不会被抢走;
  4. 循环等待:拥有锁的双方都在请求对方的锁。

根据死锁的必要条件,可以提出对应的解决方案:

  1. 对代码结构重构,打破互斥条件;
  2. 在申请锁失败阻塞的时候将自己拥有的锁释放,可以使用int pthread_mutex_trylock()尝试申请锁,如果失败返回-1,此时可以选择将自己的锁释放;
  3. 并不能抢其他线程的锁,所以无法掠夺;
  4. 打破循环条件,将两个锁同时进行申请,同时释放。

死锁还有些相关算法:1)死锁检测算法;2)银行家算法。

六. 线程同步

在保证资源安全的前提下,我们希望我买了的线程访问资源具有一定的顺序性。

线程同步就是为了解决不同线程竞争资源的能力不同,导致的饥饿问题;简单说就是解决线程排队的优先级,避免一个线程的优先级高并且一直在访问共享资源,导致其他线程都访问不到的问题。

线程同步是使用条件变量来实现的:可以将条件变量理解为一个队列,所有要访问临界资源的线程都要想到队列中进行排队;

以下是条件变量的接口:

初始化一个条件变量:
int pthread_cond_init(pthread_cond_t *restrict cond , const pthread_condatte_t *restrictatta)

  1. 第一个参数要进行初始化的条件变量;
  2. 第二个参数,可以指定条件变量的一些属性,一般不进行指定,使用NULL;
  3. 返回值0表示成功,失败返回错误码。

条件变量也可以先锁一样定义为全局的形式:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

销毁条件变量:
int pthread_cond_destroy(pthread_cond_t *cond)

让一个线程在队列中进行等待:
int pthread_cond_wait(pthread_cond_t *cond , pthread_mutex_t *mutex)

  1. 参数一,要在哪一个队列中等待;
  2. 参数二,对应的锁;

条件变量让进程进行等待是因为,临界资源没有就绪,所以才需要进行等待,但是我们如果想要直到临界资源是否就绪,就需要访问临界资源,因此条件变量的使用要在加锁和解锁之间,当线程在条件变量中等待的时候,会将对应的锁释放

线程在条件变量中等待,就必须能够被唤醒,线程库中也提供了将线程唤醒的接口:

int ptherad_cond_signal(pthread_cond_t *cond):唤醒条件变量中的一个线程;
int ptherad_cond_broadcast(pthread_cond_t *cond):唤醒条件变量中的所有线程;

下面写一个demo代码让主线程负责唤醒一个个新线程:

先定义锁和条件变量,以及一个存储线程数据的结构体:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 定义锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;    // 定义条件变量struct ThreadData
{std::string thread_name_;pthread_t thread_id_;
};

定义新线程调用的函数,只需要打印出每次访问临界区的线程名称即可:

void *thread_func(void *args)
{pthread_detach(pthread_self());ThreadData *data = static_cast<ThreadData *>(args);while (1){pthread_mutex_lock(&mutex);pthread_cond_wait(&cond, &mutex);std::cout << data->thread_name_ << " wake up" << std::endl;pthread_mutex_unlock(&mutex);}return nullptr;
}

编写主线程,负责发送信号唤醒条件队列,以及创建新线程:

int main()
{std::vector<ThreadData *> threads;for (int i = 0; i < 5; i++){ThreadData *pdata = new ThreadData();pdata->thread_name_ = "Thread-" + std::to_string(i + 1);pthread_create(&pdata->thread_id_, NULL, thread_func, pdata);threads.push_back(pdata);usleep(500);}while(1){pthread_cond_signal(&cond);sleep(1);}return 0;
}

运行结果:请添加图片描述

可以看到线程确实是一次访问临界资源的。

综上所述:cond就相当于一个队列,要访问临界资源就需要先排队,而pthread_cond_signalpthread_cond_broadcast相当于信号,告诉队列可以访问临界资源了。


文章转载自:

http://ijwMXuP7.jbqwb.cn
http://Dn0XY3gq.jbqwb.cn
http://jLwfh0Tq.jbqwb.cn
http://y6s3Z6lo.jbqwb.cn
http://gHCqNpjN.jbqwb.cn
http://v03bDZSn.jbqwb.cn
http://MpOV8VzJ.jbqwb.cn
http://3dx9nH7H.jbqwb.cn
http://Tuahg12a.jbqwb.cn
http://qLhB6z4F.jbqwb.cn
http://xB62i3uB.jbqwb.cn
http://YfZAlqmd.jbqwb.cn
http://y0vxFHCw.jbqwb.cn
http://xuLtU5nG.jbqwb.cn
http://KrELnfqR.jbqwb.cn
http://QemjGc62.jbqwb.cn
http://yGZK61qS.jbqwb.cn
http://0N7jsHMr.jbqwb.cn
http://5cQJmc2Z.jbqwb.cn
http://xCw10HYr.jbqwb.cn
http://rlv3QrtX.jbqwb.cn
http://DWOts8aQ.jbqwb.cn
http://0ZSOiJla.jbqwb.cn
http://T5XMCm9C.jbqwb.cn
http://lczAtz8K.jbqwb.cn
http://pyIxJ6Gd.jbqwb.cn
http://g1JTNTFT.jbqwb.cn
http://K7FwUp9d.jbqwb.cn
http://bies3gyV.jbqwb.cn
http://dY6HcFx1.jbqwb.cn
http://www.dtcms.com/a/387247.html

相关文章:

  • 基于微信小程序跑腿小程序设计与实现
  • 微信小程序-6-页面布局和事件绑定以及页面跳转
  • InnoDB多版本控制:揭秘MVCC核心机制
  • SpringMVC 系列博客(二):核心功能深入 —— 请求映射、返回值与参数绑定
  • HTTPS报文在SSL/TLS证书安全隧道传输的原理
  • 线性回归与 Softmax 回归技术报告
  • 不同团队如何选GIS软件?ArcGIS Pro、GISBox与SuperMap优劣势及适用方案
  • 静态标签云
  • AI解决企业内训之痛-智能企业内训平台解决方案
  • 容器化部署番外篇之docker网络通信06
  • Windows安装ES8.10流程及安装过程中出现的问题
  • 【工具代码】使用Python截取(切割)视频片段,截取视频中的音频,截取音频片段
  • Linux --- 权限
  • netty集成protobuf
  • ORA-12514:TNS:监听程序当前无法识别连接描述符中请求的服务
  • io_uring最简单的实例io_uring-test.c分析
  • 15.Linux时间管理
  • Linux 系统中的 Crond 服务:定时任务管理全指南
  • JDBC学习笔记
  • LoRA翻译
  • Linux 内存管理章节十五:内核内存的侦探工具集:深入Linux内存调试与检测机制
  • Mysql-主从复制与读写分离
  • bevformer 網絡結構
  • MySQL 基础与实战操作
  • 系统架构设计(二)
  • 【Day 58】Redis的部署
  • UVM验证工具--gvim
  • 《C++ spdlog高性能日志库快速上手》
  • 代码随想录学习(二)——二分查找
  • 【代码随想录day 27】 力扣 53. 最大子序和