目录
[4、线程安全 VS 重入](#4、线程安全 VS 重入)
[6.3、简单同步 Demo](#6.3、简单同步 Demo)
🏙️ 正文
1、资源共享问题
1.1、多线程并发访问
假设存在一个全局变量 g_val,以及两个线程 thread_A
和 thread_B
, 这两个线程同时对 g_val 进行减操作(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 步尚未完成 。
此时,thread_A 的第 3 步没有执行,内存中的 g_val 仍未修改,但切走了。接下来,thread_B 开始执行并进行多次 g_val-- 操作,直到将 g_val 修改为 10。
当 thread_B 执行完毕并被操作系统切换时,轮到 thread_A 再次执行,它会继续完成它未完成的第 3 步。此时,thread_A 将 g_val 的值修改为 99。
对于 thread_B
来说,显然感到不公平。下次读取 g_val
时,它会发现 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
正在访问 g_val
时,thread_B
会被阻塞,直到 thread_A
解锁。这保证了对 g_val
的访问不会出现中途干扰。

通过加锁,原子性 就可以得到保证。换句话说,只要一个线程开始访问临界资源,操作要么全部完成,要么完全不执行,不会出现中途停顿或错误的中间状态。
总结:
-
加锁和解锁是资源密集型操作,会影响程序的运行效率。
-
加锁会使得加锁区域内的代码串行化执行,从而降低多线程程序的性能。
-
因此,锁的粒度应该尽量细小,以减小加锁所带来的性能损失。
2、多线程抢票
实践出真知,接下来通过代码演示多线程并发访问问题。
现在是 9 月 24 号,中秋+国庆假期即将到来,各位抢到票了吗?
2.1、并发抢票
思路很简单:存在 1000 张票和 5 个线程,5 个线程同时抢票,直到票数为 0。程序结束后,可以查看每个线程分别抢到了多少张票,以及最终的票数是否为 0。
共识:购票需要时间,抢票成功后也需要时间,这里通过
usleep
函数模拟耗时。cpp#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; }
理想状态 :最终票数为 0,5 个线程抢到的票数之和为 1000。但实际情况并非如此,结果显示:
最终剩余票数为 -3,5 个线程抢到的票数之和为 1020,这显然是不合理的。
原因 :多线程并发访问时,存在共享资源(即
tickets
),而没有同步控制措施,导致数据异常。2.2、引发问题
这就是
thread_A
和thread_B
并发访问时遇到的问题。例如,假设**tickets = 500
,thread-0
** 正在抢票,准备完成第 3 步(将数据拷贝回内存)时被切走了,接着 thread-1 继续抢票并修改 tickets 为 499。等thread-0
被重新调度时,它也会把**tickets
** 修改成 499,导致 **tickets
**的值被"白嫖"了一张票。对于这种临界资源(即票数),可以通过加锁来保护,确保线程间的互斥访问,避免并发问题。
3
条汇编指令要么不执行,要么全部一起执行完
--tickets 本质上是
3
条汇编指令,在任意一条执行过程中切走线程都会引发并发访问问题
3、线程互斥
互斥(Mutual Exclusion)是指在同一时间,某些事件不能同时发生。例如,在多线程并发抢票场景中,通过添加互斥锁,确保同一张票不会被多个线程同时抢到。
3.1、互斥锁相关操作
3.1.1 互斥锁的创建与销毁
互斥锁 pthread_mutex_t
需要在使用前进行初始化,并在使用后进行销毁。
cpp
#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
互斥锁 是一种向系统申请的资源,在 使用完毕后需要销毁
销毁互斥锁:
cpp
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
其中只有一个参数 pthread_mutex_t* 表示想要销毁的 互斥锁
返回值:销毁成功返回 0
,失败返回 error number
以下是创建并销毁一把 互斥锁 的示例代码
cpp
#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;
}
注意:
- 互斥锁是一种资源,一种线程依赖的资源,因此 [初始化互斥锁] 操作应该在线程创建之前完成,[销毁互斥锁] 操作应该在线程运行结束后执行;总结就是 使用前先创建,使用后需销毁
- 对于多线程来说,应该让他们看到同一把锁,否则就没有意义
- 不能重复销毁互斥锁
- 已经销毁的互斥锁不能再使用
使用**pthread_mutex_init
** 初始化 互斥锁 的方式称为 动态分配 ,需要手动初始化和销毁,除此之外还存在 静态分配 ,即在定义 互斥锁 时初始化为PTHREAD_MUTEX_INITIALIZER。
cpp
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
静态分配 的优点在于 无需手动初始化和手动销毁,锁的生命周期伴随程序 ,缺点就是定义的 互斥锁 必须为 全局互斥锁
分配方式 | 操作 | 适用场景 |
---|---|---|
动态分配 | 手动初始化/销毁 | 局部锁/全局锁 |
静态分配 | 自动初始化/销毁 | 全局锁 |
注意: 使用静态分配时,互斥锁必须定义为全局锁。
3.1.2 加锁操作
加锁通过**pthread_mutex_lock
**实现:
cpp
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数pthread_mutex_t* 表示想要使用哪把互斥锁进行加锁操作
返回值:成功返回 0
,失败返回 error number
使用pthread_mutex_lock 加锁时可能遇到的情况:
- 当前互斥锁没有被别人持有,正常加锁,函数返回
0
- 当前互斥锁被别人持有,加锁失败,当前线程被阻塞(执行流被挂起),无法向后运行,直到获得 [锁资源]
3.1.3、解锁操作
使用 pthread_mutex_unlock 进行 解锁
cpp
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数 pthread_mutex_t*
表示想要对哪把互斥锁进行解锁
返回值:解锁成功返回 0
,失败返回 error number
在 加锁 成功并完成对 临界资源 的访问后,就应该进行 解锁 ,将 [锁资源] 让出,供其他线程(执行流)进行 加锁
注意: 如果不进行解锁操作,会导致后续线程无法申请到 [锁资源] 而永久等待,引发 死锁 问题(自死锁)
3.2、解决抢票问题
通过互斥锁保护临界资源,确保每个线程在修改**tickets
**时不会发生冲突。下面是更新后的代码,使用了互斥锁来保证线程安全。
为了方便所有线程看到同一把 锁 ,可以给线程信息创建一个类**TData
** ,其中包括 name
和 pmtx
pmtx
表示指向 互斥锁 的指针
cpp
// 需要定义在 threadRoutine 之前
class TData
{
public:
TData(const string &name, pthread_mutex_t* pmtx)
:_name(name), _pmtx(pmtx)
{}
public:
string _name;
pthread_mutex_t* _pmtx;
};
接下来就可以使用 互斥锁 解决 多线程并发抢票 问题了
cpp
#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;
}
此时无论运行多少次程序,结果都没有问题:最终的剩余票数都是 0
,并且所有线程抢到的票数之和为 1000

3.2.1、互斥锁细节
多线程加锁互斥中的细节处理是非常关键的。对于多线程编程,如何安全、有效地加锁并保护临界资源,是确保程序稳定性与性能的关键。
细节 1:凡是访问同一个临界资源的线程,都要进行加锁保护,并且必须加同一把锁
在多线程环境中,所有访问同一资源的线程必须使用相同的锁。 这是游戏规则,必须严格遵守。比如在上面的代码中,多个并发线程都必须看到并使用同一把互斥锁,只有这样,才能确保线程之间对共享资源的访问是互斥的,避免了数据竞争和不一致的问题。
细节 2:每个线程访问临界区资源之前,都要加锁
本质上,加锁是对临界区的保护。为了保证临界资源的安全性,每个线程在访问临界资源前,都必须加锁,确保只有一个线程在同一时刻能够操作共享资源。
加锁时,粒度要尽可能细小,因为加锁后的代码区域会被串行执行。如果加锁的区域过大,可能导致程序的性能下降。因此,我们建议将加锁的范围控制得尽量小,以提高并发执行的效率。
细节 3:锁本身也是临界资源,如何保证锁的安全性?
锁本身也属于临界资源,涉及锁的获取与释放操作。因此,在加锁时,我们需要考虑锁本身的安全性。加锁是为了保护临界资源的安全,但锁本身也可能成为竞争资源。
为了保证锁的安全性,锁的加锁和解锁操作本身是原子的。也就是说,这两个操作不会被中断或出现中间状态。通过原子性操作,确保了锁的安全性,使得在加锁和解锁过程中不会发生竞态条件。
细节 4:临界区本身是一行代码或一批代码
临界区是指涉及临界资源的代码区域。在多线程环境下,线程在进入临界区时可以被调度吗?如果线程在持有锁时被调度,是否会影响锁和临界资源的状态?
答案是,线程在执行临界区内的代码时是可以被调度的。举个例子,假设线程1持有锁并执行临界区操作,如果此时线程1被操作系统切换并暂停,其他线程是不会直接影响到线程1所持有的锁的。线程1继续执行时,仍然能够顺利恢复并完成锁的释放操作。
这表明,当线程在临界区内时,即便发生线程切换,也不会干扰加锁操作的原子性。
细节 5:互斥会给其他线程带来影响
当某个线程持有锁资源时,其他线程对该锁的访问将受到限制。在多线程并发访问的过程中,锁的状态分为以下两种情况:
锁被我申请了:此时,其他线程无法获取该锁,只能等待当前线程释放锁才能继续操作。
锁被我释放了:当前线程完成了对临界资源的操作,其他线程此时可以获取到该锁并访问临界资源。
这两种状态的划分确保了多线程并发访问时对共享资源的原子性,避免了数据不一致和竞争条件的发生。只有在锁被释放后,其他线程才有机会获取锁,从而保证了临界资源访问的安全性。
细节 6:加锁与解锁配套出现,并且这两个操作本身就是原子的
加锁和解锁是配套出现的。每当我们对临界资源进行加锁保护时,必须确保在操作完成后释放锁。关键在于,这两个操作本身是原子性的,也就是说,在执行过程中不会被中断或出现中间状态。
在多线程环境中,锁的原子性保证了即使线程切换发生,也不会影响锁的状态。因此,锁操作的原子性确保了多线程并发访问中的安全性和可靠性。
3.3、互斥锁的原理
在现代计算机体系结构中,大多数 CPU(如 ARM、X86、AMD 等)都提供了 swap 或 exchange 指令。这些指令允许直接交换寄存器和内存单元中的数据,并且由于这些指令只有一条语句,能够保证指令执行时的 原子性。
即便是在多处理器环境下(只有一条总线),访问内存的周期也有先后。在一个处理器上执行交换指令时,另一个处理器的交换指令会等待总线周期,这样 swap 和 exchange 指令在多处理器环境下也能保持原子性。
首先,我们来看看加锁相关的伪汇编代码(本质上是**pthread_mutex_lock()**函数):
cpp
lock:
movb $0, %al
xchgb %al, mutex
if (al > 0) {
return 0
} else {
wait
}
goto lock
在这里,movb
表示赋值,%al
是寄存器,xchgb
是支持原子操作的交换指令。
关键点总结:
-
寄存器与内容的区别 :计算机中的硬件(如 CPU 中的寄存器)是共享的,但寄存器的内容会随线程的切换而改变,通常被称为"上下文数据"。
- 寄存器 ≠ 寄存器中的内容(即执行流的上下文)。
-
加锁过程 :**当线程
thread_A
第一次加锁时,**执行如下操作:
将 0 赋值给 %al 寄存器,假设 mutex 的初始值为 1
cpp
movb $0, %al

将 %al
寄存器中的值与 mutex
的值交换(原子操作)。由于 mutex
是一个共享资源,其他线程会看到 mutex
被 thread_A
修改后的值
cpp
xchgb %al, mutex

判断 %al
寄存器中的值是否大于 0
。如果大于 0
,表示锁已被其他线程持有,当前线程无法进入临界区,需要等待;否则,thread_A
可以成功进入临界区
cpp
if (al > 0) {
return 0;
} else {
wait;
}

3.线程 thread_B
加锁过程 :当 thread_B
执行**pthread_mutex_lock()
**时,过程如下:
movb $0, %al
由于 thread_A
已经持有锁,thread_B
将被拒绝进入临界区。其他线程也无法进入,直到 thread_A
释放锁。
-
将
%al
寄存器中的值赋为0
。 -
将
%al
与mutex
的值交换。这时,mutex
的值已经被thread_A
修改,因此thread_B
看到的mutex
值会是thread_A
修改后的值。 -
判断
%al
是否大于0
,如果大于0
,则thread_B
可以进入临界区,否则被阻塞等待。
解锁过程
解锁过程本质上是**pthread_mutex_unlock()
**函数的实现:
cpp
unlock:
movb $1, mutex
wake up waiting threads;
return;
-
thread_A
解锁时,将mutex
的值赋为1
,表示锁已释放。cppmovb $1, mutex
此时,thread_A 退出临界区,唤醒所有等待锁的线程(如 thread_B)。
-
锁资源被 **
thread_B
**获取,进入临界区,重复加锁解锁的过程。
总结
-
加锁 :通过交换指令 xchgb 保证锁的原子性,即使在多处理器环境中也能安全执行。
-
解锁 :通过设置 mutex 为 1,释放锁资源,允许其他线程进入临界区。
3.4、多线程封装
在前面学习了互斥锁相关的知识后,现在可以开始封装一个简单的线程库:Demo版线程库。
目标:对原生线程库提供的接口进行封装,进一步熟练掌握线程的相关操作。
既然是封装,类是必不可少的,我们需要在类中定义以下成员:
-
线程 ID
-
线程名
name
-
线程状态
status
-
线程回调函数
fun_t
-
传递给回调函数的参数
args
以下是头文件的基本框架:
cpp
#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; // 传递给回调函数的参数
};
首先完成构造函数,初始化时只需要传递编号、函数和参数:
cpp
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;
}
接着定义获取具体信息的接口:
cpp
// 获取 ID
pthread_t getTID() const
{
return _tid;
}
// 获取线程名
std::string getName() const
{
return _name;
}
// 获取状态
Status getStatus() const
{
return _status;
}
接下来实现线程启动:
cpp
// 启动线程
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 方法回调用户传入的函数:
cpp
// 回调方法
static void* runHelper(void* args)
{
Thread* myThis = static_cast<Thread*>(args);
myThis->_func(myThis->_args);
return nullptr;
}
但问题是,pthread_create 无法直接调用 runHelper ,原因是类成员方法默认有一个隐藏的 this
指针。为了解决这个问题,可以将 runHelper 改为静态方法 ,并通过 pthread_create
的参数传递 this
指针:
cpp
// 启动线程
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; // 更改状态为运行中
}
// 回调方法
static void* runHelper(void* args)
{
Thread* myThis = static_cast<Thread*>(args);
myThis->_func(myThis->_args);
return nullptr;
}
最后完成线程等待:
cpp
// 线程等待
void join()
{
int ret = pthread_join(_tid, nullptr);
if(ret != 0)
{
std::cerr << "thread join fail!" << std::endl;
exit(1); // 等待失败,直接退出
}
_status = Status::EXIT; // 更改状态为退出
}
现在可以使用自己封装的线程库编写一个简单的多线程程序:
cpp
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
void threadRoutine(void* args)
{
// 线程任务
}
int main()
{
Thread t1(1, threadRoutine, nullptr);
std::cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << std::endl;
t1.run();
std::cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << std::endl;
t1.join();
std::cout << "thread ID: " << t1.getTID() << ", thread name: " << t1.getName() << ", thread status: " << (int)t1.getStatus() << std::endl;
return 0;
}
运行结果显示线程的状态从 0
(创建)到 2
(退出),证明我们自己封装的线程库没有问题。
3.5、互斥锁的封装
原生线程库提供的互斥锁相关代码比较简单,但需要手动加锁和解锁。如果忘记解锁,可能导致死锁。因此,我们可以对锁进行封装,简化操作。
封装思路:利用对象的构造和析构函数来自动调用加锁和解锁操作,确保在对象生命周期内完成加锁和解锁。
创建一个**LockGuard
**类进行封装:
cpp
#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;
};
接下来,将 Demo 版线程库与互斥锁封装融入到多线程抢票程序中,代码变得更加优雅:
cpp
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
#include "LockGuard.hpp"
pthread_mutex_t mtx;
int tickets = 1000;
void threadRoutine(void* args)
{
int sum = 0;
const char* name = static_cast<const char*>(args);
while (true)
{
// 自动加锁、解锁
{
LockGuard guard(&mtx);
if (tickets > 0)
{
usleep(2000); // 耗时
sum++;
tickets--;
}
else
break;
}
usleep(2000); // 抢票后处理
}
{
LockGuard guard(&mtx);
std::cout << "线程 " << name << " 抢票完毕,最终抢到的票数 " << sum << std::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);
std::cout << "剩余票数: " << tickets << std::endl;
return 0;
}

