序言
经过前面的学习,我们知道多个线程共享同一个进程地址空间的资源,所以免不了存在多个线程同时访问同一个资源的情况,这对我们的程序会产生什么影响呢?该怎么避免呢?
1. 多线程竞争
1.1 引出竞争问题
为了更好地理解问题地来源,我们采用一段程序来引出今天的主题:
cpp
#include <iostream>
#include <pthread.h>
#include <vector>
#include <unistd.h>
const int numThreads = 10; // 线程数量
const int numIncrements = 100000; // 每个线程的增量次数
int counter = 0; // 全局计数器
void *incrementCounter(void *)
{
while(counter <= numIncrements)
{
counter++;
}
return nullptr;
}
int main()
{
std::vector<pthread_t> threads(numThreads);
// 创建线程
for (int i = 0; i < numThreads; ++i)
{
pthread_create(&threads[i], nullptr, incrementCounter, nullptr);
}
// 等待线程完成
for (int i = 0; i < numThreads; ++i)
{
pthread_join(threads[i], nullptr);
}
std::cout << "Final counter value: " << counter << std::endl;
return 0;
}
整个程序的逻辑还是比较简单的,主要就是我们采取多线程的方式来对一个全局变量进行自增操作,当他增加到指定的值时,执行完毕,回收线程,整个程序退出。
现在我们运行程序,看看结果:
运行了很多次,运行结果不是固定的,时而是 100001
, 时而是 100002
!为什么会出现这种情况呢?本应该到达 100000
时,程序就应该结束了呀。
1.2 从底层思考原因
在进入底层之前,大家先要了解一个概念叫做 原子操作
:
知识点 --- 原子操作
概念 :原子操作指的是一种不可分割的操作,即这种操作一旦开始,就会一直运行到结束
,中间不会被线程调度机制打断,也不会有任何的上下文切换到其他线程。在这里阐述的概念不易理解,大家可以简单的理解为 原子性操作的汇编指令只有一条
。
特性:
不可分割性
:原子操作在执行过程中不会被其他任务或事件中断。完整性
:操作要么全部完成,要么完全不执行,不会留下部分执行的结果。线程安全
:在多线程环境中,原子操作能确保同一时间只有一个线程能执行该操作,从而避免数据竞争和不一致性。
示例 :我们上述程序其中一个指令为 counter++;
,这就是一条非原子性的操作,看着只有一句,但是在汇编层面它包含 读取数据,数据加一,写回数据
三个操作。
好的现在言归正传,回到我们的正文话题来,为什么输出结果会超出预期呢?
- 某个时刻
counter = 9999
,A
线程经过判断后满足条件,执行counter++
- 此时
B
线程跳出来了,因为++
操作是非原子的,所以此时B
线程在内存中读取的counter = 9999
- 同样符合判断条件执行
counter++
所以总结一句话 是因为 counter 变量的递增操作没有在多线程环境中被正确地同步
。
1.3 竞争的危害以及解决方案
通过实例,大家可以很明显的感觉到竞争会引起 数据不一致问题
。解决线程竞争包含很多方法,这篇文章中,我们将介绍线程互斥的互斥锁方案。
2. 线程互斥 --- 互斥锁
在进入主题之前,请先记住三个概念:
临界资源
:多线程执行流共享的资源
就叫做临界资源临界区
:每个线程内部,访问临界资源的代码
,就叫做临界区互斥
:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
2.1 互斥锁的概念
互斥锁是一种 保护共享资源不被多个线程同时访问的机制
。它通过加锁和解锁操作来控制对共享资源的访问权限。确保在同一时间内,只有一个线程能够访问特定的资源或执行特定的代码段
,从而保护共享数据的一致性和完整性。
2.2 互斥锁的使用
锁包含全局的,局部的,在这里我们使用全局的锁,他的初始化更为方便,并且不需要手动释放资源,局部的锁需要我们手动释放资源。
锁的使用步骤:
- 初始化锁:
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
在这里我们初始化了一把全局的锁 - 对
临界区
上锁,pthread_mutex_lock(&mtx);
,保证同一时间只能有一个线程访问临界资源, - 访问完毕,对
临界区
解锁,pthread_mutex_unlock(&mtx);
,保证下一个线程可以访问资源
改动的部分很少,现在就只展示有改动的地方:
cpp
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; // 互斥锁初始化
void *incrementCounter(void *)
{
while (true)
{
pthread_mutex_lock(&mtx); // 锁定互斥锁
if(counter <= numIncrements)
{
counter++;
pthread_mutex_unlock(&mtx); // 解锁互斥锁
}
else
{
break;
pthread_mutex_unlock(&mtx); // 解锁互斥锁
}
}
return nullptr;
}
再次运行我们的程序:
不管运行多少次,我们程序的输出值都符合我们的预期!
在这里使用互斥锁一定要注意一点,尽可能将互斥锁的持有时间缩小到必要的最小范围内。例如,只在需要保护共享资源的代码段中持有锁,其他代码不应在持锁的情况下执行,这样才能有更好的并发性能!
就比如,如果我在这里稍微扩大一点锁的范围:
cpp
void *incrementCounter(void *)
{
pthread_mutex_lock(&mtx); // 锁定互斥锁
while (true)
{
if(counter <= numIncrements)
{
counter++;
pthread_mutex_unlock(&mtx); // 解锁互斥锁
}
else
{
break;
pthread_mutex_unlock(&mtx); // 解锁互斥锁
}
}
return nullptr;
}
那这里完全就是一个线程揽下了所有的活,其他线程就在外面一直阻塞,丢失了并发性!
2.3 互斥锁的底层实现
我们首先查看该锁结构体的定义:
我们发现,他的成员变量的一个结构体中包含一个变量叫做 __lock
该变量是关键!
在我们申请锁使用锁的时候,所有线程都是使用一把锁(同一个结构体变量)!有了这些知识铺垫,现在我们可以开始正式介绍怎么上锁了,解锁了。
我们需要理解一下这段代码逻辑:
上锁的过程
- 首先将寄存器
%al
的值置为 0 - 将锁结构体中的
__lock
和%al
的值做交换(该操作为原子操作,不会被中断
) - 如果交换后
%al
的值为 1 ,则上锁成功,退出该函数,执行临界区的代码 - 反之,则被阻塞,等待唤醒
所以同一时间,只有一个线程可以访问临界区的代码!
解锁的过程
- 首先将寄存器
__lock
的值置为 1 - 唤醒所有被阻塞的进程
- 返回退出
模拟互斥过程
如果大家看完还有一点懵的话,我们可以使用两个线程 A
,B
模拟一下。现在两个线程都想要访问临界区的代码,A
比 B
快一丢丢(具体谁更快一点是不确定的),接触到 lock
函数,首先将寄存器 %al
的值置为 0,再将 __lock
和 %al
的值做了交换,现在 %al = 1, __lock = 0
,A
美滋滋的打开了去临界区的门,并把门关上了。B
也把 __lock
和 %al
的值做了交换,但是此时 %al = 0, __lock = 0
,好了 B
就被一直阻塞(门口排队),等待 A
开门(执行完毕退出,并且放回了钥匙 1
)。
3. 总结
在这篇文章中,我们介绍了多线程竞争以及解决的其中一个方案 --- 互斥锁,还讲解了互斥锁的实现原理,希望大家有所收获!