在学习多线程时,最开始写的程序可能都比较简单,每个线程执行自己的任务,彼此之间没有太多联系。但是,一旦多个线程开始操作同一份数据,问题就出现了。
比如一个简单的售票系统,总共有 100 张票,创建四个线程同时卖票。按理说,票应该从 100 张一直卖到 1 张,最后刚好售完。
但实际运行时,可能会出现卖出 0 号票甚至负数票的情况。这并不是打印出了问题,而是多线程访问共享数据时发生了冲突。
一、为什么多个线程操作同一个变量会出问题?
先看一个简化版的售票程序:
cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void* route(void* arg)
{
const char* name = static_cast<const char*>(arg);
while (true)
{
if (ticket > 0)
{
usleep(1000);
std::cout << name
<< " sells ticket: "
<< ticket << std::endl;
ticket--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, route,
(void*)"thread 1");
pthread_create(&t2, nullptr, route,
(void*)"thread 2");
pthread_create(&t3, nullptr, route,
(void*)"thread 3");
pthread_create(&t4, nullptr, route,
(void*)"thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
问题出现在这段逻辑:
cpp
if (ticket > 0)
{
// 处理售票业务
ticket--;
}
单看代码似乎没有什么问题:先判断还有没有票,有票就卖出一张,然后把票数减一。
但多线程程序不能保证一个线程执行完这几行代码,另一个线程才开始执行。
假设此时 ticket == 1,线程 A 判断 ticket > 0,结果为真。就在它准备继续执行时,线程 B 获得了 CPU,也判断 ticket > 0,同样得到真。
接下来,两个线程都可能继续执行售票操作。最终,原本只有一张的票可能被重复售出,变量甚至会被减成负数。
关键不是 if 写错了,而是判断有没有票、执行售票业务、修改票数,这几个操作必须作为一个整体受到保护。
二、临界资源和临界区
理解互斥锁之前,需要先把几个概念区分开。
共享资源:多个线程都能够访问的资源,例如全局变量、堆上的共享对象。
临界资源:多个执行流共享,并且需要保护其访问过程的资源。
临界区:程序中访问临界资源的那段代码。
在售票程序中,ticket 是共享变量,对它进行检查和修改的代码就是需要重点保护的临界区。
如果多个线程同时进入临界区,就可能产生数据竞争。为了让这些操作安全进行,需要让线程按照一定规则访问临界资源。
这就是互斥的作用。
三、互斥到底是什么意思?
互斥要求:在同一时刻,最多只允许一个执行流进入受到保护的临界区。
当一个线程进入临界区后,其他线程如果也想进入,就需要等待。等当前线程离开临界区并释放锁,其他线程才有机会继续执行。
Linux 的 Pthreads 提供了互斥量,也就是 mutex,来完成这项工作。
常用接口如下:
cpp
#include <pthread.h>
// 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 动态初始化
pthread_mutex_t mutex2;
pthread_mutex_init(&mutex2, nullptr);
// 加锁
pthread_mutex_lock(&mutex);
// 解锁
pthread_mutex_unlock(&mutex);
// 销毁动态初始化的互斥量
pthread_mutex_destroy(&mutex2);
如果一个线程调用 pthread_mutex_lock() 时,互斥量还没有被锁定,那么它可以获得锁;如果锁已经被其他线程持有,当前线程通常会阻塞等待。
这样一来,多个线程就不能同时进入由同一把锁保护的临界区。
需要注意,动态初始化的互斥量应在不再使用、并且没有线程持有或等待该锁时销毁。已经加锁的互斥量不能随意销毁。
四、给售票程序加锁
在检查票数之前加锁,在售票逻辑执行完后解锁:
cpp
void* route(void* arg)
{
const char* name = static_cast<const char*>(arg);
while (true)
{
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
std::cout << name
<< " sells ticket: "
<< ticket << std::endl;
ticket--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
这里的锁保护范围很重要。
不能只给 ticket-- 加锁,却把 if (ticket > 0) 放在锁外面。因为判断和扣减之间仍然可能被其他线程打断,多个线程仍可能同时认定还有票。
正确做法是把相关操作一起放进临界区:
- 获取互斥锁。
- 判断票数是否大于零。
- 有票就完成售票及票数更新。
- 释放互斥锁。
即使没有票,也需要先释放锁再退出循环,否则后续的线程可能一直等不到锁。
五、为什么 ticket-- 也不是原子操作?
有时候会觉得,ticket-- 明明只是一条 C++ 语句,怎么还会出问题?
因为一条 C++ 语句并不一定对应一条机器指令。
从执行过程来看,修改变量通常涉及读取原值、计算新值,再将结果写回内存。多个线程如果交错执行这些步骤,就可能造成更新丢失。
这也引出了原子操作的概念:对于一个满足原子性要求的操作,从其他执行流观察时,不会出现只完成了一部分的中间状态。
互斥锁的意义就在于保护一段临界区,使其他线程不能同时进入这段受保护的代码。
需要特别注意,给普通变量加互斥锁后,只有遵守同一套加锁规则的访问才会受到这把锁的保护。其他线程如果绕过锁直接修改共享变量,问题依然存在。
六、用 RAII 管理锁
上面的代码还有一个容易出错的地方:每条执行路径都要记得解锁。
如果临界区里面有很多条件判断,代码稍微复杂一点,就可能在某个分支漏掉 pthread_mutex_unlock()。一旦发生这种情况,其他线程就可能一直阻塞。
在 C++ 中,可以利用 RAII 把加锁和解锁交给对象的生命周期管理。
基本思路是:对象构造时加锁,对象析构时解锁。
cpp
#pragma once
#include <iostream>
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
pthread_mutex_t *Get()
{
return &_mutex;
}
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
LockGuard(Mutex &mutex) : _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
使用时只需要创建一个局部对象:
cpp
void sell()
{
LockGuard guard(mutex);
if (ticket > 0)
{
ticket--;
}
}
当 sell() 执行结束、离开作用域时,guard 会自动析构并释放锁。即使中途执行 return 或 break 离开相应作用域,也可以利用析构函数释放锁。
这里的 mutex 应当是一个长期有效的 Mutex 对象。使用这类封装时,还需要保证它不会在线程仍然使用锁时提前析构。
C++ 标准库已经提供了 std::mutex 和 std::lock_guard,实际写现代 C++ 程序时也经常使用它们。
七、锁是不是加得越多越安全?
不是。
锁的目的是保护共享资源,而不是把整个程序都锁起来。
临界区太大,其他线程就要等待更长时间,并发能力会下降;临界区太小,又可能没有把所有相关操作保护完整。
另外,持有一把锁时还要谨慎地获取其他锁。如果多个线程以不同顺序申请多把锁,就可能发生死锁。
所以,使用互斥锁时,应该先明确哪些数据需要保护,再划定最小但完整的临界区,并保证每条执行路径都能正确释放锁。
八、总结
多线程共享数据时,真正危险的往往不是某一行代码,而是多个操作之间的执行顺序。
售票问题说明,检查票数和扣减票数不能被拆开保护。互斥锁负责保证同一时刻只有一个线程进入受保护的临界区,而 RAII 则能让锁的释放更可靠。