C++ 多线程问题:线程安全与互斥锁
在多线程编程中,当多个线程同时访问同一块内存区域(共享资源)时,如果不加控制,最终的数据结果往往是不可预测的。这种现象被称为“数据竞争”。为了解决这个问题,C++ 提供了多种同步机制,其中最基础且最常用的就是互斥锁。
互斥锁的核心逻辑非常简单:它就像一把锁,保证同一时间只有一个线程能持有这把锁并访问受保护的资源,其他线程必须等待锁释放后才能进入。
理解线程安全与数据竞争
首先,我们需要明确什么是线程不安全的代码。
编写 一个简单的计数器程序,如果不使用锁,两个线程同时对一个全局变量 shared_data 进行 10 万次自增操作,预期的最终结果应该是 20 万,但实际运行结果往往小于这个值。
这是因为 ++shared_data 操作在底层汇编层面并非原子操作,它通常包含“读取-修改-写入”三个步骤。线程 A 刚读取了数值,还没来得及写回,线程 B 可能也读取了旧数值并进行修改,导致线程 A 的修改被覆盖。
查看 以下不安全的代码示例:
#include <iostream>
#include <thread>
int shared_data = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
shared_data++; // 危险:非原子操作
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final value: " << shared_data << std::endl;
return 0;
}
为了修复这个问题,引入 互斥锁。
使用 std::mutex 进行手动加锁
C++11 标准库在 <mutex> 头文件中提供了 std::mutex 类。最基本的用法是直接调用 lock() 和 unlock() 成员函数。
修改 上面的代码,加入互斥锁保护:
- 包含
<mutex>头文件。 - 定义 一个全局的
std::mutex对象。 - 调用
mtx.lock()在访问共享数据之前。 - 调用
mtx.unlock()在访问共享数据之后。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 定义互斥锁
int shared_data = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
mtx.lock(); // 加锁
++shared_data; // 临界区:安全访问
mtx.unlock(); // 解锁
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final value: " << shared_data << std::endl;
return 0;
}
注意:手动调用 lock() 和 unlock() 存在极高风险。如果在 lock() 和 unlock() 之间的代码发生了异常(例如抛出错误),或者因为逻辑判断提前 return 了,unlock() 就可能永远执行不到,从而导致“死锁”,其他线程将永久卡住。
为了规避这种风险,C++ 推荐使用 RAII(资源获取即初始化)风格的包装器。
使用 std::lock_guard 自动管理锁
std::lock_guard 是一个 RAII 风格的封装类。它的机制非常聪明:在构造函数中自动调用 lock(),在析构函数(作用域结束)中自动调用 unlock()。
无论代码是正常执行完毕,还是因为异常跳出,lock_guard 对象在销毁时都会确保锁被释放。
改造 代码使用 lock_guard:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int shared_data = 0;
void safe_increment() {
for (int i = 0; i < 100000; ++i) {
// 构造时自动加锁
std::lock_guard<std::mutex> lock(mtx);
++shared_data;
// 离开当前作用域(循环体末尾)时自动解锁
}
}
int main() {
std::thread t1(safe_increment);
std::thread t2(safe_increment);
t1.join();
t2.join();
std::cout << "Final value: " << shared_data << std::endl;
return 0;
}
这是最常用、最推荐的标准做法,因为它既简单又异常安全。
使用 std::unique_lock 灵活控制锁
std::unique_lock 也是 RAII 风格的包装器,但比 lock_guard 提供更多的灵活性。它允许你手动加锁、解锁,甚至可以在构造时不立即加锁。
适用场景:当你需要在临界区内部暂时解锁,或者需要配合条件变量(Condition Variable)使用时。
使用 std::defer_lock 参数来延迟加锁:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int shared_data = 0;
void flexible_increment() {
for (int i = 0; i < 100000; ++i) {
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// 这里可以执行一些不需要锁的耗时操作
// ...
lock.lock(); // 需要时手动加锁
++shared_data;
lock.unlock(); // 可以提前解锁,缩小锁的持有时间
// 这里又可以执行不需要锁的操作
}
}
int main() {
std::thread t1(flexible_increment);
std::thread t2(flexible_increment);
t1.join();
t2.join();
std::cout << "Final value: " << shared_data << std::endl;
return 0;
}
unique_lock 的缺点是相比 lock_guard 稍微增加了一点点性能开销(通常可以忽略不计),因此如果不需要高级功能,优先使用 lock_guard。
C++ 互斥锁类型一览
C++ 标准库提供了四种互斥锁类型,分别适应不同的场景。
| 互斥锁类型 | 特点 | 适用场景 |
|---|---|---|
std::mutex |
最基础的互斥锁,不可复制,不可移动。 | 一般的线程同步需求。 |
std::recursive_mutex |
允许同一个线程对同一个互斥锁多次加锁(可重入)。 | 递归函数中需要加锁的情况。 |
std::timed_mutex |
提供了带超时机制的加锁尝试(try_lock_for, try_lock_until)。 |
避免死锁,或者只愿意等待特定时间的场景。 |
std::shared_mutex (C++17) |
读写锁。允许多个线程同时读,但写操作独占。 | 读多写少的场景,能显著提高并发读取性能。 |
注意:std::recursive_mutex 虽然使用方便,但通常被认为是代码设计有问题的信号。如果能通过重构避免递归加锁,应优先使用 std::mutex。
最佳实践与常见陷阱
在编写多线程代码时,仅仅知道如何加锁是不够的,还需要遵循一些最佳实践来保证程序的高效与稳定。
1. 减小锁的粒度(临界区范围)
锁的持有时间越长,其他线程阻塞等待的时间就越长,程序并发性能就越差。
对比 以下两种写法:
// 糟糕的写法:锁住了 I/O 操作
std::lock_guard<std::mutex> lock(mtx);
++shared_data;
std::cout << "Current value: " << shared_data << std::endl; // I/O 很慢,不应该被锁住
// 推荐的写法:只锁住共享数据的修改
{
int temp = 0;
{
std::lock_guard<std::mutex> lock(mtx);
temp = ++shared_data;
} // 锁在这里就释放了
// I/O 操作在锁外执行,其他线程可以在这期间获取锁
std::cout << "Current value: " << temp << std::endl;
}
2. 注意 I/O 流的线程安全
C++ 标准库(如 std::cout)本身并不保证线程安全。如果多个线程同时向 cout 输出,字符可能会穿插在一起。
解决 办法是专门为输出定义一个独立的互斥锁:
std::mutex data_mtx;
std::mutex io_mtx; // 专门用于 I/O 的锁
void worker() {
int val;
{
std::lock_guard<std::mutex> lock(data_mtx);
val = ++shared_data;
}
{
std::lock_guard<std::mutex> lock(io_mtx);
std::cout << "Thread " << std::this_thread::get_id()
<< " Value: " << val << std::endl;
}
}
3. 避免死锁
当程序中存在多个锁时,如果线程 A 持有锁 1 并等待锁 2,而线程 B 持有锁 2 并等待锁 1,程序就会永久卡死。
预防 死锁的最简单规则是:所有线程必须以相同的顺序获取锁。
或者使用 C++ 标准库提供的 std::lock 函数,它可以同时锁定多个互斥锁,且使用死锁避免算法:
std::mutex mtx1, mtx2;
void safe_operation() {
// std::lock 会同时锁定两个锁,要么全成功,要么全失败(不阻塞)
std::lock(mtx1, mtx2);
// 使用 adopt_lock 参数告诉 lock_guard 锁已经被持有,只需负责释放
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// 执行临界区操作...
}
4. 加锁流程可视化
为了更直观地理解互斥锁的工作流程,可以参考以下逻辑:
(操作共享资源)"] D --> E["释放锁"] E --> F["线程继续执行其他任务"] B -- 是 --> G["线程进入阻塞状态
(等待)"] G --> B

暂无评论,快来抢沙发吧!