从0开始linux(41)——线程(4)线程互斥

欢迎来到博主的专栏:从0开始linux
博主ID:代码小豪

共享资源

线程之间会共享进程地址空间当中的内容。比如代码、全局变量、静态变量等数据。都是线程之间所共享的,那么有个问题就出现了,如果多个线程之间,同时使用一份资源,会出现什么情况呢?

比如在我们的设想当中,把线程当做人,而共享的数据则是桌上的4个苹果,而现在有4个人,他们现在要每人去拿一个苹果回来,那么在这个动作结束之后,桌上的苹果会变为0个。在逻辑上看这样子没错,但是我们可以写一个多线程的代码来试验一下。

我们现在设计一个车站出票的系统,创建五个线程,以表示五个售票机的出票情况,假设当前发车的列车共计有10000张票,每个票都有其票号,我们以1~10000的号码作为票号。那么假设现在就有10000个人在车站当中买票,那么他们就要排到五个售票机当中去买票。我们来看看这个出票程序到底如何设计才能正确运行。

这是博主写的第一个版本的代码:

void buyticket(std::string&name)
{
    static int i=10000;
    while(i>0)
    {
        std::cout<<name<<"购票成功,票号为:"<<i--<<std::endl;
        ::usleep(100);
    }
}

现在我们有五个线程,皆执行buyticket函数,来看看运行结果,是否符合出票逻辑。

在这里插入图片描述

诶,真奇怪,我们明明设计的车票号数是1~10000,但是我们现在确实发现出票竟然出了0,-1,-2,-3这个四个号码的票。而且代码中while循环的判断条件为i>0,为什么明明条件都错误了,while循环还能继续运行呢?莫非是c++语言出bug了?当然不是,造成这种情况的原因在于多线程在运行的过程中,污染了数据。

由多线程并发引起的共享资源污染

导致这种情况的代码语句,主要在这两个语句中:
while(i>0)
i--
我们从cpu的运行原理来剖析这两个语句。

首先是while(i>0)。
我们假设现在线程1在运行,且i的值为1。
那么此时当线程1执行while(i>0)时,此时由于i的值符合1,符合循环条件,进程1进入循环。
在这里插入图片描述
但是当执行后续代码时,还没来得及执行i–。就由于时间片到了被cpu切换到了其他线程。
在这里插入图片描述
那么现在轮到线程2执行了,由于进程1当中并没有完成i–的执行,因此现在i的值依然为1,进入循环。

以此类推,线程3、线程4、线程5都有可能因为线程切换的原因,还没来得及对i进行修改操作,就被切换了。

那么当线程1再次切换回来时,此时i的值可能已经被其他线程修改了,我们假设修改成了0,那么它继续执行i–,我们不就看到了0号的票号,或者是-1,-2的票号了吗?

因此我们可以看到,即使我们为while循环设定了判断条件,但是由于线程切换,它其实并不能很好的完成代码逻辑,

那么除开while语句有问题外,i–本身也是有问题的。那么有人可能就发出疑问了,为什么i–会有问题呢?一个i–语句,要么就是在执行它之前,被切换成其他线程,要么就是在执行完它之后,被切换成其他线程。为什么它也会有问题?

一个i–语句,其实对于cpu来说,并不是一步就能执行完的,cpu是根据指令来运行的,因此cpu执行一次,只能完成一句汇编指令。因为我们的C语言代码其实是要翻译成汇编指令的,而一个i–,其实是被翻译成了三条汇编语句。如下:
在这里插入图片描述
博主并不了解汇编指令怎么写,因此博主简单的讲讲这三条指令的作用是什么。

指令1,将i的内存数据,加载到寄存器eax当中。
指令2,将寄存器eax中的数据,-1
指令3,将寄存器的数据,返回到内存当中。

那么如果cpu发生如下的情况会怎么样呢?假设i=9999

现在线程1在执行指令1,将i的内存数据加载到eax当中、
在这里插入图片描述
结果,现在线程1的时间片到了,切换到了线程2,线程3,线程4,线程5.它们都执行完了i–这个操作,因此此时i的值为9996。保存在内存当中。

现在线程1又被切换回来执行了,cpu将会加载线程1对应的上下文数据,其中就包括eax寄存器当中的数据。
在这里插入图片描述
那么现在线程1要执行被切换之前的代码,即指令2,将eax寄存器中的数据–。
在这里插入图片描述
执行指令3,将eax寄存器中的数据,返回到内存当中。
在这里插入图片描述
此时奇怪的事情就发生了,明明在执行i–操作,怎么减着减着,这i还不减反增了呢?这当然不符合运行逻辑了。

造成这种原因有如下几个:
(1)由于线程并行,导致while循环判断错误
(2)由于i–这个操作并不具备原子性

那么这个原子性是什么呢?学过物理的都知道,原子是不可分割的,在计算机中,一个程序可以切分成多个程序块,而一个程序块,可以切分成多个语句,而一个语句,又可以切分成多个指令。对于cpu来说,运行的基本单位,就是一条指令。因此如果一个语句,翻译后只有一个指令的话,那么这个语句就是具备原子性的。

解决方案——加锁

归根结底,造成数据异常的原因,还是因为i是一个被所有线程共享一个资源,能被所有的线程看到,如果i是一个在线程栈中的一个数据,就没有这种烦恼了。但是有时候需求就是要我们写出一个全局或者静态的共享变量给所有的线程使用。所以我们需要一个能够维护共享资源的方法。

通过我们上面的分析,当线程中的资源并非所有都是共享的,我们将这部分的代码称为非临界区。而涉及到修改,或者访问到共享资源的代码,我们称为临界区。对于多线程的程序来说,临界区是需要保护起来的。那么保护的方法就是加锁了。

