【Linux】线程安全——同步和互斥

需要云服务器等云产品来学习Linux的同学可以移步/–>腾讯云<–/官网,轻量型云服务器低至112元/年,优惠多多。(联系我有折扣哦)

引入

上节中我们学习了多线程的概念和控制,但是实际上多线程的程序会有很多问题的出现。

首先我们看一个多线程程序的例子,一个模拟抢票的程序

#include <iostream>
#include <unistd.h>
#include "Thread.hpp"

int ticket = 10000;

void *getTicket(void *arg) // 执行抢票的逻辑
{
    while (true)
    {
        if (ticket > 0) // 当票量大于0的时候才能抢
        {
            usleep(1245); // 模拟抢票前执行的操作
            std::cout << static_cast<const char *>(arg) << "正在抢票" << ticket-- << std::endl; // 抢票
        }
        else // 如果票量小于0就不抢了
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    // 创建4个线程用于抢票
    Thread *thread1 = new Thread(getTicket, (void *)"thread-1");
    Thread *thread2 = new Thread(getTicket, (void *)"thread-2");
    Thread *thread3 = new Thread(getTicket, (void *)"thread-3");
    Thread *thread4 = new Thread(getTicket, (void *)"thread-4");

    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();

    return 0;
}

image-20240130152957444

这里可以看出多线程的程序出现了一些问题,那么怎么解决呢?

1. Linux线程互斥

1.1 互斥的相关概念

  • 临界资源:在计算机中存在着很多共享资源,这些资源是会被很多线程、进程共享的,为了保证这些资源的安全,所以需要被保护起来,这些被保护起来的共享资源就是临界资源

  • 临界区:临界资源被访问总是存在访问这些临界资源的代码,这些访问临界资源的代码叫做临界区

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

  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成,不会有中间状态

1.2 互斥量mutex

现在我们来分析引入中的问题产生的原因:

image-20240130155511793

我们看上面的抢票逻辑的代码,在第11行的地方访问了共享资源ticket

线程1在执行了第11行的代码之后,线程可不可以被切换?当然可以

线程1在此时被切换之后,(我们把情况推向极端,此时ticket就是1),然后线程2被切换过来执行

此时ticket还是1然后执行11行代码进入if的代码块执行完本次循环之后再切回线程1

此时线程1可以直接执行14行代码,将ticket–,此时就出现了抢到-1的情况

这就是上述问题产生的原因,那么如何解决呢?

我们在上面说到了原子性,对于一件事,要么全做,要么不做,这里如果让11行到吗到14行代码变成原子性的,也就能够让这个问题得到解决

使用互斥量mutex就能完成这个任务


我们知道,一般来说,一条汇编指令就是原子的,但是像++i或者i++都不是原子的,有可能会有数据一致性问题

这是因为这种操作对应了三条汇编指令:

  • load:将共享变量i从内存加载到寄存器中
  • updata:更新寄存器里面i的值
  • store:将新的值从寄存器协会i的内存地址

那如果要解决以上问题,就需要做到三点

  • 代码必须要有互斥行为:代码进入临界区执行的时候,不允许其他线程进入该临界区
  • 如果多个线程通知要求执行临界区代码,并且临界区没有其他进程在执行,那么只能允许一个线程进入该临界区
  • 如果线程不再临界区中执行,那么该线程不能组织其他线程进入临界区
image-20240131161805208

1.3 mutex的使用

mutex也是pthread库提供的。形象的来说,我们也可以把它叫做锁。所以也就有了互斥锁这种说法

1. 互斥锁的类型

image-20240131152814709

2. 定义初始化与销毁

image-20240131153440920

头文件:
#include <pthread.h>
函数原型:
int pthread_mutex_destory(pthread_mutex_t *mutex); // 销毁
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); // 初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 全局变量的初始化方式
参数解释:
	mutex:需要操作的锁
	attr:是一个pthread_mutexattr_t类型的联合体,这里我们不关心,设为NULL即可
