Linux系统多线程的互斥问题
Linux系统多线程的互斥问题
咱们先提出一个问题:如果多个线程都能访问同一个全局变量,比如用全局变量存票数,让多个线程并发抢票,会不会出问题。今天咱们就从这个问题入手,一步步扒开Linux多线程“互斥”的面纱,从问题复现到原理剖析,再到解决方案落地,最终解决多线程共享资源的问题。
一、先踩坑:多线程抢票为啥会出现“负数票”?
咱们先不聊理论,直接上场景——现实里电影院1000张票,最多卖1000张,绝不能卖1001张,更不能出现“-1张票”。那如果用多线程模拟这个过程,会不会出现这种离谱的情况?咱们一起写段代码试试
1.1 抢票场景的代码实现
首先,咱们定义一个全局变量int tickets = 1000;
,代表1000张票。然后创建4个线程,每个线程执行get_ticket
函数——核心逻辑就是“判断票数>0 → 抢票(打印线程名和抢到的票号)→ 票数减1”,中间加个usleep(1000)
模拟抢票时的网络延迟(比如更新数据库的时间)。
代码的大致结构是这样的(咱们用C++封装线程数据,方便打日志):
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;// 全局票数(共享资源)
int tickets = 1000;// 线程数据:存线程名,方便打印日志
class ThreadData {
public:string name;ThreadData(int id) {name = "Thread-" + to_string(id); // 线程名:Thread-1、Thread-2...}
};// 抢票函数:每个线程执行的逻辑
void* get_ticket(void* arg) {ThreadData* data = (ThreadData*)arg;while (true) {// 1. 判断票数是否大于0if (tickets > 0) {// 模拟抢票耗时(比如更新数据库)usleep(1000); // 2. 打印抢到的票号cout << data->name << " 抢到票:" << tickets << endl;// 3. 票数减1tickets--;} else {// 没票了,退出循环cout << data->name << " 没票了,退出抢票" << endl;break;}}delete data; // 释放线程数据return nullptr;
}int main() {vector<pthread_t> tids; // 存线程IDvector<ThreadData*> thread_datas; // 存线程数据(避免野指针)// 创建4个线程for (int i = 1; i <= 4; ++i) {ThreadData* data = new ThreadData(i);thread_datas.push_back(data);pthread_t tid;// 创建线程,执行get_ticket,传入线程数据int ret = pthread_create(&tid, nullptr, get_ticket, data);if (ret != 0) {cerr << "创建线程" << i << "失败!" << endl;return -1;}tids.push_back(tid);}// 等待所有线程结束for (pthread_t tid : tids) {pthread_join(tid, nullptr);}// 释放线程数据for (ThreadData* data : thread_datas) {delete data;}cout << "最终剩余票数:" << tickets << endl;return 0;
}
大家可以把这段代码复制到Linux里编译运行(编译命令:g++ ticket.cpp -o ticket -lpthread
),跑个几次试试。我敢打赌,不出3次,你一定会看到这样的结果:
- 有的线程打印“抢到票:1”,接着另一个线程打印“抢到票:1”
- 最后剩余票数是
-1
甚至-2
- 明明1000张票,却打印出1001条“抢到票”的日志
是不是很离谱?1000张票怎么能卖出1001张?还能出现负数票?这就是多线程并发访问共享资源时的“数据不一致问题”。咱们先别急着解决,先搞懂“为什么会这样”——只有知道根因,才能找到靠谱的解决方案。
1.2 问题根因:线程切换+非原子操作
很多人会说“不就是if(tickets>0)
和tickets--
吗?怎么会错?”。但大家不知道的是,这两句看似简单的代码,在CPU层面其实是“多步操作”,而且线程随时可能被切换——这就是问题的核心。
1.2.1 先搞懂:tickets--
不是“一步到位”
咱们在C语言里写tickets--
,看起来是“减1”一个动作,但CPU要干3件事:
- 读(Load):把内存里的
tickets
值读到CPU的寄存器里(比如内存里是1000,读到寄存器就变成1000); - 改(Modify):在寄存器里把这个值减1(1000→999);
- 写(Store):把寄存器里的结果写回内存(把999写回内存,覆盖原来的1000)。
这三步不是“一口气干完”的——Linux的线程调度是抢占式的,线程在执行任何一步后,都可能被操作系统“切走”,把CPU让给其他线程。
举个具体的例子,假设现在 tickets=1000
,线程A和线程B同时抢票:
- 线程A执行第一步(读):把1000读到自己的寄存器,刚读完,被操作系统切走了;
- 线程B开始执行:同样读内存里的1000到自己的寄存器,然后完成减1(变成999),再写回内存——此时内存里
tickets=999
; - 线程A被切回来,继续执行第二步(改):它寄存器里的1000减1变成999;
- 线程A执行第三步(写):把999写回内存——此时内存里还是999。
你看,线程A和线程B都“抢了一次票”,但 tickets
只从1000变成999,相当于“漏算了一次减1”;更糟的是,如果多个线程都读了同一个值,最后写回的时候会互相覆盖,导致票数统计错误。
1.2.2 再注意:if(tickets>0)
也有坑
除了tickets--
,判断if(tickets>0)
也不是“一步到位”——它同样需要把内存里的 tickets
读到寄存器,再和0做逻辑比较。如果多个线程同时执行这个判断,也会出问题。
比如现在 tickets=1
,线程A、B、C同时执行判断:
- 线程A读
tickets=1
到寄存器,判断“1>0”成立,刚要执行usleep
,被切走; - 线程B读
tickets=1
到寄存器,判断成立,也被切走; - 线程C读
tickets=1
到寄存器,判断成立,继续执行——它抢了票,tickets
变成0; - 线程A被切回来,继续执行
usleep
,然后打印“抢到票:1”(此时内存里已经是0了,但它不知道),再执行tickets--
——tickets
变成-1; - 线程B被切回来,同样打印“抢到票:1”,再
tickets--
——tickets
变成-2。
这就是为什么会出现“负数票”的原因:多个线程在判断时都认为“有票”,但实际后续执行减1时,票数已经被其他线程改完了,最后越减越小,变成负数。
1.2.3 底层本质:内存与CPU的交互逻辑
咱们再往底层挖一层——为什么线程切换会导致数据混乱?核心是“内存是共享的,寄存器是‘伪共享’的”。
- 全局变量
tickets
存在内存里,所有线程都能访问; - CPU的寄存器只有一套,但每个线程在执行时,会把内存里的数据“拷贝”到寄存器里计算——这部分拷贝的数据,会作为线程的“上下文”保存起来;
- 当线程被切换时,它会把寄存器里的临时数据(比如刚读到的
tickets=1000
)保存到自己的“线程控制块(PCB)”里;等它再次被调度时,再把这些数据恢复到寄存器,继续执行。
简单说:线程读数据到寄存器,本质是“拷贝一份到自己的上下文”——从这一刻起,它操作的是“自己的拷贝”,直到写回内存才会影响其他线程。如果在写回之前被切换,其他线程读的还是内存里的旧值,自然会出问题。
看到这里,应该能理解了吧?如果还没懂,咱们再举个生活例子:内存就像“公共黑板”,寄存器就像每个同学的“草稿本”。同学A把黑板上的“1000”抄到草稿本,刚要减1,老师叫他出去;同学B过来抄黑板上的“1000”,减1变成999,写到黑板上;同学A回来,继续在草稿本上减1变成999,再写到黑板上——黑板上还是999,相当于A白算了一次。这就是多线程数据不一致的本质。
二、破局思路:用“互斥”保证同一时间只有一个线程访问共享资源
既然问题出在“多个线程同时操作共享资源”,那解决方案就很明确了:保证同一时间,只有一个线程能访问共享资源(比如读/写 tickets
)——这就是“互斥”(Mutual Exclusion)的核心思想。
就像现实里的厕所:多个同学要上厕所,但同一时间只能有一个人用,其他人得排队等。这里的“厕所”是共享资源,“排队”就是互斥机制。在Linux多线程里,实现互斥的核心工具就是“互斥锁”(Mutex Lock)——咱们可以把它理解成“厕所的门钥匙”,谁拿到钥匙谁就能用,用完了还回去,下一个人再拿。
2.1 认识Linux的互斥锁:pthread_mutex_t
Linux的原生线程库(pthread)提供了完整的互斥锁接口,核心是pthread_mutex_t
类型的锁对象。咱们先记住它的4个核心操作:初始化、加锁、解锁、销毁。
2.1.1 互斥锁的4个核心接口
接口函数 | 功能描述 |
---|---|
pthread_mutex_init | 初始化互斥锁,给pthread_mutex_t 对象设置属性(默认属性一般够用) |
pthread_mutex_lock | 加锁:如果锁没被占用,当前线程拿到锁;如果已被占用,线程阻塞(等待锁释放) |
pthread_mutex_unlock | 解锁:释放锁,唤醒等待锁的线程(具体唤醒哪个,由操作系统调度决定) |
pthread_mutex_destroy | 销毁互斥锁,释放锁占用的资源(全局锁可以不用手动销毁) |
咱们先看接口的具体用法:
-
定义锁对象:
- 全局锁:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
(用宏初始化,不用手动init
和destroy
); - 局部锁:
pthread_mutex_t mutex;
(需要用pthread_mutex_init
初始化,用pthread_mutex_destroy
销毁)。
- 全局锁:
-
初始化局部锁:
pthread_mutex_t mutex; // 第二个参数是锁的属性,NULL表示默认属性 int ret = pthread_mutex_init(&mutex, NULL); if (ret != 0) {cerr << "初始化锁失败!" << endl;return -1; }
-
加锁和解锁:
// 加锁:没拿到锁会阻塞 pthread_mutex_lock(&mutex); // 访问共享资源的代码(临界区) if (tickets > 0) {usleep(1000);cout << data->name << " 抢到票:" << tickets << endl;tickets--; } // 解锁:必须在临界区结束后释放 pthread_mutex_unlock(&mutex);
-
销毁局部锁:
pthread_mutex_destroy(&mutex);
2.1.2 给抢票代码加锁:解决负数票问题
咱们把之前的抢票代码改一改,加上互斥锁,看看效果。首先定义一个全局锁:
// 全局互斥锁(用宏初始化,不用手动init和destroy)
pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER;
然后修改get_ticket
函数,在访问 tickets
前后加锁、解锁:
void* get_ticket(void* arg) {ThreadData* data = (ThreadData*)arg;while (true) {// 1. 加锁:没拿到锁会阻塞,直到拿到锁pthread_mutex_lock(&ticket_mutex);// 2. 临界区:访问共享资源(tickets)if (tickets > 0) {usleep(1000); // 模拟抢票耗时cout << data->name << " 抢到票:" << tickets << endl;tickets--;} else {// 没票了,先解锁再退出(不然锁会被带走,导致死锁)pthread_mutex_unlock(&ticket_mutex);cout << data->name << " 没票了,退出抢票" << endl;break;}// 3. 解锁:释放锁,让其他线程抢pthread_mutex_unlock(&ticket_mutex);}delete data;return nullptr;
}
这里有个关键点:没票的时候一定要先解锁再break!如果不解锁,线程退出时会带着锁走,其他线程永远拿不到锁,会一直阻塞——这就是后面要讲的“死锁”。
现在咱们再编译运行代码,你会发现:
- 再也没有“同一票号被多个线程抢到”的情况;
- 最终剩余票数一定是0;
- 每个线程抢票时,都是“排队”执行的,不会互相干扰。
这就是互斥锁的魔力——它把“多个线程并发访问共享资源”变成了“串行访问”,从而保证数据一致。
2.2 关键概念:临界资源与临界区
用锁的时候,咱们得明确两个概念——不然很容易把锁加错地方,要么导致死锁,要么降低并发效率。
2.2.1 临界资源:多个线程共享的资源
临界资源是指多个线程都需要访问的资源,比如:
- 全局变量(如
tickets
)、静态变量; - 堆内存(多个线程都能
malloc
和free
); - 文件描述符(多个线程都能读写同一个文件)。
这些资源的特点是“共享”——如果多个线程同时操作,就可能出问题,所以必须用互斥锁保护。
2.2.2 临界区:访问临界资源的代码段
临界区是指“访问临界资源的那部分代码”——比如抢票代码里的“判断票数>0 → 打印 → 票数减1”。
咱们用锁的时候,只需要把临界区保护起来,不需要把整个线程函数都加锁。比如抢票代码里的usleep(1000)
是“模拟抢票后的后续操作”,不访问 tickets
,就不用放在临界区里:
pthread_mutex_lock(&ticket_mutex);
// 临界区:只包含访问tickets的代码
if (tickets > 0) {cout << data->name << " 抢到票:" << tickets << endl;tickets--;
} else {pthread_mutex_unlock(&ticket_mutex);break;
}
pthread_mutex_unlock(&ticket_mutex);// 非临界区:不访问共享资源,不用加锁
usleep(1000); // 模拟后续操作,不影响tickets
为什么要缩小临界区?因为临界区是“串行执行”的——临界区越大,串行的代码越多,多线程的并发效率就越低。比如如果把usleep(1000)
放在临界区里,线程持有锁睡觉1毫秒,其他线程都得等1毫秒,效率会非常低。
记住:锁要加在“刀刃上”,临界区能小则小——这是多线程编程的核心优化原则之一。
三、深入原理:互斥锁为什么能保证“原子性”?
咱们用锁解决了问题,但肯定有人会问:“锁本身也是共享资源(多个线程都要申请锁),那谁来保证锁的安全?万一多个线程同时申请锁,会不会出问题?”
这个问题问得好!核心答案是:申请锁(pthread_mutex_lock
)和释放锁(pthread_mutex_unlock
)的操作本身是“原子的”——要么做了,要么没做,没有中间状态,所以不会出问题。
3.1 先明确:什么是“原子操作”?
“原子操作”(Atomic Operation)是指“不可被中断的操作”——在CPU执行这个操作的过程中,不会有其他线程或中断来打断它。比如咱们之前说的tickets--
不是原子的(分三步),但锁的lock
和unlock
是原子的。
为什么锁的操作必须是原子的?如果lock
不是原子的,比如线程A执行“检查锁是否空闲”,发现空闲,刚要“标记锁为占用”,就被切走;线程B也检查锁空闲,标记为占用——这样两个线程都拿到了锁,互斥就失效了。
3.2 底层实现:CPU的swap/exchange指令
锁的原子性,靠的是CPU硬件提供的“原子指令”——比如X86架构的XCHG
(交换)指令,ARM架构的SWP
(交换)指令。这些指令的特点是:一条指令完成“读取内存值→修改→写回内存”的过程,不可被中断。
咱们用XCHG
指令举个例子,看看锁的lock
操作是怎么实现的(伪代码):
// 假设锁的初始值是1(表示未占用),0表示已占用
// 1. 把寄存器eax的值设为0(代表“要占用锁”)
mov eax, 0
// 2. 用XCHG指令,交换eax和内存中锁的值
xchg eax, [mutex]
// 3. 判断交换后eax的值:如果是1,说明之前锁是空闲的,申请成功;如果是0,说明锁已被占用,阻塞
cmp eax, 1
jne 等待锁的逻辑
具体过程是这样的:
- 线程要申请锁时,先把寄存器
eax
设为0(代表“我要占用锁”); - 执行
XCHG
指令,把eax
的值(0)和内存中锁的值(比如1)交换——这一步是原子的,不会被中断; - 交换后,
eax
里是原来锁的值:- 如果
eax=1
:说明之前锁是空闲的,现在已经被当前线程占用(锁的内存值变成0),申请成功,继续执行; - 如果
eax=0
:说明锁已经被其他线程占用,当前线程进入等待队列,阻塞。
- 如果
解锁操作更简单,就是把锁的内存值设为1(表示释放),这一步也是原子的(一条MOV
指令):
// 把内存中锁的值设为1(释放锁)
mov [mutex], 1
3.3 多CPU场景的安全性
有人会问:“如果电脑有多个CPU(比如4核),每个CPU都有自己的寄存器,会不会多个CPU同时执行XCHG
指令,导致锁失效?”
不会!因为电脑里的“系统总线”(连接CPU和内存的通道)只有一条,CPU访问内存时,会通过“总线仲裁器”(硬件)保证“同一时间只有一个CPU能访问内存”。所以即使多个CPU同时执行XCHG
指令,总线仲裁器也会让它们排队执行,确保XCHG
的原子性。
简单说:不管是单CPU还是多CPU,锁的原子操作都能保证——这是硬件层面的保障,咱们不用操心。
四、锁的进阶:封装与避坑
咱们已经会用原生的pthread_mutex_t
了,但在实际开发中,直接用原生接口容易出问题——比如忘记解锁导致死锁,或者锁的初始化/销毁逻辑混乱。这时候就需要对锁进行“封装”,让代码更优雅、更安全。
4.1 RAII风格封装:用类管理锁的生命周期
RAII(Resource Acquisition Is Initialization)是C++的一种编程思想:“资源的获取就是初始化”——在类的构造函数中获取资源,在析构函数中释放资源,利用C++的对象生命周期自动管理资源。
咱们用RAII思想封装两个类:
Mutex
类:封装pthread_mutex_t
,负责锁的初始化和销毁;LockGuard
类:封装锁的lock
和unlock
,负责在作用域内自动加锁和解锁。
4.1.1 Mutex类的实现
#include <pthread.h>
#include <cerrno>
#include <cassert>class Mutex {
public:Mutex() {// 初始化锁,默认属性int ret = pthread_mutex_init(&mutex_, nullptr);assert(ret == 0); // 断言:初始化失败则崩溃(实际开发可抛异常)}~Mutex() {// 销毁锁int ret = pthread_mutex_destroy(&mutex_);assert(ret == 0);}// 加锁(供LockGuard调用)void lock() {int ret = pthread_mutex_lock(&mutex_);assert(ret == 0);}// 解锁(供LockGuard调用)void unlock() {int ret = pthread_mutex_unlock(&mutex_);assert(ret == 0);}// 获取原生锁对象(避免暴露细节,尽量少用)pthread_mutex_t* get() {return &mutex_;}private:pthread_mutex_t mutex_; // 原生锁对象// 禁止拷贝构造和赋值(避免锁被拷贝,导致多个对象操作同一把锁)Mutex(const Mutex&) = delete;Mutex& operator=(const Mutex&) = delete;
};
这里有个细节:咱们禁用了拷贝构造和赋值运算符——因为如果允许拷贝,多个Mutex
对象会操作同一把原生锁,可能导致重复销毁或解锁,引发错误。
4.1.2 LockGuard类的实现
class LockGuard {
public:// 构造函数:传入Mutex对象,自动加锁explicit LockGuard(Mutex& mutex) : mutex_(mutex) {mutex_.lock();}// 析构函数:自动解锁~LockGuard() {mutex_.unlock();}private:Mutex& mutex_; // 引用Mutex对象,避免拷贝// 禁止拷贝构造和赋值(LockGuard对象不能拷贝)LockGuard(const LockGuard&) = delete;LockGuard& operator=(const LockGuard&) = delete;
};
LockGuard
的核心逻辑是:
- 构造时调用
Mutex
的lock
,自动加锁; - 析构时调用
Mutex
的unlock
,自动解锁; - 当
LockGuard
对象出作用域时(比如临界区结束),析构函数会自动执行,解锁操作不用手动调用。
4.1.3 封装后的抢票代码
咱们用封装后的锁改写抢票代码,看看有多方便:
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include "Mutex.h" // 包含上面的Mutex和LockGuard类// 全局票数
int tickets = 1000;
// 全局Mutex对象(构造时自动初始化锁)
Mutex ticket_mutex;// 线程数据
class ThreadData {
public:string name;ThreadData(int id) : name("Thread-" + to_string(id)) {}
};void* get_ticket(void* arg) {ThreadData* data = (ThreadData*)arg;while (true) {// 定义LockGuard对象:构造时自动加锁LockGuard lock(ticket_mutex);// 临界区:访问共享资源if (tickets > 0) {cout << data->name << " 抢到票:" << tickets << endl;tickets--;} else {// 没票了,退出循环(LockGuard出作用域会自动解锁)cout << data->name << " 没票了,退出抢票" << endl;break;}// LockGuard出作用域,自动调用析构函数解锁}// 非临界区:模拟后续操作usleep(1000);delete data;return nullptr;
}// main函数和之前类似,省略...
发现没?用LockGuard
之后,咱们再也不用手动调用lock
和unlock
了——即使在临界区里用break
或return
退出,LockGuard
的析构函数也会自动解锁,从根本上避免了“忘记解锁导致死锁”的问题。
这就是封装的好处:隐藏底层细节,减少人为错误,让代码更易维护。实际开发中,大家一定要用这种方式管理锁。
4.2 锁的常见坑:饥饿与死锁
用锁解决了互斥问题,但如果用不好,会引入新的问题——比如“饥饿”和“死锁”。
4.2.1 饥饿问题:有的线程永远抢不到锁
“饥饿”是指:某个线程长时间申请不到锁,一直处于阻塞状态,无法继续执行。比如咱们之前的抢票代码,如果去掉usleep(1000)
:
while (true) {LockGuard lock(ticket_mutex);if (tickets > 0) {cout << data->name << " 抢到票:" << tickets << endl;tickets--;} else {break;}// 没有usleep,释放锁后立马又申请锁
}
此时会出现什么情况?线程A释放锁后,因为它还在while
循环里,会立马再次申请锁——而其他线程还在等待队列里,没来得及被唤醒,线程A就又拿到了锁。结果就是:线程A一直抢票,其他线程永远抢不到,这就是“饥饿”。
怎么避免饥饿?
- 模拟“后续操作”:在临界区外加
usleep
或其他耗时操作,让当前线程释放锁后,有时间让其他线程申请锁; - 使用“公平锁”:pthread_mutex_t支持“公平锁”属性(通过
pthread_mutexattr_settype
设置为PTHREAD_MUTEX_FAIR
),公平锁会按照线程申请锁的顺序唤醒线程,避免某个线程一直抢不到; - 减少锁持有时间:缩小临界区,让线程持有锁的时间尽可能短,其他线程等待的时间也会缩短。
4.2.2 死锁问题:线程互相等待对方的锁
“死锁”是比饥饿更严重的问题——多个线程互相持有对方需要的锁,都不释放,导致所有线程都阻塞,永远无法继续执行。比如:
- 线程A持有锁A,需要申请锁B;
- 线程B持有锁B,需要申请锁A;
- 两者都不释放自己的锁,也拿不到对方的锁,一直等待。
更离谱的是:一把锁也能导致死锁!比如线程申请同一把锁两次:
LockGuard lock1(ticket_mutex); // 第一次加锁,成功
// ... 其他代码
LockGuard lock2(ticket_mutex); // 第二次加锁,阻塞
线程第一次加锁成功后,第二次再申请同一把锁,会阻塞——但锁在它自己手里,永远不会释放,其他线程也申请不到锁,整个程序就卡死了。
怎么避免死锁?
- 避免重复申请同一把锁:确保每个线程对同一把锁只申请一次;
- 固定锁的申请顺序:如果多个线程需要申请多把锁,按固定顺序申请(比如先申请锁A,再申请锁B),所有线程都遵守这个顺序;
- 避免持有锁时阻塞:如果线程持有锁,尽量不要调用可能阻塞的函数(比如
pthread_join
、sleep
),如果必须阻塞,先释放锁; - 使用“定时锁”:pthread提供
pthread_mutex_timedlock
接口,如果超时还没拿到锁,会返回错误,线程可以释放已持有的锁,避免死锁; - 及时释放锁:用
LockGuard
等RAII工具,确保锁会被自动释放,避免忘记解锁。
五、延伸概念:线程安全与可重入
咱们聊完了锁,再扩展两个重要概念——“线程安全”和“可重入”,这两个概念经常被混淆,但其实侧重点完全不同。
5.1 线程安全:多线程并发访问的正确性
线程安全(Thread-Safe)是指:多个线程并发访问一段代码或一个函数时,无论线程的调度顺序如何,最终的结果都能符合预期,不会出现数据不一致、崩溃或其他错误。
比如咱们加了锁的抢票代码是“线程安全”的——多个线程抢票,最终票数是0,没有负数;但没加锁的代码是“线程不安全”的——会出现负数票。
常见的线程不安全情况:
- 访问全局变量、静态变量不加锁:比如
tickets
没加锁,多个线程同时修改; - 调用线程不安全的函数:比如
printf
(用了全局缓冲区,多线程打印会乱码)、strtok
(用了静态变量); - 函数状态随调用变化:比如一个函数用静态变量统计调用次数,多线程调用时统计结果会错。
常见的线程安全情况:
- 不访问共享资源:函数只操作局部变量(栈上的变量),比如
int add(int a, int b) { return a + b; }
; - 访问共享资源但加锁保护:比如加了
Mutex
的抢票逻辑; - 使用原子操作:比如用
std::atomic
(C++11)或CPU原子指令操作共享变量。
5.2 可重入函数:多个执行流同时调用的正确性
可重入函数(Reentrant Function)是指:同一个函数被多个执行流(比如线程、信号处理函数)调用时,即使一个执行流还没执行完,另一个执行流又进来,最终的结果也能符合预期,不会出现错误。
比如strlen
函数是可重入的——它只操作参数传入的字符串,用局部变量计算长度,多个线程同时调用也不会错;但strtok
函数是不可重入的——它用静态变量保存上次分割的位置,多个线程调用会互相干扰。
可重入函数的条件:
- 不使用全局变量、静态变量;
- 不调用不可重入的函数;
- 不使用
malloc
/free
(堆内存是共享的); - 不使用文件描述符等共享资源;
- 只使用函数参数和栈上的局部变量。
不可重入函数的例子:
// 不可重入:用了静态变量
char* my_strtok(char* str, const char* delimiters) {static char* last; // 静态变量,保存上次分割的位置if (str != NULL) {last = str;}// ... 分割逻辑,依赖last的值return result;
}
如果线程A调用my_strtok("a,b,c", ",")
,刚执行到一半被切走;线程B调用my_strtok("x,y,z", ",")
,会修改last
的值;线程A回来继续执行时,last
已经被改了,结果就会错。
5.3 线程安全与可重入的关系
很多人会把这两个概念搞混,咱们用两句话总结它们的关系:
- 可重入函数一定是线程安全的:因为可重入函数不使用共享资源,多个线程调用不会有竞争;
- 线程安全函数不一定是可重入的:比如一个函数访问全局变量,但加了锁——它是线程安全的(多个线程排队访问),但不是可重入的(如果被信号处理函数调用,信号处理函数会阻塞在锁上,导致死锁)。
举个例子:
add(int a, int b)
:可重入 → 线程安全;- 加锁的
get_ticket
函数:线程安全 → 不可重入(如果被信号处理函数调用,会阻塞在锁上); - 没加锁的
get_ticket
函数:既不是线程安全,也不是可重入。
记住这个关系,以后写代码或用别人的函数时,就能判断是否需要加锁了。
六、总结与答疑
咱们花了这么多时间,从问题复现到原理剖析,再到封装避坑,把Linux多线程的互斥问题讲透了。最后咱们总结一下核心知识点,再解答几个常见问题。
6.1 核心知识点总结
- 多线程共享资源的问题:多个线程并发访问共享资源(如全局变量)时,由于线程切换和非原子操作,会导致数据不一致(如负数票);
- 互斥锁的作用:通过
pthread_mutex_t
保证同一时间只有一个线程访问临界区,解决数据不一致问题; - 锁的核心概念:
- 临界资源:多个线程共享的资源;
- 临界区:访问临界资源的代码段(尽量缩小);
- 原子操作:锁的
lock
和unlock
靠CPU原子指令实现;
- 锁的封装:用RAII风格的
Mutex
和LockGuard
类,自动管理锁的生命周期,避免忘记解锁; - 锁的坑点:
- 饥饿:某个线程长时间抢不到锁,需用公平锁或增加锁释放后的延迟;
- 死锁:多个线程互相等待锁,需固定申请顺序、避免重复加锁;
- 线程安全与可重入:可重入函数一定线程安全,线程安全函数不一定可重入。
6.2 常见问题答疑
Q1:主线程退出后,其他线程会怎么样?
A:分两种情况:
- 如果主线程用
exit
退出:整个进程会终止,所有线程都会被强制退出; - 如果主线程用
pthread_exit
退出:主线程自己退出,其他线程会继续执行,直到完成任务。
建议大家在main
函数里用pthread_join
等待所有子线程结束,再退出主线程,避免子线程还在执行时进程被终止。
Q2:锁的属性有哪些?除了默认属性还有什么用?
A:pthread_mutex_t的常见属性有三种:
PTHREAD_MUTEX_NORMAL
(默认):普通锁,不检查死锁,同一线程重复加锁会导致死锁;PTHREAD_MUTEX_ERRORCHECK
(错误检查锁):会检查死锁情况,如果同一线程重复加锁,会返回错误码;PTHREAD_MUTEX_RECURSIVE
(递归锁):允许同一线程多次加锁,解锁次数要和加锁次数一致,避免死锁。
递归锁适合“函数A调用函数B,两者都需要加同一把锁”的场景,但要注意解锁次数,避免漏解。
Q3:多线程打印cout
为什么会乱码?怎么解决?
A:cout
是线程不安全的——它用了全局的缓冲区,多个线程同时打印时,会把数据写到同一个缓冲区,导致乱码。
解决办法:给cout
加锁,比如定义一个PrintMutex
,每次打印前加锁,打印后解锁:
Mutex print_mutex;
#define SAFE_COUT(msg) { \LockGuard lock(print_mutex); \cout << msg << endl; \
}// 使用时:
SAFE_COUT(data->name << " 抢到票:" << tickets);
Q4:原子变量(如std::atomic<int>
)和互斥锁有什么区别?
A:原子变量适合“简单的共享变量操作”(如加1、减1、赋值),它的操作是原子的,效率比锁高;互斥锁适合“复杂的共享资源访问”(如多个变量的操作、函数调用),可以保护一段代码。
比如std::atomic<int> tickets(1000);
,tickets--
是原子的,不用加锁;但如果要同时操作tickets
和user_count
两个变量,就需要用互斥锁保护。