Linux线程【互斥与同步】
目录
1.资源共享问题
1.1多线程并发访问
1.2临界区和临界资源
1.3互斥锁
2.多线程抢票
2.1并发抢票
2.2 引发问题
3.线程互斥
3.1互斥锁相关操作
3.1.1互斥锁创建与销毁
3.1.2、加锁操作
3.1.3 解锁操作
3.2.解决抢票问题
3.2.1互斥锁细节
3.3互斥锁原理
3.4多线程封装
3.5互斥锁的封装
3.5.1RAII风格
4.线程安全VS重入
5、常见锁概念
5.1、死锁问题
6.线程同步
6.1同步概念
6.2.同步相关操作
6.2.1条件变量创建与销毁
6.2.2条件等待
6.2.3唤醒线程
6.3同步demo
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 调度冲突的问题。例如,在此情况下,默认情况下 thread_A 完成第2步后被强行替换为 thread_B 运行以解决冲突。

在 thread_A 的第三步尚未完成的情况下,在内存单元格 g_val 未被修改之前(尽管 thread_A 错误地认为自己已完成第二阶段的操作),在操作系统进行线程调度的过程中,在其所在的线程执行环境及其相关数据将被保留下来的状态下,在一旦 thread_A 被剥夺资源使用权后,在另一个线程 thread_B 将会立即被调入执行相应的操作以继续执行 g_val-- 操作的过程中
thread_B 的运气比较好,进行很多次 g_val-- 操作后都没有被切走

当 thread_B 将变量 g_val 更改为数值 10 时(随后),该事件被操作系统打断了(接着由线程A重新开始处理)。在此期间(或在此时),线程A将利用自己的先前状态(即其之前的未完成任务)继续执行其后续操作(即完成第三步操作)。值得注意的是,在此过程中(或期间),变量 g\_val 的原始上下文信息也会得以保存。

如今陷入了令人尴尬的局面:代码中的修改行为导致了一个不合理的现象——当后续操作者再次从内存中获取该变量时会得到不一致的结果。换言之,在这种情况下双方都未能尽到其应尽的责任
thread_A:修复后会自动启动后续流程,请确保一切正常thread_B:根据指示对变量g_val进行赋值操作,请严格按照流程执行
由于 thread_A 在不当的时间点被移除后存储了过时的 g_val 值(从 thread_B 的角度来看),其主要后果是 g_val 值变得不稳定。
如果再引入一个线程 thread_C 进行持续不断地打印操作,则会观察到 g_val的值从10降至后突然跳转到99的异常现象。
产出结论:多线程场景中对全局变量并发访问不是 100% 可靠的
1.2临界区和临界资源
在多线程环境中,在处理多个独立进程时会遇到一类特殊的共享内存区域——即所谓的同步区域。每一个能够被所有进程访问的一段共享内存空间被称为 互斥区域 ,而与之相关的代码块则统称为 互斥块。

在多线程编程中,**核心概念:临界资源(Critical Resource)与关键机制:临界区(Critical Section)**是处理多线程共享资源的核心
1.3互斥锁
临界资源 要想被安全的访问,就得确保 临界资源使用时的安全性
对于安全机制来处理临界资源访问的问题,并通过加锁操作来保证多线程间的互斥访问。其中将采用的互斥锁机制就是解决多线程并发访问问题的重要手段之一。
在进入临界区之前进行锁操作,在退出临界区后释放锁。这样做的目的是为了确保在并发访问 临界资源 时的操作严格按顺序执行。例如,在这种机制下,当 thread_A 和 thread_B 同时试图对 g_val 进行操作时(即并发访问),如果对资源进行了 锁操作,则当 thread_A 被剥夺对该资源的控制后(即被切走),thread_B 就无法对 g_val 进行任何操作。这是因为此时 lock 已经被 thread_A 持有,在没有获得 exclusive lock 的情况下其他线程必须等待(即阻塞)直到 thread_A 完成其 lock 的释放过程(这意味着该 exclusive lock 操作已经完成)。

