一、线程为什么需要互斥
同一进程中的多个线程共享进程的地址空间,因此多个线程可能同时访问同一份数据。
例如:
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条语句实现,每一条语句在实现时都有可能被其他线程切换走,不具备原子性,所以不能