引言
在之前的多线程学习中,我们常遇到乱码或抢占输出的问题。这背后往往隐藏着更深层的并发隐患。本章我们通过一个经典的买票场景,深入剖析线程间的竞争关系,并学习如何用互斥锁来保障数据安全。
问题的根源:竞态条件
假设我们有 100 张电影票,5 个线程同时抢票。如果直接操作共享变量 ticket,会发生什么?
#include <iostream>
#include <thread>
#include <vector>
#include <string>
#include <cstdio>
#include <unistd.h>
int ticket = 100;
void routine(std::string name) {
while (true) {
if (ticket > 0) {
usleep(1000); // 模拟抢票耗时
ticket--;
printf("%s shell ticket, now tickets number:%d\n", name.c_str(), ticket);
} else {
std::cout << ticket << std::endl;
break;
}
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; i++) {
std::string name = "thread-";
name += std::to_string(i);
threads.emplace_back(routine, name);
}
for (auto& thread : threads) {
thread.join();
}
return 0;
}
这里的 ticket 是公共资源。理论上票数减到 0 就该停,但实际运行结果可能变成 -4。这是因为多线程环境下的竞态条件(Race Condition)。
为什么会发生这种情况
单线程不会有问题,但多线程存在资源竞争。每个线程读取 ticket、判断、休眠、再写入的过程不是原子的。
当票数为 1 时:
- 线程 A 读取
ticket为 1,准备执行减法。 - 线程 A 休眠(
usleep),此时 CPU 切换给线程 B。 - 线程 B 也读取
ticket为 1,同样准备执行减法。 - 线程 A 醒来,将
ticket减为 0 写回内存。 - 线程 B 醒来,基于之前读取的值再次减 1,写回 -1。
这就是经典的 Check-Then-Act Race。底层汇编逻辑大致如下:
; if (ticket > 0)
LOAD R1, [ticket] ; R1 = ticket
CMP R1, 0 ; 比较 R1 和 0
JLE END_IF ; 如果 <= 0,跳走
; usleep(1000)
CALL usleep
; ticket--
LOAD R2, [ticket] ; R2 = 当前 ticket (注意这里重新读了一次)
SUB R2, 1 ; R2 = R2 - 1
STORE [ticket], R2 ; 写回 ticket
END_IF:
关键点在于:判断时用的是旧值,真正减法时又重新读了一次内存。如果中间被其他线程修改了数据,当前线程就会基于错误的情报进行操作。
引入互斥锁
为了避免这种乌龙事件,我们需要引入锁的概念。互斥锁(Mutex)能保证同一时间只有一个线程持有锁,从而独占临界区。
#include <iostream>
#include <thread>
#include <vector>
#include <string>
#include <cstdio>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void routine(std::string name) {
while (true) {
pthread_mutex_lock(&lock); // 加锁
if (ticket > 0) {
usleep(1000);
ticket--;
printf("%s shell ticket, now tickets number:%d\n", name.c_str(), ticket);
pthread_mutex_unlock(&lock); // 解锁
} else {
pthread_mutex_unlock(&lock); // 解锁
break;
}
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; i++) {
std::string name = "thread-";
name += std::to_string(i);
threads.emplace_back(routine, name);
}
for (auto& thread : threads) {
thread.join();
}
return 0;
}
加上锁之后,只有拿到锁的线程才能进入 if 块操作 ticket,其他线程会被阻塞等待。这样就能保证票数不会变成负数。
临界区与锁的范围
为什么要加锁?核心是为了避免竞争。访问共享资源(如全局变量、文件等)的那部分代码称为临界区(Critical Section)。
核心规则:同一时刻,只允许一个线程进入临界区。 保护方式:进入前加锁,离开后解锁。
解锁时机与死锁风险
注意上面的代码,无论 if 还是 else 分支,都在最后释放了锁。如果写成下面这样:
pthread_mutex_lock(&lock);
if (ticket > 0) {
// ... 操作 ...
// pthread_mutex_unlock(&lock); // 忘记解锁了
} else {
// pthread_mutex_unlock(&lock); // 忘记解锁了
break;
}
pthread_mutex_unlock(&lock); // 这里永远执行不到
一旦线程在持有锁的情况下直接退出(如 break、return 或异常),而未释放锁,其他需要该锁的线程将永远等待。这就是典型的死锁诱因之一。
死锁产生的四个必要条件(Coffman 条件)包括:互斥、占有并等待、不可剥夺、循环等待。虽然这里只是简单的未解锁,但理解这些有助于排查复杂死锁。
锁的粒度:别抱着锁睡觉
还有一个细节容易被忽视:usleep(1000) 应该放在锁内还是锁外?
如果在锁内休眠,意味着持有锁的线程在睡觉,其他想抢票的线程只能干等着。这是非常不合理的资源浪费。
但在本例中,为了演示互斥效果,我们将 usleep 放在锁内模拟'处理过程'。在实际工程中,耗时操作应尽量移出临界区,只保护对共享数据的读写操作。
修正后的推荐写法:
void routine(std::string name) {
while (true) {
pthread_mutex_lock(&lock);
if (ticket > 0) {
ticket--;
printf("%s shell ticket, now tickets number:%d\n", name.c_str(), ticket);
pthread_mutex_unlock(&lock);
} else {
pthread_mutex_unlock(&lock);
break;
}
}
}
互斥锁不保证公平
你可能会发现,某个线程似乎一直在卖票。这说明这段时间里它反复拿到了 CPU,并且每次也都先抢到了那把锁。
互斥锁只保证了互斥性(Mutual Exclusion),即同一时间只有一个线程能访问资源,但它并不保证公平性(Fairness)。谁抢到 CPU 且先调用 pthread_mutex_lock,谁就有机会先拿到锁。如果需要公平调度,通常需要配合条件变量或其他同步机制。
总结
多线程环境下,共享资源访问易引发竞态条件,导致数据错乱(如负数车票)。互斥锁是解决此类问题的基础工具,通过锁定临界区确保原子性操作。
掌握互斥锁的关键点在于:
- 临界区范围:仅包裹访问共享资源的代码,避免包含耗时操作。
- 解锁完整性:所有分支路径都必须记得解锁,防止死锁。
- 公平性认知:锁只保安全,不保顺序,高并发下需关注调度策略。
打好这些基本功,才能在并发编程中写出既高效又安全的代码。