因此,在加锁环境中处理g_val时,“只要接收了对关键资源g_val的访问请求,则必定成功或彻底失败;而这种无半途而废的状态被称为原子性。”
说白了 加锁 的本质就是为了实现 原子性
注意:
- 加锁和解锁意味着系统资源会被消耗掉一定的数量。
- 加锁后的代码会按顺序执行,在多线程环境下可能会导致性能问题。
- 因此,在最大限度地减少性能损失的前提下...
2.多线程抢票
实践出真知,接下来通过代码演示多线程并发访问问题
2.1并发抢票
问题描述较为基础:系统中设置有1000张虚拟票和五个独立线程,在运行过程中这些线程协同运行以争夺资源。这些线程会轮流尝试获取资源并分配票务直至所有资源都被成功获取并耗尽。完成整个程序运行后能够统计每个线程各自获得的具体数量以及确认最终的资源分配结果是否达到了预期目标
共识:买票也需要时间;抢到票后同样需要时间;这里我们使用了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'这样的结果呢?按照系统设计逻辑' 5 个线程累计得到的结果却为 ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' '' ',实际总共有 ' ' 张票却意外地多了 ' $' 张?
显然多线程并发访问是绝对存在问题的
2.2 引发问题
这个模型在处理并发操作时容易出现严重的竞争条件问题,在具体实现中可以通过以下方式观察其行为:例如,在一个简单的抢票系统中(如 tickets = 500),主线程 thread-0 正在努力抢购第500张票,在完成第3步操作时意外地将数据从外设拷贝回内存的过程中被其他线程截断了;随后主线程 thread-1 又成功抢购到了第500张票并完成了后续操作;当轮到主线程 thread-0 进行回环检查时发现 tickets 已经被修改为499张;然而根据理论计算结果(即正常情况下 tickets 应该减少2张),系统实际总数却只减少了1张(即从500减少到499),这就意味着主线程 thread-0 和 thread-1 之间存在一个资源竞争的情况(实际上总数只应为498张)
为了保护 票 这种 临界资源 的安全运行,可以通过引入一种 加锁 机制来进行保护;从而实现 不同线程之间实现并发安全 ,确保多线程购票操作的 原始数据一致性。
3条汇编指令要么不执行,要么全部一起执行完
--ticks 由三条汇编指令构成,在任何一条汇编指令执行时若被截断线程会导致系统出现并发访问问题。

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 用于表示想要初始化的锁, 这里传递的是地址, 因为在初始化函数中需要对 互斥锁 进行相应的操作和管理
第二个参数const pthread_mutexattr_t*用于指定初始化阶段与互斥锁相关的属性进行设置;当传递null值时采用默认属性设置。
返回值:初始化成功返回 0,失败返回 error number
互斥锁 是一种向系统申请的资源,在 使用完毕后需要销毁
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
其中只有一个参数 pthread_mutex_t* 表示想要销毁的 互斥锁
返回值:销毁成功返回0,失败返回 error number
以下是创建并销毁一把 互斥锁 的示例代码
#include <iostream>
#include <pthread.h>
using namespace std;
int main()
{
pthread_mutex_t mtx; //定义互斥锁
pthread_mutex_init(&mtx, nullptr); // 初始化互斥锁
// ...
pthread_mutex_destroy(&mtx); // 销毁互斥锁
return 0;
}
注意:
- 互斥锁既是一种资源,又具有独特性与安全性特征,在设计时需要充分考虑其特性与行为逻辑;因此,在实现时应当优先确保 [初始化互斥锁] 操作应在新线程启动前完成,并保证 [销毁互 exclusive lock] 操作应在现有线程退出后立即完成;综上所述,在程序设计中应当遵循"先创建、后销毁"的原则。
- 对于一个多核或高并发环境中的系统来说;应当确保所有参与运算的 thread 都能正确地访问同一个 lock**;否则会导致数据不一致与不可预测行为的发生。
- 为了避免程序运行中的死lock 问题;系统应当严格限制对同一个 lock 的多次释放操作**;即不允许在已经释放的情况下再进行 release 操作。
- 已经被其他 thread 释出的 lock 应当严格禁止其他 thread 使用**以保证系统的安全性和稳定性。
采用pthread_mutex_init作为初始化互斥锁的方式被称为动态分配。该方法要求必须手动进行初始化与销毁操作。此外还存在静态分配的做法即在定义互斥锁时将其初始化为PTHREAD_MUTEX_INITIALIZER
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
静态分配的优势在于其避免了对资源进行人工初始化及回收,并确保其生命周期与程序同步;然而,在这种设计中,默认情况下所定义的互斥锁只能是全局互斥锁。

