互斥量相关概念,不加互斥量的代码的问题,互斥量相关接口(申请,加锁,解锁),修改代码+注意点,互斥量保护的原理(原子性),lock/unlock的底层汇编+过程分析,可重入和线程安全

目录

引入

概念

临界资源

临界区

互斥

原子性

重入函数

可重入函数

访问未加保护的临界资源 示例

原因

if判断

--操作

解决 

互斥量 

介绍

基本思想

申请互斥量

介绍

pthread_mutex_init()

函数原型

pthread_mutex_t类型

mutex

attr

pthread_mutex_destroy()

示例 

PTHREAD_MUTEX_INITIALIZER

介绍

示例

加锁和解锁 

介绍

返回值

pthread_mutex_lock()

介绍

pthread_mutex_unlock()

介绍

示例

代码中的注意点 

临界区的粒度 -- 串行执行

线程调度 -- 同一个线程不断执行

线程数量和效率的关系

示例

pthread_mutex_trylock()

互斥量保护临界区的原理 -- 原子性

lock和unlock的底层汇编 

引入

swap/exchange指令

lock介绍

mutex

过程

图解

如果中间被切换

当某个线程已经持有锁

unlock介绍

过程

那原先线程的旧锁呢?

可重入函数和线程安全

概念

线程安全

可重入函数

关系 


引入

概念

首先,我们先回顾一下今天会用到的概念:

临界资源

多线程执行流共享的资源

临界区

每个线程内部,访问临界资源的代码

互斥

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

原子性

  • 不会被任何调度机制打断的操作
  • 该操作只有两态要么完成,要么未完成

重入函数

同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入
可重入函数
  • 一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题
  • 否则,是不可重入函数

访问未加保护的临界资源 示例

  • 有时候我们的代码看似没有问题
  • 但如果放在多线程共同执行的情况下,就可能因为未加保护的临界资源 + 线程间的时序问题,而导致未知的问题
  • 比如下面这份类似抢票的代码:
int count = 1000;
void *func5(void *args)
{
    while (true)
    {
        if (count >= 0)
        {
            usleep(1000); //模拟可能花费的时间
            cout << count << endl;
            --count;
        }
    }
    return nullptr;
}
void test5()
{
    pthread_t tid;
    vector<pthread_t> tids;
    for (size_t i = 0; i < 3; ++i)
    {
        pthread_create(&tid, nullptr, func5, nullptr);
        tids.push_back(tid);
    }
    for (auto it : tids)
    {
        pthread_join(it, nullptr);
    }
}

看到执行结果,我们会发现,票数怎么会被抢到负数呢?

我们的代码明明只有当count>=0时才会执行-1啊

原因

  • 原因就在于count是一个全局变量,可以被所有线程共享,而使用这个临界资源的函数是重入函数
  • 但我们并没有对其进行访问控制:
  • 我们的多个线程同时运行时,会受到调度器的调度,不断被切换
  • 这可能就会因时序问题而导致不受约束的临界资源产生数据紊乱

比如说这里的代码,我们会有多种可能导致出现问题

  • 首先我们要明确,判断也是一种运算
  • 其次,运算操作一般有三个步骤: 将内存内的数据load进cpu的寄存器,在寄存器内运算(分为逻辑运算和数值运算),将结果写回内存
  • 也就是说,判断和运算都不具有原子性

以下是可能会出现的问题:

if判断
  • 如果每个线程的时间片很短,就可能让线程在[进行判断的三个步骤的过程中] / [改变变量的数值前]被替换
  • 也就是说,会让多个线程拿着同样的值,同时进入if为true的语句
  • 那么就会让多个线程对count直接进行修改,而不需要判断
  • 自然也就会出现count为负数的情况
--操作
  • 如果A进程在运算,他原本应该完成图中三个操作:
  • 但在cpu完成运算时,时间片到了,那么count此时的值(比如999)就会作为该线程的上下文被保存起来
  • 然后新的B线程来执行一系列操作 (比如已经将count减为400)
  • B线程的时间片到了后,又将A线程重新换入
  • 它会从原先执行位置继续执行,这样就会让原本已经修改的count值,重新从999开始
  • 这样就会出现数据紊乱的问题

当然,这些问题都只是可能出现

总之,我们如果不对临界资源进行保护,就可能出现各种未知问题

解决 

如何进行保护呢?

  • 就是让临界区代码在有线程执行时,不允许其他线程继续进入

