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

【Linux】系统部分——线程互斥

28.线程互斥

文章目录

  • 28.线程互斥
      • 相关概念
      • 互斥量
        • 多线程并发操作共享变量带来的问题
          • 线程切换的触发条件
        • 解决方法
        • 互斥量接口
        • 总结
      • 互斥锁的封装
      • 总结

相关概念

  • 临界资源:多线程执⾏流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤
  • 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

互斥量

多线程并发操作共享变量带来的问题

⼤部分情况,线程使⽤的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程⽆法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来⼀些问题:

设计一个多线程抢票模拟场景,创建多个线程,每个线程尝试抢票,有票则抢,无票则退出循环。票用全局变量表示,作为共享资源被所有线程访问。

// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;void *route(void *arg)
{char *id = (char *)arg;while (1){if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", id, ticket--);usleep(11);}elsebreak;}
}
int main(void)
{pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);
}

结果:

thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

共享资源ticket被多个线程同时访问和修改,没有加任何保护措施。对全局变量的访问和修改操作(判断和减减)构成临界区代码,这部分代码在多线程环境下可能引发问题。其余代码如输出信息和休眠操作属于非临界区。预期结果是票数最多减到零,如果出现负数则说明多线程同步出现问题。编译并运行程序,观察抢票结果出现负数票。这种现象是由于多个线程同时进入临界区,对共享资源进行非原子操作导致的。

  • 非原子操作造成的原因:

    if语句判断条件为真以后,代码可以并发的切换到其他线程 ,而usleep 这个模拟漫⻓业务的过程,在这个漫⻓的业务过程中,可能有很多个线程会进⼊该代码段,而且--ticket 操作本⾝就不是⼀个原⼦操作:而是对应三条汇编指令:将ticket载入寄存器,寄存器执行-1操作,将新值写回内存中,在任何一个阶段都可能被中断(如时钟中断)。

  • 非原子操作造成的后果:
    非原子操作的典型表现是操作过程可能停留在中间状态。以全局变量减减为例,当线程刚把变量值从内存加载到CPU寄存器(如ex寄存器)时,若此时发生线程切换,其他线程看到的可能是未更新的旧值。这种中间状态会导致数据不一致问题,因为原线程尚未完成计算和结果回写。

  • 总结:主要是由于if判断的非原子性导致在票数为1时不止一个线程通过了if的条件判断,之后的--ticket 操作虽然不是原子的,但在这里变成了串行执行(可能因为调度问题有先有后),使得ticket被多个线程减减,从而出现票数为负的情况。

线程切换的触发条件
  1. 时间片耗尽——通过时钟中断周期性检查线程已运行时间,当时间片计数器归零时触发调度;

  2. 高优先级抢占——现代操作系统采用优先级调度算法,当更高优先级线程就绪时立即剥夺当前线程执行权;

  3. 资源等待——线程主动放弃CPU(如等待I/O或锁)。

解决方法

要避免票数减至负数的情况,需要从两个方面入手:

  • 首先,需要确保if判断和后续减操作的原子性,使得这两个操作作为一个不可分割的整体执行。其次,需要控制线程在执行这段关键代码时的调度行为,防止在执行过程中被切换。具体实现上,可以使用互斥锁等同步机制来保护这段代码,确保同一时间只有一个线程能够执行。(这是我们这次的学习内容)。

  • 另一种方法是使用原子操作指令,直接提供判断和减操作的原子性保证。此外,还可以考虑使用更高级的同步原语,如信号量或条件变量,来精确控制线程的执行顺序。这些解决方案的核心思想都是限制多个线程对共享变量的并发访问,确保每个操作都能完整执行而不被干扰。在实际应用中,需要根据具体场景选择最适合的同步策略,在保证正确性的同时兼顾性能需求。(目前我们暂不考虑)

在这里插入图片描述