函数描述:
	pthread_mutex_init:初始化一个互斥锁
	pthread_mutex_destory:销毁一个互斥锁
    锁有初始化就要有销毁
返回值:
	成功返回0,失败返回错误码

注意:mutex的定义可以是全局的也可以是局部的,如果是全局的话,可以直接使用宏来初始化PTHREAD_MUTEX_INITIALIZER,同时也就不需要销毁了,使用init函数初始化的就一定要使用destory销毁


小tips:restrict关键字表示mutexattr指针不会被函数以外的方式访问或修改,可以提高编译器对互斥锁初始化的优化

3. 互斥锁的加锁和解锁

image-20240131162735759

头文件:
	#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);
参数解释:
	mutex:要操作的互斥锁
函数描述:
    pthread_mutex_lock:加锁。如果互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功;发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
    pthread_mutex_trylock:尝试给mutex加锁,如果此时互斥量未锁状态,或者竞争到互斥量,就加锁然后返回0,如果没有竞争到或者已经被锁定,就执行其他内容,不会被挂起
    pthread_mutex_unlock:给传入的mutex解锁
返回值:
	lock和unlock调用成功返回0否则返回错误码,trylock如果获取到这个互斥锁,就返回0,否则返回错误码

使用互斥量改进抢票代码:

#include <iostream>
#include <unistd.h>
#include "Thread.hpp"

int ticket = 10000;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *getTicket(void *arg)
{
    while (true)
    {
        pthread_mutex_lock(&mutex); // 给mutex加锁
        if (ticket > 0)
        {
            usleep(1245);
            std::cout << static_cast<const char *>(arg) << "正在抢票" << ticket-- << std::endl;
            pthread_mutex_unlock(&mutex); // 给mutex解锁
        }
        else
        {
            pthread_mutex_unlock(&mutex); // 给mutex解锁
            break;
        }
    }
    return nullptr;
}

int main()
{
    Thread *thread1 = new Thread(getTicket, (void *)"thread-1");
    Thread *thread2 = new Thread(getTicket, (void *)"thread-2");
    Thread *thread3 = new Thread(getTicket, (void *)"thread-3");
    Thread *thread4 = new Thread(getTicket, (void *)"thread-4");

    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();

    return 0;
}

image-20240131190211262

1.4 mutex的理解与实现

1. 如何看待锁

经过了前面的使用,我们发现**锁本身就要被多个线程共同看见,所以锁本身就是一个共享资源!!**我们知道锁是用来保护共享资源的,那锁的安全由谁来保护?

实际上我们对锁的操作pthread_mutex_lockpthread_mutex_unlock本身就是原子的,所以加锁解锁的过程天生就是安全的。谁持有锁谁就进入临界区,否则就会在阻塞等待

image-20240201190427555

2. mutex加锁解锁的汇编实现

事实上,为了实现互斥锁的操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据进行交换,由于只有一条指令,所以保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行期间另一个处理器的交换指令也只能等待总线周期

lock:
	movb $0, %al
	xchgb %al, mutex
	if(%al > 0){
		return 0;
	} else
		挂起等待;
	goto lock;
unlock:
	movb $1, mutex
	唤醒等待Mutex的线程;
	return 0;

汇编代码的分析:

加锁过程

  • 首先将寄存器al的内容清0,本质上是将该线程的上下文中对应的al的内容清零,(因为每个线程都有一个对应的al寄存器内容)

  • 然后使用原子的指令xchgb将al寄存器和内存中mutex的值进行交换,如果当前锁没有被申请,那么可以理解成mutex中的值为1,如果被申请,那么就是0。

  • 交换后检查al寄存器中的值,如果大于0,那么就说明当前线程竞争到了锁资源,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

解锁过程

  • 首先把内存中mutex的值置1,使得下一次使用xchgb指令能够交换到1进寄存器,表示获取锁
  • 唤醒正在等待mutex的线程,让其竞争这个锁
  • 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
  • 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
  • CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器数据,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。

1.5 mutex的封装——C++版本