注意: 使用静态分配时,互斥锁必须定义为全局锁
3.1.2、加锁操作
互斥锁的主要功能是执行加锁与解锁操作,并通常采用pthread_mutex_lock来完成加锁过程
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数 pthread_mutex_t* 表示想要使用哪把互斥锁进行加锁操作
返回值:成功返回0,失败返回 error number
使用 pthread_mutex_lock 加锁时可能遇到的情况:
- 当互斥 lock 未被其他线程占有时,在尝试进行加 lock 操作后会立即返回值
0。 - 当互斥 lock 已经为其他线程所占有时,在尝试进行加 lock 操作时会失败,并导致 current thread 进入等待状态(导致 execution flow 悬停),无法继续执行后续代码块直至重新获取 lock 资源。
3.1.3 解锁操作
使用 pthread_mutex_unlock 进行 解锁
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数 pthread_mutex_t* 表示想要对哪把互斥锁进行解锁
返回值:解锁成功返回 0,失败返回 error number
当 加锁 成功且完成后,在 临界资源 上实现了互斥保护时,则应立即 进行 解.locked操作,并将该 锁资源 释放出来以便其他 执行流 线程 获得该 锁资源 进行 再次 加.locked操作。
特别提示:在未执行解锁操作的情况下(如果不进行相应的解锁操作),后续的所有线程请求都无法成功地获取到指定的[锁资源]而必须长时间停留在等待状态(即可能导致长时间停留在等待状态而无法获取[锁资源]),从而引发潜在的死锁风险)。
3.2.解决抢票问题
为了便于任意线程访问同一个 锁 ,可以通过将所有线程信息编码为一个类 TData 来实现
pmtx 表示指向 互斥锁 的指针
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 有 1000 张票
// 需要定义在 threadRoutine 之前
class TData
{
public:
TData(const string &name, pthread_mutex_t* pmtx)
:_name(name), _pmtx(pmtx)
{}
public:
string _name;
pthread_mutex_t* _pmtx;
};
void* threadRoutine(void* args)
{
int sum = 0;
TData* td = static_cast<TData*>(args);
while(true)
{
// 进入临界区,加锁
pthread_mutex_lock(td->_pmtx);
// 如果票数 > 0 才能抢
if(tickets > 0)
{
usleep(2000); // 耗时 2ms
sum++;
tickets--;
// 出临界区了,解锁
pthread_mutex_unlock(td->_pmtx);
}
else
{
// 如果判断没有票了,也应该解锁
pthread_mutex_unlock(td->_pmtx);
break; // 没有票了
}
// 抢到票后还有后续动作
usleep(2000); //抢到票后也需要时间处理
}
// 屏幕也是共享资源,加锁可以有效防止打印结果错行
pthread_mutex_lock(td->_pmtx);
cout << "线程 " << td->_name << " 抢票完毕,最终抢到的票数 " << sum << endl;
pthread_mutex_unlock(td->_pmtx);
delete td;
return nullptr;
}
int main()
{
// 创建一把锁
pthread_mutex_t mtx;
// 在线程创建前,初始化互斥锁
pthread_mutex_init(&mtx, nullptr);
pthread_t pt[5];
for(int i = 0; i < 5; i++)
{
char* name = new char(16);
snprintf(name, 16, "thread-%d", i);
TData *td = new TData(name, &mtx);
pthread_create(pt + i, nullptr, threadRoutine, td);
}
for(int i = 0; i < 5; i++)
pthread_join(pt[i], nullptr);
cout << "所有线程均已退出,剩余票数: " << tickets << endl;
// 线程退出后,销毁互斥锁
pthread_mutex_destroy(&mtx);
return 0;
}

当一个线程完成解锁操作后若无后续操作指令,则该线程会立即重新加锁并持续执行本职工作这一行为反复就会导致竞争锁现象的发生 此时 该线程将独占一段时期的时间资源
- 为了解决上述问题 可采取以下措施:在解锁操作完成后让当前线程执行其他类型的任务或者让它短暂休眠以保证 [锁资源] 能尽可能均衡地分配给其他所有在线处理的线程
3.2.1互斥锁细节
多线程加锁互斥中的细节处理才是重头戏
任何访问同一个临界资源的线程都应实施加锁机制,并且必须使用相同的锁来保护共享资源。这些规定是维护系统稳定性的必要条件。
任何访问同一个临界资源的线程都应实施加锁机制,并且必须使用相同的锁来保护共享资源。这些规定是维护系统稳定性的必要条件。
比如在上面的代码中,在同一个并行程序中运行的 5 个并发线程使用了同一把 互斥锁 ,为了确保线程间的 互斥 必须保证每个线程都能获得同一个 互 exclusive lock(mutex)。

