【Linux】线程互斥

文章目录

  • 一. 引子
  • 二. 知识储备
  • 三. 互斥量的使用
  • 四. 加锁/解锁的原理
  • 结束语

【Linux】线程互斥_第1张图片

一. 引子

在多线程操作中,因为线程共享虚拟地址空间,所以定义在全局的代码和数据,对于线程来说,都是共享的。
而CPU给予每个线程的时间片都是一定的。可能出现一个线程对于共享数据的修改还没完成,时间片就到达了,换成另一个线程。而另一个线程可能也会对共享数据作修改,这样就会影响上一个线程未完成的操作,或者事后被影响。所以多线程操作,需要相互独立,相互排斥

二. 知识储备

首先,我们需要有一些知识储备。

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性:即两态性。就如同二进制一般,要么1,要么0,只有两种状态。原子操作是不会被任何调度机制打断的操作,该操作只有两态,要么执行完成,要么没执行

接下来,我们举例多线程操作经典的抢票问题

#include
#include
#include

using namespace std;

//票数
int tickets=50;

//模拟抢票
void* ThreadRoutine(void* args)
{
    string name=static_cast<const char*>(args);
	//一直抢票
    while(true)
    {
        if(tickets>0)
        {
            //如果票大于0,就可以抢
            usleep(2000);
            cout<<name<< " got the ticket: "<<tickets--<<endl;
        }
        else
        {
            //小于等于0代表抢完了
            break;
        }
    }
    return nullptr;
}

int main()
{
    //创建4个线程模拟抢票
    pthread_t th[4];
    int num=sizeof(th)/sizeof(th[0]);

    for(int i=0;i<num;++i)
    {
        //给每个线程命名
        char *name=new char[64];
        snprintf(name,64,"thread_%d",i+1);
        //创建线程
        pthread_create(th+i,nullptr,ThreadRoutine,name);
    }

    //线程等待
    for(int i=0;i<num;++i)
    {
        pthread_join(th[i],nullptr);
    }

    return 0;
}

运行结果如下:

【Linux】线程互斥_第2张图片

票数最后抢到了负数,这显然是不合理的
出现这种现象的原因正是因为多线程并发运行

假设线程A运行时,判断现在票数为1,进行抢票,但是在抢票前,时间片到达,不得不切换成其他线程。
而其他线程看到票数大于0,也纷纷进行抢票,抢完票,票数为0。切换回线程A,A已经完成判断,所以只需要让票数-1,所以就导致最终票数出现了小于0的情况。

同时,在抢票过程中,多个操作都不是原子性的,都可能进行线程的切换

就比如说 ticket-1 这一操作,其本身在汇编层面对应三条指令

在这里插入图片描述

  • load :将共享变量ticket从内存加载到寄存器中
  • update : 更新寄存器里面的值,执行-1操作
  • store :将新值,从寄存器写回共享变量ticket的内存地址

单独的指令是原子性的,但是多条指令,就不是原子性的了。

要解决上述问题,我们需要做到以下三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区

临界区就像一个房间,里面的东西是临界资源。而为了保证同一时间只能一个人使用这间房间。需要给这个房间加上一把,并且配备唯一的钥匙,只有抢到钥匙的人才有房间的使用权。每个人在使用房间时,都从内反锁,防止其他人打扰。

在Linux中,这把锁叫作互斥量

三. 互斥量的使用

Linux的互斥量是pthread_mutex_t

有两种初始化方式

  • 静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;

PTHREAD_MUTEX_INITIALIZER是线程库定义的一个宏

  • 动态分配
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *attr);

mutex :要初始化的互斥量
attr :互斥量的属性,一般传nullptr,代表使用默认属性

互斥量不使用时,需要销毁
销毁互斥量的函数是:

int pthread_mutex_destroy(pthread_mutex_t * mutex);

mutex:销毁的互斥量

需要注意的是:

  • 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要手动销毁
  • 不能销毁一个已经加锁但未解锁的互斥量
  • 已经销毁的互斥量,后续不能再有线程尝试加锁

接下来是加锁和解锁的函数

【Linux】线程互斥_第3张图片

  • 加锁
pthread_mutex_lock(pthread_mutex_t *mutex);

mutex:加锁的互斥量

注意事项
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,如果其他线程已经锁定互斥量,或者存在有其他线程同时申请互斥量,但自己没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁

  • 解锁
