Linux系统编程——多线程[中]:互斥与同步

0.关注博主有更多知识

操作系统入门知识合集

目录

1.并发过程中的问题

2.互斥

2.1互斥锁

2.2如何看待互斥锁

2.3加锁和解锁的本质

2.4对锁做一个封装

2.5可重入函数与线程安全

2.6死锁

3.同步

3.1条件变量

1.并发过程中的问题

我们知道,同一个进程中的多个线程共享绝大部分资源,这就意味着这些线程可以很轻易的访问进程当中的全局变量,那么这就说明在多线程并发执行的过程当中可以对这些全局变量做访问,那么就会产生数据不一致的问题。我们以一个模拟抢票的例子来说明这个问题:

#include <iostream>
#include <vector>
#include <string>
using namespace std;
#include <pthread.h>
#include <unistd.h>

/*定义一个全局变量,模拟票*/
int tickets = 10000;

struct threadData
{
    pthread_t _tid;
    string _name;
};

void *getTickets(void *args)
{
    string name = (static_cast<threadData *>(args))->_name;
    while(true)
    {
        if(tickets > 0)
        {
            /*因为线程出问题的情况很难模拟,所以我们要尽可能保证线程交叉运行
             *而交叉运行的实现手段就是尽可能多的进行线程切换
             *当线程执行usleep()时会让自己陷入阻塞,那么调度程序会调度另一个线程执行*/
            usleep(1234);
            cout << name << " get tickets:" << tickets-- << endl;
        }
        else 
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    vector<threadData *> vec;
#define NUM  4
    for(int i=0;i<NUM;i++)
    {
        threadData *td = new threadData;
        td->_name = "thread" + to_string(i+1);
        pthread_create(&td->_tid,nullptr,getTickets,td);
        vec.push_back(td);
    }

    for(auto& td:vec)
    {
        pthread_join(td->_tid,nullptr);
        delete td;
    }
    return 0;
}

最后的输出结果对于我们来说是不正确的,因为抢票怎么可能抢到0和负数票呢?所以这里就产生了数据不一致问题。那么在代码的注释部分当中说到了要让线程尽可能的发生切换,那么切换的工作就是在线程由内核态返回用户态的时候做的,那么线程陷入内核的方式有很多,例如调用系统调用、请求I/O、发生异常等等,那么当线程的时间片结束之后,就会陷入内核,在内核态返回到用户态之前会检查需不需要切换该线程,如果确实需要,那么就切换该线程,否则返回用户空间执行用户代码。

那么数据不一致的问题是由于线程切换导致的,试想一下,如果一个线程从头到尾一直抢票不给其他线程任何机会,那么就不会产生数据不一致问题。大家可以将上面代码当中的"usleep(1234)"写到"cout..."语句的后面。我们用一个特殊的例子来模拟线程切换导致数据不一致的场景(单CPU):

在上述这个例子当中就会产生一个问题,即当tickets的值为1的时候,这就已经说明了只有一个线程能买票,但是不幸的是,当线程1做完判断之后,陷入阻塞,此时调度线程2运行,线程2也能做判断,然后陷入阻塞;当线程1醒来的时候,执行输出的操作,即将1输出到控制台上,然后执行--操作,--操作有三条汇编指令,三条汇编的意思按顺序依次为:将内存的值读入CPU寄存器,CPU对寄存器的值做--,将计算的结果写回内存。所以线程1执行完--之后,那么内存中的tickets的值就为0了;那么线程2醒来之后,内存当中的tickets的值虽然为0,但是线程2已经做过判断了,并且它的上下文中的tickets的值依然为1,此时线程2输出tickets(输出的是内存中的值),即将0输出到控制台上,然后做--操作,最后内存当中的值被写成-1。

那么从这个例子当中就可以得出一个结论,那就是并发执行的过程当中,当一个线程对数据做出了更改,但是另一个线程可能并没有及时更新其上下文的信息,从而导致的数据不一致问题。那么我们再以一个简单的例子来理解:

这个例子就很好的诠释了数据不一致问题。当线程1可能由于执行某些任务,在时间片快结束的附近执行了--操作,而--操作的最后一步没有执行完便发生了切换;此时线程2被投入运行,并一直执行--操作,那么内存当中的值由1000不断地被覆盖成999、998、997......当最后一次覆盖,即将200覆盖到内存之后,线程2被切换;此时线程1又投入运行,但是发生切换时线程1的上下文保存的值为999,那么需要恢复上下文,即将上下文的值写回寄存器,然后线程2继续执行上一次没有执行完的任务,即将寄存器的值写回内存,此时,线程1便将999写回内存,内存的值由200又回到了999。

那么在C/C++当中,++、--操作看似只有一条语句,实际上编译之后生成的汇编代码,++、--有三条汇编指令

2.互斥

那么像上面抢票代码中,四个线程对一个全局变量做访问,那么这个全局变量就可以看是共享资源,又因为该共享资源没有任何保护机制,所以在并发执行的过程中会产生某些数据不一致问题。那么在多线程当中,有几个概念是比较关键的:

  1.多个执行流访问同一份没有安全保护的资源,该资源称为共享资源

  2.多个执行流访问同一份具有安全保护的资源,该资源称为临界资源

 3.多个执行流当中,访问临界资源的代码称为临界区

  4.临界区理论上来说是很小的一部分,因为线程当中的大部分语句都是在访问自己的私有资源

那么什么是具有安全保护的资源?那就是当某个执行流访问了这个资源,这个资源在同一时刻不能再被其他执行流访问,该资源就是具有安全保护的资源。那么用两个字来概括,就是互斥,互斥就是安全保护的一种机制。那么对于上面的抢票代码,解决数据不一致的问题手段便是让这些线程不能同时访问tickets全局变量,即只能串行访问,也就是达到互斥的效果。

那么互斥表现出来的效果就是当一个执行流访问一个资源时其他执行流不能访问该资源,那么这里就不得不提到原子性原子性指的是任何事物只有两态,没有中间过程。例如互斥表现出来的效果,那么执行流要访问资源的时候,看到的资源就只有两种状态,即未被使用和被使用。

实际上数据不一致是一个问题,那么采用一种手段解决一个问题就会带出新的问题,由此往复问题是一直存在的。但是这并不意味着这些解决问题的手段不够好,而是因为这些问题根本无法解决,所以在不同的场景下就有不同的解决方案,这是一种特性。

2.1互斥锁

那么对于上面的抢票代码,我们可以使用一种互斥机制来让线程访问全局变量时做到串行访问的效果。那么这个机制可以是锁,可以是信号量等等,我们先介绍锁。

锁是一种安全保护的机制,保护的是执行流的共享资源的访问,也就是说,共享资源被锁保护起来之后,就变成了临界资源,而各个执行流当中访问临界资源的代码就是临界区。我们先以上面的抢票代码为例,使用锁来完成我们的目的

  那么首先,Linux的原生线程库提供了有关于锁的接口:

  其中,pthread_mutex_lock()表示加锁,即保护要访问的共享资源;pthread_mutex_unlock()表示解锁,即访问共享资源结束时,停止保护。我们还可以看到参数当中的pthread_mutex_t类型,这个类型就是锁的类型,当锁被定义时,需要被初始化:

  其中需要注意,当锁定义为局部变量时,必须调用pthread_mutex_init()完成初始化,在锁不需要被使用时,必须调用pthread_mutex_destroy()销毁锁(内部可能使用了动态开辟的资源);而当锁被定义为全局变量时,直接初始化为PTHREAD_MUTEX_INITIALIZER即可,不需要销毁。我们看修改之后的代码:

#include <iostream>
#include <vector>
#include <string>
using namespace std;
#include <pthread.h>
#include <unistd.h>

/*锁被定义为全局变量,直接初始化即可*/
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int tickets = 10000;

struct threadData
{
    pthread_t _tid;
    string _name;
};

void *getTickets(void *args)
{
    string name = (static_cast<threadData *>(args))->_name;
    while(true)
    {
        /*需要访问共享资源的代码就加锁*/
        pthread_mutex_lock(&lock);
        if(tickets > 0)
        {
            usleep(1234);
            cout << name << " get tickets:" << tickets-- << endl;

            /*结束访问,解锁*/
            pthread_mutex_unlock(&lock);
        }
        else 
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    return nullptr;
}

int main()
{
    vector<threadData *> vec;
#define NUM  4
    for(int i=0;i<NUM;i++)
    {
        threadData *td = new threadData;
        td->_name = "thread" + to_string(i+1);
        pthread_create(&td->_tid,nullptr,getTickets,td);
        vec.push_back(td);
    }

    for(auto& td:vec)
    {
        pthread_join(td->_tid,nullptr);
        delete td;
    }
    return 0;
}

那么将互斥锁作为局部变量时的代码可以是这样的:

#include <iostream>
#include <vector>
#include <string>
using namespace std;
#include <pthread.h>
#include <unistd.h>

int tickets = 10000;

/*将互斥锁的指针作为线程例程的参数*/
struct threadData
{
    pthread_t _tid;
    string _name;
    pthread_mutex_t *_pmutex;
};

void *getTickets(void *args)
{
    threadData *pd = static_cast<threadData *>(args);
    while(true)
    {
        /*需要访问共享资源的代码就加锁*/
        pthread_mutex_lock(pd->_pmutex);
        if(tickets > 0)
        {
            usleep(1234);
            cout << pd->_name << " get tickets:" << tickets-- << endl;

            /*结束访问,解锁*/
            pthread_mutex_unlock(pd->_pmutex);
        }
        else 
        {
            pthread_mutex_unlock(pd->_pmutex);
            break;
        }
    }
    return nullptr;
}

int main()
{
    vector<threadData *> vec;
    pthread_mutex_t lock;
    pthread_mutex_init(&lock,nullptr);

#define NUM  4
    for(int i=0;i<NUM;i++)
    {
        threadData *td = new threadData;
        td->_name = "thread" + to_string(i+1);
        td->_pmutex = &lock;
        pthread_create(&td->_tid,nullptr,getTickets,td);
        vec.push_back(td);
    }

    for(auto& td:vec)
    {
        pthread_join(td->_tid,nullptr);
        delete td;
    }
    pthread_mutex_destroy(&lock);
    return 0;
}

这两种方案的执行结果都是正确的。大家可以将这几份拷贝走然后编译运行,可以明显的看到加锁之后的输出速度明显变慢了。

2.2如何看待互斥锁

上面两个有关于互斥锁的程序最后的执行结果都是正确的,这就证明了互斥锁有能力让多个执行流串行访问某一共享资源,那么被串行访问的共享资源我们称为临界资源,因为该资源被保护起来了,表现的形式就是被串行访问。那么访问临界资源的代码就是临界区了,也就是在pthread_mutex_lock()和pthread_mutex_unlock()之间的代码就是临界区

那么我们细心观察可以发现,上面的两个输出结果从头到尾都只有一个线程在抢票,也就是说,虽然共享资源能被串行访问了,但是访问临界资源的执行流永远都是那一个。这就表明了互斥锁提供互斥、资源保护然后让执行流串行访问资源的功能,而不提供哪个执行流来访问资源的策略。造成只有一个线程在抢票的原因是线程与线程之间要竞争锁,即竞争到并且拿到锁的线程才能进入临界区访问临界资源,而这些线程当中有一线程对锁的竞争力特别强,造成其他线程一直竞争不到锁,这些一直竞争不到锁的线程我们认为它处于饥饿状态。那么从现实的角度出发,正常的抢票软件不仅仅是将一个数字--就完成抢票功能了,还需要登记购票人的信息、推送消息等等工作,我们使用usleep()来模拟这个过程:

void *getTickets(void *args)
{
    threadData *pd = static_cast<threadData *>(args);
    while(true)
    {
        /*需要访问共享资源的代码就加锁*/
        pthread_mutex_lock(pd->_pmutex);
        if(tickets > 0)
        {
            usleep(1234);
            cout << pd->_name << " get tickets:" << tickets-- << endl;

            /*结束访问,解锁*/
            pthread_mutex_unlock(pd->_pmutex);
        }
        else 
        {
            pthread_mutex_unlock(pd->_pmutex);
            break;
        }

        /*无论抢没抢到票,都要给购票人推送结果*/
        usleep(1000);
    }
    return nullptr;
}

同时我们思考一下,既然多个线程要竞争锁,那么这个锁就是一份共享资源,那么锁是用来保护共享资源的,但是锁本身就是一份共享资源,那么锁本身的安全保护是谁提供的?这个问题就印证了上面所说的,解决一个问题又会带来一个新的问题。事实上我们不用担心,因为加锁的过程就是原子性的。这样就意味着,在其他线程看来,如果这个锁没被占用,那这个线程就直接用这把锁了;如果锁被占用了,那么该线程只能阻塞等待锁,也就是说,锁在其他线程看来只有两种状态,即未被使用和被使用。我们以一个简单的例子来证明未获取到锁的线程会阻塞等待:

void *getTickets(void *args)
{
    threadData *pd = static_cast<threadData *>(args);
    while(true)
    {
        /*对同一把锁获取两次,第一次获取成功之后,第二次就获取不到了*/
        pthread_mutex_lock(pd->_pmutex);
        pthread_mutex_lock(pd->_pmutex);
        if(tickets > 0)
        {
            usleep(1234);
            cout << pd->_name << " get tickets:" << tickets-- << endl;
            pthread_mutex_unlock(pd->_pmutex);
        }
        else 
        {
            pthread_mutex_unlock(pd->_pmutex);
            break;
        }
        usleep(1000);
    }
    return nullptr;
}

注意我说的是加锁的过程是原子性的,解锁的过程不一定是原子的,因为pthread_mutex_unlock()从某种意义上来说它就处于临界区,而临界区在同一时刻只能有一个执行流进入,那么解锁就只有一个线程来解锁了。因为解锁的过程不是原子的,所以解锁的过程可以连续发生多次。

那么我们可以试着推理一下,当持有锁的线程处于临界区时,它可不可以发生线程切换?当然可以,持有锁的线程发生切换时会抱着锁一起切换,即锁处于线程的上下文之中,所以当持有锁的线程发生切换时,其他线程依然在阻塞等待,直到锁被释放。再次强调一下原子性,当一个线程拿到锁并且进入临界区之后,那么其他没有拿到锁的线程看待临界资源的状态就是正在被使用状态,这些未持有锁的线程无法进入临界区;当一个持有锁的线程释放锁之后,其他线程看待看待临界资源的状态就是未被使用状态,这些未持有锁的线程去竞争锁,产生一个唯一的线程然后进入临界区。除了这两种状态之外,没有其他状态。

那么这里需要提醒一下,在使用锁机制的时候,一定要保证临界区的粒度足够小。也就是说,临界区的代码越短越好,在设计代码的时候尽量让访问临界资源的代码集中起来。同时也需要注意,加锁和解锁是一种程序员行为,即程序的正确与否是成我们写代码的人决定的,这也就意味着在编写程序的时候,如果需要对访问共享资源的代码加锁,那么所有访问这个共享资源的代码都要加锁,否则会产生不是我们预期的结果,也就是俗称"找不到的bug"(如果生产场景需要这么做,那么忽略的我的提醒)。

2.3加锁和解锁的本质

实际上就是要解释一下为什么加锁的过程是原子性的。实际上原子性的概念范围是比较广的,我们上面所说的是一个较大的范围,这里介绍一个具体的一个实例:一条汇编代码的执行是原子的。因为这样,所以大多数体系结构(即CPU体系结构,类似于x86、ARM这种)提供了swap或者exchange指令,该指令的作用就是把寄存器和内存单元的数据交换。由于只有一条指令就可以完成交换,所以保证了原子性。

那么加锁过程的伪代码是这样的:

lock:
    movb $0,%al
    xchgb %al,mutex
    if(al > 0)
    {
        return 0;
    }
    else goto lock;

也就是说,每个竞争锁的线程都会将一个0写入到当前线程上下文中的al寄存器里,然后将al寄存器的值与内存当中的mutex变量进行交换。那么假设当mutex(pthread_t就是锁的类型,既然是类型那么就有值)的值为1时,表明该锁可用;为0时表示该锁不可用。那么寄存器的值与内存单元的值交换之后,如果al的值为1,就说明竞争到锁了,此时退出加锁的函数,进入临界区;如果al的值为0,证明没有竞争到锁,那么就一直循环等待锁。执行加锁的过程中,执行任何一条指令时都可以发生切换,因为一条汇编指令是原子的,并且它们将值写在了寄存器当中,而寄存器属于线程的上下文,切换的时候会将上下文保存起来,这也是为什么前面我们说线程切换时抱着锁一起走的原因。

我们再看解锁过程的伪代码:

unlock :
    movb $1 ,mutex
    唤醒等待Mutex的线程;
    return 0;

解锁的过程非常简单,只需要向内存单元写入一个1即可。

2.4对锁做一个封装

在上一篇博客当中,我们对原生线程库提供的接口做出了封装,使得我们可以像C++11的线程库那样使用。那么现在我们对锁有关的接口进行一个封装,使其称为一个RAII风格的锁(RAII风格的锁是非常有用的,可以避免很多问题):

// Mutex.hpp
#pragma once

#include <pthread.h>

namespace ly
{
    /*Mutex类负责将有关于锁的接口封装起来*/
    class Mutex
    {
    public:
        Mutex(pthread_mutex_t *pmutex = nullptr):_pmutex(pmutex) 
        {
            if(_pmutex != nullptr) pthread_mutex_lock(_pmutex);
        }
        ~Mutex()
        {
            if(_pmutex != nullptr) pthread_mutex_unlock(_pmutex);
        }
    private:
        pthread_mutex_t *_pmutex;
    };

    /*LockGuard负责给外部使用
     *即RAII风格的锁*/
    class LockGuard
    {
    public:
        /*LockGuard的构造函数当中了定义了Mutex对象
         *Mutex对象被定义时会调用构造函数
         *Mutex类的构造函数会执行上锁操作*/
        LockGuard(pthread_mutex_t *pmutex):_mutex(pmutex)
        {}
        ~LockGuard()
        {
            _mutex.~Mutex();
        }
    private:
        Mutex _mutex;
    };
}/*namespace ly ends here*/

2.5可重入函数与线程安全

在上面的抢票逻辑当中,抢票函数(线程的执行例程,即getTickets()函数)被多个执行流同时访问,这样的函数我们称为被冲入函数。被重入函数分为两种:

  1.可重入函数:如果多个执行流同时进入一个函数(或者是前一个执行流还没执行完,后一个执行流就进入该函数了,总之就是执行流不串行的访问一个函数),如果这个函数的结果没有出错,就说明这个函数是可重入函数。当然,结果出错不一定是报错,而是输出的结果不符合我们的预期,或者结果在我们的意料以外

  2.不可重入函数:如果多个执行流同时进入一个函数,如果这个函数的结果出错了,也就是结果不符合我们的预期,这样的函数我们称为不可重入函数。

那么线程安全与不安全指的是

  1.线程安全:多个执行流并发执行同一段代码时,不会产生数据不一致问题。

  2.线程不安全:多个执行流并发执行同一段代码时,会产生数据不一致问题。

实际上线程安全和可不可重入没什么关系,因为可不可重入指的是函数,线程安全指的是线程。但是它们两个之间又存在一定的关系,那么这里可以总结以下几点:

  1.可重入函数一定是线程安全的:这种情况发生在函数当中根本就没有访问共享资源的代码,所以所有执行流都在访问自己的私有资源

  2.不可重入函数说明多个执行流不能同时进入该函数,那么就有可能引发线程不安全

  3.如果线程是安全的,那么这并不代表函数是可重入的

2.6死锁

死锁不是一把锁,它是一种状态,用通俗的话来说就是因为编码不当造成"bug"了。这也就印证了我们前面所说的话:解决一个问题的同时会带来新的问题。我们想让执行流之间并发执行而提高CPU的利用率,采用了多线程技术;而多线程之间的数据绝大部分都是共享的,那么线程之间在做资源访问时有可能会产生数据不一致问题,所以又提出了一种互斥的解决方案,对资源进行保护;解决了数据不一致的问题之后,又会产生出一个新的问题,即死锁,死锁指的是每个执行流都占有自己不会释放的资源,而每个执行流又想要获取其他执行流不会释放的资源。一个死锁的简单例子就是使用同一把锁并加锁两次

void *getTickets(void *args)
{
    threadData *pd = static_cast<threadData *>(args);
    while(true)
    {
        /*线程第一次获取锁,成功,继续向下执行*/
        pthread_mutex_lock(pd->_pmutex);
        /*此时线程想要第二次获取锁
         *但是现在这把锁已经被占用了(被自己占用)
         *被占用的锁当前不会释放
         *从而线程在这里阻塞等待
         *因为锁不会被释放,所以线程一直在阻塞等待*/
        pthread_mutex_lock(pd->_pmutex);
        if(tickets > 0)
        {
            usleep(1234);
            cout << pd->_name << " get tickets:" << tickets-- << endl;
            pthread_mutex_unlock(pd->_pmutex);
        }
        else 
        {
            pthread_mutex_unlock(pd->_pmutex);
            break;
        }
        usleep(1000);
    }
    return nullptr;
}

那么用一个小例子来描述死锁,就是这样的:假设小朋友A和下朋友B各有5毛钱,它们同时经过一家商店,这家商店的棒棒糖要1块钱此时小朋友A跟小朋友B说:我有5毛钱,你把你的5毛钱给我吧,这样我就能买一根棒棒糖了。小朋友B一想不对劲,所以跟小朋友A说:你怎么不把你的5毛钱给我,这样我就能买一根棒棒糖了。因为小朋友A和小朋友B对自己的5毛钱互不相让,并且都要对方的5毛钱,所以这两个小朋友谁也得不到棒棒糖。

那么构成死锁的必要条件有四个

  1.互斥:一个资源每次只能被一个执行流单独访问

  2.请求与保持:执行流请求其他执行流占有的资源,但是资源却不释放

  3.不剥夺条件:执行流在请求其他资源时,对方不释放,那么这个执行流就得不到资源,即不能强行剥夺其他执行流占有的资源  

  4.环路等待条件:若干执行流之间形成一种头尾相接的环路等待资源的关系

典型的死锁例子就是哲学家就餐问题,有兴趣的朋友可以点开链接了解一下:死锁——哲学家就餐问题

那么以上面小朋友买棒棒糖的例子,就是一个死锁,他们之间有互斥,因为棒棒糖只有一个,只允许一个小朋友吃;他们之间存在请求与保持,都在互相请求对方的5毛钱,但是都不会给;他们之间存在不剥夺条件,他们在互相请求5毛钱时没有强行剥夺对方的5毛钱;他们之间构成环路等待条件,小朋友A请求小朋友B的资源,小朋友B请求小朋友A的资源。那么逻辑关系图就像下面这样:

避免死锁有以下几种方法

  1.破坏死锁的四个必要条件中的任意一个或多个:因为构成死锁的四个必要条件必须同时成立

  2.加锁顺序一致:例如线程A需要加两把锁,分别为1号、2号,那么假设线程A的加锁顺序为1号、2号,那么其他线程也要加这两把锁时,加锁的顺序要与线程A的加锁顺序保持一致。举一个反例来说明加锁顺序不一致可能会导致死锁:假设线程A的加锁顺序为1号、2号,线程B的加锁顺序为2号、1号,那么在同一时刻,线程A能够占用1号锁,线程B能够占用2号锁;而当线程A继续向后加2号锁,因为2号锁已经被线程B占用,所以阻塞,而线程B又要加1号锁,而1号锁此时又被线程A占用,所以陷入阻塞;正因为线程A和线程B都在阻塞状态,这都不往后运行,所以谁都不会释放锁,进而造成死锁。

  3.避免锁未释放的场景:因为加锁和解锁的行为是程序员控制的,所以我们在编写代码时有必要避免锁未释放的场景

  4.资源一次性分配:例如上面线程A和线程B加锁的问题,如果现在一次性将1号锁和2号锁分配给线程A,线程B直接陷入阻塞,那么当线程A释放两把锁之后,再把这两把锁交给线程B,那么他们之间就不会产生死锁

死锁的产生大部分原因是因为程序员的处理不当,所以我们编写程序时要注意这些问题。

3.同步

在上面的抢票程序当中,我们对资源(票)做了保护,其最后的访问结果是正确的,但是从头到尾只有一个线程在抢票,也就是说,上面的抢票程序的结果正确但不合理。那么同步就是保证结果的合理性,互斥提供安全保护机制

同步就是让互斥的几个执行流,每一个执行流都按特定的顺序访问临界资源。还是以抢票程序为例,我们虽然让四个线程互斥地访问了临界区,但是其输出结果总是一个线程在抢票,这是因为线程之间的竞争结果,因为刚刚访问完临界资源的线程释放锁之后,它离锁是最近的,所以下一次循环上来时,其他线程刚要准备加锁,但是刚刚解锁的线程距离最近,所以直接将锁给抢走了,所以最后的输出结果表现为一个线程一直在抢票。用一个简单的例子来理解竞争:假设有一间自习室只有一个座位,那么每天都有很多人争先恐后的去抢这个自习室,假设小明最先拿到挂在自习室门口的钥匙,然后小明便可以进入自习室学习,而其他同学只能在门口等待;当小明在自习室待了一段时间后,想出去上个厕所,从自习室出来,然后把门关上,带着钥匙去上厕所了,然后回来又自己开门进入自习室(这就相当于线程切换);那么当小明今天的学习任务完成之后,刚从自习室出来,刚把钥匙挂回自习室门口时(此时就相当于线程刚把锁释放),此时所有同学都想竞争这把钥匙,但是此时小明距离这把钥匙最近,那么小明也是同学中的一员,那他心想万一明天自习室没了怎么办?此时他又想与其他同学竞争这个自习室,但是小明距离钥匙最近,所以小明可以很轻松的拿到这把钥匙,然后又进入自习室。那么一直在门口等待自习室、而又一直得不到钥匙的同学,就处于饥饿状态

那么同步就是添加一个访问规则,例如上面的抢票程序,我们可以规定一个队列,每个想要抢票的线程,在抢票之前都要先进入该队列排队,那么当一个线程抢完票时又想继续抢票,那么它只能从队列的尾端进入队列,然后排队进行抢票。那么也如同上面的自习室例子,当小明从自习室出来之后又想继续进入自习室时,他只能选择跟在队伍后面重新排队。

事实上确实有一个名为条件变量的东西,提供了一个阻塞队列,用于线程的"排队"。

3.1条件变量

条件变量实质上就是提供一个阻塞队列并且提供一些控制的接口,因为它属于POSIX标准,所以它的接口与锁的接口非常相似。那么我们先来看一下条件变量的创建接口:

可以看到条件变量的数据类型为pthread_cond_t,那么如果条件变量定义为全局或者静态变量时,那么它不需要使用pthread_cond_init()初始化和使用pthread_cond_destroy()销毁;相反,如果条件变量被定义为局部变量,那么它一定要被初始化和销毁。

那么既然条件变量的接口与锁的接口十分类似,锁有加锁和解锁,那么条件变量也应该有放入阻塞队列和从阻塞队列唤醒(实际上就是放入条件变量)的接口:

  我们主要关注pthread_cond_wait()这个接口,这个接口的意义就是哪个线程调用该接口,哪个线程就被放入条件变量。需要注意的是,调用此接口时必须把锁也带上,具体的行为我们在后面的生产者与消费者模型当中再做解释。

  这两个接口就是将在条件变量中阻塞的线程唤醒,其中,pthread_cond_broadcast()是唤醒一批线程,即将条件变量当中的所有线程都唤醒;pthread_cond_signal()是唤醒一个线程,即将条件变量当中处于阻塞队列头部的线程唤醒。

此时我们可以对以前的抢票程序做出修改,也就是在保证互斥的前提下加上同步,也就是让每个线程都按照特定的规则来访问临界资源,即要访问临界资源的线程必须先在阻塞队列"排队":

int tickets = 10000;

/*将条件变量指针作为线程例程的参数*/
struct threadData
{
    pthread_t _tid;
    string _name;
    pthread_mutex_t *_pmutex;
    pthread_cond_t *_pcond;
};

void *getTickets(void *args)
{
    threadData *pd = static_cast<threadData *>(args);
    while (true)
    {
        {
            /*使用RAII风格的互斥锁
             *每个线程访问临界资源之前先"排队"*/
            LockGuard lockguard(pd->_pmutex);
            pthread_cond_wait(pd->_pcond,pd->_pmutex);
            if (tickets > 0)
            {
                cout << pd->_name << " get tickets:" << tickets-- << endl;
            }
            else
            {
                break;
            }
        }
    }
    return nullptr;
}

int main()
{
    vector<threadData *> vec;

    /*互斥锁与条件变量的初始化*/
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    pthread_cond_t cond;
    pthread_cond_init(&cond,nullptr);

#define NUM 4
    for (int i = 0; i < NUM; i++)
    {
        threadData *td = new threadData;
        td->_name = "thread" + to_string(i + 1);
        td->_pmutex = &lock;
        td->_pcond = &cond;
        pthread_create(&td->_tid, nullptr, getTickets, td);
        vec.push_back(td);
    }

    /*每隔1234微妙唤醒一个线程*/
    while(true)
    {
        usleep(1234);
        pthread_cond_signal(&cond);
    }

    for (auto &td : vec)
    {
        pthread_join(td->_tid, nullptr);
        delete td;
    }

    /*互斥锁与条件变量的销毁*/
    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);
    return 0;
}

可以看到最后的输出结果,首先使用了互斥保证了线程安全,其次使用了同步保证了线程访问资源的合理性。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小龙向钱进

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

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

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

打赏作者

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

抵扣说明:

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

余额充值