【Linux】线程互斥

🌈前言

这篇文章给大家带来线程同步与互斥的学习!!!


🌸1、Linux线程互斥

🍧1.1、线程间互斥相关背景概念

这些名词,我们在共享内存中已经了解过⭐⭐

概念
  • 临界资源:多线程执行流共享(都能看到,并且能访问)的资源就叫做临界资源
  • 临界区:每个线程执行流内部,访问临界资源的代码,就叫做临界区
  • 互斥任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源(全局、静态变量、共享内存等),通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两种状态,要么完成,要么未完成,没有中间状态

🍨1.2、互斥量(锁)相关背景

互斥量mutex
  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
  • 多个线程并发的操作共享变量,会带来一些问题(原子性问题)

验证:设置一个多线程来进行抢票,票数为共享资源 – 售票系统代码

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

int ticket = 10000;

void *GrabTickets(void *args)
{
    // 多线程一直抢票,直到票数<=0为止
    const char *name = static_cast<const char *>(args);
    while (true)
    {
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s: 抢到了票, 票的编号为: %d\n", name, ticket);
            ticket--;
        }
        else
        {
            printf("%s: 已经放弃抢票了,因为没有了...\n", name);
            break;
        }
    }
    return nullptr;
}

int main()
{
    // 定义线程id
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;

    // 创建线程
    if (pthread_create(&tid1, nullptr, GrabTickets, (void *)"Thread1") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid2, nullptr, GrabTickets, (void *)"Thread2") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid3, nullptr, GrabTickets, (void *)"Thread3") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid4, nullptr, GrabTickets, (void *)"Thread4") != 0)
    {
        exit(EXIT_FAILURE);
    }

    // 线程等待 -- 不获取线程退出码
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);
    return 0;
}

一次运行结果:出现溢出抢票的情况!!!

Thread3: 已经放弃抢票了,因为没有了...
Thread2: 抢到了票, 票的编号为: 3
Thread2: 已经放弃抢票了,因为没有了...
Thread4: 抢到了票, 票的编号为: -1
Thread4: 已经放弃抢票了,因为没有了...
Thread1: 抢到了票, 票的编号为: -2
Thread1: 已经放弃抢票了,因为没有了...
为什么可能无法获得正确的结果呢?
  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • usleep函数用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段(代码区)
  • ticke自减操作本身就不是一个原子操作有中间动作,线程切换时会被挂起
  • CPU内的寄存器是被所有执行流(线程)共享的,但是寄存器里面的数据是属于当前执行流的上下文数据
  • 线程被切换时,需要保存上下文数据。线程被换回时,要恢复上下文数据
  // 取出ticket--部分的汇编代码
  // 指令:objdump -d a.o > test.objdump
  //-------------------------------------------------------------------------------------
  44:   8b 05 00 00 00 00       mov    0x0(%rip),%eax        # 4a <_Z11GrabTicketsPv+0x4a>
  4a:   83 e8 01                sub    $0x1,%eax
  4d:   89 05 00 00 00 00       mov    %eax,0x0(%rip)        # 53 <_Z11GrabTicketsPv+0x53>
ticket自减操作对应三条汇编指令
  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

在这里插入图片描述

为什么说ticket不是原子操作呢?

多线程访问共享资源问题
  • 因为CPU在运算ticket自减操作时(比如计算完后),线程的时间片到了,需要进行线程切换,但是ticket计算完后的数据没有拷贝回内存,就被切换了
  • 线程切换时,将保存线程的上下文,下一个线程运算完后,ticket的值变成9999
  • 随后切换回原来的线程,恢复线程的上下文,将运算好的9999拷回内存的ticket中,导致数值不一样问题(应该变成9998)!

在这里插入图片描述

要解决以上问题,需要做到三点
  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
  • 要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
    在这里插入图片描述

🍯1.3、互斥量(锁)相关API

🍯1.3.1、初始化和销毁互斥锁

互斥锁概念
  • 互斥锁只能对临界区进行加锁,加锁的本质是让线程执行临界区代码串行化
  • 加锁是一套规范,通过临界区对临界资源进行访问的时候,要加就都要加。如果一部分代码加,一部分不加,会出现bug
  • 临界区加锁时,加锁的粒度约细越好,否则可能出现死锁的情况(没有解锁)
  • 加锁保护的是临界区, 任何线程执行临界区代码访问临界资源,都必须先申请锁,前提是都必须先看到锁
  • 多线程竞争和申请锁的过程,就是原子的

