文章目录
引入问题
通过对线程的相关概念的学习,我们知道一个进程内部有多个线程,而所有的线程都共享进程地址空间,因此,进程的大部分资源都会被线程共享。那么对于共享的资源,如果多个线程同时访问它会产生什么后果呢🤔??
我们在学习线程控制的相关操作时,我们时常会见到这样一个现象:多个线程向显示器打印数据时,会出现严重的信息干扰。
在Linux眼中,显示器本质上也就是一个文件,也是一个共享资源,多线程访问共享资源,必然会引发数据不一致问题 。
如何解决呢🤔??这就需要我们学习本文所讲的同步与互斥相关内容了。
一、线程互斥
1.1、相关概念
🔥临界资源🔥
多线程执行流被保护 的共享资源就叫做临界资源。
🔥临界区🔥:
每个线程内部,访问临界资源的代码,就叫做临界区。
🔥互斥🔥:
任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
🔥原子性🔥:
不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
1.2、简单介绍互斥量
大部分情况,线程使用的数据都是局部变量,变量处于对应线程的栈空间内,这种情况,变量归属单个线程,其他线程是无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量 ,可以通过数据的共享,完成线程之间的交互。
当多个线程并发的操作共享变量时,会引发一些问题。我们来看一下代码💻:
cpp
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 100;
void *route(void *arg)
{
char *id = (char *)arg;
while (true)
{
if (tickets > 0)
{
usleep(1000); // 模拟抢票花费时间
printf("%s sells ticket:%d\n", id, ticket);
tickets--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, route, (void *)"thread 1");
pthread_create(&t2, nullptr, route, (void *)"thread 2");
pthread_create(&t3, nullptr, route, (void *)"thread 3");
pthread_create(&t4, nullptr, route, (void *)"thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
结果如下:

这个结果显然不符合我们的期望,并且还有点违反常识🤯。
问题1️⃣:为什么会数据不一致呢🤔??
我们在计算机组成原理这门课中学过:CPU可以处理两种运算,一个是算术运算 ,另一个是逻辑运算 。而我们代码中对tickes大小的判断就属于逻辑运算,在运算之前,CPU会将内存中对应的tickets值导入到寄存器中,而这一过程正是导致问题的元凶之一。

除了代码中的if语句可能会造成数据不一致问题,代码中的--操作同样也不可忽视,--操作本身也不是原子的 。在C/C++中,tickets--是一条语句,但在CPU眼中,实际为三条汇编语句:1️⃣将内存中的tickets移入寄存器exa中;2️⃣对exa减1;3️⃣再将exa中的tickets传入内存。因此,很有可能对exa减之前或将tickets数值更新前就进行线程切换,而导致数据不一致问题(数据重复)。对全局变量++或--,非常容易引发线程安全问题。
问题2️⃣:我们如何解决这一问题呢🤔??
tickets属于共享资源,为了避免共享资源一次性被多次访问,我们应该将共享资源保护起来,而被保护的共享资源,我们也称之为临界资源。因此一句话概括就是:将共享资源转化为临界资源。
保护共享资源不被多线程同时访问,需要满足以下三点:
1️⃣代码必须要有互斥行为 :当代码进入临界区执行时,不允许其他线程进入该临界区。
2️⃣如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
3️⃣如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
而要满足以上三点,我们仅需要使用Linux提供的互斥量mutex(也称互斥锁)即可。

在我们的代码中,if判断语句中多次访问了临界资源,因此,根据临界区的定义可知,临界区的范围如下:

因此,保护临界资源的本质就是保护临界区 ,保护临界区的手段就是利用互斥 ,也就是利用锁。
1.3、互斥量的相关接口
上文,我们简单引出了互斥量的相关话题,接下来,我们将简单介绍互斥量的相关接口。
相关接口都包含在<pthread.h>中
1️⃣初始化互斥量
初始化互斥量有两种方法:一种是静态分配,也就是利用宏来初始化;
c
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
另一种则是动态分配。
c
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
pthread_mutexattr_t *restrict attr);
其中mutex参数就是我们要初始化的互斥量 ;attr是对应互斥量的状态 ,一般我们不必理会,将它设为NULL即可。
2️⃣销毁互斥量
c
int pthread_mutex_destroy(pthread_mutex_t *mutex);
其中mutex参数就是我们要销毁的互斥量 。
在销毁互斥量的时候,我们要注意以下三点:
- 使用静态分配初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
3️⃣加锁与解锁
c
int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); // 解锁
当加锁或解锁成功返回0,失败返回对应的错误号。
简单介绍相关接口后,接下来我们对先前的抢票系统进行优化。

可以发现,通过加锁等操作,的确避免了数据不一致问题。但是从运行结果来看,为什么抢到票的都是同一个线程呢??要回答这个问题,就必须等到下一节讲同步话题地时候了。
此外,关于互斥量相关的接口还有五个相关的细节问题:
问题1️⃣:加锁的原则问题
- 由于互斥量的特性,同一时间段,有且只能有一个线程进入临界区,因此线程进入临界区后,就会由并行转为串行 。这样必然会导致效率降低,而这样的操作又是无法避免的,因此,在实践中,加锁的粒度必须足够的细。

问题2️⃣:mutex也是共享资源,那么谁来保护它呢🤔??
- 在我们先前的测试代码当中,我们使用的时静态分配,即定义了一个全局变量,但是全局变量不就是一个典型的共享资源吗??所有的线程都可以去使用,既然它来保护别人,那么谁来保护它呢??
- 实际上,当年互斥量的设计师们也考虑过这个问题,为了避免mutex还需要被保护,于是就将 加锁和解锁操作设计为原子性的,也就是说加锁或者解锁这两个操作是一步到位的,并不会因为CPU的调度,而造成我们先前所讲的线程安全问题。
问题3️⃣:一些线程遵守先加锁再解锁,而有一些线程不遵守呢🤔??
- 这种情况基本上是不会发生的,除非有人故意写bug。
- 访问临界资源,所有的线程都必须遵守加锁和解锁的规则,绝不能有例外,这是一个共识。
问题4️⃣:申请锁失败的线程在做些什么呢🤔??
- 一般多线程同时申请互斥量,但是只会有一个申请成功,没有竞争 到互斥量的线程会在
pthread_ lock陷入阻塞(执行流被挂起),等待互斥量解锁。
问题5️⃣:临界区内部也会有多行代码,那么会发生线程切换嘛🤔??
- 当然,即使是执行到加锁解锁操作内部,也会发生线程切换。因为,临界区本质是人为规定的一个概念,而在CPU眼中,本质上都是代码。因此,不管是不是在临界区,都会发生线程切换。
1.4、互斥量的原理
首先,我们得了解一个知识点:如果一个语句只有一行汇编代码就可以表示,那么执行该语句就是原子的。或者简单来说,一条汇编语句操作是原子的💧。
互斥锁的加锁解锁操作的汇编伪码如下图:

其中,lock的第一行就是将某一个寄存器存入0,而第二行则是整个加锁逻辑中最关键的一行!!,它将寄存器中的值与内存中的mutex值进行了交换!!
我们知道CPU在调度线程的时候,是以线程为载体执行的加锁逻辑。当寄存器的值与内存中mutex的值交换过后,原本mutex的值就归属于当前线程 了,即使交换后立刻就发生了线程调度,该值也会变成硬件上下文一直跟着这个线程。而交换后的mutex在内存中存储的值则会一直为零,后来的线程执行到交换语句后也只会0与0交换,完全没有任何影响。

实际上,锁就是我们上文中所谈到的1!!exchange始终没有拷贝,也就是说始终只有一个1 ,谁拥有这个1,谁就拥有这个锁!!
再回看先前的问题5️⃣:临界区内部也会有多行代码,那么会发生线程切换嘛🤔??
现在来看,当然不会,锁只有一份,只要线程1不还回来,锁就属于线程1私有,线程切换不影响。
综上,互斥锁的本质就是由1至0的过程,互斥的本质就是独占!!🌟🌟
除此以外,锁的实现方式是多种多样的,除了上述利用软件的方式实现互斥锁,我们还可以利用硬件方式。
互斥锁的存在,本质就是让临界区中只存在一个线程,那么如果当某一个线程执行到临界区的代码时,立刻就将时钟中断关闭,此时操作系统就无法再进行调度了,线程则无法切换,这样就无人能够打扰该线程执行临界区的代码了。
这个操作在逻辑上一定是行得通的,但是一定不建议这么做,时钟中断可以说是操作系统的灵魂,这种"触及灵魂"的事是具有极大风险的。
之所以说这种实现方式,只是想说明一个结论:锁的实现是多种多样的!!💧
1.5、互斥量的封装
C++中也有互斥量相关的接口,只不过是利用面向对象的方式将他们封装起来了。
现在,我们也利用面向对象的方式,将互斥量接口封装。
cpp
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void unLock()
{
pthread_mutex_unlock(&lock);
}
~Mutex()
{
pthread_mutex_destroy(&lock);
}
private:
pthread_mutex_t lock;
};
此外,我们亦可以按照RAII风格进行进一步封装。
补充:
RAII是C++中一种非常重要的编程惯用法,核心思想是:资源的获取与对象的初始化绑定,资源的释放与对象的析构绑定,从而利用 C++ 对象生命周期(构造、析构)的自动管理机制来安全、简洁地管理资源(如动态内存、文件句柄、锁、套接字等)。
cpp
#include <pthread.h>
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void unLock()
{
pthread_mutex_unlock(&lock);
}
~Mutex()
{
pthread_mutex_destroy(&lock);
}
private:
pthread_mutex_t lock;
};
class LockGuard // RAII风格
{
public:
LockGuard(Mutex& lock) //引用了传进来的参数
:_lock(lock)
{
_lock.Lock();
}
~LockGuard()
{
_lock.unLock();
}
private:
Mutex& _lock; //引用的引用了
};
我们依旧用抢票程序进行测试:

完🌄🌄🌄