互斥量接口
  • 锁的定义和初始化

    //全局锁初始化
    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//局部锁初始化
    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);	
    //参数:mutex:要初始化的互斥量 attr:NULL
    
    • pthread_mutex_t是pci库提供的一种数据类型,用于定义锁对象,定义锁后需要进行初始化
    • 当我们将锁定义为全局的,可以使用第一个方法,通过宏PTHREAD_MUTEX_INITIALIZER来初始化这个全局变量,通过这种方法定义的锁不需要手动destroy
    • 当我们将锁定义为局部的,需要使用pthread_mutex_init初始化锁,最后再用pthread_mutex_destroy释放锁
  • 锁的使用(加锁和解锁)

    int pthread_mutex_lock(pthread_mutex_t *mutex);
    int pthread_mutex_unlock(pthread_mutex_t *mutex);
    
    • 参数是锁的地址,返回值:成功返回0,失败返回错误码

    • 加锁和解锁是保护临界区代码的关键操作。加锁的粒度要尽量小,只保护访问全局资源的临界区代码,避免影响多线程的并发效率。加锁和解锁的操作需要成对出现,确保锁的正确释放。在抢票代码中,加锁和解锁需要包围临界区代码,确保线程安全。

    • 每个线程在访问临界区时都需要先申请锁资源,只有成功获取锁的线程才能进入临界区访问共享资源。因此不同线程调用加锁解锁代码时会遇到不同的情况:

      • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
      • 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。对其他线程而言,临界区代码要么完整执行,要么完全不执行。即便持有锁的线程被切换,其他线程也只能在锁上等待,无法干扰临界区的执行。
    • 锁本身也是共享资源,需要被所有线程看到。锁的作用是保护全局资源(如票数)的安全,被保护的共享资源称为临界资源。换种角度理解:加锁本质上是对临界资源的预定机制,成功申请锁意味着获得了资源的独占访问权(将锁与信号量进行类比。信号量本质上是计数器,二元信号量(计数器为1)就相当于锁机制。)线程在临界区内执行时仍可能被操作系统切换,但由于锁未被释放,其他线程仍无法进入临界区。

    • 注意,在使用锁的时候,如果定义的是局部锁,线程创建时需要传递包含锁指针和线程名称等参数的结构体。通过指针确保所有线程共享同一把锁。

添加互斥锁之后的抢票代码:

#include "pthread.hpp"
#include <iostream>
#include <vector>
#include <string>#define NUM 4
int tickets = 10000;class ThreadData
{
public:std::string _name;pthread_mutex_t *_lock_ptr;
};void ticket(ThreadData &td)
{while (true){pthread_mutex_lock(td._lock_ptr);//加锁if (tickets > 0){usleep(1000);std::cout << td._name.c_str() << ": " << std::to_string(tickets--) << std::endl;usleep(100);}else{pthread_mutex_unlock(td._lock_ptr);//解锁break;}pthread_mutex_unlock(td._lock_ptr);//解锁,if进入之后会执行到这一条代码,此时需要解锁再运行}
}int main()
{std::vector<My_Thread::Thread<ThreadData>> threas; // 这里使用了自己封装的threadpthread_mutex_t lock;pthread_mutex_init(&lock, nullptr);for (int i = 0; i < NUM; i++){ThreadData *td = new ThreadData;td->_lock_ptr = &lock;threas.emplace_back(ticket, *td);td->_name = threas.back().Name();}for (auto &t : threas){t.Start();}for (auto &t : threas){t.Jion();}pthread_mutex_destroy(&lock);return 0;
}
总结

1)锁本质上是资源的预定机制;

2)锁保证了临界区代码的原子性访问;

3)申请锁失败的线程必须阻塞等待;

4)线程在临界区内仍可能被切换但不影响安全性;

5)所有访问共享资源的线程都必须遵守加锁约定。

互斥锁的封装

为了便于使用,我么可以把加锁解锁的操作进行封装,使代码更符合C++面向对象的编程特性。

