C++17 多线程系列(二):共享数据与同步——mutex 与 condition_variable

核心目标:深入理解 C++ 互斥锁体系,掌握正确保护共享数据的方法,熟练运用条件变量实现生产者-消费者模式,并有效避免死锁问题。

前置知识 :Part 1 中的 std::thread 基础(线程创建、join 操作、参数传递等)。


2.1 数据竞争(Data Race)与未定义行为

2.1.1 数据竞争的本质

当多个线程同时访问同一内存位置,且至少有一个线程执行写操作,而缺乏适当的同步机制时,就会发生数据竞争(Data Race) 。根据 C++ 标准,数据竞争属于未定义行为,程序的行为将无法预测。

cpp 复制代码
#include <thread>
#include <iostream>

int counter = 0;  // 共享变量,无保护

void increment() {
    for (int i = 0; i < 100000; ++i) {
        ++counter;  // ❌ Data Race! 两个线程同时读写
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();

    std::cout << "counter = " << counter << "\n";
    // 预期: 200000
    // 实际: 通常是 120000~180000 之间的随机值!
}

2.1.2 汇编视角看 counter++

复制代码
; counter++ 不是原子操作, 而是三步:
;
;   mov  eax, [counter]    ; ① 从内存加载到寄存器
;   inc  eax               ; ② 寄存器中递增
;   mov  [counter], eax    ; ③ 写回内存
;
; 两个线程交错执行:
;   Thread A: ① load (counter=0) → ② inc (eax=1) → ③ store (counter=1)
;   Thread B: ① load (counter=0) → ② inc (eax=1) → ③ store (counter=1)
;   结果: 两次递增, counter 只增加了 1!

Thread B
Thread A
交错
覆盖
load: 0
inc: 1
store: 1
load: 0
inc: 1
store: 1
结果: counter = 1

(期望 2)

检测工具:用 Thread Sanitizer 一秒发现:

bash 复制代码
g++ -std=c++17 -pthread -fsanitize=thread -g race.cpp
./a.out
# TSAN: WARNING: ThreadSanitizer: data race on counter

2.2 std::mutex------保护共享数据

2.2.1 基本用法

cpp 复制代码
#include <mutex>

int counter = 0;
std::mutex mtx;  // 互斥量

void increment_safe() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++counter;       // 临界区
        mtx.unlock();
    }
}
// 结果: counter = 200000 ✅ 正确

2.2.2 为什么不直接使用 lock()/unlock()

cpp 复制代码
void dangerous() {
    mtx.lock();
    
    // ⚠️ 如果这里有异常或 return ...
    throw std::runtime_error("oops");
    // unlock() 永远不会执行 → 死锁!
    
    mtx.unlock();
}

2.2.3 std::lock_guard------RAII 自动管理锁

cpp 复制代码
void safe_with_guard() {
    std::lock_guard<std::mutex> lock(mtx);  // 构造时 lock
    ++counter;
    // ... 复杂逻辑, 可能抛异常 ...
    // 无论怎样退出作用域, 析构函数自动 unlock
}

进入作用域
lock_guard 构造

→ mtx.lock()
✓ 临界区代码

(任何异常都安全)
离开作用域
抛异常
lock_guard 析构

→ mtx.unlock()

2.2.4 C++17 CTAD 简化写法

cpp 复制代码
// C++14 写法
std::lock_guard<std::mutex> lock(mtx);

// C++17 CTAD (类模板参数推导) ------ 自动推导类型
std::lock_guard lock(mtx);  // ✅ 等效
std::scoped_lock lock(mtx); // ✅ C++17 推荐 (后面详解)

2.3 std::unique_lock------灵活性优于 lock_guard

2.3.1 与 lock_guard 的对比

特性 std::lock_guard std::unique_lock
RAII 自动管理
手动 unlock
延迟加锁 ✅ (defer_lock)
尝试加锁 ✅ (try_to_lock)
可移动 ✅ (moveable)
适合 condition_variable ✅ 必须
开销 最小 稍大(多了状态标志)

2.3.2 延迟加锁 std::defer_lock

cpp 复制代码
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// 此时 mtx 未被锁定

// 稍后手动加锁
lock.lock();
// ... 临界区 ...
lock.unlock();

// 再次加锁也可以
lock.lock();
// ... 另一个临界区 ...

2.3.3 尝试加锁 std::try_to_lock

cpp 复制代码
std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);

if (lock) {  // 或 lock.owns_lock()
    // 成功获得锁
    ++counter;
} else {
    // 锁被占用, 做点别的事
    do_other_work();
}

2.3.4 接管已有锁 std::adopt_lock

cpp 复制代码
mtx.lock();  // 手动加锁
// ... 一些前置操作 ...

// 将已锁的 mutex 交给 unique_lock 管理
std::unique_lock<std::mutex> lock(mtx, std::adopt_lock);
// lock 对象接管所有权, 析构时会自动 unlock

2.3.5 应用场景选择

cpp 复制代码
// 场景 1: 简单临界区 → lock_guard (够用且零开销)
{
    std::lock_guard lock(mtx);
    shared_data = new_value;
}

// 场景 2: 条件变量 → unique_lock (必须)
std::unique_lock lock(mtx);
cv.wait(lock, []{ return ready; });

// 场景 3: 需要中途解锁 → unique_lock
std::unique_lock lock(mtx);
while (!done) {
    lock.unlock();       // 释放锁
    do_io_work();        // IO 不应持锁
    lock.lock();         // 重新获取
}

// 场景 4: 需要移动 → unique_lock
std::unique_lock lock1(mtx);
std::unique_lock lock2 = std::move(lock1);  // 转移所有权

2.4 std::scoped_lock(C++17)------多锁 RAII 利器

2.4.1 同时锁定多个 mutex

cpp 复制代码
std::mutex mtx1, mtx2;

// C++14 写法: 容易出错
void transfer_cpp14(int amount) {
    std::lock(mtx1, mtx2);              // 同时锁定两个
    std::lock_guard<std::mutex> lk1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lk2(mtx2, std::adopt_lock);
    // ... 临界区 ...
}

// C++17 写法: 一行搞定
void transfer_cpp17(int amount) {
    std::scoped_lock lock(mtx1, mtx2);  // ✅ 自动多锁 + 防死锁
    // ... 临界区 ...
}

std::scoped_lock 等价于 std::lock() + N 个 lock_guard,但更简洁、更不易出错。

2.4.2 scoped_lock 防死锁原理

cpp 复制代码
// std::scoped_lock 内部使用 std::lock() 算法:
//   如果无法同时获得所有锁 → 释放已获得的 → 重试
//   避免了"th1 持有 A 等 B,th2 持有 B 等 A"的经典死锁

2.5 死锁与避免策略

2.5.1 死锁经典场景

cpp 复制代码
std::mutex mtx_a, mtx_b;

// Thread 1: 先锁 A, 再锁 B
void thread1() {
    std::lock_guard lock_a(mtx_a);
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
    std::lock_guard lock_b(mtx_b);  // 等待 B
    // ... 永远不会执行到 ...
}

// Thread 2: 先锁 B, 再锁 A
void thread2() {
    std::lock_guard lock_b(mtx_b);
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
    std::lock_guard lock_a(mtx_a);  // 等待 A
    // ... 永远不会执行到 ...
}

Thread 2 Mutex B Mutex A Thread 1 Thread 2 Mutex B Mutex A Thread 1 🔒 死锁! 互相等待 lock A ✅ lock B ✅ 等待 lock B... 等待 lock A...

2.5.2 避免死锁的四种策略

策略 1:固定上锁顺序

cpp 复制代码
// ✅ 所有线程按相同顺序锁定
void transfer(Account& from, Account& to, int amount) {
    // 按地址排序, 确保锁顺序一致
    if (&from < &to) {
        std::scoped_lock lock(from.mtx, to.mtx);
        // ...
    } else {
        std::scoped_lock lock(to.mtx, from.mtx);
        // ...
    }
}

策略 2:使用 std::lock() 或 std::scoped_lock

cpp 复制代码
// ✅ 让标准库处理顺序
std::scoped_lock lock(mtx_a, mtx_b, mtx_c);  // 自动防死锁

策略 3:使用层级锁(Hierarchical Lock)

cpp 复制代码
// 自定义: 只能从高层级向低层级加锁
class HierarchicalMutex {
    std::mutex mtx_;
    int level_;
    static thread_local int this_thread_level_;

public:
    explicit HierarchicalMutex(int level) : level_(level) {}

    void lock() {
        if (this_thread_level_ <= level_) {
            throw std::logic_error("lock order violation!");
        }
        mtx_.lock();
        this_thread_level_ = level_;
    }

    void unlock() {
        this_thread_level_ = level_ + 1;
        mtx_.unlock();
    }
};

策略 4:避免嵌套锁

cpp 复制代码
// ❌ 持有锁时调用外部函数
void bad() {
    std::lock_guard lock(mtx);
    external_function();  // 不知道它会不会加锁!
}

// ✅ 重构: 锁仅保护数据, 不在持锁时调用外部代码
void good() {
    int local;
    {
        std::lock_guard lock(mtx);
        local = shared_data;
    }  // 释放锁
    external_function(local);  // 安全
}

2.6 其他互斥量类型

2.6.1 std::recursive_mutex

cpp 复制代码
class RecursiveDemo {
    std::recursive_mutex mtx_;
    int data_ = 0;

public:
    void outer() {
        std::lock_guard lock(mtx_);
        ++data_;
        inner();  // ✅ 同一线程再次加锁, 不阻塞
    }

    void inner() {
        std::lock_guard lock(mtx_);  // 递归加锁
        ++data_;
    }
};

// ⚠️ 大多数情况不需要 recursive_mutex
// 如果设计中出现需要递归加锁, 往往说明锁的边界划分有问题

2.6.2 std::timed_mutex

cpp 复制代码
std::timed_mutex tmtx;

void try_acquire_with_timeout() {
    auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1);

    if (tmtx.try_lock_until(deadline)) {   // 等待最多 1 秒
        std::lock_guard<std::timed_mutex> lock(tmtx, std::adopt_lock);
        // ... 临界区 ...
    } else {
        std::cout << "未能获得锁, 超时\n";
    }
}

// 也可以用 try_lock_for
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
    // ...
}

2.7 std::once_flag 与 std::call_once

2.7.1 线程安全的单次初始化

cpp 复制代码
std::once_flag init_flag;
ExpensiveResource* resource = nullptr;

void initialize_resource() {
    resource = new ExpensiveResource();  // 只会被调用一次
}

void use_resource() {
    std::call_once(init_flag, initialize_resource);
    resource->do_work();  // 安全: 保证初始化已完成
}

2.7.2 比 DCLP 更安全

cpp 复制代码
// ❌ 双重检查锁定 (DCLP) ------ 在 C++11 之前是 broken 的
ExpensiveResource* dclp_broken() {
    if (resource == nullptr) {         // ① 第一次检查
        std::lock_guard lock(mtx);
        if (resource == nullptr) {     // ② 第二次检查
            resource = new ExpensiveResource();
            // ⚠️ C++03: new 的三步可能重排 → 其他线程看到"半成品"
        }
    }
    return resource;
}

// ✅ C++11+: 用 call_once 或 函数内 static
ExpensiveResource& safe_singleton() {
    static ExpensiveResource instance;  // C++11 保证线程安全
    return instance;
}

2.8 std::condition_variable------等待与唤醒

2.8.1 为什么需要条件变量

cpp 复制代码
// ❌ 忙等待 (Busy Wait): 浪费 CPU
while (!ready) {
    std::this_thread::sleep_for(std::chrono::milliseconds(10));  // 还是忙
}

// ✅ 条件变量: 让线程休眠, 直到条件满足
std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void waiter() {
    std::unique_lock lock(mtx);
    cv.wait(lock, []{ return ready; });  // 自动 unlock + 休眠 + 醒来 + lock
    // 此时已持有锁, ready == true
    std::cout << "Processing...\n";
}

void signaler() {
    {
        std::lock_guard lock(mtx);
        ready = true;
    }
    cv.notify_one();  // 唤醒一个等待线程
}

2.8.2 wait() 的执行流程



wait(lock, predicate)
predicate()

== true?
继续执行

(已持有锁)
① unlock

② 线程休眠
③ 被 notify 唤醒
④ 重新 lock

2.8.3 为什么必须循环检查(虚假唤醒)

cpp 复制代码
// ❌ 错误: 只用 if 检查
cv.wait(lock);  // 不带 predicate
if (ready) {    // 虚假唤醒后不会重新检查!
    // ...
}

// ✅ 正确写法 1: Lambda predicate
cv.wait(lock, []{ return ready; });

// ✅ 正确写法 2: while 循环 (等效)
while (!ready) {
    cv.wait(lock);
}

虚假唤醒(Spurious Wakeup):操作系统可能没有原因地唤醒线程。用 predicate 或 while 循环自动防御。

2.8.4 notify_one vs notify_all

cpp 复制代码
// notify_one: 唤醒一个等待线程
cv.notify_one();  // 适合: 一次生产, 一个消费者处理

// notify_all: 唤醒所有等待线程
cv.notify_all();  // 适合: 状态变更影响所有等待者

2.9 生产者-消费者模式

2.9.1 经典实现:有界缓冲区

cpp 复制代码
#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>

template <typename T>
class BoundedQueue {
    std::queue<T> queue_;
    std::mutex mtx_;
    std::condition_variable cv_not_full_;
    std::condition_variable cv_not_empty_;
    size_t capacity_;

public:
    explicit BoundedQueue(size_t capacity) : capacity_(capacity) {}

    // 生产者: 放入元素 (队列满则阻塞)
    void push(T item) {
        std::unique_lock lock(mtx_);
        cv_not_full_.wait(lock, [this] {
            return queue_.size() < capacity_;
        });
        queue_.push(std::move(item));
        cv_not_empty_.notify_one();
    }

    // 消费者: 取出元素 (队列空则阻塞)
    T pop() {
        std::unique_lock lock(mtx_);
        cv_not_empty_.wait(lock, [this] {
            return !queue_.empty();
        });
        T item = std::move(queue_.front());
        queue_.pop();
        cv_not_full_.notify_one();
        return item;
    }

    // 非阻塞尝试
    std::optional<T> try_pop() {
        std::lock_guard lock(mtx_);
        if (queue_.empty()) return std::nullopt;
        T item = std::move(queue_.front());
        queue_.pop();
        cv_not_full_.notify_one();
        return item;
    }
};

2.9.2 交互时序

Consumer BoundedQueue Producer Consumer BoundedQueue Producer 队列满, Producer 等待 push(item1) notify 唤醒 push(item2) push(item3) pop() → item1 notify 唤醒 Producer push(item4)

2.9.3 完整使用示例

cpp 复制代码
int main() {
    BoundedQueue<int> queue(5);

    // 生产者线程
    std::thread producer([&queue] {
        for (int i = 0; i < 20; ++i) {
            queue.push(i);
            std::cout << "Produced: " << i << "\n";
        }
    });

    // 消费者线程
    std::thread consumer([&queue] {
        for (int i = 0; i < 20; ++i) {
            int item = queue.pop();
            std::cout << "Consumed: " << item << "\n";
            std::this_thread::sleep_for(std::chrono::milliseconds(50));
        }
    });

    producer.join();
    consumer.join();
}

2.10 锁的粒度与性能

2.10.1 锁的边界划定

cpp 复制代码
// ❌ 锁太粗: 整个函数持锁
void too_coarse() {
    std::lock_guard lock(mtx);
    auto data = expensive_query_from_db();     // 耗时的 IO, 不应持锁!
    process(data);                              // 耗时计算
    update_shared_state(data);                  // 只有这里需要锁
}

// ✅ 细化粒度: 只在需要的时候持锁
void fine_grained() {
    auto data = expensive_query_from_db();      // 无锁
    auto result = process(data);                // 无锁

    std::lock_guard lock(mtx);                  // 最小临界区
    shared_state.update(result);
}

2.10.2 性能数据参考

操作 耗时(相对) 说明
无竞争 mutex::lock 1x (~20ns) 非常快
有轻竞争 mutex::lock 10-100x 自旋等待
有严重竞争 mutex::lock 1000x+ 系统调用 + 上下文切换
condition_variable::wait ~2000x 系统调用 + 调度
系统调用 (read/write) ~500x IO 开销
上下文切换 ~5000x 最贵

设计原则

  1. 持锁时不调用外部代码(你不知道它会做什么)
  2. 持锁时不做 IO(IO 耗时远大于锁操作)
  3. 持锁时不获取另一个锁(除非使用 std::scoped_lock
  4. 临界区代码越短越好

2.11 小结

知识点 掌握程度 核心要点
Data Race 的危害 掌握 用 TSAN(-fsanitize=thread) 自动检测
std::lock_guard 熟练 最简单安全的 RAII 锁
std::unique_lock 掌握 灵活性, 条件变量必须搭配
std::scoped_lock (C++17) 掌握 多锁场景首选
死锁避免 掌握 固定顺序 / std::scoped_lock / 避免嵌套锁
std::recursive_mutex 理解 谨慎使用, 往往设计有改进空间
std::timed_mutex 理解 超时避免永久阻塞
std::call_once 掌握 优于 DCLP, 线程安全初始化
condition_variable 掌握 必须用 predicate 防虚假唤醒
生产者-消费者 掌握 双条件变量模式
锁粒度优化 理解 最小化临界区, 不持锁做 IO

下期预告

[Part 3:原子操作与内存模型] 将深入无锁编程:

  • std::atomic<T> ------ 真正的原子操作
  • compare_exchange_weak/strong ------ CAS 循环
  • memory_order 六种内存序详解
  • 无锁栈(Lock-Free Stack)实现
  • 原子操作 vs 互斥锁的性能对比

推荐工具

  • -fsanitize=thread -g ------ 一秒发现 Data Race
  • valgrind --tool=helgrind ------ 检测锁顺序问题
  • GDB thread apply all bt ------ 定位死锁
相关推荐
吴可可1231 小时前
C++与C#版Teigha样条离散化差异解析
c++·算法·c#
搬砖的小码农_Sky1 小时前
macOS Sequoia上如何安装gcc/g++环境?
c语言·c++·macos
愈努力俞幸运1 小时前
python 三引号
android·开发语言·python
止语Lab1 小时前
Go跨平台编译的决策树:从\
开发语言·决策树·golang
Das11 小时前
【408】C语言标识符
c语言·开发语言
zxd0203111 小时前
DevOps + CI/CD:从理念到 Jenkins 实战落地
java·开发语言
qq_白羊座1 小时前
GitLab CI + Jenkins 双流水线模式Jenkins 端实现
java·开发语言
say_fall1 小时前
8086汇编程序设计_从基础到实战
开发语言·汇编·8086
郝学胜-神的一滴1 小时前
中级OpenGL教程 007:解决背面光照异常高光问题
c++·unity·游戏引擎·three.js·opengl·unreal