3.5.1、RAII 风格
这种获取资源即初始化的风格称为 RAII(Resource Acquisition Is Initialization)风格,是由 C++ 之父本贾尼·斯特劳斯特卢普提出的。这种方式利用了类和对象的特性,实现了资源的自动化管理。

4、线程安全 VS 重入
概念
-
线程安全:在多线程并发访问同一段代码时,如果结果是一致的,并且没有出现不同的执行结果,则该代码是线程安全的。如果代码在没有加锁保护的情况下访问全局变量或静态变量,导致不同的线程执行结果不同,那就是线程不安全的。
-
重入:重入是指同一个函数在多个线程中被调用的情况。在当前一个执行流还没有完成函数的执行时,其他执行流可以进入该函数执行,这种行为称为重入。如果在重入时,函数执行结果没有问题,则称该函数为可重入函数;否则,它就是不可重入的。
常见线程不安全的情况
-
不保护共享变量:例如访问全局变量或静态变量时没有同步机制(如锁)进行保护。
-
函数状态变化:函数的状态在每次调用时改变,可能会影响其他线程的执行。
-
返回指向静态变量的指针:某些函数可能返回指向静态变量的指针,而静态变量通常在多个线程间共享。
-
调用线程不安全的函数:某些标准库函数或第三方库函数本身是线程不安全的,若在多线程中调用这些函数,可能引发安全问题。
常见线程安全的情况
-
全局变量或静态变量只读:如果多个线程只能读取全局变量或静态变量,而不能修改,这样通常是线程安全的。
-
原子操作:类或接口对于线程来说是原子的,即每个操作都是不可中断的。
-
线程切换不会产生不一致的结果:多线程切换时不会导致程序结果的不确定性或二义性。
常见不可重入的情况
-
调用了
malloc
/free
:这些函数是 C 语言标准库提供的,内部使用了全局链表管理内存,可能导致多个线程同时修改该链表,导致不安全。 -
调用了标准 I/O 库函数:很多标准 I/O 库函数使用了全局数据结构,且大多实现为不可重入的。
-
可重入函数体内使用了静态数据结构:如果在函数中使用了静态数据结构,可能会导致多个线程之间的状态不一致。
常见可重入的情况
-
没有使用全局或静态变量:函数的所有数据都由参数传入,确保每个线程都有独立的数据副本。
-
没有使用
malloc
或new
进行动态内存分配:这避免了全局内存管理带来的冲突。 -
没有调用不可重入的函数:避免使用全局资源或库函数中存在重入问题的函数。
-
不返回全局或静态数据:确保返回值不会直接依赖于全局或静态变量,避免多个线程间的数据冲突。
-
使用本地数据或全局数据的本地拷贝:可以通过复制全局数据到局部变量来避免多线程访问带来的问题。
重入与线程安全的联系
-
可重入的函数一定是线程安全的。这意味着,如果一个函数是可重入的,那么多个线程同时调用该函数时,结果是不会出现问题的,因此它也是线程安全的。
-
不可重入的函数可能引发线程安全问题 。如果一个函数不是可重入的,那么多个线程同时执行时,可能会影响函数的执行结果,从而导致线程安全问题。
重入与线程安全的区别
-
可重入函数是线程安全函数的一种:可重入的函数可以在多个线程中安全执行,因此它也被认为是线程安全的。
-
线程安全不一定是可重入的 :如果我们对临界资源访问加锁,确保同一时刻只有一个线程访问该资源,那么该函数就是线程安全的。然而,这样的函数可能由于死锁问题而变得不可重入。
-
不可重入的函数可能导致线程安全问题:例如,在一个加锁的函数内部再进行加锁操作,若没有正确释放锁,就可能导致死锁问题。
总结
-
是否可重入只是函数的特征之一,没有好坏之分。
-
线程不安全的函数是需要避免的,任何时候都应该避免线程不安全的操作。
5、常见锁概念
5.1、死锁问题
死锁 :指在一组进程或线程中,若每个线程都占有某些资源,并且等待其他线程所占用且不释放的资源时,所有线程都会进入永久等待状态,造成系统无法继续执行下去。
这个概念看起来有些复杂,我们可以通过一个简单的例子来理解:
假设有两个小朋友,每人有五毛钱,去商店买辣条。两个人都看中了同一包售价一块钱的辣条,但两个人手里的钱都不够,他们都不愿意让对方买。这时,两个小朋友因为彼此的钱不够,而无法同时购买辣条,形成了僵局。
-
两个小朋友:对应两个不同的线程。
-
辣条:代表临界资源。
-
售价:访问临界资源需要的锁资源数量,这里需要两把锁。
-
小朋友手里的钱:代表锁资源。
-
僵持不下的场面:形成了死锁,程序无法继续运行。
-
所以,死锁就是当多个线程因锁资源的等待而相互挂起,导致程序陷入死循环的状态。
只有一把锁会造成死锁吗?
答案是:会的 。如果线程
thread_A
申请锁资源,在访问完临界资源后没有释放锁资源,会导致线程thread_B
无法申请到锁资源,而thread_A
自己也无法申请到锁资源,形成了死锁。死锁产生的四个必要条件
-
互斥:每个资源每次只能被一个执行流(线程)使用。
-
请求与保持:线程在请求资源时,会先持有已经获得的资源,直到资源被释放才会继续执行。
-
环路等待:多个线程之间形成首尾相接的循环等待资源的关系。
-
不剥夺条件:资源无法被强制从线程手中剥夺,线程必须释放资源。
运行结果:主线程成功帮助次线程释放了锁资源,打破了死锁。
总结
通过设计控制线程来管理锁资源,如果检测到死锁发生,就释放所有的锁,让线程重新竞争资源。虽然死锁相对较少见,但它通常是由于程序设计失误引发的。为避免死锁问题,常见的算法有死锁检测算法 和银行家算法。
只有当这四个条件都满足时,才会导致死锁问题。
如何避免死锁问题?
核心思想:通过破坏其中一个或多个必要条件,避免死锁的发生。
方法1:不加锁
不加锁意味着不保证互斥,破坏了"互斥"条件。
方法2:尝试主动释放锁
例如,进入临界区需要两把锁,thread_A
和 thread_B
各持有一把锁,并且都在尝试申请第二把锁。此时,如果 thread_A
放弃申请,主动释放锁,其他线程就有机会重新申请锁,避免死锁。可以使用 pthread_mutex_trylock
来实现该方案。
cpp
#include <pthread.h> int pthread_mutex_trylock(pthread_mutex_t *mutex);
这个函数会尝试申请锁,如果长时间未能获取到锁,它会释放当前持有的锁并放弃加锁,给其他线程一个机会。
方法3:按照顺序申请锁
通过规定线程按照特定顺序申请和释放锁,避免环路等待。例如,线程首先按顺序申请锁资源,按照相同顺序释放锁资源,这样就可以避免死锁。
方法4:控制线程统一释放锁
锁并不一定要由申请锁的线程释放。可以设计一个专门的控制线程来管理锁资源,识别死锁并释放所有锁,打破死锁的局面。
例如,主线程可以释放次线程申请的锁:
cpp
#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、同步的相关概念
同步 :在保证数据安全的前提下,使得多个线程能够按照特定的顺序访问临界资源,从而避免饥饿问题。
有两个程序员,Alice 和 Bob,他们都需要使用一台打印机来打印文档。每个人打印完文档后,都需要归还打印机才能让其他人使用。
情境是这样的:
Alice 是一个工作效率非常高的程序员,她打印完文档后,快速归还打印机,但一看到旁边等着的 Bob 也想用,她决定再打印一些文件。
Bob 每次等 Alice 打印完才能使用打印机,但每当他有机会使用时,Alice 又抢先使用了。Bob 等得有些焦虑,感觉自己永远没有机会打印。
在这个例子里,虽然 Alice 并没有违反任何规则,遵循了"谁使用了打印机,谁就归还"的规定,但由于她不断在打印后重新拿起打印机,导致 Bob 无法及时使用打印机,陷入了等待的状态。Bob 即使站在旁边,也无法顺利进行工作。
这就是典型的饥饿问题。Alice 没有错,她只是因为工作高效总是先使用打印机,但这种行为使得 Bob 一直处于"饥饿状态",无法获取自己需要的资源。
为了避免这种情况,可以采取同步措施,例如排队规则,确保 Alice 打印完后必须等 Bob 使用完再拿回去,而不是随时拿回去。这样可以确保每个人使用打印机的机会是公平的,避免资源总是被某一方占用。
通过这种方法,就能有效避免 Alice 和 Bob 之间因资源争夺而造成的饥饿问题,保证他们都能顺利完成工作。
原生线程库中提供了条件变量来实现线程同步。通过条件变量,线程可以在访问临界资源时按照一定的顺序进行操作,从而避免出现饥饿问题。
条件变量的工作原理是:当一个线程互斥地访问某个变量时,如果它发现其他线程改变状态之前,它什么也做不了。比如,当一个线程访问队列时,发现队列为空,它只能等待,直到其他线程往队列中添加数据,才可以继续执行。这时,条件变量就能够解决这个问题。
条件变量的本质是衡量资源的状态。它通过队列来保证线程访问资源时的顺序性,队列中的线程会按照一定的顺序执行。
竞态条件:由于时序问题,可能导致程序执行异常。
可以将条件变量看作一个结构体,其中包含一个队列结构,用来存储等待资源的线程信息。当条件满足时,条件变量会从队列中取出队头线程进行操作,操作完成后,线程会重新进入队尾。

