文章目录
-
- Linux线程同步与互斥(一):线程互斥原理与mutex详解
- 一、为什么需要互斥
-
- [1.1 几个基本概念](#1.1 几个基本概念)
- [1.2 售票系统的数据竞争问题](#1.2 售票系统的数据竞争问题)
- [1.3 汇编层面看 ticket-- 的非原子性](#1.3 汇编层面看 ticket-- 的非原子性)
- 二、pthread_mutex:互斥锁
-
- [2.1 互斥锁的基本使用](#2.1 互斥锁的基本使用)
-
- [2.1.1 初始化](#2.1.1 初始化)
- [2.1.2 加锁和解锁](#2.1.2 加锁和解锁)
- [2.1.3 销毁锁](#2.1.3 销毁锁)
- [2.2 修复售票系统](#2.2 修复售票系统)
- [2.3 一个常见错误](#2.3 一个常见错误)
- 三、互斥量的底层原理
-
- [3.1 如何实现原子操作](#3.1 如何实现原子操作)
-
- [3.1.1 Test-And-Set指令](#3.1.1 Test-And-Set指令)
- [3.1.2 Compare-And-Swap指令](#3.1.2 Compare-And-Swap指令)
- [3.2 mutex的实现原理](#3.2 mutex的实现原理)
-
- [3.2.1 简化的实现思路](#3.2.1 简化的实现思路)
- [3.2.2 为什么要先自旋再休眠?](#3.2.2 为什么要先自旋再休眠?)
- 四、互斥量的RAII封装
-
- [4.1 手动加解锁的问题](#4.1 手动加解锁的问题)
- [4.2 RAII风格的封装](#4.2 RAII风格的封装)
-
- [4.2.1 Mutex类封装](#4.2.1 Mutex类封装)
- [4.2.2 LockGuard自动管理](#4.2.2 LockGuard自动管理)
- [4.2.3 使用示例](#4.2.3 使用示例)
- [4.3 对比C++11的std::lock_guard](#4.3 对比C++11的std::lock_guard)
- 五、互斥的三原则
-
- [5.1 三原则内容](#5.1 三原则内容)
- [5.2 违反原则的例子](#5.2 违反原则的例子)
- 六、本篇总结
-
- [6.1 核心知识点](#6.1 核心知识点)
- [6.2 最重要的理解](#6.2 最重要的理解)
Linux线程同步与互斥(一):线程互斥原理与mutex详解
💬 重磅来袭 :前面的文章把线程创建、管理、内存布局都讲清楚了,线程能跑了。但多个线程一起跑,马上就会遇到新麻烦:它们要访问同一份数据怎么办?比如售票系统,四个窗口同时卖票,票数是共享的,不加控制就会卖出负数票。这就是本篇要解决的核心问题------如何让多个线程安全地访问共享资源。我们会从一个会出错的售票代码开始,看看数据竞争是怎么发生的,然后引入互斥锁(mutex)来解决问题,最后深入到汇编层面理解为什么需要原子操作,并用RAII风格封装出好用的锁工具。
👍 点赞、收藏与分享:本篇包含大量实战代码、汇编分析、底层原理图示,是理解多线程并发安全的基础!如果对你有帮助,请点赞、收藏并分享!
🚀 循序渐进:从问题复现到原理分析,再到工具封装,一步步掌握互斥锁的使用。
一、为什么需要互斥
1.1 几个基本概念
先把后面要用到的术语理解清楚,这些概念是理解多线程安全的基础。
临界资源 :多个线程都能访问的共享数据。比如一个全局变量 int ticket = 100;,所有线程都能读写它,它就是临界资源。
临界区 :访问临界资源的那段代码。比如下面的 ticket--; 这一行就是临界区:
c
if (ticket > 0) {
ticket--; // ← 这是临界区
}
互斥:任何时刻只允许一个线程进入临界区。这是保护临界资源的核心手段。
原子性 :一个操作要么完成,要么未完成,中间不会被打断。比如 ticket--; 看起来是一条语句,但实际上不是原子的(后面会看汇编证明)。
📌 这里要明确:临界资源和临界区是一体的,保护临界资源就是保护临界区,让同一时刻只有一个线程在临界区里执行。
1.2 售票系统的数据竞争问题
看一个典型场景------多线程抢票系统。四个线程模拟四个售票窗口,共享一个票池。
c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 100;
void *route(void *arg) {
char *id = (char*)arg;
while (1) {
if (ticket > 0) {
usleep(1000); // 模拟售票业务处理
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
}
return NULL;
}
int main() {
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
直觉上这代码没问题:先判断 ticket > 0,然后卖票,最后 ticket--。但实际运行结果:
bash
thread 4 sells ticket:100
thread 1 sells ticket:99
thread 3 sells ticket:98
...
thread 4 sells ticket:3
thread 2 sells ticket:2
thread 1 sells ticket:1
thread 4 sells ticket:0
thread 3 sells ticket:-1
thread 2 sells ticket:-2
票卖成负数了!问题出在三个地方:
问题1:if 判断后可能切换线程
线程1判断 ticket > 0 为真,但还没执行 ticket--,就被调度走了。这时线程2、3、4进来,都看到 ticket > 0,都进入了临界区。
问题2:usleep 期间大量线程进入
这个模拟业务处理的 sleep,给了其他线程充足时间进入临界区。等线程1从 sleep 醒来,可能有好几个线程都拿到了同一张票。
问题3:ticket-- 不是原子操作
这是最关键的。我们以为 ticket-- 是一条指令,但实际上它会被编译成多条机器指令。
1.3 汇编层面看 ticket-- 的非原子性
用 objdump -d 反汇编看看 ticket--; 对应的机器码:
bash
$ gcc test.c -o test -pthread
$ objdump -d test > test.asm
找到 ticket--; 对应的汇编:
asm
40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 从内存加载ticket到eax
400651: 83 e8 01 sub $0x1,%eax # eax - 1
400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 写回内存
三条指令,对应三个步骤:
bash
1. load:从内存读取 ticket 到寄存器 eax
2. update:寄存器 eax 的值减1
3. store:把 eax 的值写回内存
这三步之间都可能发生线程切换!假设当前 ticket = 5,看看两个线程交错执行会怎样:
bash
时刻 线程1 线程2 ticket值(内存)
T1 load (读到5) 5
T2 load (读到5) 5
T3 update (eax=4) 5
T4 update (eax=4) 5
T5 store (写入4) 4
T6 store (写入4) 4
两个线程都执行了 ticket--,但 ticket 只减了1!这就是数据竞争(data race)。
📌 记住这个结论:C语言里一行代码不等于原子操作。除非用特殊的原子指令(后面会讲),否则任何操作都可能被拆成多条指令,在多线程环境下就不安全。
二、pthread_mutex:互斥锁
2.1 互斥锁的基本使用
既然问题是"多个线程同时进临界区",解决办法就是"加锁":谁拿到锁谁进去,其他人等着。Linux 提供的这把锁叫互斥量(mutex)。

2.1.1 初始化
两种方式:
方式1:静态初始化(适合全局变量)
c
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
方式2:动态初始化(适合在函数中或结构体中)
c
pthread_mutex_t mutex;
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数:
mutex:要初始化的互斥量
attr:NULL 表示默认属性
返回值:成功返回0,失败返回错误号
2.1.2 加锁和解锁
c
int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); // 解锁
返回值:成功返回0,失败返回错误号
pthread_mutex_lock 的行为:
bash
情况1:锁是空闲的
→ 直接拿到锁,函数立即返回
情况2:锁被其他线程占用
→ 当前线程阻塞,进入等待队列
→ 注意是阻塞,不是忙等
→ 线程被操作系统挂起,不占用CPU
→ 等锁的线程被唤醒后,还要再竞争锁
→ 竞争成功,lock 函数才返回
2.1.3 销毁锁
c
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意事项:
bash
1. 静态初始化的 mutex 在进程结束时会被回收,通常不强制 destroy;但如果要严格资源管理或复用,仍可以在确保未加锁状态下 destroy。
2. 不要销毁已加锁的锁
3. 销毁后不能再使用
2.2 修复售票系统
加上锁之后:
c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg) {
char *id = (char*)arg;
while (1) {
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 NULL;
}
int main() {
pthread_mutex_init(&mutex, NULL); // 初始化
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex); // 销毁
return 0;
}
编译运行:
bash
$ gcc test.c -o test -pthread
$ ./test
thread 1 sells ticket:100
thread 2 sells ticket:99
thread 3 sells ticket:98
thread 4 sells ticket:97
...
thread 2 sells ticket:3
thread 1 sells ticket:2
thread 4 sells ticket:1
现在正常了,ticket 从 100 减到 1,不会出现负数。
📌 关键点 :pthread_mutex_lock 和 pthread_mutex_unlock 之间的代码就是临界区,同一时刻只有一个线程能执行。加锁的粒度要合适,太大影响并发性能,太小又保护不了数据。
2.3 一个常见错误
c
if (ticket > 0) { // 判断在锁外(错误点)
pthread_mutex_lock(&mutex);
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
看起来也加锁了,有问题吗?问题在于if 判断在锁外。
bash
时刻 线程1 线程2 ticket值
T1 if (ticket > 0) √ 1
T2 if (ticket > 0) √ 1
T3 lock 1
T4 ticket-- 0
T5 unlock 0
T6 lock 0
T7 ticket-- -1
if 判断不在保护范围内,两个线程都看到 ticket = 1,都进入了 if 块,最后 ticket 变成 -1。
正确做法:判断和操作都要在锁的保护下。
本质是:检查和修改必须在同一把锁的保护下,组成一个不可分割的临界区。
三、互斥量的底层原理
3.1 如何实现原子操作
前面看到 ticket--; 被编译成三条指令,不是原子的。那怎么实现原子操作呢?
硬件提供了一些特殊指令,保证操作的原子性。最常用的是Test-And-Set (TAS) 和 Compare-And-Swap (CAS)。
3.1.1 Test-And-Set指令
c
// 硬件提供的原子操作(伪代码)
int test_and_set(int *lock) {
int old = *lock;
*lock = 1;
return old;
}
// 整体原子执行
用 TAS 实现自旋锁:
c
typedef struct {
int flag;
} spinlock_t;
void spin_lock(spinlock_t *lock) {
while (test_and_set(&lock->flag) == 1) {
// 如果返回1,说明锁已被占用,继续自旋
}
// 如果返回0,说明拿到锁了
}
void spin_unlock(spinlock_t *lock) {
lock->flag = 0; // 释放锁
}
3.1.2 Compare-And-Swap指令
c
// 硬件提供的原子操作(伪代码)
int compare_and_swap(int *ptr, int old_val, int new_val) {
int old = *ptr;
if (old == old_val) {
*ptr = new_val;
return 1; // 成功
}
return 0; // 失败
}
// 这整个过程是原子的
x86 的 cmpxchg 指令就是 CAS 的实现:
asm
# AT&T语法
lock cmpxchg %ebx, (%ecx)
# 作用:
# 比较 eax 和 (%ecx) 的值
# 如果相等,把 ebx 的值写入 (%ecx)
# 如果不等,把 (%ecx) 的值读入 eax
# lock前缀保证操作的原子性
3.2 mutex的实现原理
pthread_mutex 的底层实现结合了自旋 和休眠两种策略。
多数 Linux/glibc 的 mutex 在特定条件下会采用自适应策略:轻度争用时可能短暂自旋,争用严重时通过 futex
休眠/唤醒。下面是"简化示意",用于理解思想,不代表真实源码。
3.2.1 简化的实现思路
c
struct pthread_mutex {
int lock; // 0:未锁,1:已锁
int owner; // 持有锁的线程ID
int waiters; // 等待队列中的线程数
// ... 其他字段
};
int pthread_mutex_lock(pthread_mutex_t *mutex) {
// 1. 先自旋一小段时间(几十到几百次循环)
for (int i = 0; i < SPIN_COUNT; i++) {
if (atomic_compare_and_swap(&mutex->lock, 0, 1)) {
mutex->owner = gettid();
return 0; // 拿到锁了
}
cpu_relax(); // 暂停一下,减少总线竞争
}
// 2. 自旋拿不到锁,进入休眠
mutex->waiters++;
futex_wait(&mutex->lock, 1); // 系统调用,线程休眠
mutex->waiters--;
// 3. 被唤醒后,再次尝试获取锁
while (!atomic_compare_and_swap(&mutex->lock, 0, 1)) {
futex_wait(&mutex->lock, 1);
}
mutex->owner = gettid();
return 0;
}
int pthread_mutex_unlock(pthread_mutex_t *mutex) {
mutex->owner = 0;
atomic_store(&mutex->lock, 0);
// 如果有等待者,唤醒一个
if (mutex->waiters > 0) {
futex_wake(&mutex->lock, 1); // 系统调用,唤醒一个线程
}
return 0;
}
3.2.2 为什么要先自旋再休眠?
bash
原因1:系统调用开销大
- futex_wait/futex_wake 是系统调用
- 用户态→内核态切换开销大(几千个CPU周期)
- 如果锁很快就被释放,自旋更高效
原因2:很多锁持有时间很短
- 数据显示,大部分锁的持有时间 < 100个CPU周期
- 自旋几十次就能拿到锁,比休眠划算
策略:
- 短时间持有的锁:自旋效率高
- 长时间持有的锁:休眠避免浪费CPU
- pthread_mutex 结合两者,先自旋后休眠
📌 这里要理解 :mutex不是纯粹的自旋锁(一直占CPU),也不是纯粹的休眠锁(立即休眠),而是混合锁,根据实际情况动态调整策略。
四、互斥量的RAII封装
4.1 手动加解锁的问题
看一段代码:
cpp
void process() {
pthread_mutex_lock(&mutex);
if (condition1) {
// ... 一些操作
pthread_mutex_unlock(&mutex);
return; // 提前返回
}
if (condition2) {
// ... 一些操作
// 忘记解锁了!!!
return; // 提前返回
}
// ... 正常流程
pthread_mutex_unlock(&mutex);
}
问题:
bash
1. 多个返回路径,每个都要记得解锁
2. 中间可能抛异常(C++),跳过解锁
3. 代码复杂后,很容易漏掉unlock
4. 造成死锁,其他线程永远等不到锁
4.2 RAII风格的封装
RAII (Resource Acquisition Is Initialization):资源获取即初始化。核心思想:利用对象的构造和析构来管理资源。
4.2.1 Mutex类封装
Lock.hpp:
cpp
#pragma once
#include <pthread.h>
namespace LockModule
{
// 对锁进行封装
class Mutex
{
public:
Mutex(const Mutex &) = delete; // 禁止拷贝
const Mutex &operator=(const Mutex &) = delete; // 禁止赋值
Mutex() {
pthread_mutex_init(&_mutex, nullptr);
}
void Lock() {
pthread_mutex_lock(&_mutex);
}
void Unlock() {
pthread_mutex_unlock(&_mutex);
}
pthread_mutex_t *GetMutexOriginal() {
return &_mutex; // 给条件变量用
}
~Mutex() {
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
}
4.2.2 LockGuard自动管理
继续在 Lock.hpp 中添加:
cpp
namespace LockModule
{
// ... Mutex类定义 ...
// 采用RAII风格,自动加锁解锁
class LockGuard
{
public:
LockGuard(Mutex &mutex) : _mutex(mutex) {
_mutex.Lock(); // 构造时加锁
}
~LockGuard() {
_mutex.Unlock(); // 析构时解锁
}
private:
Mutex &_mutex;
};
}
4.2.3 使用示例
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "Lock.hpp"
using namespace LockModule;
int ticket = 1000;
Mutex mutex;
void *route(void *arg) {
char *id = (char *)arg;
while (1) {
{
LockGuard lockguard(mutex); // ← 构造时自动加锁
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
} // ← 析构时自动解锁,即使上面有return或throw也会执行
}
return nullptr;
}
int main() {
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
编译运行:
bash
$ g++ test.cpp -o test -std=c++11 -lpthread
$ ./test
thread 1 sells ticket:1000
thread 2 sells ticket:999
thread 3 sells ticket:998
...
thread 4 sells ticket:2
thread 1 sells ticket:1
📌 RAII的好处:
bash
1. 不会忘记解锁
- 离开作用域,析构函数自动调用
2. 异常安全
- 即使中间抛异常,栈展开会调用析构
3. 代码简洁
- 不用写unlock,减少出错
4. 符合C++习惯
- std::lock_guard 也是这个思路
4.3 对比C++11的std::lock_guard
C++11 标准库提供了类似的工具:
cpp
#include <mutex>
std::mutex mtx;
void process() {
std::lock_guard<std::mutex> guard(mtx); // 自动加锁
// ... 临界区代码
} // 自动解锁
我们封装的 LockGuard 和它原理一样,只是底层用的是 pthread_mutex 而不是 std::mutex。
五、互斥的三原则
5.1 三原则内容
设计良好的互斥机制要满足三个条件:
1. 互斥性
bash
同一时刻,只有一个线程在临界区执行
- 这是最基本的要求
- pthread_mutex 通过原子操作保证
2. 有限等待
bash
如果多个线程同时要求进入临界区,
并且临界区没有线程在执行,
那么只能允许一个线程进入,且选择过程不能无限推迟
- 不能让某个线程永远饿死
- pthread_mutex 通过等待队列保证公平性
- pthread_mutex 通常能避免长期占用导致的严重饥饿,但标准并不保证严格公平/严格有限等待。
3. 空闲让进
bash
如果没有线程在临界区,
那么任何申请进入临界区的线程都应该被允许进入
- 临界区空着,不能阻止线程进入
- 不能因为之前的状态阻止新线程
5.2 违反原则的例子
违反空闲让进:
c
// 错误的"轮流"实现
int turn = 0; // 0表示线程0的回合,1表示线程1的回合
// 线程0
while (1) {
while (turn != 0); // 等待自己的回合
// 临界区
turn = 1;
}
// 线程1
while (1) {
while (turn != 1); // 等待自己的回合
// 临界区
turn = 0;
}
问题:
该算法采用严格轮流方式进入临界区。 当某线程不请求进入临界区时,另一线程仍可能因 turn
值不符而被阻塞,即使临界区处于空闲状态,因此违反"空闲让进"原则。
同时,该算法使用忙等待,会导致CPU资源浪费。
📌 记住 :设计并发控制机制时,要满足这三原则。pthread_mutex 已经帮我们实现好了,直接用就行。
六、本篇总结
6.1 核心知识点
1. 基本概念
- 临界资源:多线程共享的数据
- 临界区:访问临界资源的代码段
- 互斥:同一时刻只有一个线程在临界区
- 原子性:操作不可分割,要么完成要么未完成
2. 数据竞争
- C语言的一行代码不等于原子操作
ticket--;被编译成 load-update-store 三条指令- 多线程交错执行会导致数据不一致
3. pthread_mutex
| 函数 | 作用 |
|---|---|
| pthread_mutex_init | 初始化锁 |
| pthread_mutex_lock | 加锁 |
| pthread_mutex_unlock | 解锁 |
| pthread_mutex_destroy | 销毁锁 |
4. 底层原理
- 硬件提供原子指令:TAS、CAS
- mutex 混合策略:先自旋后休眠
- futex 系统调用管理休眠和唤醒
5. RAII封装
- 利用构造/动管理资源
- 避免忘记解锁
- 异常安全
6. 互斥三原则
- 互斥性:同一时刻只有一个线程在临界区
- 有限等待:不能无限推迟,不能饿死线程
- 空闲让进:临界区空闲时允许线程进入
6.2 最重要的理解
📌 多线程编程的核心:
bash
问题:多个线程访问共享数据会产生数据竞争
原因:操作不是原子的,线程切换导致交错执行
解决:用互斥锁保护临界区
记住:
1. 共享数据必须加锁保护
2. 加锁粒度要合适(太大影响性能,太小保护不了)
3. 判断和操作都要在锁的保护下
4. 用RAII避免忘记解锁
💬 下篇预告:互斥锁解决了安全问题,但还不够。比如生产者-消费者模型,生产者生产数据,消费者消费数据,两者要协调:队列空的时候消费者要等,队列满的时候生产者要等。这就需要**条件变量(condition variable)**来实现线程同步。下篇我们会详细讲解条件变量的原理和使用,并实现经典的生产者-消费者模型。
👍 点赞、收藏与分享:如果这篇文章对你有帮助,请点赞、收藏并分享!