pthread_mutex_unlock(pthread_mutex_t *mutex);

mutex:解锁的互斥量

我们更改一下抢票系统,加上互斥量。
PS:加锁后还必须解锁

#include
#include
#include

using namespace std;

//票数
int tickets=1000;
//静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;

//模拟抢票
void* ThreadRoutine(void* args)
{
    string name=static_cast<const char*>(args);

    while(true)
    {
    	//加锁
        pthread_mutex_lock(&mutex);
        if(tickets>0)
        {
            //如果票大于0,就可以抢
            cout<<name<< " got the ticket: "<<tickets--<<endl;
            usleep(10000);
            //解锁
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            //小于等于0代表抢完了
            //解锁
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}

int main()
{
    //创建4个线程模拟抢票
    pthread_t th[4];
    int num=sizeof(th)/sizeof(th[0]);

    for(int i=0;i<num;++i)
    {
        //给每个线程命名
        char *name=new char[64];
        snprintf(name,64,"thread_%d",i+1);
        //创建线程
        pthread_create(th+i,nullptr,ThreadRoutine,name);
    }

    //线程等待
    for(int i=0;i<num;++i)
    {
        pthread_join(th[i],nullptr);
    }

    return 0;
}

我们在判断票数数量前加上锁,在抢完票和票被抢完后解锁
同时,我们增加票数,这样才可以观测到多个线程抢票。如果票较少,基本都是一个线程完抢完了。

运行结果如下:

【Linux】线程互斥_第4张图片

多次运行都没有出现票数小于等于0的情况。


互斥量的使用还有以下细节:

  1. 凡是访问同一个临界资源的线程,都要进行加锁保护,而且必须加同一把锁
  2. 每一个线程访问临界区之前,需要加锁,加锁本质是给临界区加锁,加锁的粒度要尽量细一些。因为对于临界区的访问,同时只能一个线程,所以整体效率肯定降低。加锁的粒度细,可以减少效率的降低。
  3. 访问同一个临界区的线程需要加同一把锁,代表锁也属于临界资源,那锁如果保证自己的安全?
    其实加锁和解锁本身就是原子性的,所以不用担心。
  4. 临界区可以是一行代码,可以是一批代码
  5. 加锁不代表当前访问临界资源的线程不会被切换。就像上述所讲的房间的例子。在我拥有房间钥匙时,我也可能会出房间,但此时如果我的事情还没做完,还没到归还钥匙的时候(解锁),即使我离开房间,也会给房间重新上锁,防止他人使用房间

四. 加锁/解锁的原理

加锁并不代表当前访问临界资源的线程不会切换,那么加锁是如何实现的?又是如何保证原子操作的?

接下来,我们进行解释。

【Linux】线程互斥_第5张图片

首先看这样一组汇编指令。lock是加锁,unlock是解锁

动作是这样的

【Linux】线程互斥_第6张图片

线程的相关运算需要将数据加载到寄存器中,但是寄存器对于每个线程都是可见的,所以每个线程的切换都需要将寄存器内的数据取走,不妨碍到下个线程的运行

mutex是在内存中的一个1
%al是寄存器

  • movb $0,%al:将0写入寄存器
  • xchgb %al,mutex:交换内存的mutex和寄存器的值
  • 如果寄存器的值大于0,代表获取到访问临界资源的权利
  • 反之,挂起等待,或者重新获取锁。

而当一个线程切换时间片时,需要将寄存器中所有的数据带走。
所以,假如线程A比线程B更快尝试申请锁,那么就会先交换到内存中的mutex,也就是1。
而此时就算线程A被切换,线程B尝试申请锁,也申请不了。因为唯一的1,在线程A当中
【Linux】线程互斥_第7张图片


解锁

【Linux】线程互斥_第8张图片

而解锁实现的简单很多,只要重新往内存中写入1就可以了。
但是为什么不是将原来的1拿回来呢?
这是因为解锁并不是只能加锁的线程执行,其他线程也可以解锁,但是因为线程之间不可访问各自的资源,所以其他线程无法拿回1,所以设计成重新往内存中写入1。

结束语

感谢你的阅读

如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。
在这里插入图片描述

你可能感兴趣的:(Linux学习笔记,c++,开发语言,linux,unix)