承接上一篇学习的std::thread,这篇搞定多线程数据共享+各种类互斥锁+RAII锁管理
1:什么是数据竞争
1:数据竞争
多个线程同时读写同一个共享全局 / 堆变量 ,且没有任何同步保护 ,就会产生数据竞争,结果未定义、数值错乱。
示例(无锁错误代码)
cpp
#include <iostream>
#include <thread>
using namespace std;
int g_cnt = 0;
void add() {
for (int i = 0; i < 1000000; ++i) {
g_cnt++;
}
}
int main() {
thread t1(add);
thread t2(add);
t1.join();
t2.join();
// 理论应该 2000000,实际永远小于这个数
cout << g_cnt << endl;
return 0;
}
原因:
g_cnt++ 不是原子操作,拆成三步:读内存 → 寄存器自增 → 写回内存,多线程穿插执行就会覆盖丢失。
2:临界区和互斥锁
- 临界区:访问共享数据的代码片段
- 互斥锁 mutex :保证同一时刻只有一个线程进入临界区,其他线程阻塞等待。
2:C++11四种常见互斥锁分类
| 锁类型 | 特点 | 适用场景 |
|---|---|---|
| std::mutex | 普通互斥锁、不可递归、非定时 | 常规业务临界区,最常用 |
| std::recursive_mutex | 可递归加锁,同一线程可多次 lock | 递归函数、类成员函数嵌套加锁 |
| std::timed_mutex | 普通锁 + 超时等待 | 不想死等,等待一段时间拿不到锁就放弃 |
| std::recursive_timed_mutex | 可递归 + 超时 | 递归且需要超时控制 |
- 一个线程 lock 成功后,其他线程 lock 阻塞、try_lock 返回 false
- 锁没解锁就销毁 / 持有锁线程直接退出 → 行为未定义
3:std::mutex基础用法
1:核心接口
cpp
void lock(); // 加锁,拿不到就阻塞
void unlock(); // 解锁
bool try_lock(); // 尝试加锁,拿到返回true,拿不到立刻返回false不阻塞
2:基础加锁解锁示例
cpp
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int g_cnt = 0;
mutex mtx;
void add() {
for (int i = 0; i < 1000000; ++i) {
mtx.lock();
g_cnt++;
mtx.unlock();
}
}
int main() {
thread t1(add);
thread t2(add);
t1.join();
t2.join();
cout << g_cnt << endl; // 正常 2000000
return 0;
}
3:手写lock/unlock的致命问题
- 中途抛异常,unlock 没执行 → 死锁
- 代码分支多,容易漏写 unlock
- 可读性差,成对维护麻烦
所以必须用 RAII 锁管理:lock_guard /unique_lock
4:std::lock_guard简洁RAII自动锁
1:原理
RAII 思想:
- 构造时自动 lock
- 离开作用域析构时自动 unlock
- 不用手动写 lock /unlock,异常也能自动解锁
2:构造方式
cpp
// 1. 常规加锁:构造立刻lock
explicit lock_guard(mutex_type& m);
// 2. 接管已经加好的锁:adopt_lock
lock_guard(mutex_type& m, adopt_lock_t tag);
3:基础使用
cpp
void add() {
for (int i = 0; i < 1000000; ++i) {
lock_guard<mutex> lg(mtx);
// 临界区
g_cnt++;
// 离开作用域 lg 析构,自动 unlock
}
}
4:adopt_lock
场景:已经手动 lock 了,交给 lock_guard 接管,析构自动解锁
cpp
void print_id(int id) {
mtx.lock();
lock_guard<mutex> lck(mtx, adopt_lock);
cout << "thread #" << id << endl;
// 不用手动unlock,出作用域自动解
}
5:lock_guard限制
- 不能拷贝、不能移动
- 不能手动解锁、不能延迟加锁
- 功能极简,只适合简单固定临界区
5:std::recursive_mutex递归互斥锁
1:解决什么问题
普通 std::mutex 同一线程重复 lock 直接死锁 。recursive_mutex 允许同一个线程多次加锁,解锁次数要和加锁次数匹配。
2:适用场景
- 递归函数内部加锁
- 类多个成员函数都要加锁,互相调用嵌套
3:示例
cpp
recursive_mutex rmtx;
void funcA() {
rmtx.lock();
cout << "funcA" << endl;
funcB();
rmtx.unlock();
}
void funcB() {
rmtx.lock(); // 同一线程递归加锁,不会阻塞
cout << "funcB" << endl;
rmtx.unlock();
}
建议:尽量少用递归锁,能重构代码就重构,递归锁性能略差、容易隐藏逻辑问题。
6:std::timed_mutex带超时互斥锁
1:新增接口
cpp
// 尝试加锁,阻塞指定时长,超时拿不到返回false
bool try_lock_for(chrono::duration);
// 阻塞到某个时间点,超时返回false
bool try_lock_until(chrono::time_point);
2:经典事例
每隔 200ms 打印 -,最多等 1 秒拿锁:
cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std;
timed_mutex mtx;
void fireworks(int i) {
// 最多等待1秒,拿不到就循环打 "-"
while (!mtx.try_lock_for(chrono::milliseconds(1000))) {
cout << "-";
}
// 拿到锁
cout << i;
this_thread::sleep_for(chrono::milliseconds(5000));
cout << "*\n";
mtx.unlock();
}
int main() {
thread threads[2];
for (int i = 0; i < 2; ++i)
threads[i] = thread(fireworks, i);
for (auto& th : threads) th.join();
return 0;
}
7:std::unique_lock:功能最强的RAII锁
1:为什么有了lock_guard还要unique_lock
lock_guard 太死板,unique_lock 支持:
- 延迟加锁
- 尝试加锁
- 超时加锁
- 手动 lock/unlock
- 支持移动语义
- 可以配合条件变量
condition_variable(必须用 unique_lock)
2:七种构造方式
| 构造方式 | 含义 |
|---|---|
| 无参 | 空锁,不绑定任何 mutex |
| 传 mutex | 构造直接加锁 |
| try_to_lock | 构造尝试加锁,不阻塞 |
| defer_lock | 延迟加锁,构造不加,后面手动 lock |
| adopt_lock | 接管已经加好的锁 |
| 时间段 | try_lock_for 超时等待 |
| 时间点 | try_lock_until 超时等待 |
3:常用示例
1:延迟加锁defer_lock
cpp
mutex mtx;
void test() {
unique_lock<mutex> lk(mtx, defer_lock);
// 做一些无关操作...
lk.lock(); // 手动加锁
// 临界区
lk.unlock(); // 手动解锁
// 还可以再加锁
lk.lock();
}
2:支持移动不支持拷贝
cpp
unique_lock<mutex> lk1(mtx);
unique_lock<mutex> lk2 = move(lk1); // 移动可以
// unique_lock<mutex> lk3 = lk1; // 拷贝禁用
3:配合条件变量使用
后面讲 condition_variable 会用到:条件变量 wait 只能接收 unique_lock,不能用 lock_guard。
4:lock_guard VS unique_lock
- 简单临界区、不需要手动解锁、不需要条件变量 → lock_guard 轻量
- 需要延迟加锁、手动解锁、超时、移动、条件变量 → unique_lock
8:多锁同时加锁std::lock/std::try_lock
1:std::lock模版函数
一次性锁住多个互斥锁 ,避免死锁:内部会自动处理加锁顺序,若部分锁住、部分没锁住,会先释放已锁住的,再阻塞等待全部拿到。
cpp
mutex m1, m2;
void taskA() {
lock(m1, m2); // 同时锁两个,不会死锁
// 临界区
m1.unlock();
m2.unlock();
}
void taskB() {
lock(m2, m1); // 颠倒顺序也没事,std::lock内部规避死锁
}
2:std::try_lock
尝试一次性锁多个:
- 全部成功返回 -1
- 某个失败,返回失败的下标,并释放已拿到的锁
9:thread传参为什么必须用std::ref
cpp
template <class _Fn, class... _Args,
enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
_NODISCARD_CTOR explicit thread(_Fn &&_Fx, _Args &&..._Ax)
{
_Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
}
template <class _Fn, class... _Args>
void _Start(_Fn &&_Fx, _Args &&..._Ax)
{
// 从下⾯可以看到,线程要调⽤系统库的线程,最终还是要把参数包打包成⼀个结构体对象再传给线程,所以线程中拿到的参数包值是我们传的参数包值的拷⻉,所以要⽤ref才传参才能解决问题
using _Tuple = tuple<decay_t<_Fn>,
decay_t<_Args>...>;
auto _Decay_copied = _STD make_unique<_Tuple>(_STD
forward<_Fn>(_Fx),
_STD forward<_Args>(_Ax)...);
constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{});
// pointer or reference to potentially throwing function passed to
// extern C function under -EHc. Undefined behavior may occur
// if this function throws an exception. (/Wall)
_Thr._Hnd = reinterpret_cast<void *>(_CSTD _beginthreadex(nullptr, 0,
_Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id));
if (_Thr._Hnd)
{ // ownership transferred to the thread
(void)_Decay_copied.release();
}
else
{ // failed to start thread
_Thr._Id = 0;
_Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
}
}
线程底层会把所有参数打包成 tuple 做值拷贝 传给系统线程入口。如果你直接传普通变量,线程里拿到的是拷贝副本,修改不影响外部。
想要真正传引用:必须用 std::ref() / std::cref() 包装,让 tuple 推导为引用类型。
示例标准写法:
cpp
void func(int& x, mutex& mtx) {
lock_guard<mutex> lg(mtx);
x++;
}
int main() {
int x = 0;
mutex mtx;
// 必须ref,否则编译报错 / 传值副本
thread t(func, ref(x), ref(mtx));
t.join();
return 0;
}
替代方案:用 lambda 捕获引用,不用传参,更优雅:
cpp
thread t([&x, &mtx](){
lock_guard<mutex> lg(mtx);
x++;
});
10:总结
- 多线程共享变量必有数据竞争,必须用互斥锁保护临界区;
std::mutex基础锁,不可递归;recursive_mutex支持同线程多次加锁;timed_mutex带超时等待,避免无限阻塞;lock_guard极简 RAII,自动加解锁,性能好,功能单一;unique_lock功能最全,支持延迟 / 尝试 / 手动加解锁、移动、适配条件变量;std::lock/try_lock一次性多锁,解决死锁问题;- 线程传引用必须
std::ref,或 lambda 捕获引用更省事。