当前位置: 首页 > news >正文

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件事:

  1. 读(Load):把内存里的 tickets值读到CPU的寄存器里(比如内存里是1000,读到寄存器就变成1000);
  2. 改(Modify):在寄存器里把这个值减1(1000→999);
  3. 写(Store):把寄存器里的结果写回内存(把999写回内存,覆盖原来的1000)。

这三步不是“一口气干完”的——Linux的线程调度是抢占式的,线程在执行任何一步后,都可能被操作系统“切走”,把CPU让给其他线程。

举个具体的例子,假设现在 tickets=1000,线程A和线程B同时抢票:

  1. 线程A执行第一步(读):把1000读到自己的寄存器,刚读完,被操作系统切走了;
  2. 线程B开始执行:同样读内存里的1000到自己的寄存器,然后完成减1(变成999),再写回内存——此时内存里 tickets=999
  3. 线程A被切回来,继续执行第二步(改):它寄存器里的1000减1变成999;
  4. 线程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同时执行判断:

  1. 线程A读 tickets=1到寄存器,判断“1>0”成立,刚要执行usleep,被切走;
  2. 线程B读 tickets=1到寄存器,判断成立,也被切走;
  3. 线程C读 tickets=1到寄存器,判断成立,继续执行——它抢了票, tickets变成0;
  4. 线程A被切回来,继续执行usleep,然后打印“抢到票:1”(此时内存里已经是0了,但它不知道),再执行tickets--—— tickets变成-1;
  5. 线程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销毁互斥锁,释放锁占用的资源(全局锁可以不用手动销毁)

咱们先看接口的具体用法:

  1. 定义锁对象

    • 全局锁:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;(用宏初始化,不用手动initdestroy);
    • 局部锁:pthread_mutex_t mutex;(需要用pthread_mutex_init初始化,用pthread_mutex_destroy销毁)。
  2. 初始化局部锁

    pthread_mutex_t mutex;
    // 第二个参数是锁的属性,NULL表示默认属性
    int ret = pthread_mutex_init(&mutex, NULL);
    if (ret != 0) {cerr << "初始化锁失败!" << endl;return -1;
    }
    
  3. 加锁和解锁

    // 加锁:没拿到锁会阻塞
    pthread_mutex_lock(&mutex);
    // 访问共享资源的代码(临界区)
    if (tickets > 0) {usleep(1000);cout << data->name << " 抢到票:" << tickets << endl;tickets--;
    }
    // 解锁:必须在临界区结束后释放
    pthread_mutex_unlock(&mutex);
    
  4. 销毁局部锁

    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)、静态变量;
  • 堆内存(多个线程都能mallocfree);
  • 文件描述符(多个线程都能读写同一个文件)。

这些资源的特点是“共享”——如果多个线程同时操作,就可能出问题,所以必须用互斥锁保护。

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--不是原子的(分三步),但锁的lockunlock是原子的。

为什么锁的操作必须是原子的?如果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 等待锁的逻辑

具体过程是这样的:

  1. 线程要申请锁时,先把寄存器eax设为0(代表“我要占用锁”);
  2. 执行XCHG指令,把eax的值(0)和内存中锁的值(比如1)交换——这一步是原子的,不会被中断;
  3. 交换后,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思想封装两个类:

  1. Mutex类:封装pthread_mutex_t,负责锁的初始化和销毁;
  2. LockGuard类:封装锁的lockunlock,负责在作用域内自动加锁和解锁。
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的核心逻辑是:

  • 构造时调用Mutexlock,自动加锁;
  • 析构时调用Mutexunlock,自动解锁;
  • 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之后,咱们再也不用手动调用lockunlock了——即使在临界区里用breakreturn退出,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一直抢票,其他线程永远抢不到,这就是“饥饿”。