初始化互斥量有二种方法

第一种方法:静态分配

#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
代码解析
  • pthread_mutex_t是互斥锁,它是一个联合体,里面有一个结构体描述锁的属性
  • PTHREAD_MUTEX_INITIALIZER:它是一个,用于初始化互斥锁

第二个方法:动态分配

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
                       const pthread_mutexattr_t *restrictattr);
函数解析
  • mutex:要初始化的互斥锁(pthread_mutex_t变量的地址
  • restrictattr:设置互斥锁的属性,一般为NULL/nullptr
  • 返回值:初始化成功返回0,失败返回一个错误码errno

销毁互斥锁:

#include <pthread.h》
int pthread_mutex_destroy(pthread_mutex_t *mutex)
函数解析
  • mutex:要销毁的互斥锁(pthread_mutex_t变量的地址
  • 返回值:初始化成功返回0,失败返回一个错误码errno
销毁互斥锁需要注意
  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥锁不需要销毁
  • 不要销毁一个已经加锁的互斥锁
  • 已经销毁的互斥锁 ,要确保后面不会有线程再尝试加锁

🍰1.3.2、互斥量加锁和解锁

加锁和解锁:

#include <pthread.h》
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
函数解析
  • mutex:要加锁或解锁的互斥量(pthread_mutex_t变量的地址
  • 返回值:初始化成功返回0,失败返回一个错误码errno

调用 pthread_ lock 时,可能会遇到以下情况:⭐⭐

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功

  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁

修改前面的售票系统代码:使用动态分配互斥锁,需要释放互斥锁(pthread_mutex_destroy)

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

// 1、定义互斥锁,主线程初始化
pthread_mutex_t Mutex;

int ticket = 10000;

void *GrabTickets(void *args)
{
    const char *name = static_cast<const char *>(args);
    while (true)
    {
        // 3、加锁
        pthread_mutex_lock(&Mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s: 抢到了票, 票的编号为: %d\n", name, ticket);
            ticket--;
            // 解锁 -- 互斥量的粒度越细越好
            pthread_mutex_unlock(&Mutex);
        }
        else
        {
            // 解锁 -- 如果没有解锁,线程再次加锁时,会一直阻塞
            pthread_mutex_unlock(&Mutex);
            printf("%s: 已经放弃抢票了,因为没有了...\n", name);
            break;
        }
    }
    return nullptr;
}

int main()
{
    // 2、初始化互斥锁 -- 动态分配互斥锁
    pthread_mutex_init(&Mutex, nullptr);

    // 定义线程id
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;

    // 创建线程
    if (pthread_create(&tid1, nullptr, GrabTickets, (void *)"Thread1") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid2, nullptr, GrabTickets, (void *)"Thread2") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid3, nullptr, GrabTickets, (void *)"Thread3") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid4, nullptr, GrabTickets, (void *)"Thread4") != 0)
    {
        exit(EXIT_FAILURE);
    }

    // 线程等待 -- 不获取线程退出码
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);

    // 释放互斥锁 -- 动态申请的互斥锁
    pthread_mutex_destroy(&Mutex);
    return 0;
}

修改前面的售票系统代码:使用静态分配互斥锁,不需要释放互斥锁

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

// 1、定义互斥锁,主线程初始化 -- 静态分配互斥锁
pthread_mutex_t Mutex = PTHREAD_MUTEX_INITIALIZER;

int ticket = 10000;

void *GrabTickets(void *args)
{
    const char *name = static_cast<const char *>(args);
    while (true)
    {
        // 3、加锁
        pthread_mutex_lock(&Mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s: 抢到了票, 票的编号为: %d\n", name, ticket);
            ticket--;
            // 解锁 -- 互斥量的粒度越细越好
            pthread_mutex_unlock(&Mutex);
        }
        else
        {
            // 解锁 -- 如果没有解锁,线程再次加锁时,会一直阻塞
            pthread_mutex_unlock(&Mutex);
            printf("%s: 已经放弃抢票了,因为没有了...\n", name);
            break;
        }
    }
    return nullptr;
}

int main()
{
    // 2、初始化互斥锁
    pthread_mutex_init(&Mutex, nullptr);

    // 定义线程id
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;

    // 创建线程
    if (pthread_create(&tid1, nullptr, GrabTickets, (void *)"Thread1") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid2, nullptr, GrabTickets, (void *)"Thread2") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid3, nullptr, GrabTickets, (void *)"Thread3") != 0)
    {
        exit(EXIT_FAILURE);
    }

    if (pthread_create(&tid4, nullptr, GrabTickets, (void *)"Thread4") != 0)
    {
        exit(EXIT_FAILURE);
    }

    // 线程等待 -- 不获取线程退出码
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);
    return 0;
}

临界区的临界资源被锁后,当前线程时间片到了,还能进行线程间切换吗?加锁 == 不会切换?⭐⭐⭐

结论
  • 完全可以切换,因为线程执行加锁解锁对应的也是代码
  • 但是线程加锁是原子的,要么拿到锁,要么没拿到(多线程竞争申请互斥锁资源)

比如:我们有线程A和其他线程

  • 线程A申请到了锁,执行临界区代码中途被切走了,切走时也是把锁抱走的
  • 在线程A被切走的时候,绝对不会有线程进入临界区
  • 因为进入临界区要申请互斥锁的资源,但是线程A已经申请了,其他线程只能一直阻塞等待资源就绪,然后竞争资源
  • 线程A访问临界区只有进入和使用完毕二种状态(原子性),这样才对其他线程有意义
总结
  • 不要再临界区做过多的事情,临界区代码最好越短越好
  • 因为可能执行到一部分时,时间片就到了,然后其他线程一直阻塞等待,耗时长

在这里插入图片描述


🍲1.3.3、互斥锁的实现原理

前言
  • 经过上面的例子,我们都已经意识到单纯的 ticket++ 或者 ++ticket 都不是原子的,有可能会导致数据一致性问题
  • 为了实现互斥锁操作,大多数计算机体系结构都提供了swap或exchange汇编指令
  • 该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性
  • 即使是多处理器平台(多核CPU),访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期
  • swap或exchange交换指令只有一句,意味着只有没做和做完二种状态,它是原子操作

在这里插入图片描述

当执行完二条汇编语句时,时间片到了,线程切换后,会出问题吗?

没有问题

当一个线程在执行时,CPU中一组寄存器中保存的值被称为该线程的上下文

  • 因为线程切换时,会将寄存器中的数据全部带走!!!
  • 凡是在寄存器中的数据,全部都是线程内部的上下文!!!
  • 寄存器是在多线程看来,是共享的资源(CPU只有一套寄存器),但是在线程看来是自己的私有资源(因为线程会拿着寄存器的数据切换走)
  • 多线程看起来同时在访问寄存器,但是它们互不影响

如果多线程同时竞争锁时,同时将0数据传输到al寄存器中,会出现问题吗?

比如:mutex = 1
  • 不会出现问题,因为多线程中竞争资源时,至少有一个线程执行第二条交换指令
  • 当第一个线程执行完这个指令后,寄存器的数据就会变成1,内存的数据变成0,而其他线程执行第二条指令,0跟0交换,没有发生变化
  • 第一次执行交换指令的线程,会进入if语句,并且返回0表示申请锁成功,而其他线程会一直挂起/阻塞等待

在这里插入图片描述


🍁2、线程安全与重入函数

🍧2.1、概念

概念
  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题
  • 重入函数:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入
  • 一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数

🍨2.2、常见线程安全情况