细节2: 每一个线程访问临界区资源之前都要加锁,本质上是给临界区加锁
并且在加锁过程中建议采用最小粒度的操作单元,在这种情况下能够确保由于加锁后的操作区域会串行执行从而能够在多线程环境下提升执行效率
细节3:线程在进入临界区之前必须加锁;所有线程都共享同一把互斥锁;该互斥锁自身同样被视为一种资源;这种互斥锁如何确保自身的安全性?
加锁旨在保障 临界资源 的安全;然而 靠近(或称为)也是一种 临界资源;这与"鸡生蛋还是蛋生鸡"的哲学命题类似;设计者对此给予了充分考虑;于是将其进行了特殊的规范:即加锁与解锁操作均为原子操作;不存在中间状态;因此无需额外防护
细节4: 临界区本身是一行代码,或者一批代码
首先,在执行[锁资源]所在的临界区间的代码时是可以被调度的(例如,在持有该锁资源后结束运行)。其次,在已经处于锁定状态下的情况下进行调度是不会打乱原有的加锁次序(例如,在持有锁的情况下被调度)。
简单举例说明
设想在一个学校的环境中拥有 顶级VIP自习室设施的一套专门设备, 每次仅限于一人占用, 这一配置旨在提供一个私密且高效的学术空间. 在此框架下, 这一 顶级VIP自习室 被视为学校的公共资源, 其全部功能则向所有学生随时开放.
使用规则:
仅限一人每次使用
顶级VIP自习室的大门上锁着一把钥匙,最先到达这里的同学能够取出钥匙并进入学习区域
该区域不受限制,可无限次停留直至选择退出,若需离开则需将此处的钥匙交给打算在此处学习的下一位同学
假设某天早上6:00张三成功抵达顶级VIP自习室并顺利取出钥匙,随后成功解锁进入学习区域;随后陆续有同学来到此区域门口,但由于他们都持有不了钥匙,只能耐心等待张三或是刚进入此区域的最后一人给予钥匙交接。
此时张三持有的是 [锁资源] ,其所在的线程运行着 临界资源 的访问。其他线程无法进入 共享区域 ,必须等待张三释放 [锁资源] 或者交出 钥匙 。
假设张三此时有去上厕所的打算,并且不想丢失钥匙,则他会必然携带钥匙前往上厕所;即使自习室内空无一人,其他同学也无法进入。
张三上厕所的行为类似于一个线程在获取并执行**[锁资源]后被调用/执行了这一操作;由此可见,在当前情况下对整个程序系统并未产生任何影响;因为锁仍然保持在锁定状态**(lock state),因此其他所有线程都无法进入其对应的临界区(critical region)。
假设张三的自习时间足够长。他轻松离开,并将钥匙放在门上。李四同学恰好夺走了他的钥匙。因此,在这种情况下,顶级VIP 自习室 就归李四所有了。
交接钥匙的本质是授予他人使用自习区域的权限。其实也就是,在线程解开锁并退出同步区域之后,其他线程随后加锁以进入该区域。
综上所述,在分析过程中可以通过张三与顶级VIP自习室这一具体案例深入理解线程在持有锁时所处的各种状态。
细节5: 互斥会给其他线程带来影响
当某个线程持有 [锁资源] 时,对于其他线程的有意义的状态:
- 锁被我申请了(其他线程无法获取)
- 锁被我释放了(其他线程可以获取锁)
在这两种状态的划分下,确保了多线程并发访问时的 原子性
细节6: 加锁与解锁配套出现,并且这两个对于锁的操作本身就是原子的
至于如何确保 加锁和解锁 时的原子性,可以接着往下看
3.3互斥锁原理
如今多数现代CPU体系结构提供了swap或exchange功能(如ARM、X86、AMD等),这些功能能够实现寄存器与内存单元数据的直接调换。由于这些功能仅包含一条命令行操作,在操作过程中确保了操作行为的高度一致性
即使在多处理器环境中(仅有一条总线),内存访问的时间段也会有先后顺序。
当一个处理器执行交换指令时,另一个处理器必须等待共享总线周期。
即swap和exchange指令在多处理器环境中仍保持原子性。
首先看一段伪汇编代码(加锁相关的)
本质上就是 pthread_mutex_lock() 函数
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器里的内容 > 0){
return 0;
} else
挂起等待;
goto lock;
其中 movb 是赋值操作符,在计算机体系结构中被用来完成数据的加载或存储;al 则是一个寄存器变量,在程序执行过程中用于临时存储数值;而 xchgb 则是一种支持原子操作的交换指令,在多线程环境中能够确保数据完整性。
共识机制:在计算机体系结构中的一类核心组件(如CPU中的寄存器),通常设计为仅有一个实例,并由所有参与计算的线程或程序单元进行共享使用。每个线程可能拥有不同的内容,并且不同线程之间可能包含不同的数据。
寄存器不等于寄存器中的存储内容(执行环境)。
当线程 thread_A 首次获取锁权限时,整体流程如下:
将 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 被切走(处于未退出状态且 [锁资源] 未释放 ),操作系统会记录 thread_A 的执行状态,并将执行权限交给 thread_B。

thread_B 也是执行 pthread_mutex_lock() 的代码,试图进入 临界区
首先将 al 寄存器中的值赋为 0
movb $0, %al

其次将 al 寄存器中的值与 mutex 的值交换(原子操作 )
mutex 作为内存中的变量由所有线程共享这表明 thread_B 观察到的是已被 thread_A 修改过的 mutex 值

显然此时交换了个寂寞
最后判断 al 寄存器中的值是否 > 0
if(al寄存器里的内容 > 0){
return 0;
} else
挂起等待;

