目录
1.资源共享问题
1.1多线程并发访问
比如存在全局变量 g_val
以及两个线程 thread_A
和 thread_B
,两个线程同时不断对 g_val
做 减减 --
操作
注意:用户的代码无法直接对内存中的
g_val
做修改,需要借助CPU
如果想要对 g_val
进行修改,至少要分为三步:
- 先将
g_val
的值拷贝至寄存器中 - 在
CPU
内部通过运算寄存器完成计算 - 将寄存器中的值拷贝回内存
假设 g_val
初始值为 100
,如果 thread_A
想要进行 g_val--
,就必须这样做
也就是说,简单的一句 g_val--
语句实际上至少会被分成 三步
单线程场景下步骤分得再细也没事,因为没有其他线程干扰它,但我们现在是在 多线程 场景中,存在 线程调度问题,假设此时 thread_A
在执行完第2步后被强行切走了,换成 thread_B
运行
thread_A 的第3步还没有完成,内存中 g_val 的值还没有被修改,但 thread_A 认为自己已经修改了(完成了第2步),在线程调度时,thread_A 的上下文及相关数据会被保存,thread_A 被切走后,thread_B 会被即刻调度入场,不断执行 g_val-- 操作
thread_B 的运气比较好,进行很多次 g_val-- 操作后都没有被切走
当 thread_B
将 g_val
中的值修改为 10
后,就被操作系统切走了,此时轮到 thread_A
登场,thread_A
带着自己的之前的上下文数据,继续进行它的未尽事业(完成第3步操作),当然 thread_B
的上下文数据也会被保存
此时尴尬的事情发生了:thread_A
把 g_val
的值改成了 99
,这对于 thread_B
来说很不公平,倘若下次再从内存中读取 g_val
时,结果为 99
,自己又得重新进行计算,但站在两个线程的角度来说,两者都没有错
thread_A:
将自己的上下文恢复后继续执行操作,合情合理thread_B:
按照要求不断对g_val
进行操作,也是合情合理
错就错在 thread_A 在错误的时机被切走了,保存了老旧的 g_val 值(对于 thread_B 来说),直接影响就是 g_val 的值飘忽不定
倘若再出现一个线程 thread_C 不断打印 g_val 的值,那么将会看到 g_val 值减为 10 后又突然变为 99 的 “灵异现象”
产出结论:多线程场景中对全局变量并发访问不是 100% 可靠的
1.2临界区和临界资源
在多线程场景中,对于诸如 g_val
这种可以被多线程看到的同一份资源称为 临界资源,涉及对 临界资源 进行操作的上下文代码区域称为 临界区
临界资源 本质上就是 多线程共享资源,而 临界区 则是 涉及共享资源操作的代码区间
1.3互斥锁
临界资源 要想被安全的访问,就得确保 临界资源使用时的安全性
对于 临界资源 访问时的安全问题,也可以通过 加锁 来保证,实现多线程间的 互斥访问,互斥锁 就是解决多线程并发访问问题的手段之一
我们可以 在进入临界区之前加锁,出临界区之后解锁, 这样可以确保并发访问 临界资源 时的绝对串行化,比如之前的 thread_A 和 thread_B 在并发访问 g_val 时,如果进行了 加锁,在 thread_A 被切走后,thread_B 无法对 g_val 进行操作,因为此时 锁 被 thread_A 持有,thread_B 只能 阻塞式等待锁,直到 thread_A 解锁(意味着 thread_A 的整个操作都完成了)
因此,对于
thread_A
来说,在 加锁 环境中,只要接手了访问临界资源 g_val
的任务,要么完成、要么不完成,不会出现中间状态,像这种不会出现中间状态、结果可预期的特性称为 原子性
说白了 加锁 的本质就是为了实现 原子性
注意:
- 加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度
- 加锁后的代码是串行化执行的,势必会影响多线程场景中的运行速度
- 所以为了尽可能的降低影响,加锁粒度要尽可能的细
2.多线程抢票
实践出真知,接下来通过代码演示多线程并发访问问题
2.1并发抢票
思路很简单:存在 1000
张票和 5
个线程,5
个线程同时抢票,直到票数为 0
,程序结束后,可以看看每个线程分别抢到了几张票,以及最终的票数是否为 0
共识:购票需要时间,抢票成功后也需要时间,这里通过 usleep
函数模拟耗费时间
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 有 1000 张票
void* threadRoutine(void* args)
{
int sum = 0;
const char* name = static_cast<const char*>(args);
while(true)
{
// 如果票数 > 0 才能抢
if(tickets > 0)
{
usleep(2000); // 耗时 2ms
sum++;
--tickets;
}
else
break; // 没有票了
usleep(2000); //抢到票后也需要时间处理
}
cout << "线程 " << name << " 抢票完毕,最终抢到的票数 " << sum << endl;
delete name;
return nullptr;
}
int main()
{
pthread_t pt[5];
for(int i = 0; i < 5; i++)
{
char* name = new char(16);
snprintf(name, 16, "thread-%d", i);
pthread_create(pt + i, nullptr, threadRoutine, name);
}
for(int i = 0; i < 5; i++)
pthread_join(pt[i], nullptr);
cout << "所有线程均已退出,剩余票数: " << tickets << endl;
return 0;
}
最终剩余票数
-1
,难道 12306
还欠了 1张票?这显然是不可能的,5
个线程抢到的票数之和为 1001
,这就更奇怪了,总共 1000
张票还多出来 1 张?
显然多线程并发访问是绝对存在问题的
2.2 引发问题
这其实就是 thread_A 和 thread_B 并发访问 g_val 时遇到的问题,举个例子:假设 tickets = 500,thread-0 在抢票,准备完成第3步,将数据拷贝回内存时被切走了,thread-1 抢票后,tickets = 499;轮到 thread-0 回来时,它也是把 tickets 修改成了 499,这就意味着 thread-0 和 thread-1 之间有一个人白嫖了一张票(按理来说 tickets = 498 才对)
对于 票 这种 临界资源,可以通过 加锁 进行保护,即实现 线程间的互斥访问,确保多线程购票时的 原子性
3
条汇编指令要么不执行,要么全部一起执行完
--tickets
本质上是3
条汇编指令,在任意一条执行过程中切走线程都会引发并发访问问题
3.线程互斥
互斥 -> 互斥排斥:事件 A
与事件 B
不会同时发生
比如 多线程并发抢票场景中可以通过添加 互斥锁 的方式,来确保同一张票不会被多个线程同时抢到
3.1互斥锁相关操作
3.1.1互斥锁创建与销毁
互斥锁 同样出自 原生线程库,类型为 pthread_mutex_t
,互斥锁 在创建后需要进行 初始化
#include <pthread.h>
pthread_mutex_t mtx; // 定义一把互斥锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
其中:
参数1 pthread_mutex_t* 表示想要初始化的锁,这里传的是地址,因为需要在初始化函数中对 互斥锁 进行初始化
参数2 const pthread_mutexattr_t* 表示初始化时 互斥锁 的相关属性设置,传递 nullptr 使用默认属性
返回值:初始化成功返回 0,失败返回 error number
互斥锁 是一种向系统申请的资源,在 使用完毕后需要销毁
#include <pthread.h>
int pthread_mutex_destroy(p