线程不安全情况
  • 不保护共享变量(可以被全部线程访问的临界资源)的函数
  • 函数状态随着被调用,状态发生变化的函数(计数器,记录函数被调用次数
  • 调用线程不安全函数的函数(一般不带_r的函数为不安全的)
  • 返回指向静态变量指针的函数(多线程返回静态指针,导致数据不稳定

线程安全情况
  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

🍨2.3、常见重入情况

不可重入函数情况
  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆
  • 调用了标准I/O库函数,标准I/O库的实现都以不可重入方式来管理全局数据结构的
  • 可重入函数体内使用了静态的数据结构

可重入函数情况
  • 不使用全局变量或静态变量,不使用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据(线程独立栈变量),或者通过制作全局数据的本地拷贝来保护全局数据

🍨2.3、可重入与线程安全

它们之间联系
  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

它们之间的区别
  • 线程安全是:描述多线程并发所带来的问题
  • 可重入与不可重入是:描述函数是否可以重复进入,它们是不同的概念
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的

🍂3、常见锁概念

死锁
  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态
死锁四个必要条件
  • 互斥条件:一个资源每次只能被一个执行流使用\
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

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

🍃4、线程安全的设计模式

C++里面已经讲过了设计模式,不过不是线程安全的

单例模式是一种 “经典的, 常用的, 常考的” 设计模式

什么是设计模式?
  • IT行业这么火,涌入的人很多,俗话说林子大了啥鸟都有
  • 大佬和菜鸡们两极分化的越来越严重,为了让菜鸡们不太拖大佬的后腿,于是大佬们针对一些经典的常见的场景,给定了一些对应的解决方案,这个就是:设计模式

🍧4.1、单例模式

【C++单例模式】

概念
  • 某些类,只应该具有一个对象(实例),就称之为单例模式
  • 该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享
  • 在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据

单例有两种模式:

饿汉模式:
  • 程序启动时,自动创建一个唯一的实例对象
  • 类中的构造函数和赋值拷贝函数私有化,这样外部就不能创建对象了
  • 在类中创建一个指向静态类对象的指针并且设为私有,防止外部调用其进行构造,类外面进行初始化,随后再设置一个返回该指针的静态成员函数即可
namespace Hungry_Man
{
	// 单例模式:一个类只能创建一个对象,即单例模式,
	// 该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享

	// 饿汉模式 -- 程序启动(进入main函数之前)前创建(初始化)对象
	class Singleton
	{
	public:
		// 只能通过该接口进行成员的修改或调用
		static Singleton* GetInstance()
		{
			return psg;
		}

		void SetName(const std::string& _name)
		{
			name = _name;
		}

		void SetAddress(const std::string& _address)
		{
			address = _address;
		}

		void Print() const
		{
			std::cout << "name: " << name << std::endl;
			std::cout << "address: " << address << std::endl;
		}
	private:
		Singleton(std::string _name = "", std::string _address = "")
		{}
		Singleton& operator=(const Singleton&) = delete;
		static Singleton* psg; // 静态成员对象 -- 不会造成无穷的创建,因为静态成员不属于类,类外初始化
	private:
		std::string name;
		std::string address;
	};

	// 静态成员类外定义 -- 程序从上到下开始编译,main前静态成员就已初始化,只有一个实例
	Singleton* Singleton::psg = new Singleton();
	
	void test()
	{
		Singleton::GetInstance()->SetName("DEGTY");
		Singleton::GetInstance()->SetAddress("GFRVED OPRT");
		Singleton::GetInstance()->Print();
	}
}

懒汉模式
  • 与饿汉模式大同小异,就是需要加锁
  • 也是将构造和赋值构造设为私有
  • 创建一个指向静态类对象的指针,在类外初始化成空
  • 写一个外部可以访问的静态成员函数,如果指针为空则构造一个新的类并且返回,不为空返回该指针
template <typename T>
class Singleton 
{
	static T* inst;
	public:
	static T* GetInstance() 
	{
		if (inst == nullptr) 
		{
			inst = new T();
		}
	}
	return inst;
};
注意
  • 存在一个严重的问题, 线程不安全
  • 第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份T对象的实例
  • 但是后续再次调用,就没有问题了

线程安全版本

// 懒汉模式, 线程安全
template <typename T>
class Singleton 
{
	volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
	static std::mutex lock;
public:
	static T* GetInstance() 
	{
		if (inst == NULL) 
		{ 
			// 双重判定空指针, 降低锁冲突的概率, 提高性能
			lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
			if (inst == NULL) 
			{
				inst = new T();
			} 
			lock.unlock();
		} 
		return inst;
	}
};

注意:

  1. 加锁解锁的位置
  2. 双重 if 判定, 避免不必要的锁竞争
  3. volatile关键字防止过度优化

🍧4.2、STL、智能指针线程安全问题

STL中的容器是否是线程安全的? 答案是:不是的!!!

原因
  • STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响
  • 而且对于不同的容器加锁方式的不同, 性能可能也不同(例如:hash表的锁表和锁桶)
  • 因此STL默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全的问题

智能指针是否是线程安全的? 一部分是,一部分不是!

原因
  • 对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题
  • 对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题
  • 但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效看,原子的操作引用计数
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值