怎么避免饥饿?

  1. 模拟“后续操作”:在临界区外加usleep或其他耗时操作,让当前线程释放锁后,有时间让其他线程申请锁;
  2. 使用“公平锁”:pthread_mutex_t支持“公平锁”属性(通过pthread_mutexattr_settype设置为PTHREAD_MUTEX_FAIR),公平锁会按照线程申请锁的顺序唤醒线程,避免某个线程一直抢不到;
  3. 减少锁持有时间:缩小临界区,让线程持有锁的时间尽可能短,其他线程等待的时间也会缩短。
4.2.2 死锁问题:线程互相等待对方的锁

“死锁”是比饥饿更严重的问题——多个线程互相持有对方需要的锁,都不释放,导致所有线程都阻塞,永远无法继续执行。比如:

  • 线程A持有锁A,需要申请锁B;
  • 线程B持有锁B,需要申请锁A;
  • 两者都不释放自己的锁,也拿不到对方的锁,一直等待。

更离谱的是:一把锁也能导致死锁!比如线程申请同一把锁两次:

LockGuard lock1(ticket_mutex); // 第一次加锁,成功
// ... 其他代码
LockGuard lock2(ticket_mutex); // 第二次加锁,阻塞

线程第一次加锁成功后,第二次再申请同一把锁,会阻塞——但锁在它自己手里,永远不会释放,其他线程也申请不到锁,整个程序就卡死了。

怎么避免死锁?

  1. 避免重复申请同一把锁:确保每个线程对同一把锁只申请一次;
  2. 固定锁的申请顺序:如果多个线程需要申请多把锁,按固定顺序申请(比如先申请锁A,再申请锁B),所有线程都遵守这个顺序;
  3. 避免持有锁时阻塞:如果线程持有锁,尽量不要调用可能阻塞的函数(比如pthread_joinsleep),如果必须阻塞,先释放锁;
  4. 使用“定时锁”:pthread提供pthread_mutex_timedlock接口,如果超时还没拿到锁,会返回错误,线程可以释放已持有的锁,避免死锁;
  5. 及时释放锁:用LockGuard等RAII工具,确保锁会被自动释放,避免忘记解锁。

五、延伸概念:线程安全与可重入

咱们聊完了锁,再扩展两个重要概念——“线程安全”和“可重入”,这两个概念经常被混淆,但其实侧重点完全不同。

5.1 线程安全:多线程并发访问的正确性

线程安全(Thread-Safe)是指:多个线程并发访问一段代码或一个函数时,无论线程的调度顺序如何,最终的结果都能符合预期,不会出现数据不一致、崩溃或其他错误。

比如咱们加了锁的抢票代码是“线程安全”的——多个线程抢票,最终票数是0,没有负数;但没加锁的代码是“线程不安全”的——会出现负数票。

常见的线程不安全情况:
  1. 访问全局变量、静态变量不加锁:比如tickets没加锁,多个线程同时修改;
  2. 调用线程不安全的函数:比如printf(用了全局缓冲区,多线程打印会乱码)、strtok(用了静态变量);
  3. 函数状态随调用变化:比如一个函数用静态变量统计调用次数,多线程调用时统计结果会错。
常见的线程安全情况:
  1. 不访问共享资源:函数只操作局部变量(栈上的变量),比如int add(int a, int b) { return a + b; }
  2. 访问共享资源但加锁保护:比如加了Mutex的抢票逻辑;
  3. 使用原子操作:比如用std::atomic(C++11)或CPU原子指令操作共享变量。

5.2 可重入函数:多个执行流同时调用的正确性

可重入函数(Reentrant Function)是指:同一个函数被多个执行流(比如线程、信号处理函数)调用时,即使一个执行流还没执行完,另一个执行流又进来,最终的结果也能符合预期,不会出现错误。

比如strlen函数是可重入的——它只操作参数传入的字符串,用局部变量计算长度,多个线程同时调用也不会错;但strtok函数是不可重入的——它用静态变量保存上次分割的位置,多个线程调用会互相干扰。

可重入函数的条件:
  1. 不使用全局变量、静态变量;
  2. 不调用不可重入的函数;
  3. 不使用malloc/free(堆内存是共享的);
  4. 不使用文件描述符等共享资源;
  5. 只使用函数参数和栈上的局部变量。