比如你在酒店住了一个房间,那么首先酒店会将该房间的房卡给你,在你使用房间的时候,你会将房间锁起来,不给其他人使用。即使有人想要在住在你的房间,也得等你使用完该房间之后,将房卡退还给酒店,酒店才会将这个房间租给别人。

那么临界区加锁的原理也是这样。当线程执行到临界区后,会先判断这段区域是否上锁了, 如果上锁了,那么线程就会阻塞在申请锁的代码。如果没有上锁,那么线程就会申请锁,申请到锁之后就会进入临界区,此时临界区就会被锁上,其他线程无法进入,直到线程执行完临界区,将锁归还,此时其他线程才能用到这个临界区。

那么既然要用锁,来锁上这个临界区,那么首先我们要有锁,在<pthread.h>库中,定义出了锁的类型,pthread_mutex_t。首先我们要先初始化出这个锁。pthread_mutex_t类型的变量,可以用下面两种方法进行初始化。

  • (1)如果这个锁是一个局部变量,那么初始化需要用到pthread_mutex_init函数
  • (2)如果这个锁是一个全局变量,那么初始化则需要用宏:PTHREAD_MUTEX_INITIALIZER;

那么在刚刚的问题上,我们可以定义出一个全局的锁,代码如下:

pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;

那么现在,我们的进程已经有了一把锁了,这个锁叫做lock。我们来到临界区的起始位置,将锁放在这里。

在<pthread.h>中,上锁的函数为pthread_mutex_lock

int pthread_mutex_lock(pthread_mutex_t *mutex);

我们将锁的指针传递给pthread_mutex_lock。当线程执行到该代码时,如果有其它线程先上锁了,那么线程就会阻塞等待,而如果没有其他线程上锁,那么该线程就会进入临界区,并且将锁锁上。

而解锁的函数是pthread_metux_unlock

int pthread_mutex_unlock(pthread_mutex_t *mutex);

一般这个函数会放在临界区的出口位置,现在不是有一个线程在进入临界区的时候上锁了吗?那么其他线程还等着用呢。所以当线程来到临界区的出口后,就要把锁解开,让其他进程来用了。当锁被解开后,阻塞住的线程就能进来临界区了,但是也是进去一个,立马就会上锁,所以,上锁区间的临界区,最多只有一个线程在执行,这样不就会由于线程并发的原因,造成数据污染了。

我们将上面的示例代码改一改,主要是要将对i的修改和读取的代码加上锁。防止对i进行数据污染。

void buyticket(std::string&name)
{
    static int i=10000;
    while(true)
    {
        pthread_mutex_lock(&lock);//加锁
        if(i>0)
        {
            ::usleep(100);
            std::cout<<name<<"购票成功,票号为:"<<i--<<std::endl;
            pthread_mutex_unlock(&lock);//解锁
        }
        else
        {
            break;
        }
    }
    pthread_mutex_unlock(&lock);//这里也要解锁哦
}

现在我们重新运行代码,看看效果如何。
在这里插入图片描述
我们可以看到,现在出票的票号就正常了,但是有个奇怪的点不知道大家有没有发现?为什么到后面都是出票台3(线程3)一直在出票?这一点就是我们下一篇的内容,线程同步。博主先友情预告。

加锁的原理

加锁之所以能用,是因为将临界区进行了保护,只允许一个线程运行,以达到避免数据污染的作用。但是我们来思考一个问题?这个锁是不是由多个线程共同使用的?如果是的话,这个锁不就是由多个线程共享的吗?那么这个锁不也是一个共享资源吗?那么我们前面说了,共享资源是需要保护的,那么这个锁又是如何被保护的呢?

实际上这个锁并不需要被保护,而是这个关锁,开锁的行为,它是原子性的,因此,对任何一个线程来说,要么就是关到锁了,要么就是被锁在外面,不存在类似于i–这样问题,即执行到指令的某个阶段,就被cpu切换了。因此,关锁、开锁这个操作,一定不会导致锁被污染。

为了实现互斥锁操作,⼤多数CPU都支持swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性,即使是多处理器平台,访问内存的总线周期也有先后,⼀个处理器上的交换指令执⾏时另⼀个处理器的交换指令只能等待总线周期。

我们在对锁初始化时,锁会获得一个正整数值。我们假设这个值为1。而在cpu的内部,存在一个寄存器,我们假设它名为reg。现在线程1在执行任务,来到临界区,此时锁的值为1,线程1准备上锁。

那么上锁的这个过程,其实就是调用exchange或者swap指令,将寄存器与内存的值进行交换。
在这里插入图片描述
cpu在调度线程1时,由于reg的值为1,允许线程1进入临界区。

现在线程1被切换走了,线程2被调度,由于调度的过程中需要获取线程对应的上下文,其中就包括线程的寄存器数据,在上一次调度的过程中,线程2的reg保存的数据为0。因此线程2运行的情况如下:

在这里插入图片描述
由于线程2无论是reg寄存器的值,还是lock的值都是0(因为线程2使用的lock和线程1使用的lock是同一个变量,而在线程1运行的时候,lock的值变为0了),因此在使用exchange指令,交换数据后,reg寄存器依然是0。此时线程2没有进入临界区的资格,被阻塞在了入口处。

现在线程1被切换调度了,并且来到了临界区的出口位置,现在线程1要执行开锁动作了,而开锁这个操作,也是具有原子性的,即使用exchange函数,将reg寄存器的值,和内存当中的lock变量交换。

在这里插入图片描述
那么现在在切换回线程2,由于此时lock的值为1,reg的值为0,交换之后reg的数据会变成1,因此又能进入临界区了,而在交换之后,reg的数据依然为0的线程,就会被挡在临界区外。这就是加锁的原理

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

代码小豪

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

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

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

打赏作者

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

抵扣说明:

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

余额充值