由于 thread_B 因为没有 [锁资源] 而无法进入 临界区之外的区域,则后续所有未采用 thread_A 的线程也无法进入 临界区。
可以看出,在 thread_A 的上下文中,al = 1 正是实现互斥锁的关键参数值。该值的存在使得其他线程无法取得互斥锁资源。这是因为互斥锁只能被分配一份。
在汇编代码中,xchgb %al,matrix指令的本质作用是加锁。当matrix不为零时,则表示钥匙可用,并能够实现加锁操作;而且由于xchgb %al,matrix仅包含一条汇编指令设计内容,则能确保整个加锁过程呈现出完整的原子性特征。
现在再来看看 解锁 操作吧,本质上就是执行 pthread_mutex_unlock() 函数
原理相同:
unlock:
movb $1, mutex
唤醒等待 [锁资源] 的线程;
return
注意:
- 加锁机制作为一种有效手段,在阻止不被释放方面起到了关键作用。
- 交换指令swap或exchange均为原子操作,在保证互斥性的同时有效防止了死锁现象的发生。
- 未成功获取[锁资源]的线程会在调用pthread_mutex_lock()时被阻塞。
3.4多线程封装
对 原生线程库 提供的接口进行打包处理,并通过强化操作者的熟悉程度来提升对其相关接口的操作能力
既然是封装,那必然离不开类,这里的类成员包括:
- thread ID{ID}
- thread name{name}
- thread status{status}
- thread callback function{fun_t}
- parameters passed to the callback function{args}
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
enum class Status
{
NEW = 0, // 新建
RUNNING, // 运行中
EXIT // 已退出
};
// 参数、返回值为 void 的函数类型
typedef void (*func_t)(void*);
class Thread
{
private:
pthread_t _tid; // 线程 ID
std::string _name; // 线程名
Status _status; // 线程状态
func_t _func; // 线程回调函数
void* args; // 传递给回调函数的参数
};
首先完成 构造函数 ,初始化时只需要传递 编号、函数、参数 就行了
Thread(int num = 0, func_t func = nullptr, void* args = nullptr)
:_tid(0), _status(Status::NEW), _func(func), _args(args)
{
// 根据编号写入名字
char name[128];
snprintf(name, sizeof name, "thread-%d", num);
_name = name;
}
其次完成各种获取具体信息的接口
// 获取 ID
pthread_t getTID() const
{
return _tid;
}
// 获取线程名
std::string getName() const
{
return _name;
}
// 获取状态
Status getStatus() const
{
return _status;
}
接下来就是处理 线程启动
// 启动线程
void run()
{
int ret = pthread_create(&_tid, nullptr, runHelper, nullptr /*需要考虑*/);
if(ret != 0)
{
std::cerr << "create thread fail!" << std::endl;
exit(1); // 创建线程失败,直接退出
}
_status = Status::RUNNING; // 更改状态为 运行中
}
线程执行的方法依赖于回调函数 runHelper
// 回调方法
void* runHelper(void* args)
{
// 很简单,回调用户传进来的 func 函数即可
_func(_args);
}
此时这里出现问题了,pthread_create 无法使用 runHelper 进行回调

参数类型不匹配
归因于该类中的函数/方法通常包含一个私有的This指针以指向当前对象这一事实,在这种情况下tunHelper中的参数列表难以匹配
解决方法:存在多种解决方案,在这里我们选择一种较为直接的方式即可完成任务——将 runHelper 函数定义为 static 静态函数,则该函数将不再拥有隐藏的 this 指针

不过此时又出现了一个新问题:缺少了this引用后就无法访问类内的成员变量或其他属性,并且也就导致无法进行回调操作了!
不无尴尬的是事实;实在有点尴尬的是情况;不过另辟蹊径吧——既然他想要 this 指针(即函数返回值),那我们就可以通过引用操作符将其巧妙地传递到 pthread_create 函数的参数4位置了。
// 回调方法
static void* runHelper(void* args)
{
Thread* myThis = static_cast<Thread*>(args);
// 很简单,回调用户传进来的 func 函数即可
myThis->_func(myThis->_args);
return nullptr;
}
// 启动线程
void run()
{
int ret = pthread_create(&_tid, nullptr, runHelper, this);
if(ret != 0)
{
std::cerr << "create thread fail!" << std::endl;
exit(1); // 创建线程失败,直接退出
}
_status = Status::RUNNING; // 更改状态为 运行中
}
在最后完成 线程等待
// 线程等待
void join()
{
int ret = pthread_join(_tid, nullptr);
if(ret != 0)
{
std::cerr << "thread join fail!" << std::endl;
exit(1); // 等待失败,直接退出
}
_status = Status::EXIT; // 更改状态为 退出
}
现在使用自己封装的 Demo版线程库,简单编写多线程程序
注意: 需要包含头文件,我这里是Thread.hpp
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
using namespace std;
void threadRoutine(void* args)
{}
int main()
{
Thread t1(1, threadRoutine, nullptr);
cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;
t1.run();
cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;
t1.join();
cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << endl;
return 0;
}