如何实现这一点呢?

  • 可以让执行临界区代码的线程,在执行前为它加上一把"锁",让其他线程进不来
  • 只有当自己完成任务后,解开锁,其他线程才能执行

在linux下,"锁"这个概念被称为"互斥量"

 

互斥量 

介绍

是一种实现互斥的机制,通过[对共享资源的访问进行控制]来实现互斥

基本思想

  • 每当一个线程希望访问共享资源时,它必须先获得互斥量的锁
  • 如果某个线程已经获得了锁,其他线程就无法同时获得锁,而必须等待当前拥有锁的线程释放锁
  • 这样可以确保在任何时刻只有一个线程可以访问共享资源,从而防止数据的不一致性

其中,pthread库中,就为我们提供了互斥量的相关接口

 

申请互斥量

介绍

我们有两种方式来申请互斥量:

  • 动态申请(局部) -- 使用函数接口,定义一个局部锁
  • 静态申请(全局) -- 使用宏定义,定义一个全局锁

pthread_mutex_init()

用于初始化互斥量(也就是我们申请的锁)

函数原型

pthread_mutex_t类型

它是互斥量的类型

实质上就是一个联合体,里面定义了互斥量相关的数据

mutex

一个要被初始化的互斥量指针

attr

互斥量属性

一般我们设置为nullptr即可,表示使用默认属性

pthread_mutex_destroy()

注意,使用了init的同时,我们必须要及时销毁互斥量

示例 

这里我们将锁在主线程申请并释放:

void *func2(void *args)
{
    pthread_mutex_t mux = *((pthread_mutex_t *)args);
    while (true)
    {
        if (count >= 0)
        {
            usleep(1000); // 模拟可能花费的时间
            --count;
            cout << count << endl;
        }
    }
    return nullptr;
}
void test1()
{
    pthread_mutex_t mux;
    pthread_mutex_init(&mux, nullptr);

    pthread_t tid;
    vector<pthread_t> tids;
    for (size_t i = 0; i < 3; ++i)
    {
        pthread_create(&tid, nullptr, func1, (void *)&mux);
        tids.push_back(tid);
    }
    for (auto it : tids)
    {
        pthread_join(it, nullptr);
    }
    pthread_mutex_destroy(&mux);
}

 

PTHREAD_MUTEX_INITIALIZER

介绍

这是一个宏定义:

它会创建一个静态初始化的互斥量,避免了显式调用函数接口的需要,同时也不需要手动销毁

示例
pthread_mutex_t g_mux = PTHREAD_MUTEX_INITIALIZER;

 

加锁和解锁 

介绍

锁的实际操作就两种,加锁和解锁 

返回值

  • 这三个接口都是成功,返回0
  • 失败,返回错误码

pthread_mutex_lock()

介绍

用于上锁

如果互斥量已被其他线程锁定,则当前线程将被阻塞,直到互斥量可用

pthread_mutex_unlock()

介绍

用于解锁,释放互斥量,使其它线程可以获得锁

 

介绍完上锁和解锁,就可以修改上面的抢票代码了 

示例

当我们为上面的抢票代码加上互斥量之后:

int count = 1000;

struct data //为了将自定义的线程名和互斥量都传入线程
{
    data(pthread_mutex_t *px, const string &n) : pmux(px), name(n)
    {
    }
    pthread_mutex_t *pmux;
    string name;
};

pthread_mutex_t g_mux = PTHREAD_MUTEX_INITIALIZER;

void *func2(void *args)
{
    data *info = (data *)args;
    while (true)
    {
        int n = pthread_mutex_lock(info->pmux); // 从if就开始访问count了
        assert(n == 0);
        if (count > 0)
        {
            usleep(1000); // 模拟可能花费的时间

            cout << info->name << " : " << count << endl;
            --count;
            n = pthread_mutex_unlock(info->pmux);
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(info->pmux); // 注意,一定要保证上锁后会解锁
            assert(n == 0);
            break;
        }
        usleep(1000); //模拟抢票成功后的花费时间(为了尽量让不同线程去抢票)
    }
    return nullptr;
}