不可重入函数的例子:
// 不可重入:用了静态变量
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 线程安全与可重入的关系

很多人会把这两个概念搞混,咱们用两句话总结它们的关系:

  1. 可重入函数一定是线程安全的:因为可重入函数不使用共享资源,多个线程调用不会有竞争;
  2. 线程安全函数不一定是可重入的:比如一个函数访问全局变量,但加了锁——它是线程安全的(多个线程排队访问),但不是可重入的(如果被信号处理函数调用,信号处理函数会阻塞在锁上,导致死锁)。

举个例子:

  • add(int a, int b):可重入 → 线程安全;
  • 加锁的get_ticket函数:线程安全 → 不可重入(如果被信号处理函数调用,会阻塞在锁上);
  • 没加锁的get_ticket函数:既不是线程安全,也不是可重入。

记住这个关系,以后写代码或用别人的函数时,就能判断是否需要加锁了。

六、总结与答疑

咱们花了这么多时间,从问题复现到原理剖析,再到封装避坑,把Linux多线程的互斥问题讲透了。最后咱们总结一下核心知识点,再解答几个常见问题。

6.1 核心知识点总结

  1. 多线程共享资源的问题:多个线程并发访问共享资源(如全局变量)时,由于线程切换和非原子操作,会导致数据不一致(如负数票);
  2. 互斥锁的作用:通过pthread_mutex_t保证同一时间只有一个线程访问临界区,解决数据不一致问题;
  3. 锁的核心概念
    • 临界资源:多个线程共享的资源;
    • 临界区:访问临界资源的代码段(尽量缩小);
    • 原子操作:锁的lockunlock靠CPU原子指令实现;
  4. 锁的封装:用RAII风格的MutexLockGuard类,自动管理锁的生命周期,避免忘记解锁;
  5. 锁的坑点
    • 饥饿:某个线程长时间抢不到锁,需用公平锁或增加锁释放后的延迟;
    • 死锁:多个线程互相等待锁,需固定申请顺序、避免重复加锁;
  6. 线程安全与可重入:可重入函数一定线程安全,线程安全函数不一定可重入。

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--是原子的,不用加锁;但如果要同时操作ticketsuser_count两个变量,就需要用互斥锁保护。

http://www.dtcms.com/a/391725.html

相关文章:

  • Python 之监控服务器服务
  • el-select 多选增加全部选项
  • Day24 窗口操作
  • 5. Linux 文件系统基本管理
  • 【MySQL】GROUP BY详解与优化
  • 深度学习:DenseNet 稠密连接​ -- 缓解梯度消失
  • Linux DNS 子域授权实践
  • 团体程序设计天梯赛-练习集 L1-041 寻找250
  • mellanox网卡(ConnectX-7)开启SACK
  • 相机镜头靶面
  • 【语法进阶】gevent的使用与总结
  • Java优选算法——前缀和
  • ARM不同层次开发
  • 【Python】高质量解析 PDF 文件框架和工具
  • RSS-2025 | 无地图具身导航新范式!CREStE:基于互联网规模先验与反事实引导的可扩展无地图导航
  • RNA-seq分析之共识聚类分析
  • Linux开发——ARM介绍
  • Force Dimension Sigma力反馈设备远程遥操作机械臂外科手术应用
  • 泛函驻点方程与边界条件的推导:含四阶导数与给定边界
  • C#开发USB报警灯服务,提供MES或者其它系统通过WebAPI调用控制报警灯
  • Docker基础篇08:Docker常规安装简介
  • 【软考-系统架构设计师】软件架构分析方法(SAAM)
  • 广西保安员考试题库及答案
  • 【Vue】Vue 项目中常见的埋点方案
  • 投稿之前去重还是投稿之后去重?
  • 【包教包会】CocosCreator3.x全局单例最优解
  • 为什么要使用dynamic_cast
  • 随机过程笔记
  • OpenHarmony:NDK开发
  • Dify 从入门到精通(第 87/100 篇):Dify 的多模态模型可观测性(高级篇)