3.5互斥锁的封装
原生线程库支持的互斥锁实现细节相对简洁,并且易于使用。然而存在一个较为不便之处:即每次都需要手动执行加锁和解锁操作。如果忘记执行 Unlock 操作,则可能导致其他线程陷入长时间阻塞的状态。
因此我们对锁进行封装,实现一个简单易用的 小组件
封装思路:基于对象创建时执行构造函数以及生命周期结束时调用析构函数的特点,在实现中可以自然融入加锁与解锁操作。
非常简单,直接创建一个 LockGuard 类
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
#include "LockGuard.hpp"
using namespace std;
// 创建一把全局锁
pthread_mutex_t mtx;
int tickets = 1000; // 有 1000 张票
// 自己封装的线程库返回值为 void
void threadRoutine(void *args)
{
int sum = 0;
const char* name = static_cast<const char*>(args);
while (true)
{
// 进入临界区,加锁
{
// 自动加锁、解锁
LockGuard guard(&mtx);
// 如果票数 > 0 才能抢
if (tickets > 0)
{
usleep(2000); // 耗时 2ms
sum++;
tickets--;
}
else
break; // 没有票了
}
// 抢到票后还有后续动作
usleep(2000); // 抢到票后也需要时间处理
}
// 屏幕也是共享资源,加锁可以有效防止打印结果错行
{
LockGuard guard(&mtx);
cout << "线程 " << name << " 抢票完毕,最终抢到的票数 " << sum << endl;
}
}
int main()
{
// 在线程创建前,初始化互斥锁
pthread_mutex_init(&mtx, nullptr);
// 创建一批线程
Thread t1(1, threadRoutine, (void*)"thread-1");
Thread t2(2, threadRoutine, (void*)"thread-2");
Thread t3(3, threadRoutine, (void*)"thread-3");
// 启动
t1.run();
t2.run();
t3.run();
// 等待
t1.join();
t2.join();
t3.join();
// 线程退出后,销毁互斥锁
pthread_mutex_destroy(&mtx);
cout << "剩余票数: " << tickets << endl;
return 0;
}
3.5.1RAII风格
被称为 RAII(Resource Acquisition Is Initialization)编程风格。这一风格由 C++ 语言之父本贾尼·斯特劳斯特鲁普首次提出这一独特的设计思想。该方法巧妙地利用了类与对象的特性,在操作过程中实现了半自动化的流程。
4.线程安全VS重入
线程安全:当多线程在同一代码块上并发执行时会保证一致性的状态称为线程安全;而若在无锁机制保护下修改全局变量或静态变量可能导致结果不一致这种情况则称作线程不稳定
重入:同一函数被多个进程(执行流)调用当前一个进程尚未完成当前操作时其他进程仍可进入该函数这种现象被称为 重入现象;在发生重入时程序运行结果不会出现异常状况则称该函数为 可重入函数否则就称其为 不可重入函数
常见线程不安全的情况
- 未加防护地使用共享资源(如全局变量和静态变量)
- 当某个函数被调用时会导致其内部状态发生变化
- 会产生指向该静态存储空间地址的指针
- 可能导致线程级操作的安全性问题出现(例如未经过适当的安全性验证或权限控制机制保护地运行相应操作)
常见线程安全的情况
- 每个线程仅可对全局变量或静态变量进行读取操作
- 类或接口在多线程环境下被视为原子操作
- 不同线程之间的切换不会产生执行结果的歧义
常见不可重入的情况
采用了malloc和free函数;由于这些接口均属于C语言,并由全局链表进行管理。
采用了标准I/O库函数;其中许多实现均不具有可重入特性。
内部采用了静态数据结构。
常见可重入的情况
- 避免使用全局或静态变量
- 不要利用malloc或new来动态分配内存空间
- 禁止调用不可重入函数
- 不要将全局或静态的数据返回给函数调用者
- 所有使用的数据必须由调用该函数的程序段提供
重入与线程安全的联系
- 如果是可重入的话...功能就必定具备线程安全性;而不可重入的功能则可能会出现线程安全问题
- 如果某个函数在处理过程中使用了全局变量...则该功能既不能实现线程安全也不能满足可重入的要求
重入与线程安全的区别
- 可重入函数属于线程安全函数的一种类型。
- 线程安全性质不一定具备可重入性;但反之,则任何可重入函数必定具备线程安全性质。
- 当对临界资源实施加锁保护时,则该函数具备线程安全性;然而,在不具备返回值的情况下未释放锁可能导致死锁问题出现;因此,在这种情况下无法实现对该函数进行重入调用。
重入是否作为只是函数的一种特性?无优无劣地存在即可;然而线程不安全需加以避免
5、常见锁概念
5.1、死锁问题
Deadlock, also known as a deadlock, refers to a situation where multiple processes compete for shared resources that cannot be released, ultimately causing all involved processes to enter an indefinite blocking state.
概念比较绕,简单举个例子
两个孩子各自带着五角钱去商店买东西。两个人同时看中了一包辣条,这包辣条的价格是一块钱,两人手头都只有五角钱,但双方都心切想买下这包辣条,争执不下,最终导致局面无法达成一致。
两名小朋友:两条主线程
所以死锁就是 多个线程因为锁资源的等待而被同时阻塞 ,导致程序陷入僵局
是否仅凭一把锁就可能导致死锁?
答案是肯定的。
当线程
thread_A获取了共享资源的锁定权限后,在完成对其临界区的操作后未及时解锁时,
不仅会导致其他线程thread_B无法获取该资源,
而且自身也无法重新获取该资源,
这实际上就是典型的 死锁 的一种情况。
死锁 产生的四个必要条件
- 互斥性:在一个执行流中仅能分配到一个资源实例
2. 当一个执行流因资源请求而被阻塞时:
- 若该执行流已获取其他相关联的资源,则必须保持其当前所持有的所有资源实例
- 同时不得剥夺任何其他线程对其拥有的一组共享资源的所有权
3. 多个执行流程相互依存地形成一个首尾相连的循环依赖关系
4. 每个线程都须保证对其所拥有的一组共享资源始终拥有完全控制权
只有四个条件都满足了,才会引发 死锁 问题
如何避免 死锁 问题?
核心思想:破坏四个必要条件的其中一个或多个
方法1:不加锁
不加锁的本质是不保证 互斥 ,即破坏条件1
方法2:尝试主动释放锁
在进入临界区时访问资源需配置两把互斥锁的方式实现,并确保每条线程都分别持有其中一把。当每条线程均在试图获取第二把互斥锁时,在某时刻若选择释放其持有的互斥锁(即选择将该资源释放),则能有效解除死lock状态。这种设计主要基于一个权衡原则:牺牲某一线程当前的操作以换取整体系统的安全性提升。
可以借助 pthread_mutex_trylock 函数实现这种方案
#include <pthread.h>
int pthread_mutex_trylock(pthread_mutex_t *mutex);
该函数旨在模拟请求获取资源的流程。当长时间得不到应答时, 会主动释放当前持有的资源, 并放弃再次进行加锁请求。这种机制的目的是为了给其他试图进行加锁操作的线程提供机会。
方法3:按照顺序申请锁
按照顺序申请锁 -> 按照顺序释放锁 -> 就不会出现环路等待的情况
方法4:控制线程统一释放锁
首先要明白:锁不一定要由申请锁的线程释放,其他线程也可以释放锁
比如在下面这个程序中,在主程序中次线程申请的锁被解除了从而克服了死锁问题
#include <iostream>
#include <pthread.h>
#include <unistd.h>
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);
// 等待次线程先跑
sleep(3);
// 主线程帮忙释放锁
pthread_mutex_unlock(&mtx);
cout << "我是主线程,我已经帮次线程释放了一把锁" << endl;
// 等待次线程后续动作
sleep(3);
pthread_join(t, nullptr);
cout << "线程等待成功" << endl;
return 0;
}
因此我们能够设计一个 专有线程 专门负责管理所有 资源保护 问题一旦检测到 存储冲突 情况就需要释放并重新获取所有 锁机制 让各个线程再次争夺资源
注意: 规定只有申请锁的人才能释放锁,规定可以不遵守,但最好遵守
死锁 一般比较少见,因为这是因代码编写失误而引发的问题
常见的避免 死锁 问题的算法:死锁检测算法、银行家算法
6.线程同步
6.1同步概念
同步:在保障数据安全的基础上,通过让线程遵循特定的访问顺序来访问临界资源,从而确保不发生饥饿问题。
至于该如何正确理解 饥饿问题 ,需要再次请张三出场
话说张三在早上 6:00 抢到了自习室的钥匙,并开开心心的进入了自习室自习
自习室外热闹非凡,在午后的十二点时分依然挤满了等候交还钥匙的人群。然而张三却并不着急,在下午12点时分的自习课上依然十分悠闲。到了这时分张三也感到有些饿意涌动想着出去找家餐馆用餐。吃饭就意味着归还钥匙成为了必须完成的任务(这是规定)
张三小心地将门上的钥匙妥善放置好后转身离去,在他抬头时发现许多同学正 waiting for the key to be returned. 心里深处他开始思考着如果自己就这样 return the key now……那么若是我此时就放回钥匙……那么等到我吃完午饭时该怎么办呢?
于是法外狂徒张三拒绝进食后离开餐桌,并被自己强行带入了自习室开始学习;仅仅几分钟后他就感到极度饥饿并发出"咕咕"声表示抗议接着他便想要外出觅食;当他走出教室将钥匙归还给同学时回过头看去发现有大量学生到来时感到有些不安;接着他便再一次拿起了自己的书包钥匙回到了自习室内如此反复直至下午六点仍未能吃完上午饭菜且不仅自己未能吃完上午饭菜且未专心学习也使得其他同学无法正常静心复习!
张三错了吗?张三没错,十分符合自习室的规定,只是 不合理
因为张三的不当行为造成了 自习室 资源被不必要的浪费,在此期间 外等待的同学也严重影响了 自习效率 ,不得不 亲自体验了一把 “ starved to death ”
为此校方更新了 自习室 的规则:
所有完成自习的同学在归还钥匙后不得立即再次申请钥匙。在外面等待钥匙的同学应遵守规定并排队等候。调整后的新规则将不再出现类似饥饿问题。解决此类问题的关键在于,在确保安全的前提下实现多线程访问资源时保持一定的操作顺序性。
即通过 线程同步 解决 饥饿问题
原生线程库 中提供了 条件变量 这种方式来实现 线程同步
逻辑链:通过条件变量 - > 实现线程同步 -> 解决饥饿问题
条件变量用于判断,在一个线程执行互斥锁获取时,在其等待期间无法在其他线程修改该状态前采取任何行动。
比如当一个线程进入队列时, 检测到队列为空时, 它必须等待直到其他线程将数据插入到队列中, 这时就可以考虑使用条件变量
条件变量的本质就是 衡量访问资源的状态
竞态条件:因为时序问题而导致程序出现异常
可以将条件变量视为一个数据结构体容器。
它内部包含了一个队列数据结构。
该容器用于存储当前等待进队的线程信息。
一旦系统检测到某个特定条件被满足时,
系统会立即取出该容器中的第一个元素来进行处理。
处理完毕后会将该元素重新插入到容器末尾位置。

