线程互斥解析

一 初识同步互斥

        同步互斥以前也没听过,之前写的程序跑也没问题,就是在创建了多线程后才出现这种情况,显然是多线程下程序会出现一些问题,才要提出同步互斥,接下来就用模拟抢票代码来演示多线程可能出现的问题。

int ticket = 10;
#define NUM 3
struct threadData  线程数据就是一个自定义类型,
{
    string threadname;
};
void* threadRun(void*arg)  尝试抢票,让所有线程循环抢票,有剩余票数才抢,没有就不抢。
{
    threadData* td = static_cast<threadData*>(arg);
    string id = to_string(pthread_self());
    while(true)
    {
        if(ticket > 0)
        {
            cout<<"i am "<<td->threadname<<" id: "<<id<<" 抢得票号 "<<ticket<<endl;
            ticket--;
            sleep(1);
        }
        else
            break;
    }
    return nullptr;
}
int main()
{
有两个vector,一个用来保存pthread_t,方便回收,一个用来保存线程的的数据,方便delete
    vector<pthread_t> vp;
    vector<threadData*> vtd;
    for(int i = 0; i < NUM;i++)
    {
        pthread_t ph;
        threadData* td = new threadData();
        td->threadname = "thread->"+to_string(i);

每次要传td变量给抢票函数,传类比直接传一个字符串更具有拓展性。

        pthread_create(&ph,nullptr,threadRun,td);
        vp.push_back(ph);
        vtd.push_back(td);    
    }
    for(int i = 0; i < NUM;i++)
    {
        pthread_join(vp[i],nullptr);
    }
    for(int i = 0; i < NUM;i++)
    {
        delete vtd[i];
    }
    return 0;
}

        为什么大家都抢了同一张票,然后就结束了,1,2,3号票呢,首先肯定是和多线程访问一个全局变量有关系。

        首先ticket--操作不是线程安全的,什么是线程安全呢?  当多线程切换调度对ticket变量的修改符合逻辑,就称为该操作线程安全。上图结果,显然多线程获取的ticket变量不合逻辑,原因:我们知道一行ticket--代码分三步汇编,如下图,实际上线程在任何时候都可以被切换,当然有些原子性操作要执行完才能切换,可以认为大部分的汇编语句是原子性的。

        那我们的程序就会出现这样一种情况,如果当线程1读取完数据到寄存器后,线程1就被切换了,此时1000这个数字是存了两份的,一份在cpu上,cpu上的数据又称程序上下文,会被线程1拷贝带走,还有一份在内存中,假设切换的线程叫线程2,此时它不可能直接用原来线程存在cpu上的数据,而是重新从ticket读取放到cpu上,再假设它一直把票抢到了10,如果此时再切换成线程1,线程1认为ticket是1000,然后就直接--了,再把999写入ticket变量了。这就出问题了!

        这称--操作不是原子性的,因为--执行中可以被切换,而原子性是意味着该代码执行完和执行前才能被切换,执行中不可被打扰,所以结果为什么会有负数的原理已经差不多说清楚了,还要再结合我们的代码再进一步论述。

        在if判断时也会被分为多个汇编语句,假如ticket为1,线程1拿1判断成立,进入if语句,然后线程1被切换,再到线程2来判断,显然ticket变量的值还是1,那此时也进入了if语句,关键就在于线程1先对票数--了,此时内存的票数值就是0了,但是对于线程2来说,还是直接--,注意--操作要重新读入,可能你会说编译器优化,为了方便理解,暂时不考虑优化,因为它已经进入到if语句了,再--就变成了负数,这就是票数出现负数的原因。如果考虑编译器优化,那线程2就会用先前读取的票号1做--,也会出问题,因为两个线程抢到了同一张票,关键并非是有无负数票号,而是在于两个线程可以随意被切换,导致一同进入了if判断,线程2应该要等线程1访问完再访问ticket才是符合逻辑的。

如何解决?

        我们应该让ticket(一个共享资源)在一段代码内只能被一个执行流访问,这就叫互斥,此时这个共享资源又叫临界资源,而访问临界资源的代码就叫临界区 (会有多个吗),而要实现互斥,就要用锁。

        不过为何会刚好在if判断时,或者判断后切换。因为usleep和printf增加了从内核到用户的次数,也就可以增加被切换的概率,什么意思,是因为代码多就可以多用几个系统调用吗。

我们先用锁解决问题,然后说原理以及锁的缺点。

二 初识互斥锁

