<4>. Linux 线程互斥

一. 基本概念

  1. 临界资源

资源被多线程共享时,任何时候只能被一个执行流访问的资源

所以判断一个资源是否是临界资源前提是1. 这个资源先被共享 2.我们必须要通过某些策略让该资源在任何时候只能由一个执行流访问,否则会出问题

临界资源可能是:全局数据

  1. 临界区

线程执行进程代码的一部分,这部分代码中访问临界资源的部分称为临界区,进入临界区就是为了访问共享资源

所以我们要对临界资源加以保护,所以在任何时候只允许一个执行流进入临界区,访问临界资源

  1. 互斥

对临界资源加以保护 ——>在任何时候只允许一个执行流进入临界区 ——>采用互斥策略

  1. 原子性

任何时候要么做完,要么不做,也就是不会因为CPU的调度被影响到

二. 多线程抢票问题

  1. 发现问题

执行如下代码

#include <iostream>
#include <thread>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题!

void *getTickets(void *args)
{
    (void)args;
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else{
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1,t2,t3;
    // 多线程抢票的逻辑
    pthread_create(&t1, nullptr, getTickets, nullptr);
    pthread_create(&t2, nullptr, getTickets, nullptr);
    pthread_create(&t3, nullptr, getTickets, nullptr);

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

场景一:

最后抢票出现了抢到第0张和-1张的情况?

分析:

多个线程调用同一个抢票的代码,这种现象叫做对该函数进行重入,会产生线程安全的问题。

如果一个线程检测tickets=1(>0),于是将tickets读取到他的上下文中,发现tickets大于0于是进入

if(tickets>0)函数内部开始休眠(执行usleep)时被切换进入运行队列尾部,下一个线程继续检测,因为上一个线程还未对tickets有--操作,所以tickets依旧为1,当前线程强到票后将新的票数修改,此时上一个线程被切换回来,上一次线程切换,上下文保护,记录上一次执行的位置,对tickets--,因此我们发现票数减到了0的情况

场景二:

将usleep去掉发现这种情况

原因:tickets--这个操作不是原子的

从汇编的角度ticket--由三条指令构成 1.读取数据到cpu寄存器中 2.在cpu内部进行计算 3.将结果写回内存

取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00     mov 0x2004e3(%rip),%eax     # 600b34 <ticket>
153 400651: 83 e8 01               sub $0x1,%eax
154 400654: 89 05 da 04 20 00     mov %eax,0x2004da(%rip)     # 600b34 <ticket>
-- 操作并不是原子操作,而是对应三条汇编指令: load :将共享变量ticket从内存加载到寄存器中 update : 更新寄存器里面的值,执行-1操作 store :将新值,从寄存器写回共享变量ticket的内存地址
综上:
为什么可能无法获得争取结果? 1. if 语句判断条件为真以后,代码可以并发的切换到其他线程 2. usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段 3. --ticket 操作本身就不是一个原子操作
要解决以上问题,需要做到三点: 1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。 2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临 界区。 3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。 Linux上提供的这把锁叫互斥量

  1. 互斥锁

解决问题:采用互斥锁,对tickets做保护,任何时候只能由一个线程访问它

  1. 互斥量的接口

1.初始化互斥量
初始化互斥量有两种方法: 方法1,静态分配: pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
pthread_mutex_t :原生线程库提供的数据类型/全局的锁,初始化/ 方法2,动态分配: int pthread_mutex_init (pthread_mutex_t *restrict mutex ,
const pthread_mutexattr_t * restrict attr); /main内部的锁,没有static
局部定义的锁,通过pthread_create传参的方式让新线程拿到 参数: mutex:要初始化的互斥量
pthread_mutex_t mtx;
传入&mtx attr:NULL
2. 销毁互斥量 销毁互斥量需要注意: 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁 不要销毁一个已经加锁的互斥量 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);传入& mtx
3. 互斥量加锁和解锁 int pthread_mutex_lock(pthread_mutex_t *mutex); 在需要串行化的地方加锁,参数为自己定义的锁,访问临界资源时加锁
未来所有线程在执行抢票逻辑的代码时,每个线程都要执行加锁,任何时候只允许一个线程获取这把锁,其他没有得到锁的线程只能默认阻塞等待,直到拿到锁的线程最终把锁释放掉,其他线程才能进来——互斥! int pthread_mutex_unlock(pthread_mutex_t *mutex); 返回值:成功返回0,失败返回错误码
加锁和解锁之间的代码为临界区,需要串行化访问的临界资源是全局的tickets

加锁会导致代码被强制串行执行,执行流上相互影响,导致效率变低

  1. 互斥锁的原理

1. 如果多线程访问同一个全局变量,并对它进行数据计算,多线程会互相影响吗?
会!所以要加锁保护

2.加锁保护:加锁的时候,一定要保证加锁的粒度,越小越好!!
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // pthread_mutex_t 就是原生线程库提供的一个数据类型

3.加锁就是串行执行了吗?是的
4.加锁了之后,线程在临界区中,是否会切换,会有问题吗?不会,因为原子性的体现
5.加锁了之后,线程在临界区中,是否会切换,会有问题吗?
会切换,不会!第一次理解:虽然被切换了,但是你是持有锁被切换的, 所以其他抢票线程要执行临界区代码,也必须先申请锁,锁它是无法申请成功的,所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性!
6.我是一个线程,我不申请锁,就是单纯的访问临界资源!-- 错误的编码方式
7.在没有持有锁的线程看来,对我最有意义的情况只有两种:1. 线程1没有持有锁(什么都没做) 2. 线程1释放锁(做完),此时我可以申请锁!我被唤醒如果我当前被阻塞,然后我就参与到竞争锁的过程中
8.加锁就是串行执行了吗? 是的,执行临界区代码一定是串行的!
9.要访问临界资源,每一个线程都必须现申请锁,每一个线程都必须先看到同一把锁&&访问它,锁本身是不是就是一种共享资源?谁来保证锁的安全呢??所以,为了保证锁的安全,申请和释放锁,必须是 原子的!!!自己保证——如何保证??锁是如何实现的?
经过上面的例子,我们知道单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单 元的数据相交换,由于 只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一 个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪 代码改一下

——交换的本质:

内存所有资源共享,锁一开始在内存中,属于共享资源,交换后,锁由内存中共享资源变为我这个线程的上下文也就是私有化

  1. 可重入/不可重入函数VS线程安全/不安全

  1. 一个函数是不是可重入函数是一个函数的特点,没有对错之分,绝大多数的函数都是不可重入的,也就是绝大多数函数被多个执行流重复进入都会出现问题,可重入函数被多个执行流重复进入不会出现问题,但这种函数编写复杂

  1. 线程安全针对线程,我们一定要编写出线程安全的代码,可重入函数一定是线程安全的代码,但是不是可重入函数也可能是线程安全的代码

  1. 不仅是多线程不加锁会导致不可重入函数的线程安全问题,信号也会让不可重入函数出问题

  1. 线程被调度,不仅仅是重复进入一个函数会出问题,不同函数只要访问了一个全局变量没有加锁也是会出问题

  1. 概念小结:

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

  1. 常见的线程不安全的情况

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

  1. 常见的线程安全的情况

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

  1. 常见不可重入的情况

调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构 可重入函数体内使用了静态的数据结构 常见可重入的情况 不使用全局变量或静态变量 不使用用malloc或者new开辟出的空间 不调用不可重入函数 不返回静态或全局数据,所有数据都有函数的调用者提供 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

  1. 可重入与线程安全联系

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

  1. 可重入与线程安全区别

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

  1. 利用互斥锁改进代码

#include <iostream>
#include <thread>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
#include <cassert>
#include <cstdio>

using namespace std;

int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题!临界资源

#define THREAD_NUM 800

class ThreadData
{
public:
    ThreadData(const std::string &n,pthread_mutex_t *pm):tname(n), pmtx(pm)
    {}
public:
    std::string tname;
    pthread_mutex_t *pmtx;
};

void *getTickets(void *args)
{
    // int myerrno = errno;
    ThreadData *td = (ThreadData*)args;
    while(true)
    {
        // 抢票逻辑
        int n = pthread_mutex_lock(td->pmtx);
        assert(n == 0);
        // 临界区
        if(tickets > 0) // 1. 判断的本质也是计算的一种
        {
            usleep(rand()%1500);
            printf("%s: %d\n", td->tname.c_str(), tickets);
            tickets--; // 2. 也可能出现问题
            n = pthread_mutex_unlock(td->pmtx);
            assert(n == 0);
        }
        else{
            n = pthread_mutex_unlock(td->pmtx);
            assert(n == 0);
            break;
        }
        
        // 抢完票,其实还需要后续的动作
        usleep(rand()%2000);
        //  errno = myerrno;
    }
    delete td;
    return nullptr;
}

int main()
{
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);

    srand((unsigned long)time(nullptr) ^ getpid() ^ 0x147);
    pthread_t t[THREAD_NUM];
    // 多线程抢票的逻辑
    for(int i = 0; i < THREAD_NUM; i++)
    {
        std::string name = "thread ";
        name += std::to_string(i+1);
        ThreadData *td = new ThreadData(name, &mtx);
        pthread_create(t + i, nullptr, getTickets, (void*)td);
    }

    for(int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
    }

    pthread_mutex_destroy(&mtx);

}