队列是保证顺序性的重要工具
6.2.同步相关操作
6.2.1条件变量创建与销毁
源自于原生线程库中的条件变量,并采用类似于互斥锁的风格;例如其类型为pthread_cond_t;同时在创建之后仍需进行初始化
#include <pthread.h>
pthread_cond_t cond; // 定义一个条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
参数1 pthread_cond_t* 表示想要初始化的条件变量
参数2(类型const pthread_condattr_t*)用于表示初始化时的相关属性信息。当其值设为空指针时,默认采用预定义属性配置。
返回值:成功返回 0,失败返回 error number
条件变量 在使用结束后需要销毁
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
pthread_cond_t* 表示想要销毁的条件变量
返回值:成功返回0,失败返回 error number
注:与互斥锁类似地(或者与互斥锁类似),条件变量也支持静态分配机制。具体来说,在创建全局条件变量时(或者当创建全局条件变量时),我们将其定义为PTHREAD.CondCondition_INITIALIZER(或者将它设置为PTHREAD.CondCondition_INITIALIZER)。这种情况下(或者这种配置下),该条件变量会自动生成初始值并自动销毁对象(或者意味着会自动生成初始值并自动销毁对象)。
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
注意: 这种定义方式只支持全局条件变量
6.2.2条件等待
原生线程库 中提供了 pthread_cond_wait 函数用于等待
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
参数1 pthread_cond_t* 想要加入等待的条件变量
参数2 pthread_mutex_t* 互斥锁,用于辅助条件变量
返回值:成功返回 0,失败返回 error number
有必要对参数2进行详细阐述。在理解条件变量时,应意识到其必须与互斥锁配合使用。必要步骤包括首先获取锁资源,并随后通过条件变量判断条件是否满足。
传递互斥锁的理由:
- 关键变量同样是重要的资源,并应予以保护
- 一旦条件未被触发(即未被唤醒),该线程会被暂停执行并等待获取到必要的锁;同时所有expects获得该lock的所有其他线程也会因此受到影响。
为了防止出现deadlock的情况, 条件变量必须能够实现自动生成与相关进程共享该资源的能力。
在某个线程被唤醒的过程中,在触发条件变量释放互斥锁之后,在该线程能够获得锁定权限,并进入到等待模式的状态下
6.2.3唤醒线程
在条件变量中定义的线程必须被唤醒;否则将无法判断何时处理队头线程;可以通过 pthread_cond_signal 函数来实现其唤醒作用。
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_t 表示想要从哪个条件变量中唤醒线程
返回值:成功返回 0,失败返回 error number
注意: 使用 pthread_cond_signal 一次只会唤醒一个线程,即队头线程
如果想唤醒全部线程,可以使用 pthread_cond_broadcast
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
6.3同步demo
接下来简单使用一下 线程同步 相关接口
目标:创建5 个次线程,等待条件满足,主线程负责唤醒
这里演示 单个唤醒 与 广播 两种方式,先来看看 单个唤醒 相关代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 互斥锁和条件变量都定义为自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
const 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 << "\t线程 " << 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, name);
}
// 等待所有次线程就位
sleep(3);
// 主线程唤醒次线程
while(true)
{
cout << "Main thread wake up Other thread!" << endl;
pthread_cond_signal(&cond); // 单个唤醒
sleep(1);
}
for(int i = 0; i < num; i++)
pthread_join(pt[i], nullptr);
return 0;
}
在 单个唤醒 模式下,在每次操作中只会有一个线程被唤醒,并且由于采用的 条件变量 ,所有线程的唤醒顺序都完全一致

可以将唤醒方式换成 广播
// ......
pthread_cond_broadcast(&cond); // 广播
// ......

该系统能够通过互斥锁和条件变量来实现生产者消费者模型的管理。关于其具体实现细节以及对条件变量的应用将在后续内容中进一步阐述
