目录
资源共享问题
(一)临界资源与临界区
(二)多线程并发访问问题
(三)锁
互斥锁原理
加锁原理
解锁原理
互斥锁相关操作接口
互斥锁封装
死锁
死锁产生的四个必要条件
解决死锁方法
(四)线程同步
资源共享问题
(一)临界资源与临界区
- 被多线程看到的同一份资源称为临界资源,涉及对临界资源进行操作的上下文代码区域称为临界区
- 临界资源本质上就是多线程共享资源,而临界区则是涉及共享资源操作的代码区间。
(二)多线程并发访问问题
多线程并发访问问题的核心是多个线程同时访问共享资源,而这些共享资源就是临界资源。涉及对临界资源进行操作的代码区域就是临界区。如果不对临界区进行保护,就会出现竞态条件、数据不一致等问题。
比如存在全局变量
g_val
=100 以及两个线程thread_A
和thread_B
,两个线程同时不断对g_val
做减减。
如果想要对
g_val
进行修改,至少要分为三步:
- 先将
g_val
的值拷贝至寄存器中- 在
CPU
内部通过运算寄存器完成计算- 将寄存器中的值拷贝回内存
在 多线程 场景中,存在 线程调度问题,假设此时
A
在执行完第2步后被强行切走了,换成B
运行:
A 的第3步还没有完成,内存中 g_val 的值还没有被修改,但 A 认为自己已经修改了(完成了第2步),在线程调度时,A 的上下文及相关数据会被保存,A 被切走后,B 会被即刻调度入场,不断执行 g_val -- 操作。
共识:计算机中的硬件,如
CPU
中的寄存器只有一份,被所有线程共享,但其中的内容随线程,不同线程的内容可能不同,也就是我们常说的上下文数据。假设 B 的运气比较好,进行很多次 g_val -- 操作后都没有被切走:
当 B 将 g_val 中的值修改为
10
后,就被操作系统切走了,此时轮到 A 登场,A 带着自己的之前的上下文数据,继续进行它的未尽事业(完成第3步操作),当然 B 的上下文数据也会被保存:
这时候就会出现线程安全问题:A把 g_val的值改成了
99,
(三)锁
临界资源 要想被安全的访问,就得确保 临界资源使用时的安全性。
对于临界资源访问时的安全问题,可以通过加锁来保证,实现多线程间的 互斥访问,互斥锁就是解决多线程并发访问问题的手段之一。
- 我们可以 在进入临界区之前加锁,出临界区之后解锁, 这样可以确保并发访问 临界资源 时的绝对串行化,比如之前的 thread_A 和 thread_B 在并发访问 g_val 时,如果进行了加锁,在 thread_A被切走后,thread_B 无法对 g_val 进行操作,因为此时锁被 thread_A 持有,thread_B 只能 阻塞式等待解锁,直到 thread_A 解锁(意味着 thread_A 的整个操作都完成了)。
- 因此,对于thread_A来说,在 加锁 环境中,只要接手了访问临界资源 g_val 的任务,要么完成、要么不完成,不会出现中间状态,像这种不会出现中间状态、结果可预期的特性称为 原子性 。
- 加锁 的本质就是为了实现 原子性
注意:
- 加锁、解锁是比较耗费系统资源的,会在一定程序上降低程序的运行速度。
- 加锁后的代码是串行化执行的,势必会影响多线程场景中的运行速度。
- 所以为了尽可能的降低影响,加锁粒度要尽可能的细。
互斥锁原理
加锁原理
lock:movb $0, %alxchgb %al, mutexif(al寄存器里的内容 > 0){return 0;} else挂起等待;goto lock;
解析:
①将 0
赋值给 al 寄存器,这里假设 mutex
默认值为 1
(其他不为 0
的整数也行)
movb $0, %al
②将 al 寄存器中的值与 mutex
的值交换(原子操作)
xchgb %al, mutex
③判断当前 al 寄存器中的值是否 >0
if(al寄存器里的内容 > 0){return 0;} else挂起等待;
④此时线程 thread_A
就访问 临界区 代码了,如果此时线程 thread_A
被切走了(并没有出临界区,[锁资源] 也没有释放),OS
会保存 thread_A
的上下文数据,并让线程 thread_B
入场:
⑤首先将 al 寄存器中的值赋为 0
movb $0, %al
⑥其次将 al 寄存器中的值与 mutex
的值交换(原子操作)
mutex
作为内存中的值,被所有线程共享,因此thread_B
看到的mutex
是被thread_A
修改后的值。
- 此时的 thread_B 因为没有 [锁资源] 而被拒绝进入 临界区,不止是 thread_B , 后续再多线程(除了 thread_A) 都无法进入 临界区。
- 而汇编代码中 xchgb %al, mutex 的本质就是 加锁,当 mutex 不为 0 时,表示 钥匙可用,可以进行 加锁;并且因为 xchgb %al, mutex 只有一条汇编指令,足以确保 加锁 过程是 原子性 的
锁的状态由
mutex
决定:
- 如果
mutex == 0
,表示锁未被占用,线程可以进入临界区。- 如果
mutex != 0
,表示锁已被占用,线程需要等待。
解锁原理
unlock:movb $1, mutex唤醒等待 [锁资源] 的线程;return
解析:
①当thread_A走到临界区终点时
,进行解锁,将 mutex
中的值赋为 1,
movb $1, mutex
thread_A
都走到了 解锁 这一步,证明它thread_A解锁,
已经不需要再访问 临界资源 了,可以让其他线程去访问,也就是 唤醒其他等待 [锁资源] 的线程,然后 return 0
走出 临界区
唤醒等待 [锁资源] 的线程;
return 0;
注意:
- 加锁是一个让不让你通过的策略
- 交换指令
swap
或exchange
是原子的,确保 锁 这个临界资源不会出现问题- 未获取到 [锁资源] 的线程会被阻塞至
pthread_mutex_lock()
处线程在访问临界区前,需要先加锁,所有线程都要看到同一把锁,锁本身也是临界资源
- 锁 这种 临界资源 进行了特殊化处理:加锁 和 解锁 操作都是原子的,不存在中间状态,也就不需要保护了。
互斥锁相关操作接口
函数 | 返回值 | 参数 | 备注 |
//先定义锁 pthread_mutex_t mtx; // 定义一把互斥锁 //后初始化锁 | 初始化成功返回 失败返回 | 参数1 参数2 |
静态分配 的优点在于 无需手动初始化和手动销毁,锁的生命周期伴随程序,缺点就是定义的 互斥锁 必须为 全局互斥锁。 使用静态分配时,互斥锁必须定义为全局锁。 互斥锁是一种资源,一种线程依赖的资源,因此 [初始化互斥锁] 操作应该在线程创建之前完成,[销毁互斥锁] 操作应该在线程运行结束后执行;总结就是 使用前先创建,使用后需销毁
|
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; | |||
//销毁锁 int pthread_mutex_destroy(pthread_mutex_t *mutex); | 初始化成功返回 失败返回 | 参数 pthread_mutex_t* 表示想要销毁的互斥锁的地址 | |
//加锁 int pthread_mutex_lock(pthread_mutex_t *mutex); | 初始化成功返回 失败返回 | 参数mutex:表示想要对哪把互斥锁进行加锁 | 加锁时可能遇到的情况: 当前互斥锁没有被别人持有,正常加锁,函数返回 当前互斥锁被别人持有,加锁失败,当前线程被阻塞(执行流被挂起),无法向后运行,直到获得 [锁资源] |
//解锁 int pthread_mutex_unlock(pthread_mutex_t *mutex); | 初始化成功返回 失败返回 | 参数mutex:表示想要对哪把互斥锁进行解锁 | 在 加锁 成功并完成对 临界资源 的访问后,就应该进行 解锁,将 [锁资源] 让出,供其他线程(执行流)进行 加锁。 注意: 如果不进行解锁操作,会导致后续线程无法申请到 [锁资源] 而永久等待,引发 死锁 问题 |
互斥锁封装
封装思路:利用创建对象时调用构造函数,对象生命周期结束时调用析构函数的特点,融入 加锁、解锁操作
#pragma once#include <pthread.h>class LockGuard
{
public:LockGuard(pthread_mutex_t *pmtx):_pmtx(pmtx){// 加锁pthread_mutex_lock(_pmtx);}~LockGuard(){// 解锁pthread_mutex_unlock(_pmtx);}
private:pthread_mutex_t *_pmtx;
};
死锁
指在一组进程中的各个线程均占有不会释放的资源,但因相互申请被其他线程所占用不会释放的资源处于一种永久等待状态。
- 例如,设存在两个线程SetThread和GetThread,SetThread持有了资源ObjectA并请求资源ObjectB,而GetThread持有了资源ObjectB并请求资源ObjectA。如果这两个资源的获取是互斥的,并且两个线程都不释放各自持有的资源,那么它们就会无限期地等待对方释放资源,从而形成死锁。
死锁产生的四个必要条件
- 互斥:至少有一个资源必须处于非共享状态,即一次只能被一个进程或线程占用。这表示如果一个资源被一个进程占用,那么其他进程就不能使用这个资源,直到第一个进程释放它。
- 请求与保持(或称为占有且等待):进程或线程至少需要持有一个资源,并且在等待其他资源时不释放已占有的资源。这表示一个进程或线程在持有至少一个资源的同时,还在请求其他被其他进程或线程持有的资源。
- 不可剥夺(或称为非抢占):已分配给进程或线程的资源不能被强制性地剥夺,只能由持有资源的进程或线程主动释放。这意味着资源不能被其他进程或线程强行拿走,除非资源的持有者自愿释放它。
- 循环等待:存在一个进程或线程的资源申请序列,使得每个进程或线程都在等待下一个进程或线程所持有的资源。这构成了一个闭环,每个进程或线程都在等待其他某个进程或线程释放资源,从而导致了死锁。
解决死锁方法
方法1:不加锁
不加锁的本质是不保证 互斥,即破坏条件1。
方法2:尝试主动释放锁
比如进入 临界区 访问 临界资源,需要两把锁,thread_A 和 thread_B 各自持有一把锁,并且都在尝试申请第二把锁,但如果此时 thread_A 放弃申请,主动把锁释放,这样就能打破死锁的局面
可以借助 pthread_mutex_trylock 函数实现这种方案
int pthread_mutex_trylock(pthread_mutex_t *mutex);
这个函数就是尝试申请锁,如果长时间申请不到锁,就会把自己当前持有的锁释放,然后放弃加锁,给其他想要加锁的线程一个机会。
方法3:按照顺序申请锁
按照顺序申请锁 -> 按照顺序释放锁 -> 就不会出现环路等待的情况
当需要访问多个资源时,尽量保持一致的加锁顺序。这样可以避免循环等待的情况,因为每个线程或进程都会以相同的顺序请求锁。
方法4:控制线程统一释放锁
锁不一定要由申请锁的线程释放,其他线程也可以释放锁。通常情况下,锁是由申请锁的线程释放的,但特殊机制或框架可能允许其他线程释放锁。
这是由释放锁的机制决定的,直接向 mutex 赋值而非交换,意味着其他线程也能解锁
unlock:movb $1, mutex唤醒等待 [锁资源] 的线程;return
代码演示:
#include<pthread.h>
#include<unistd.h>
#include<iostream>
using namespace std;// 全局互斥锁,无需手动初始化和销毁
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void *threadRoutine(void *args)
{cout << "我是次线程,开始运行" << endl;// 申请锁pthread_mutex_lock(&mtx);cout << "次线程申请到了一把锁" << endl;// 在不释放锁定情况相下,再次申请锁,陷入 死锁 状态pthread_mutex_lock(&mtx);cout << "次线程又申请到了一把锁" << endl;pthread_mutex_unlock(&mtx);return nullptr;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRoutine, nullptr);// 等待次线程先运行3秒sleep(3);cout << "等待3秒..." << endl;// 主线程帮忙释放锁pthread_mutex_unlock(&mtx);cout << "我是主线程,我已帮次线程释放了一把锁" << endl;// 等待次线程后续动作sleep(3);pthread_join(t, nullptr);cout << "线程等待成功" << endl;return 0;
}
演示结果:
因此,我们可以设计一个 控制线程,专门掌管所有的锁资源,如果识别到发生了 死锁 问题,就释放所有的锁,让线程重新竞争。
注意:通常情况下,每个线程或进程只能释放自己持有的锁。如果一个线程持有一个锁,其他线程是无法直接释放这个锁的。这是因为锁通常与特定的线程或进程相关联,以确保资源的独占性和安全性。
(四)线程同步
线程同步是解决饥饿问题的一种方法。饥饿是指在多线程环境中,由于某些线程无法获取必要的资源(如锁、信号量等),导致它们长时间得不到执行的现象。这种情况通常是由于资源分配策略不当或同步机制设计不合理引起的。
原生线程库 中提供了 条件变量 这种方式来实现 线程同步
逻辑链:通过条件变量 -> 实现线程同步 -> 解决饥饿问题
条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中这个线程才被唤醒,这种情况就需要用到条件变量。
其本质就是:衡量访问资源的状态。一旦共享资源的状态发生变化,使得等待的线程或进程可以安全地访问它时,条件变量就会被触发,唤醒等待的线程或进程。这样,线程或进程就可以继续执行,并安全地访问共享资源。
竞态条件:两个或更多进程或线程在并发执行时,其最终的结果依赖于这些进程或线程执行的精确时序。当程序的运行结果因执行顺序的改变而受到影响时,就发生了竞态条件。竞态条件可能会导致超出预期的情况,因此在编程中通常需要避免这种情况。
可以把 条件变量 看作一个结构体,其中包含一个 队列 结构,用来存储正在排队等候的线程信息,当条件满足时,就会取 队头 线程进行操作,操作完成后重新进入 队尾
线程同步相关接口
函数 | 返回值 | 参数 | 备注 |
//定义1 pthread_cond_t cond; // 定义一个条件变量 //初始化 int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr); //定义2 pthread_cond_t cond = PTHREAD_COND_INITIALIZER; | 成功返回 失败返回 | 参数1 参数2 | 同互斥锁一样,条件变量支持静态分配,即在创建全局条件变量时,定义为 这种定义方式只支持全局条件变量。 |
int pthread_cond_destroy(pthread_cond_t *cond); | 成功返回 失败返回 | 参数pthread_cond_t* 表示想销毁的条件变量 | |
//条件等待 int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex); | 成功返回 失败返回 | 参数1 参数2 | 条件变量是需要配合互斥锁使用的,需要在获取 [锁资源] 之后,在通过条件变量判断条件是否满足。 传递互斥锁的理由: 条件变量也是临界资源,需要保护。
|
//条件唤醒 int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond); | 成功返回 失败返回 | 参数pthread_cond_t* 表示想要从哪个条件变量中唤醒线程 | 使用 pthread_cond_signal 一次只会唤醒一个线程,即队头线程。如果想唤醒全部线程,可以使用 pthread_cond_broadcast 。broadcast 就是广播的意思,也就是挨个通知该 条件变量 中的所有线程访问 临界资源 |
代码演示:
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;// 互斥锁和条件变量都定义为自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;int num = 5; // 五个线程void *Active(void *args)
{const char *name = static_cast<const char*>(args);while(true){// 加锁pthread_mutex_lock(&mtx);// 等待条件满足pthread_cond_wait(&cond, &mtx);cout << "thread " << name << " 正在运行" << endl;// 解锁pthread_mutex_unlock(&mtx);}delete[] name;return nullptr;
}int main()
{pthread_t pt[num];for(int i = 0; i < num; i++){char *name = new char[32];snprintf(name, 32, "thread-%d", i);pthread_create(pt+i, nullptr, Active, (void*)name);}// 等待所以次线程就位sleep(3);// 主线程唤醒次线程while(true){cout << "Main thread wake up another thread..." << endl;pthread_cond_signal(&cond); // 单个// pthread_cond_broadcast(&cond); // 广播sleep(1);}for(int i = 0; i < num; i++)pthread_join(pt[i], nullptr);return 0;
}
单个唤醒演示结果:
广播唤醒演示结果: