C++11并发编程:互斥锁

承接上一篇学习的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的致命问题

  1. 中途抛异常,unlock 没执行 → 死锁
  2. 代码分支多,容易漏写 unlock
  3. 可读性差,成对维护麻烦

所以必须用 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 捕获引用更省事。
相关推荐
顺风尿一寸1 小时前
深度解析 Linux touch 命令:从用户输入到磁盘 Inode 的完整旅程
linux
AC赳赳老秦1 小时前
OpenClaw批量任务队列优化:解决任务堆积、执行缓慢、优先级混乱问题
java·大数据·数据库·c++·自动化·php·openclaw
晚风予卿云月1 小时前
《二分答案》算法练习
数据结构·c++·算法·二分·竞赛·算法随笔
郭涤生1 小时前
C++ 各类数据的内存分区与读写性能详解
开发语言·c++
Pluchon1 小时前
萌萌技术分享笔记——Java综合项目
java·开发语言·笔记·git·github·mybatis·postman
j_xxx404_1 小时前
Linux 线程日志系统设计:从策略模式、RAII 到 pthread 线程安全与内核写入路径|附源码
linux·运维·服务器·开发语言·c++·人工智能·策略模式
明天…ling1 小时前
CentOS 7 安装 Docker 踩坑全记录(含 sudo 权限、yum 源失效、命令报错解决方案)
linux·docker·centos
江华森1 小时前
Linux 内核调优 + TCP/IP 协议栈深度解析 + 低延迟网络优化
linux·网络·tcp/ip
方也_arkling1 小时前
【Java-Day13】内部类
java·开发语言