执行结果:不同线程都能抢到票,最后的线程抢到了最后一张票

代码分析:添加的usleep是为了防止一个线程重复抢到票,因为在真正的业务场景下面不仅仅有抢票还需要处理的后续操作,这些需要被模拟

小结:

锁的本质:内存中的变量,一条汇编

临界区,串行化,临界区范围越大,串行化涉及范围越大,多线程并发度降低,加锁涉及的范围和临界区强相关

如何给多线程传入一个字段以上的参数,外部构建对象,new一下数据在堆上开辟,传入指针

三. 死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的 一种永久等待状态
持有锁的线程持有自己的锁的同时还去申请对方的锁导致代码无法向下推进
  1. 死锁四个必要条件互斥条件:

一个资源每次只能被一个执行流使用请求与保持条件:/避免互斥/

一个执行流因请求资源而阻塞时,对已获得的资源保持不放不剥夺条件: /不要不释放自己的/

一个执行流已获得的资源,在末使用完之前,不能强行剥夺循环等待条件: /把他人的抢过来/

若干执行流之间形成一种头尾相接的循环等待资源的关系。 /避免环路申请链/

避免在编码时出现死锁问题(一把锁或者两把锁都会有死锁问题):AB相互申请对方的锁资源, 两个锁的位置不一样;忘记自己曾经申请了 一把锁又去申请,导致这个进程被库挂起,持有锁而且不可能被唤醒,并且自己无法释放锁

  1. 避免死锁

  1. 破坏死锁的四个必要条件 中的任何一个

  1. 不加锁就不会产生死锁,没有临界资源的使用就不会需要互斥条件,没有互斥就不需要加锁(可以不加锁吗)

  1. 如果我申请第二个锁申请很多次都失败了,那我就把自己之前的锁释放掉,这样其他人来了可以用我的锁

  1. 尝试去把别人的锁抢过来,可以通过优先级来强制执行

  1. 设计让所有的线程申请资源的时候都按照一定的顺序去申请,不要交叉申请,可以避免环路等待问题,例如申请锁1,然后锁2

  1. 加锁顺序一致

  1. 避免锁未释放的场景

  1. 资源一次性分配

  1. 具体实现:

lock 加锁时,没有锁,就申请不到,会被阻塞,进程会在这里等待别人释放锁

  1. trylock非阻塞检测,未被占用就申请到

  1. 计数器,若申请很多次都不成功就把之前申请的释放掉,过段时间再来重复申请,破坏了请求与保持条件

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值