Linux 多线程——线程互斥:从抢票问题到 Mutex 底层原理
2026/9/1 4:51:00 网站建设 项目流程

一、线程为什么需要互斥

同一进程中的多个线程共享进程的地址空间,因此多个线程可能同时访问同一份数据。

例如:

int ticket = 100;

如果创建多个线程共同执行售票逻辑:

if (ticket > 0) { printf("sell ticket:%d\n", ticket); ticket--; }

多个线程都会访问和修改同一个ticket

如果这些线程的执行过程发生交叉,就可能造成数据不一致。

因此,在学习互斥锁之前,需要先理解几个基本概念。讲义首先给出了共享资源、临界资源、临界区、互斥和原子性的概念。

1. 共享资源

可以被多个执行流共同访问的资源,就是共享资源。

例如:

int ticket = 100;

多个线程都可以访问ticket,所以ticket是共享资源。

2. 临界资源

需要受到保护、不能被多个线程随意并发访问的共享资源,称为临界资源。

例如售票程序中的:

ticket

就是临界资源。

3. 临界区

线程中访问临界资源的那部分代码,称为临界区。

例如:

if (ticket > 0) { printf("ticket = %d\n", ticket); ticket--; }

这段代码访问和修改了ticket,所以它属于临界区。

简单记:

ticket ↓ 临界资源 访问 ticket 的代码 ↓ 临界区

4. 互斥

互斥指的是:

同一时刻只允许一个执行流进入临界区访问临界资源。

例如:

线程1 ──→ 进入临界区 线程2 ──→ 等待 线程3 ──→ 等待 线程4 ──→ 等待

需要特别注意:

互斥并不是让整个程序变成单线程,而只是保证临界区同一时刻只能由一个线程执行。

5. 原子性

原子操作指的是一个操作在执行过程中不能被其他执行流看到“执行一半”的状态。

简单理解:

要么完全没执行 要么已经全部执行完成

不存在中间状态。


二、为什么抢票程序会出问题

先看没有加锁的版本:

int ticket = 100; void* route(void* arg) { char* id = (char*)arg; while (true) { if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; } else { break; } } return nullptr; }

创建多个线程同时运行后,可能出现:

thread 4 sells ticket:1 thread 2 sells ticket:0 thread 1 sells ticket:-1 thread 3 sells ticket:-2

讲义中的抢票实验就出现了票数变成0、-1、-2的情况。

问题的关键就在:

ticket--;

看起来只有一行,但它并不是原子操作。


三、ticket--为什么不是原子的

从底层汇编角度看,ticket--大致可以分成三步:

mov ticket, %eax sub $1, %eax mov %eax, ticket

分别对应:

1. load 从内存读取 ticket 到寄存器 2. update 寄存器中的值减 1 3. store 把结果重新写回内存

讲义也正是通过load → update → store三个步骤说明--ticket不是原子操作。

所以 CPU 完全可能在这三步之间发生线程切换。

假设:

ticket = 1

线程 A:

读取 ticket = 1

此时 CPU 把 A 切走。

线程 B 开始运行,也读取:

ticket = 1

于是:

线程A认为还有1张票 线程B也认为还有1张票

最终两个线程都可能执行售票。

问题的本质就是:

多个线程对共享数据的操作发生了交叉。


四、为什么if(ticket > 0)也没用

有人可能会觉得:

if (ticket > 0) { ticket--; }

不是已经检查过了吗?

但是:

if (ticket > 0)

和:

ticket--;

并不是一个不可分割的整体。

例如:

线程A: 判断 ticket > 0 ↓ 成立 ↓ CPU切换 线程B: 判断 ticket > 0 ↓ 成立 ↓ 卖票并 ticket-- CPU重新切回线程A 线程A继续卖票

因此:

判断时条件成立,不代表真正执行修改时条件仍然成立。

所以售票中的:

判断 + 输出 + ticket--

必须作为一个整体进行保护。


五、使用 mutex 实现线程互斥

Linux pthread 库提供了互斥量:

pthread_mutex_t

基本使用结构就是:

pthread_mutex_lock(&mutex); // 临界区 pthread_mutex_unlock(&mutex);

多个线程竞争同一把锁时:

mutex │ ┌────────┼────────┐ ↓ ↓ ↓ thread1 thread2 thread3 │ 抢锁成功 │ ↓ 临界区 thread2、thread3等待

当一个线程已经进入临界区后,其他线程就不能再进入。

讲义总结互斥要求时指出:临界区执行过程中不能让其他线程进入;多个线程同时申请进入临界区时,只能有一个线程成功。Linux 中通过互斥量完成这种保护。


六、pthread mutex基本接口

1. 定义互斥量

pthread_mutex_t mutex;

2. 初始化

动态初始化:

pthread_mutex_init(&mutex, nullptr);

完整接口:

int pthread_mutex_init( pthread_mutex_t* mutex, const pthread_mutexattr_t* attr );

学习阶段第二个参数一般使用:

nullptr

也可以静态初始化:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

这两种方式都是讲义介绍的标准初始化方法。


3. 加锁

pthread_mutex_lock(&mutex);

如果锁当前没有被其他线程持有:

锁空闲 ↓ 当前线程抢锁成功 ↓ 进入临界区

如果锁已经被其他线程持有:

锁被占用 ↓ 当前线程抢锁失败 ↓ 阻塞等待

讲义指出,如果 mutex 已经被其他线程锁定,pthread_mutex_lock可能使当前线程进入阻塞状态,等待锁被释放。


4. 解锁

pthread_mutex_unlock(&mutex);

表示当前线程已经完成临界区操作,可以让其他线程继续竞争这把锁。


5. 销毁

pthread_mutex_destroy(&mutex);

一般在线程全部结束,并且确定后面不会继续使用该 mutex 时销毁。


七、使用mutex改造抢票程序

核心代码如下:

int ticket = 100; pthread_mutex_t mutex; void* route(void* arg) { const char* id = static_cast<const char*>(arg); while (true) { pthread_mutex_lock(&mutex); if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; pthread_mutex_unlock(&mutex); } else { pthread_mutex_unlock(&mutex); break; } } return nullptr; }

整个过程:

lock ↓ 判断 ticket ↓ 售票 ↓ ticket-- ↓ unlock

讲义中的改进版本也是通过这种方式保护整个售票临界区。

这里有一个很重要的细节:

else { pthread_mutex_unlock(&mutex); break; }

不能写成:

else { break; }

因为在执行break之前,当前线程仍然持有 mutex。

如果直接退出:

线程A获得锁 ↓ 发现没票 ↓ 直接break ↓ 线程退出 ↓ 锁没有释放 ↓ 其他线程一直抢不到锁

所以要记住:

成功 lock 以后,最终一定要有对应的 unlock。


八、加锁以后线程还能被CPU切走吗

当然可以。

例如:

pthread_mutex_lock(&mutex); ticket--; pthread_mutex_unlock(&mutex);

假设线程 A 获得 mutex:

线程A获得锁 ↓ 开始执行临界区 ↓ CPU把A切走

这时候线程 B 开始运行,并调用:

pthread_mutex_lock(&mutex);

但是 mutex 仍然属于 A,因此:

线程B抢锁失败 ↓ 等待

等 A 再次被调度:

线程A继续运行 ↓ 完成临界区 ↓ unlock

B 才有机会继续竞争锁。

因此:

mutex 不是禁止线程调度。

mutex 真正保证的是:

即使持锁线程被 CPU 切换出去,其他线程也不能进入同一个临界区。


九、为什么锁本身必须依赖原子操作

假设我们自己设计一把锁:

mutex = 1:锁空闲 mutex = 0:锁被占用

然后:

if (mutex == 1) { mutex = 0; }

看起来似乎可以抢锁。

实际上还是不安全。

例如:

mutex = 1 线程A 线程B │ 发现mutex==1 发现mutex==1 │ mutex=0 mutex=0

两个线程最终都认为:

我获得锁了

问题在于:

检查 mutex

和:

修改 mutex

仍然是两个独立操作。

所以锁最重要的一步是:

“检查锁状态”和“修改锁状态”必须一次性完成。

这就需要 CPU 提供原子指令。


十、exchange / xchg如何实现互斥

讲义介绍了利用swap/exchange原子交换指令实现 mutex 的基本思想。

假设:

mutex = 1:锁空闲 mutex = 0:锁被占用

简化后的加锁伪代码:

lock: movb $0, %al xchgb %al, mutex if (%al > 0) return 0; 挂起等待; goto lock;

首先:

movb $0, %al

表示:

AL = 0

然后:

xchgb %al, mutex

原子交换:

AL 和 mutex

第一个线程抢锁

原来:

AL = 0 mutex = 1

交换后:

AL = 1 mutex = 0

AL = 1说明 mutex 原来处于空闲状态,因此:

当前线程抢锁成功

同时:

mutex = 0

表示锁已经被占用。


第二个线程抢锁

第二个线程同样先执行:

AL = 0

但是现在:

mutex = 0

交换:

AL = 0 mutex = 0

于是:

AL == 0

说明这把锁之前就已经被其他线程持有。

所以:

抢锁失败 ↓ 等待

多个线程竞争时:

mutex = 1 │ 原子 xchg │ ┌──────────┼──────────┐ ↓ ↓ ↓ thread1 thread2 thread3 │ │ │ 成功 失败 失败 │ mutex = 0 │ ↓ 临界区

所以互斥锁底层最核心的一点就是:

使用原子操作一次性完成“检查锁状态 + 修改锁状态”。

这样无论多少线程同时竞争,也只有一个线程能够成功。


十一、为什么unlock可以直接释放锁

释放锁时不需要再次竞争。

因为执行 unlock 的线程本身已经持有这把锁。

所以只需要把:

mutex = 0

恢复成:

mutex = 1

例如:

movb $1, mutex

过程:

线程持有mutex ↓ 完成临界区 ↓ mutex = 1 ↓ 锁重新空闲 ↓ 唤醒等待线程

需要注意:

被唤醒的线程并不是直接获得锁。

它只是重新进入:

lock ↓ xchg ↓ 重新竞争mutex

因此:

lock

负责竞争锁;

unlock

负责释放锁。

另外,同一个临界资源如果在多个地方被访问,都应该遵守同一套加锁规则,否则仍然可能发生数据竞争。


十二、使用RAII管理互斥锁

使用 pthread 时,我们经常手动写:

pthread_mutex_lock(&mutex); // 临界区 pthread_mutex_unlock(&mutex);

最大的风险就是:

忘记 unlock。

例如:

pthread_mutex_lock(&mutex); if (error) { return nullptr; // 忘记解锁 } pthread_mutex_unlock(&mutex);

因此可以把 mutex 封装起来。

讲义中首先封装了Mutex类,再通过LockGuard实现 RAII 风格的锁管理。

例如:

class LockGuard { public: LockGuard(Mutex& mutex) : _mutex(mutex) { _mutex.Lock(); } ~LockGuard() { _mutex.Unlock(); } private: Mutex& _mutex; };

使用:

LockGuard guard(mutex);

创建对象时:

构造函数 ↓ Lock()

对象离开作用域时:

析构函数 ↓ Unlock()

这就是 RAII:

利用对象生命周期自动管理资源。

这样即使:

return; break;

提前离开作用域,guard的析构函数仍然会自动调用,从而释放 mutex。


十三、C++11中的标准互斥锁

C++11 已经提供了:

#include <mutex> std::mutex mutex;

以及:

std::lock_guard<std::mutex>

例如:

std::mutex mutex; void func() { std::lock_guard<std::mutex> guard(mutex); // 临界区 }

执行过程:

创建 guard ↓ 自动 lock ↓ 执行临界区 ↓ 离开作用域 ↓ guard 析构 ↓ 自动 unlock

互斥题分析

第一题:if中应该判断是否有锁,即lock=false

第二题: 左边swap只有一条语句,交换具有原子性,右边有3条语句实现,每一条语句在实现时都有可能被其他线程切换走,不具备原子性,所以不能

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询