前言: 上文我们讲到了线程的概念与控制【Linux系统】初见线程,概念与控制-CSDN博客 本文我们再来讲讲,线程的下一部分内容:线程的互斥与同步!
在上一文章中我们讲到了线程的概念与控制,知道了线程是共享进程中的绝大部分资源的!但是当多个线程同时访问同一个公共资源时,就会出现一个问题:数据不一致! 为解决数据不一致问题,我们就想要再深入理解线程中的互斥与同步!
临界资源:多线程执行流中被保护的共享资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码区,就叫做临界区
互斥:对临界资源起保护作用!保证任何时候,有且只有一个执行流进入临界区,访问临界资源!
原子性:表示一个操作拥有两态:要么完成,要么没做,不存在做了一半的情况!这就表示这个操作拥有原子性!不会被任何调度打断当前任务进行的性质!
//模拟抢票流程
#include <iostream>
#include <stdlib.h>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int ticket = 1000;
void *route(void *args)
{
string name = static_cast<char *>(args);
while (true)
{
if (ticket > 0)
{
usleep(1000);
cout << name << ":sells ticket:" << ticket << endl;
ticket--;
}
else
break;
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
我们发现,票竟然被抢到了负数!可明明我们的判断条件是 ticket > 0 啊?
这就是多线程下,数据不一致的问题!也叫做线程安全问题!
// 用锁解决,数据不一致问题(pthread锁)
int ticket = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 定义并初始化锁
void *route(void *args)
{
string name = static_cast<char *>(args);
while (true)
{
// 对临界区上锁
pthread_mutex_lock(&lock);
if (ticket > 0)
{
usleep(1000);
cout << name << ":sells ticket:" << ticket << endl;
ticket--;
// 结束访问临界区,解锁
pthread_mutex_unlock(&lock);
}
else
{
// 结束访问临界区,解锁
pthread_mutex_unlock(&lock);
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
通过锁的方式,对临界区进行保护,解决数据不一致的问题!

首先,我们要知道ticket--操作,对于计算机来说并不是原子的!为什么呢?因为ticket--的操作对于计算机来说并不是一个步骤,而是3个步骤。
一条汇编语句当然是原子的,因为只存在做了或者没做的情况。但多条的汇编语言,一般都是不具有原子性的!正如图中的ticket--操作一样!
ticket--操作分为3步:第一步,将ticket的值加载到CPU的运行寄存器ebx中。第二部,运算寄存器ebx进行减一操作,将1000减为999。第三步,将ebx中的内容写回至内存中的ticket。由此完成了ticket--操作。
理解了ticket--操作不是原子的,下面我们再来看看:
我们都知道线程是有时间片的,当时间耗尽时,线程会保存上下文数据,并被剥离出CPU!
那么,当一个线程执行上述代码时。因为ticket--操作不是原子的,所以ticket--操作的3个步骤没执行完就被剥离走是常见的情况!那么当一轮执行完成后,没有执行完的线程又会回来继续执行,但没有执行完的线程的上下文数据会直接覆盖CPU中的寄存器,接着执行。这意味着,在这个线程之前的线程所做的一切都没有意义,会被当前这个线程之间覆盖掉!!!
举例理解:假设线程有一线程a,执行到运算寄存器ebx减1后,时间片耗尽线程的上下文数据被保存后被剥离!新的线程进来,执行线程操作。假设后续代码一切顺利执行,成功的将ticket减到了900。当旧线程重新被CPU调度时,旧线程的上下文数据覆盖CPU中的寄存器数据,继续执行还没有执行的代码,此时旧线程已经将运算寄存器ebx中的内容减1了,下一步将ebx的中内容写回ticket中!此时ticket中的900被覆盖为999!着也就意味着之前的线程说做的事是无意义的了!
综上所述,这个就是数据不一致问题的原因!

在多线程中,存在多个线程同时通过if判断,进入临界区中,访问临界资源的情况!
那如果当ticket此时为1,被多个线程执行减一操作,那ticket不就变为负数了吗!!!
所以在此代码中,存在严重问题!数据不一致问题 + "超售"。
锁的全称:互斥锁(又叫互斥量)
顾名思义,互斥锁。其作用就是互斥!保护临界区资源,保证同一时间有且只有一个线程可以进入临界区,访问临界资源!杜绝数据不一致问题!
全局初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t是数据类型
PTHREAD_MUTEX_INITIALIZER是宏
全局申请互斥锁,代码结束会自动释放局部初始化锁:
pthread_mutex_t mutex;
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数二:为属性指针,默认传nullptr
注意:局部初始化的锁想要我们自己手动的销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);申请锁:
将已经初始化后的锁,传入给pthread_mutex_lock进行申请锁
int pthread_mutex_lock(pthread_mutex_t* mutex)解锁:
线程获得锁后必须解锁,保证后续的线程也可以进入临界区,访问临界资源
int pthread_mutex_unlock(pthread_mutex_t* mutex);申请成功:线程进入临界区,访问临界资源
申请失败:线程挂起,等待下一次的申请
锁提供的作用:将执行临界区的代码由并行转化为串行!保证线程执行期间不会被打扰。

如果线程不遵守锁的规则呢? 这将是一个bug!所有线程都必须遵守!
加锁之后,在临界区线程进行了切换会怎么样? 不怎么样。因为锁是被线程持有的,线程被切换了其申请的锁并没有解锁!这就意味着其他线程必须等待被切换的线程回来继续执行,直到退出临界区解锁。
形象理解: 现在有一个自习室,只能容纳一个人进入其中。 大家都来争抢锁,只有获得锁的人才能够进入自习室!在你获得锁的期间,没有任何人没有进入自习室,必须等待你归还钥匙,解锁后。再次获得锁的人才能够进入自习室。 在你获得锁的期间,即使你出去上厕所或者吃饭,只要你没有归还锁,这个自习室依然是只有你可以进入的。

lock:
movb $0, % al
xchgb% al, mutex
if (al寄存器的内容 > 0)
{
return 0;
}
else
挂起等待;
goto lock;
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;锁是为了保护临界资源的,让线程申请锁。申请到锁的线程才可以进入临界区,访问临界资源,保证了同一时间有且只有一个线程进入临界区。
但是我们会发现,锁只有一个,但是会有多个线程同时申请锁!此时锁不就变成临界资源了吗?要保证同一时间下锁不会被多个线程都申请到,就正如同保证临界资源不会同时被多个线程同时访问一样!
那如何解决呢?请看:实现锁的伪代码!
lock部分:
lock:
movb $0, % al
xchgb% al, mutex
if (al寄存器的内容 > 0)
{
return 0;
}
else
挂起等待;
goto lock;
步骤:
1.将寄存器al的值设置为0
2.交换寄存器al与mutex的值(xchg表示原子性操作)
3.判断寄存器al中的值
4.如果值大于0,则申请锁成功。反之失败,挂起等待
goto lock:表示再次回到lock的第一行注:初始化的锁mutex其值是1!
步骤1:重置为0

步骤2:交换

判断:寄存器al中的值大于1,成功申请锁,线程可以进入临界区,访问临界资源!
那么在锁已经被申请且没有解锁的前提下,其他线程再来申请锁会发生什么?
首先,该线程保存上下文并被剥离CPU,此时mutex已经被交换为0值了。然后新线程加载之CPU,进行重置为0、交换操作。 此时不论时寄存器al,还是mutex都是0值了,交换了之后依然是0值。所以判断当前这个新线程申请锁失败!
申请锁的操作很有意思,数据的流动不是拷贝 而是交换,这就保证了整个锁中只有一个1值,也就是最多只有一个线程能够获得锁!
unlock部分:
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
步骤:
1.将mutex赋值为1
2.唤醒之前因申请失败而挂起等待的线程当前线程已经不再需要访问临界资源了,需要将锁还给内存,让其他等待的线程可以申请锁。
解锁的步骤很简单。直接将mutex赋值为1即可!当新的线程加载之CPU中时,寄存器al被赋值为0,进行交换后al再次为1,新线程申请锁成功!