void test1()
{
    pthread_mutex_t mux;
    pthread_mutex_init(&mux, nullptr);

    pthread_t tid;
    vector<pthread_t> tids;
    for (size_t i = 0; i < 3; ++i)
    {
        string t = "thread";
        t += to_string(i + 1);
        data *pd = new data(&mux, t);
        pthread_create(&tid, nullptr, func2, (void *)pd);
        tids.push_back(tid);
    }
    for (auto it : tids)
    {
        pthread_join(it, nullptr);
    }
    pthread_mutex_destroy(&mux);
}
  • 这是结果:

  • 可以看到,我们成功让最后的结果以1结束,而不是0或者负数了
  • 说明,我们对其增加的保护机制是有效的

代码中的注意点 

临界区的粒度 -- 串行执行

当我们加锁后,会形成下面的情况:

  • 也就是说,在加锁区域内,线程是串行执行的
  • 只有一个线程可以执行代码,其他线程申请锁失败后,会阻塞等待
  • 所以,为了效率,我们一定要保证加锁区域足够小(也就是临界区的粒度)
线程调度 -- 同一个线程不断执行

  • 注意,这里我们增加了usleep来模拟[让抢票成功后的线程去执行代码]所耗费的时间
  • 否则,就会出现下面这种情况:
  • (同一线程会在抢票成功后,会马上循环进来,继续竞争到锁)

线程数量和效率的关系

我们可以通过计算执行任务所用时间,来判断效率

  • time() -- 粗略计算(以s为单位)
  • gettimeofday() -- 更加精确(以微秒或纳秒为单位)
示例
int count = 10000; //我们这里使用10000的数据量,可以更好的观察时间的差异

struct data
{
    data(pthread_mutex_t *px, const string &n) : pmux(px), name(n)
    {
    }
    pthread_mutex_t *pmux;
    string name;
};
pthread_mutex_t g_mux = PTHREAD_MUTEX_INITIALIZER;


void *func2(void *args)
{
    data *info = (data *)args;
    while (true)
    {
        int n = pthread_mutex_lock(info->pmux); // 从if就开始访问count了
        assert(n == 0);
        if (count > 0)
        {
            usleep(1000); // 模拟可能花费的时间

            cout << info->name << " : " << count << endl;
            --count;
            n = pthread_mutex_unlock(info->pmux);
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(info->pmux); // 注意,一定要保证上锁后可以解锁
            assert(n == 0);
            break;
        }
        usleep(1000); // 模拟抢票成功后的花费时间(为了尽量让不同线程去抢票)
    }
    return nullptr;
}

#define thread_num 2

void test1()
{
    time_t start, end;
    start = time(nullptr);
    pthread_mutex_t mux;
    pthread_mutex_init(&mux, nullptr);

    pthread_t tid;
    vector<pthread_t> tids;
    for (size_t i = 0; i < thread_num; ++i)
    {
        string t = "thread";
        t += to_string(i + 1);
        data *pd = new data(&mux, t);
        pthread_create(&tid, nullptr, func2, (void *)pd);
        tids.push_back(tid);
    }
    for (auto it : tids)
    {
        pthread_join(it, nullptr);
    }
    pthread_mutex_destroy(&mux);
    end = time(nullptr);
    cout << "time : " << (end - start) << "s" << endl;
}

2个线程:

3个线程:

6个线程:

  • 所以,我们可以得出结论 -- 线程确实可以提高程序效率
  • 但并不是线程数越多越多,过多的线程数会使线程切换的成本增大

除此之外,我们还有一个接口,可以实现不阻塞等待锁的释放:

pthread_mutex_trylock()

  • 和lock类似,都可以加锁
  • 不同的是,如果互斥量已被其他线程锁定,则立即返回错误码,而不是阻塞
  • 这样可以更灵活的让线程执行任务
  • 在临界区上锁时,不需要阻塞等待,而是可以去先完成其他代码

 

互斥量保护临界区的原理 -- 原子性

  • 通过上面的代码,我们确实看到加了锁之后,打出来的结果是正确的

为什么加了锁就正确了?

  • 因为每个线程进来,在进入临界区之前,都需要先申请锁
  • 但是,锁只能被一个线程拥有,所以其他没有竞争到锁的线程就只能等待
  • 那么对于其他线程来说,它被调度的情况就是 -- 没有线程持有锁时,他们可以自由被调度 / 有线程持有锁,它们只能等待那个线程执行完成 / 线程释放了锁,说明任务被执行完了,它又可以被调度了
  • 所以在有线程持有锁时,只能等待那一个线程完成任务后再执行,也就不会出现前面说的那一大堆可能出现的问题了
  • 也就不会出现数据紊乱的情况