1 创建互斥锁

        接下来就尝试用互斥锁来解决上面线程安全的问题。下面就是互斥锁的使用函数,初始化和销毁,pthread_mutex_t是库提供的一种数据类型,这是锁的类型,Linux把这把锁称为互斥量。

        init初始化锁函数中参数1就是先传入一个锁,而参数2就是锁的属性,不设置就设为nullptr。显然用pthread_mutex_t这个类型定义的变量有全局和静态,也有局部的概念。

        全局和静态(局部静态和全局静态都可以)就可以用下面的这个来初始化,可以不用pthread_mutex_init来初始化,也可以不用自己调用destroy函数,不是说不能做,而是不用自己做了。

        下面我们来尝试用锁修改代码,保证线程安全。代码只是稍微改动了一下,如下,先定义一把锁并且初始化。

        然后因为要在线程执行函数内加锁,所以主线程要把锁传入函数中,所以前面是传一个threadData类,就是为了后续可以增加传入的成员。

class threadData
{
public:
    threadData(int number)
    {
        threadname = "thread-> " + to_string(number);
    }
    std::string threadname;
    pthread_mutex_t* lock;//保存锁
};

2 开始加锁   

        用lock函数加锁,unlock函数解锁,参数都是我们定义的锁,trylock是lock函数的非阻塞版本。

        先前说了线程不安全是多线程访问共享资源导致的,所以要对其进行加锁,使得多线程互斥访问共享资源。显然加锁的本质是让某个区域的代码只能被一个线程执行,也就只有一个线程能访问共享资源了,让多线程执行变成串行执行,这是时间换安全的做法。

        如下图,此时lock和unlock区域的代码就是被加锁了,只能由一个线程访问,这个区域就是临界区,可是这样会让一个线程把票抢完了,才让下一个线程拿到锁,结果票没了,全都break结束了。可是好像还有点不好理解,应该在哪里加锁,我们再举几个例子来解析一下。

        既然不在循环外加,我们就在循环内加锁解锁,前面出问题是因为if判断时多线程都来判断吗,那我就在if判断处加锁。

        结果如下,噢,当一个线程进入了if语句,释放锁后遇上usleep,直接被切换,此时其它线程拿到锁后不就又进入if语句了吗,所以应该是要tickt--后才能释放锁。

        最终版本。注意:要在else内部也要释放锁。还有一点就是我在最后又usleep了一下,如果不这样做,线程1在释放锁后还会去竞争锁,会导致线程1一直在抢票,因为此时只有它还在运行,其它线程都在休眠。让线程usleep一下,os就会挑选其它线程来申请锁。

        所以如果换成cout等语句,此时会增加代码量,让线程有一定概率在此处被被切换,实际上我们抢到票,后面也是要做不少工作的,一般都会被切换,所以很少会出现一个线程一直竞争锁的情况。

        注意1:锁本身也是个共享资源,那多线程不就可以共享访问了,那谁保护锁呢?这个不用担心,是原子性的,此外解锁也是原子性的,估计要到原理才可以进一步理解。

        注意2:在临界区中,线程是否可以被切换,切换了也没事,切换成其它线程,他们没拿到锁,也无法运行,又只能换成有锁的线程继续执行。

     到后面我们学了原理就明白,lock函数这个加锁函数内部会帮我们做一个申请锁的动作,其它线程没申请到锁就会被阻塞挂起,那如何保证大家申请同一把锁呢,现在理解就是传同一个变量就好,看了原理我们就知道为什么一个变量对应一把锁了。

三 锁的原理

        cpu芯片内部是会内置一个个指令的,这些指令集的名字是二进制的,汇编就是将二进制的指令集名和一些英文单词映射起来,而我们的代码最后就会被分成一个个指令,然后让cpu执行,简单理解部分汇编指令在被执行时是设为原子的。

        前面用的lock函数最后就转为下图的汇编指令,指令解释第一句move:现将al寄存器中的值设为0,第二句xchgb:然后将mutex(这个是我们定义的锁)的值和al寄存器做交换(al寄存器是eax寄存器的一部分,不用过多了解),虽然这些汇编语句执行完都能被切换走,但是只要有一个线程开始执行xchgb,锁的值就被切换到线程的上下文了,此时锁就被这个线程申请走了,因为一句汇编是原子性的,所以说申请锁是原子的,此时其它线程再执行交换,也只是拿到0,就会被挂起等待了

        同样,解锁的汇编指令如下,解释:这句把1写回mutex中就很有意思,不是把先前交换的1还回去,而是直接写1,这说明其它线程都可以去解锁,注意这不是bug,这是为了使得出现死锁后其它线程还能解开。这还解释了为什么一开始要初始化al寄存器的值为0,就是免得交换后,让其它线程又拿到了1,那就都申请到了锁,这还谈什么互斥。

四 锁的应用

1 锁的封装

class Mutex
{
public:
    Mutex(pthread_mutex_t* lock)
    :lock_(lock)
    {
        ;
    }
    ~Mutex();
    void Lock()
    {
        pthread_mutex_lock(lock_);//加锁
    }
    void Unlock()
    {
        pthread_mutex_unlock(lock_);//解锁
    }
private:
    pthread_mutex_t* lock_;
};

        用下面这个类对加锁和释放做智能化,当然也可以把pthread_mutex_lock等函数直接放入,我们调用Mutex类中的Lock和Unlock也只是为了降低一下耦合度,因为我们后面可能要实现多种锁,如果每个都是直接调用系统调用,如果系统调用发生更改,那影响就广了,所以就变成调用Mutex的Lock和Unlock函数。

class LockGuard
{
    LockGuard(pthread_mutex_t* lock)
    :mt_(lock)
    {
        mt_.Lock();
    }
    ~LockGuard()
    {
        mt_.Unlock();
    }
private:
    Mutex mt_;
}; 

2 简化代码

        先前我们写了加锁,已经保证了线程安全,但是还是不太优雅,接下来我们用封装的锁来实现线程安全。

简化如下。

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *getticket(void *arg)
{
    threadData *td = static_cast<threadData *>(arg);
    while (true)
    {
        {
            LockGuard lg(&mutex);
            if (ticket > 0)
            {
                usleep(1000000);
                std::cout << td->threadname << " get " << ticket << std::endl;
                ticket--;
            }
            else
            {
                std::cout << td->threadname << " break" << std::endl;
                break;
            }
        }
        usleep(1000000);
    }
    return NULL;
}

        最有意思的就是这个代码框,有种神来之笔的感觉,它把锁限定在这个区域内,出了区域后局部变量自动析构释放锁,也不用担心break忘记释放锁,智能化释放。如果没有这个框,那锁的生命周期就是整个while循环了,就会一个线程一直持有锁。

3 线程安全和可重入函数

        如果多个线程并发运行时结果合乎逻辑,就称为线程安全,如果多线程下访问了全局变量,导致结果不合乎逻辑,此时就称为线程不安全。可重入函数是指,多个线程并发执行某个函数,对结果无影响,就称这个函数为可重入函数。显然可重入函数是线程安全函数的一种,是指线程在执行这段代码是线程安全的,但不意味着线程安全等价于调用可重入函数,假如多个线程各自调用自己的函数,此时这个程序是线程安全的,由此可见线程安全比调用可重入函数概念大得多。而且多线程调用同一个函数如果是线程安全,但是发生死锁了,此时该函数就是不可重入的,显然线程安全函数也不等价于可重入函数。

五 死锁

1 死锁情况

        一把锁被一个线程二次申请也会产生死锁,由锁的原理得,一定会申请失败,然后自己就抱着锁去休眠了,然后其它线程也申请不到,就产生死锁了。

   有时候一个线程持有了一把锁,又要申请第二把锁,但是这把锁被其它线程拿着,而且这个线程也在申请我拥有的这把锁,大家都不会放弃自己持有的锁,此时又互相申请,这种永久等待的情况就是死锁。

2 死锁必要条件

​​条件1,互斥。死锁不就是用了锁呗,那不用锁就不会有死锁了

条件2,请求和保持,当申请第二把锁申请失败还对已有资源保持不放。

条件3,不剥夺条件,其它线程没办法直接抢,只能等持有锁的线程释放。

条件4,循环等待,若干执行流之间循环等待,例如张三等李四的锁,李四等张三的锁。

如果李四是等待别人的锁,没有形成环路就不会出问题。

3 死锁解决

        理念就是破坏前面的四个必要条件。破坏条件1就只能是不加锁了,因噎废食显然不行。还可以用trylock函数,没申请到也返回,这样就不会卡在这里了。

        破坏条件3,剥夺锁,也就是我们在锁的原理时提到的,释放锁不仅仅只有持有锁的线程可以做,所有线程都可以做,而且我们甚至可以用一个锁来控制某个线程是否可以往下运行,不剥夺指的是调用lock函数不会去抢别人的锁,但是我们可以用unlock函数主动释放锁。

        也可以让主线程统一释放所有的锁,这样就能解决死锁,下次死锁,我再释放。

        环路问题的解决则算是编码的问题,如果我们让所有线程按顺序申请锁1,锁2,就不会出现一个线程持有锁1,要锁2,另一个线程持有锁2,要锁1,这个和后面提及的同步也有些关系。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小何只露尖尖角

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

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

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

打赏作者

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

抵扣说明:

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

余额充值