当然,使用pthread版本的锁是C语言风格的,而且较为复杂,有可能会写出逻辑异常的代码,在C++的开发中,我们希望使用的还是一个C++式的锁,也就是面向对象的结构,那么接下来我们可以手动封装出一个C++版本的mutex。顺手实现成RAII风格的,帮助我们自动加锁和解锁

/*Mutex.hpp*/
#pragma once

#include <pthread.h>

class Mutex
{
public:
    Mutex(pthread_mutex_t *lock_p = nullptr) : _lock_p(lock_p) {} // 构造函数

    void lock() // 加锁
    {
        if (_lock_p)
            pthread_mutex_lock(_lock_p);
    }
    void unlock() // 解锁
    {
        if (_lock_p)
            pthread_mutex_lock(_lock_p);
    }

    ~Mutex() {} // 析构函数

private:
    pthread_mutex_t *_lock_p;
};

class LockGuard // RAII风格的锁的实现
{
public:
    LockGuard(pthread_mutex_t *mutex) : _mutex(mutex) // 构造函数
    {
        _mutex.lock(); // 在构造函数中加锁
    }
    ~LockGuard() // 析构函数
    {
        _mutex.unlock(); // 在析构函数中解锁
    }

private:
    Mutex _mutex;
};

改写抢票程序:

#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
#include "Mutex.hpp"

int ticket = 10000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *getTicket(void *arg)
{
    while (true)
    {
         LockGuard lockguard(&lock); // 这里在栈区创建Mutex对象并加锁,在这个代码块结束之后解锁,可以手动设置代码块,以达到在指定位置解锁的功能
        {
            if (ticket > 0)
            {
                std::cout << static_cast<const char *>(arg) << "正在抢票" << ticket-- << std::endl;
            }
            else
            {
                break;
            }
        }
        usleep(1245);
    }
    return nullptr;
}

int main()
{
    Thread *thread1 = new Thread(getTicket, (void *)"thread-1");
    Thread *thread2 = new Thread(getTicket, (void *)"thread-2");
    Thread *thread3 = new Thread(getTicket, (void *)"thread-3");
    Thread *thread4 = new Thread(getTicket, (void *)"thread-4");

    thread1->join();
    thread2->join();
    thread3->join();
    thread4->join();

    return 0;
}

1.6 可重入与线程安全

1.6.1 常见的线程不安全情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

1.6.2 常见的线程安全情况

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

1.6.3 常见的不可重入的情况

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

1.6.4 常见的可重入情况

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

1.6.5 可重入与线程安全的联系

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

1.6.6 可重入与线程安全的区别

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

1.7 常见的锁的概念

  • 死锁:一组执行流(不管进程还是线程)持有自己锁资源的同时,还想要申请对方的锁,锁是不可抢占的(除非自己主动归还),会导致多个执行流互相等待对方的资源,而导致代码无法推进。

一把锁可以造成死锁,比如说在抢票的时候,如果在申请锁之后再加申请一次锁导致死锁。

为什么会有死锁?一定是你用了锁,锁保证临界资源的安全

多线程访问我们可能出现数据不一致的问题 多线程共享全局资源全局资源

解决问题的同时带来了新的问题:死锁,任何技术都有自己的边界,在解决问题的同时一定可能会引入新的问题

死锁四个必要条件:

  • 互斥:一个共享资源每次被一个执行流使用

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

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

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

避免死锁的方法:

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

也有一些算法可以避免死锁(了解):死锁检测算法、银行家算法

但是,在实际开发过程中,我们要尽可能少的使用锁

2. Linux线程同步

2.1 线程同步的相关概念和理解

举一个例子:现在学校里面有一个VIP自习室,里面只有一个座位,统一时间容纳一个人自习,大家都想去这个自习室学习,其中某个人起床特别早,抢到了这个自习室的名额,然后就一直在里面学习,其他人想要使用这个自习室,就在外面排队,等这个人出来之后才能进去。中午这个人想出去吃饭,但是刚出门又犹豫了,好不容易抢到的自习室,一定要多学一会,此时这个人刚出门,所以离自习室最近,所以肯定能抢到这个自习室的名额。这个往复很多次,进行了很多次的资源争夺,但是每次都是这个人能抢到,其他人抢不到。这种情况显然是不符合逻辑的,所以需要采用一些措施来避免这种情况。