为什么在持有锁期间,其他进程只能等待?

  • 难道调度器不切换线程了吗?难道时间片不起作用了吗?
  • 当然不是!当然可能会出现线程切换
  • 但是,切换也是那个线程在持有锁的情况下切换的
  • 那个锁,是作为线程的上下文被带走的
  • 其他线程要进入临界区,必须要申请锁,但是锁已经被带走了
  • 所以他们会被阻塞
  • 阻塞了自然也就被调度器切换了
  • 所以最终,还是会让那个持有锁的线程,顺畅的完成它的任务

只针对[其他线程看到的临界资源的完成情况]是怎样的呢?

  • 没有线程持有锁,资源没有被访问 / 有线程持有锁,资源正在被访问,但是它无法去访问,自然也就与它无关 / 锁被释放,资源已经访问完毕
  • 所以,可以认为,被锁保护的区域,其执行是具有原子性的
  • 因为它无法被打扰,也就没有中间状态,它要么没有执行,要么已经执行完了!
  • (当然,前提是我们正确的使用了互斥量)

 

lock和unlock的底层汇编 

引入

  • 我们上面虽然讨论出,临界区代码的执行是具有原子性的
  • 那么,上锁和解锁的那两个函数呢?
  • 如果他们不具有原子性,是不是会出现多个线程都持有锁的情况呢?
  • 确实是这样的
  • 所以,互斥量被设计时,就考虑到了这个问题,它的核心指令必然是原子的

swap/exchange指令

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

lock介绍

下面是两个函数的伪汇编:

mutex
  • 注意,这里的mutex可以认为是互斥量在内存中开辟的空间
  • 是一个整型,初始值为1
过程
  • move为al寄存器填充0
  • xchge和上面介绍的exchange有异曲同工之妙,它只用了一句汇编,就让al寄存器和内存中的mutex交换了值 (也就是将自己的私有数据和内存数据交换)
  • 如果此时寄存器中的值不是1,则说明该线程持有锁,成功返回
  • 是0,说明已经有线程持有锁了,该线程被挂起等待
  • 当线程等待完成后,会回到lock的开头,从move重新执行
图解

这就是第一句指令执行后的样子:

第二句执行后:

如果中间被切换
  • 即使被切换,寄存器中的数据也是作为线程的上下文(线程的私有数据)被带走了
  • 无论在哪个地方被切换,都不会影响持有锁的操作,因为它是原子的
  • 从图中,我们可以知道,第二句汇编就是lock的核心指令,谁拿到了mutex的初始值1,谁就拿到了锁
当某个线程已经持有锁
  • 那么mutex中的值,就是和某个线程交换后的0
  • 当执行xchge时,就是0和0交换,al寄存器中的值是0,会被挂起等待

unlock介绍

过程
  • move将mutex中的值直接赋为1
  • 唤醒其他线程
  • 当其他线程被唤醒后,会从lock的第一句开始执行
  • 那么就会将寄存器中的0和mutex中新的1交换
  • 这样就得到了新锁
那原先线程的旧锁呢?
  • 原先线程中的al寄存器,应该还是1吧

会不会有影响呢?

  • 不会的
  • 因为如果该线程还需要访问那块临界区的话,是从lock的第一句开始执行
  • 也就是将al寄存器的值赋为0
  • 如果不被切换,就以0继续执行
  • 如果中间被切换,这个0会替换掉原来的1

可重入函数和线程安全

概念

线程安全

  • 一个线程安全的系统可以由多个线程同时访问,而不会导致数据损坏或不一致
  • 一般的,我们对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题

可重入函数

  • 重入和可重入的概念在开头已经介绍过了
  • 简单来说,可重入函数就是 -- 一个函数被不同的执行流调用时,不会出现任何问题

关系 

  • 线程安全针对于一个线程,可重入针对一个函数

  • 可重入函数一定是线程安全的,可以认为可重入函数是线程安全函数的一种
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

  • 可重入的目标,是确保单个函数的安全执行,而不受外部影响
  • 线程安全的目标,是确保整个系统或库在多线程环境下的安全运行,包括多个函数的相互协作和对共享资源的访问

  • 如果对临界资源的访问加上锁,则这个函数是线程安全的,但这个重入函数不一定是可重入的(比如出现死锁问题)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值