#pragma once#include <iostream>
#include <pthread.h>namespace My_Mutex
{class Mutex{public:Mutex(const Mutex&) = delete;const Mutex& operator=(const Mutex&) = delete;Mutex(){int n = pthread_mutex_init(&_lock, nullptr);if(n != 0)  {std::cerr << "err: pthread_mutex_init" << std::endl;exit(1); }}int Lock(){return pthread_mutex_lock(&_lock);}int Unlock(){return pthread_mutex_unlock(&_lock);}~Mutex(){int n = pthread_mutex_destroy(&_lock);if(n != 0){std::cerr << "err: pthread_mutex_destroy" << std::endl;exit(2);}}private:pthread_mutex_t _lock;};
}

使用这个封装之后的抢票代码;

#define NUM 4
int tickets = 10000;class ThreadData
{
public:std::string _name;My_Mutex::Mutex *_lock_ptr;
};void ticket(ThreadData &td)
{while (true){td._lock_ptr->Lock();if (tickets > 0){usleep(1000);std::cout << td._name.c_str() << ": " << std::to_string(tickets--) << std::endl;usleep(1000);}else{td._lock_ptr->Unlock();break;}td._lock_ptr->Unlock();}
}int main()
{std::vector<My_Thread::Thread<ThreadData>> threas; // 这里使用了自己封装的threadMy_Mutex::Mutex lock;for (int i = 0; i < NUM; i++){ThreadData *td = new ThreadData;td->_lock_ptr = &lock;threas.emplace_back(ticket, *td);td->_name = threas.back().Name();}for (auto &t : threas){t.Start();}for (auto &t : threas){t.Jion();}return 0;
}

总结

多线程环境下因并发操作共享资源(如全局变量)可能导致的数据不一致问题(如票数出现负数),其根本原因在于非原子操作的判断和修改过程可能被线程切换打断;进而提出了使用互斥锁(Mutex) 作为核心解决方案,通过加锁和解锁机制确保临界区代码的原子性执行,使得同一时刻仅有一个线程能访问共享资源,从而保障了多线程程序的安全性与正确性。

http://www.dtcms.com/a/389450.html

相关文章:

  • Qt QVBoxPlotModelMapper详解
  • Arcgis中的模型构建器技术之按属性批量建库并对应输出
  • Selenium UI 自动化:自定义 send_keys 方法实现与优化
  • golang后端面试复习
  • webpack学习笔记-entry
  • webpack学习之output
  • 应急响应靶机-WindowsServer2022-web2
  • Netty:网络编程基础
  • VulnHub打靶记录——AdmX_new
  • 筑牢安全防线,守护线上招标采购管理软件
  • TP8框架安全文件与文件夹权限相关设置
  • 练习:客户端从终端不断读取数据,通过UDP,发送给服务端,服务端输出
  • Android Studio报错 C Users User .gradle caches... (系统找不到指定的文件)
  • 微服务分页查询:MyBatis-Plus vs 自定义实现
  • Opera Neon:Opera 推出的AI智能代理浏览器
  • Java 基础知识整理:字面量、常量与变量的区别
  • 模型部署:(六)安卓端部署Yolov8分类项目全流程记录
  • android 查看apk签名信息
  • SQL提取国家名称与延伸词技巧
  • 通过 商业智能 BI 数据分析提升客流量和销售额
  • PostgreSQL 与 MySQL 谁的地位更高?——全方位对比分析
  • rust编写web服务08-配置管理与日志
  • 浏览器事件机制里,事件冒泡和事件捕获的具体区别是什么?在React的合成事件体系下有什么不同的?
  • 企业级实战:构建基于Qt、C++与YOLOv8的模块化工业视觉检测系统(基于QML)
  • 【Java】Ubuntu上发布Springboot 网站
  • 【入门级-算法-3、基础算法:贪心法】
  • Linux 网络
  • 【LVS入门宝典】探秘LVS透明性:客户端如何“看不见”后端服务器的魔法
  • 23届考研-C++面经(OD)
  • 运维安全06,服务安全