6.2、同步相关操作
条件变量创建与销毁
原生线程库中的条件变量使用方法与互斥锁类似,条件变量类型为 pthread_cond_t,需要进行初始化。
cpp
#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*
** 是初始化时的属性,设置为nullptr
表示使用默认属性。 -
返回值:成功返回 0,失败返回错误号。
在使用结束后,需要销毁条件变量:
cpp
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
-
参数:**
pthread_cond_t*
**是要销毁的条件变量。 -
返回值:成功返回 0,失败返回错误号。
注意 :与互斥锁一样,条件变量支持静态分配。对于全局条件变量,可以使用:
cpp
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
条件等待
pthread_cond_wait 函数用于让线程等待条件变量满足条件:
cpp
#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,失败返回错误号。
传递互斥锁的理由:
-
条件变量本身也是临界资源,需要保护。
-
当条件不满足时(即没有被唤醒),当前持有锁的线程会被挂起,其他线程仍在等待锁资源。因此,为了避免死锁,条件变量需要具备自动释放锁的能力。
唤醒线程
通过**pthread_cond_signal
函**数唤醒等待条件变量的线程:
cpp
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
-
参数:**
pthread_cond_t*
,**表示想要从哪个条件变量中唤醒线程。 -
返回值:成功返回 0,失败返回错误号。
注意 :pthread_cond_signal
一次只会唤醒一个线程,即队头线程。如果想要唤醒所有线程,可以使用 pthread_cond_broadcast
。
cpp
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond);
参数和返回值与前者一致,**broadcast
**表示广播,意味着会通知所有等待该条件变量的线程。
6.3、简单同步 Demo
下面我们通过一个简单的同步例子,创建5个线程,主线程负责唤醒它们。
单个唤醒
cpp
#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;
}
运行结果:在单个唤醒模式下,每次只有一个线程被唤醒,并且线程唤醒的顺序是一致的。
广播唤醒
将唤醒方式更改为广播:
cpp
pthread_cond_broadcast(&cond); // 广播唤醒
这时,主线程会一次唤醒所有线程,且线程唤醒顺序仍然保持一致。