在回到上文,我们可以发现一个现象,当我们给抢票代码加锁之后,每次抢票的都是同一个线程,这样就造成了其他线程的饥饿现象。为了解决这个问题:我们在数据安全的情况下让这些线程按照一定的顺序进行访问,这就是线程同步

首先我们明确几个概念:

  • 饥饿状态:由于一直得不到锁资源而无法访问公共资源的线程的状态。虽然并没有错,但是不合理
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件
  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

2.2 条件变量

2.2.1 条件变量的概念

当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了(因为被阻塞)

例如一个线程访问队列时,发现队列为空,它只能等待,直到其他线程将一个节点添加到队列中。这种情况就需要用到条件变量

  • 条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

使用条件变量主要包括两个动作:

  • 一个线程等待条件变量的条件成立而被挂起。
  • 另一个线程使条件成立后唤醒等待的线程。

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

2.2.2 条件变量的使用

1. 初始化和销毁

image-20240202191824614

头文件:
#include <pthread.h>
函数原型:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr *restrict attr);//初始化
int pthread_cond_destory(pthread_cond_t cond);
参数解释:
	cond:需要初始化的条件变量,类型是pthread_t
	attr:这里我们不关心,设为NULL即可
函数描述:
	初始化和销毁条件变量
返回值:
	如果调用成功,函数返回0,否则返回错误码

当然,和mutex一样,如果创建全局的条件变量,那么可以直接使用PTHREAD_COND_INITIALIZER初始化

2. 等待条件满足

image-20240202194928919

头文件:
#include <pthread.h>
函数原型:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t mutex);
参数解释:
	cond:在指定条件变量下等待
	mutex:传入一个锁,这里我们后面在解释
返回值:
	成功返回0,失败返回错误码

3. 唤醒等待

image-20240202200109606

头文件:
#include <pthread.h>
函数原型:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数解释:
	cond:要操作的条件变量
函数描述:
	pthread_cond_broadcast:唤醒等待队列中首个线程
	pthread_cond_signal:唤醒等待队列中所有线程
返回值:
	成功返回0,失败返回错误码

一个小例子:

#include <iostream>
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *start_routine(void *args)
{
    while (true)
    {
        pthread_mutex_lock(&mutex);       // 假设这里是临界区,使用mutex保护
        pthread_cond_wait(&cond, &mutex); // 判断条件变量条件
        std::cout << static_cast<const char *>(args) << "is running" << std::endl;

        pthread_mutex_unlock(&mutex);
    }
}

int main()
{
    pthread_t t1, t2, t3;

    pthread_create(&t1, nullptr, start_routine, (void *)"thread-1");
    pthread_create(&t2, nullptr, start_routine, (void *)"thread-2");
    pthread_create(&t3, nullptr, start_routine, (void *)"thread-3");

    while (true)
    {
        getchar();
        pthread_cond_signal(&cond);
        std::cout << "main thread is waked up a thread" << std::endl;
    }

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

image-20240202202339926

2.2.3 条件变量的理解

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

后来HR重新进行管理:设立一个等待区,先排队去等待区进行等待面试,现在每个人都进行排队,都有机会面试了,而这个等待区就是条件变量,如果一个人想面试,先得去排队等待区等待,未来所有应聘者都要去条件变量等

条件不满足的时候,线程必须去某些定义好的条件变量上进行等待

所以我们可以知道,条件变量应该是一个结构体(struct cond)里面包含状态,队列,而我们定义好的条件变量包含一个队列,不满足条件的线程就链接在这个队列上进行等待。

由于条件变量本身并不具有互斥功能,所以我们在进行等待的时候必须配合互斥锁使用

本节完。。。

  • 16
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

凌云志.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值