【C++并发系列】第二章:锁解决了什么问题?

博主介绍:程序喵大人

上一章中,我们分析了多线程并发执行自增操作时由于缺少同步导致的竞态条件:两个线程并发执行 counter++,因为底层的 load、add、store 三个步骤被操作系统的调度器交叉执行,最终导致部分更新丢失。面对这类由于并发引发的数据竞争问题,绝大多数开发者的首选方案往往非常一致,那就是直接使用互斥锁。

作为并发编程中最常用的同步工具,互斥锁(Mutex,意为 Mutual Exclusion 互斥)的作用往往被简化为"将并行代码强制转换为串行排队执行",但这种理解实际上只触及了互斥锁的表象,其在硬件底层和软件架构中所承担的可见性与数据一致性职责,远比简单的排队机制更为深刻。

mutex:让临界区排队执行

为了确保计数器能够正确地自增,我们需要使用 std::mutex 把自增操作完整地封装在临界区中,以下是一个典型的互斥锁使用示例:

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

int counter = 0;
std::mutex counter_mutex;

void Worker() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(counter_mutex);
        ++counter;
    }
}

int main() {
    std::thread t1(Worker);
    std::thread t2(Worker);
    t1.join();
    t2.join();
    std::cout << counter << std::endl;
    // 结果一定是完美的 200000
}

在这段代码中,通过互斥锁的同步,计数器最终的输出结果能够准确无误地达到预期的总数。但为了在更复杂的业务中正确运用互斥锁,我们需要深入理解其底层的执行机制。

在底层的机器指令层面上,++counter 在编译后依然是由 load、add 和 store 三个步骤组成的,CPU 的硬件执行逻辑并不会因为加锁而将这三条指令合并为一条原子的 CPU 指令。真正改变的是线程的访问规则:当 Thread 1 成功获取了 counter_mutex 的独占权并进入临界区时,即使在此期间操作系统的调度器发生上下文切换、将 CPU 执行权让渡给 Thread 2,Thread 2 也会在尝试获取锁(即执行 lock 操作)时被阻塞。由于锁已被独占,Thread 2 会被挂起,直至 Thread 1 执行完所有指令并释放该锁。

这种通过阻止多线程在临界区交错执行的同步协议,正是互斥机制的核心。虽然持有锁的线程在临界区内部依然是分步执行其内部代码的,但对于其他在外部排队等待锁的线程而言,这一整套状态修改操作在逻辑上就表现为一个不可分割的原子整体,从而避免了由于指令交错执行引起的数据不一致。

锁保护的是业务不变量,不是几行代码

在计数器的例子中,锁很容易被误解为一种用来专门保护某几行特定代码或某一个特定变量的物理屏障。然而在实际工程开发中,锁真正应该被用来保护的核心目标,是整个业务系统在并发修改时必须时刻维持的不变量(Invariant)。

不变量通常是指系统状态必须满足的一致性规则。在单变量自增的简单场景中,这个不变量是"计数结果必须与执行过的操作次数一致",由于过于直观,我们往往会忽略它的存在;但是当面对涉及多个变量联动修改的场景(例如金融转账系统)时,不变量的作用就会变得尤为关键:

cpp 复制代码
struct Account {
    int balance = 0;
};

std::mutex accounts_mutex;

void Transfer(Account& from, Account& to, int amount) {
    std::lock_guard<std::mutex> lock(accounts_mutex);
    from.balance -= amount;
    to.balance += amount;
}

在这个例子中,业务层面的不变量是"两个账户的资金总和在转账前后必须保持完全一致"。当账户 A 的扣款已经完成、而账户 B 尚未执行加款时,如果允许其他线程(例如后台用于数据核对的对账线程)在此刻读取两个账户的余额,由于读取到了处于中间过渡状态的数据,系统就会判定发生账目不一致。

为了防止这种破坏一致性的中间状态对其他线程可见,我们必须使用同一把锁将扣款与加款这两个独立的步骤封装在一起。这能确保任何遵守同步约定的线程,均无法在此操作序列的中间时刻去访问相关账户。如果开发者试图将加锁范围缩小、给每个账户分别配一把独立的互斥锁来对转账的两个阶段单独保护,由于在释放 A 锁与获取 B 锁之间存在一个无锁的时间缝隙,在此期间两个账户的总额依然是不处于一致状态的,后台对账线程只要在此刻执行读取就会获取到错误的数据。

因此,将锁的同步范围与业务逻辑中的一致性不变量边界完全对齐,是设计并发协议时必须掌握的原则。只要一组共享数据在业务语义上是强关联的,并且它们的状态变化必须同进同退,我们就必须使用同一把互斥锁来为这整组数据建立统一的临界区保护。

RAII 是加锁的铁律:lock_guard

在前面的示例代码中,我们没有直接显式地调用 mutex.lock()mutex.unlock() 来手动操作互斥锁,而是将锁的管理交给了标准库的 std::lock_guard。因为在复杂的工程项目中,依赖程序员在每一条执行路径下都不遗漏解锁操作是非常困难的。

我们可以通过下面这段简单的业务逻辑来说明手动解锁的潜在隐患:

cpp 复制代码
void ProcessData() {
    data_mutex.lock();
    
    if (!IsDataValid()) {
        return; // 这里如果不写 unlock,后续线程全死等
    }
    
    try {
        DoHeavyCalculation(); // 万一里面抛出内存不足异常
    } catch (...) {
        LogError();
        return; // 同样必须记住 unlock
    }
    
    data_mutex.unlock(); // 只有一切顺利才能走到这里
}

在上述实现中,如果在数据校验阶段不满足条件提前 return,或者在执行密集计算时内部代码抛出异常,整个函数的执行流就会中断,从而使最后的 unlock() 操作无法被执行。这将导致该互斥锁持续处于被占用状态,任何其他尝试获取该锁的线程都会被永久阻塞,进而导致系统资源的彻底泄漏与死锁。

为了防范由于控制流复杂化或异常处理带来的锁释放遗漏问题,C++ 推荐使用 RAII(Resource Acquisition Is Initialization,资源获取即初始化)模式来管理锁的生命周期。

std::lock_guard 正是这一模式在互斥锁同步中的具体实现。在其生命周期开始、执行构造函数时,它会自动调用传入互斥锁的 lock() 操作以实现加锁。

更关键的保障在于其析构函数:由于 RAII 变量是分配在栈上的,无论当前的控制流是以何种方式(正常执行结束、遇到 return 跳转,或是由于发生异常导致栈展开)离开该 lock 变量所在的作用域,编译器都会确保该变量的析构函数被调用。而在 std::lock_guard 的析构函数中,系统会自动且可靠地执行互斥锁的 unlock() 操作。因此,在日常编码中利用 RAII 对象来自动管理锁的释放,是确保并发系统健壮性的重要手段。

当你需要主动释放锁:unique_lock 和条件变量

虽然 std::lock_guard 提供了极其安全的作用域锁管理,但在许多高级的并发协作场景中,由于需要对锁的持有权进行更精细粒度的控制,它的局限性就显露了出来。其中最典型的例子莫过于在生产者-消费者模型中实现的阻塞队列。

在此场景下,如果消费者线程在尝试从队列获取任务时发现队列为空,它若持续持有锁并保持同步等待,就会导致生产者线程因为无法获得锁而不能将新任务插入队列,这最终将导致整个系统死锁。因此,在队列为空时,消费者必须执行以下同步协议:首先暂时释放持有的互斥锁,随后挂起自身线程进入等待状态,在生产者写入任务并发出唤醒信号后再重新获取互斥锁。

这种在生命周期中途允许手动或自动解锁与重新加锁的操作,通常由 std::unique_lock 配合 std::condition_variable 来协同完成:

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

std::mutex queue_mutex;
std::condition_variable queue_cv;
std::queue<int> queue;

// 生产者负责往里塞任务
void Push(int value) {
    {
        std::lock_guard<std::mutex> lock(queue_mutex);
        queue.push(value);
    }
    // 塞完数据,马上发信号叫醒一个正在等待的消费者
    queue_cv.notify_one();
}

// 消费者负责在非空时取任务
int Pop() {
    std::unique_lock<std::mutex> lock(queue_mutex);
    
    // 等待队列非空。如果不满足条件,就在这里交出锁并强制睡觉。
    queue_cv.wait(lock, [] { return !queue.empty(); });

    // 走到这里,说明醒了,而且队列里有东西,而且我们手里稳稳拿着锁。
    int value = queue.front();
    queue.pop();
    return value;
}

在消费者提取任务的 Pop 操作中,我们首先使用 std::unique_lock<std::mutex> 锁定互斥量以安全地检查队列状态,防止发生并发读写的冲突。当调用 queue_cv.wait(lock, ...) 时,条件变量内部会执行两个关键步骤:它会原子化地释放传入的 lock 所关联的互斥锁,并将当前线程挂起并移动至条件变量的等待队列中。在此期间,该线程不再占用任何 CPU 计算资源,从而实现阻塞等待。

当生产者向队列写入任务并调用 notify_one() 时,该信号会指示操作系统将等待队列中的一个消费者线程唤醒,使其重新进入可运行状态。然而在 wait 函数返回并允许消费者线程继续执行后续代码之前,该线程必须在底层重新竞争并成功获取到最初被释放的那把互斥锁;如果此时互斥锁仍被生产者或其他线程持有,被唤醒的消费者将继续在锁竞争路径上等待,直到成功持有锁后,wait 函数才会正式返回。

通过这套协调机制可以看出,std::condition_variable 自身并不具备保护共享数据的职责,它仅仅是一个用于管理线程状态变更的通知与调度机制。真正保护数据安全和一致性的依然是底层的互斥量本身,这也就是为什么条件变量在执行 wait 操作时必须要求传入 std::unique_lock 的原因------它需要在等待开始时释放锁,并在等待结束后重新恢复锁状态。

伪唤醒的防线:Predicate 检查

在上述 Pop 的等待实现中,wait 函数接收了一个额外的参数,即 Lambda 表达式 [] { return !queue.empty(); }。这个表达式被称为谓词(Predicate),其主要作用是在线程从挂起状态被唤醒时,提供一层保障数据安全性的前置条件校验。如果省略该谓词,仅仅将等待流程简化为一次性的条件判断,系统就会面临非常严重的竞态风险和异常错误:

cpp 复制代码
// 极其危险的错误写法!绝对不要在工程里这么写!
if (queue.empty()) {
    queue_cv.wait(lock); // 没有 predicate,醒了就直接往下走
}
int value = queue.front();

在缺少谓词的简易实现中,主要存在两个极其危险的安全隐患。首先是多消费者之间的资源竞争问题:假设队列原为空,此时两个消费者线程均挂起在等待队列中。当生产者向队列推入一个任务并发出唤醒信号时,操作系统的调度机制可能会同时唤醒这两个等待线程。其中速度较快的消费者 A 首先竞争到互斥锁,移除了队列中的任务并释放锁;当消费者 B 随后拿到锁并恢复执行时,如果它不再次校验队列状态直接调用 queue.front(),就会由于对空队列执行非法读取而导致程序崩溃。

另一个无法避免的问题是底层操作系统所特有的伪唤醒(Spurious Wakeup)机制。在基于 POSIX 标准的多线程编程环境中,由于系统中断、信号分发或底层线程调度的影响,挂起在条件变量上的线程即使没有收到任何显式的 notify 信号,也可能会自发恢复到可执行状态。这种非预期性的唤醒在多线程底层架构中是常态,属于操作系统的标准设计行为。

因此,为了防范抢占式竞争和伪唤醒带来的错误,线程在唤醒之后必须以防御性的原则重新校验不变量状态,如果发现队列在逻辑上依然为空,必须再次自动挂起进入等待状态。在标准库中,带有谓词参数的 wait 函数其内部逻辑本质上可以展开为经典的循环检查:

cpp 复制代码
// 这才是防御拉满的标准姿势
while (queue.empty()) {
    queue_cv.wait(lock);
}

通过将校验操作封装在 while 循环中,能够确保只有当临界区中的不变量条件真正得到满足后,消费线程才被允许退出 wait 流程并继续执行后续的数据访问操作。

锁带来的可见性保证

除了提供互斥排队和线程状态协调之外,互斥锁还承担了另一个至关重要的系统职责,即跨线程的内存可见性保证。在 01-多线程读写同一个变量为什么会出错.md 中我们讨论过,现代 CPU 的多级缓存机制会导致一个线程写入的最新数据可能仅停留在其本地缓存中,而无法被其他 CPU 核心及时观测到。

这背后并不是依靠某种硬件上的巧合,而是 C++ 内存模型在语义层面对互斥量提供的强制性保证:一个线程对某个互斥量执行的解锁(unlock)操作,与后续另一个线程对同一个互斥量执行的加锁(lock)操作之间,在逻辑上构成 synchronizes-with(同步于)关系。

这种同步关系在底层硬件层面的意义在于,当持有锁的线程执行解锁操作时,它在临界区内所做的所有内存写入操作,都会被强制从当前核心的私有缓存中刷新出来,使其成为对全局物理内存可见的状态;而当下一个线程成功获取锁并开始执行时,其所在的 CPU 核心会被强制将本地私有缓存中的旧数据作废,从而重新从全局可见的主存中读取最新值。

这一可见性保障与互斥特性同样重要,因为缺少了内存刷新机制,即使实现了线程间的排队执行,也可能因为读取到非一致的本地旧缓存而导致业务逻辑失败。但是在工程实践中,为了使这层保障能安全生效,存在一个必须严格遵守的前置要求,那就是所有涉及该共享数据的读操作和写操作,都必须统一在同一把锁的保护下执行。

如果开发者为了避免读锁带来的性能开销,在执行写操作时使用互斥锁、而在执行只读操作时直接裸读共享数据,这种非对称的设计在 C++ 内存模型层面便构成了数据竞争(Data Race),它不仅会破坏可见性链条、导致读取到过期的数据状态,更会导致编译器优化出现未定义行为(UB)。在并发编程的规范中,对共享变量的所有访问操作都必须在同一个同步屏障的保护之下运行,这是保证数据安全性的底线。

锁的代价:死锁与读写分离

虽然互斥锁在功能设计上提供了完善的同步保障,但在实际的工程落地中,过度或者不合理的加锁设计通常会带来两类严重的副作用,即系统吞吐量的大幅下降与潜在的死锁风险。

如果我们将互斥锁的同步范围划定得过大(即采用大粒度锁),系统的高并发吞吐性能通常会受到严重限制。例如在高性能的网络网关设计中,若直接使用一把全局互斥锁保护所有用户连接的状态管理,就会导致大量的工作线程在尝试获取该锁时发生严重的锁竞争,原本多核心处理器的并发优势就会退化为实质上的单线程排队执行,系统资源利用率和吞吐量将出现断崖式下跌。

为了优化锁竞争,开发人员通常会考虑将大锁拆解为小锁(即采用细粒度锁)。但是,一旦系统中存在跨多个不同对象的联动修改逻辑时,如果对多把独立的互斥锁获取顺序设计不当,就会引发经典的死锁(Deadlock)问题:

cpp 复制代码
// 危险!极易触发死锁的初级写法
void Transfer(Account& a, Account& b) {
    std::lock_guard<std::mutex> lock_a(a.mutex); // 满心欢喜地先锁住 A
    // 如果好巧不巧这时候线程发生了微小的上下文切换...
    std::lock_guard<std::mutex> lock_b(b.mutex); // 然后尝试去锁住 B
    
    a.balance -= 100;
    b.balance += 100;
}

在这一实现中,如果在极短的时延内,Thread 1 尝试将资金从账户 A 转移到账户 B,而 Thread 2 尝试将资金从账户 B 转移到账户 A,那么 Thread 1 获取到账户 A 的锁并等待获取账户 B 的锁,而 Thread 2 成功持有账户 B 的锁并等待获取账户 A 的锁。此时两个线程由于互相持有对方所需的临界区锁资源,导致操作流程陷入无休止的互锁状态。

针对此类需要同时获取多把互斥锁的并发控制场景,标准库从 C++17 开始提供了更为安全的 std::scoped_lock。它内部采用了死锁避免算法,能够确保传入的锁对象集以一种全局统一的顺序或探测机制被原子化地全部安全持有:

cpp 复制代码
// 稳如磐石的安全写法
void Transfer(Account& a, Account& b) {
    // scoped_lock 一次性稳稳搞定两把锁,断绝所有死锁后患
    std::scoped_lock lock(a.mutex, b.mutex);
    
    a.balance -= 100;
    b.balance += 100;
}

除了调整锁的粒度以外,针对并发吞吐瓶颈的另一项经典优化方法是实现读写分离。

在许多工业级系统中,共享状态的读写比例通常表现出极强的读多写少特征,比如全局配置的路由映射表,每秒可能要承受数以万计的并发查询,而写入动作可能数天才会执行一次。如果在此类读取占绝对主导的场景中继续使用排他性的 std::mutex 限制并发,会让大量不带修改意图的查询操作排队等待,从而造成系统资源的严重浪费。

因此,C++17 引入了 std::shared_mutex(即读写锁),用来将读写操作的并发规则进行分类细化:

  • 共享锁(读锁):允许多个线程在同一时刻以只读方式并发访问临界区,读线程之间不互斥
  • 独占锁(写锁):在有线程需要对数据执行修改时获取,该写锁具有极强的排他性,会阻塞其他的读取和写入操作,以保证修改的安全
cpp 复制代码
#include <shared_mutex>
#include <map>

std::map<int, std::string> config_map;
std::shared_mutex config_mutex;

std::string ReadConfig(int key) {
    // 读锁:大家和气生财,可以多个线程同时拿,互相之间绝不互斥
    std::shared_lock<std::shared_mutex> lock(config_mutex);
    return config_map[key];
}

void UpdateConfig(int key, std::string value) {
    // 写锁:排他性拉满,只要拿到手,别人既不能读也不能写
    std::unique_lock<std::shared_mutex> lock(config_mutex);
    config_map[key] = value;
}

虽然读写锁在设计上极大缓解了只读操作并发的瓶颈,但是它的使用同样需要付出相应的系统代价。因为读写锁内部在调度多个读取者和写入者时,需要维持较为复杂的统计状态与线程就绪队列,其锁本身的加锁和解锁指令开销要显著大于传统的普通 std::mutex。如果临界区中被保护的仅仅是简单的整数递增或单次原子赋值,使用读写锁带来的额外运行时开销甚至可能会超过并发冲突本身的代价;在此类轻量级操作中,使用传统的互斥锁或直接采用 std::atomic 才是更符合性能最优解的选择。

小结

在多线程同步的设计中,使用互斥锁进行保护通常是确保并发安全性最稳妥、也最易于逻辑推导的防线。

虽然互斥机制会引入排队延迟,但它能从根本上消除多线程交错执行带来的状态冲突,同时依靠底层内存模型的 happens-before 同步机制来提供可靠的内存可见性保障。在实际开发中,只要合理划定锁所保护的业务不变量边界,并使用 RAII 模式管理锁的生存周期,同时在条件唤醒时搭配严格的 Predicate 检查,这套机制就能够解决绝大多数并发状态同步问题。

正是因为互斥锁强制串行排队的物理限制,许多开发者在追求吞吐量的过程中,会尝试寻找一些自以为开销更低的同步替代手段。在 C 和 C++ 的开发社群中,曾经长期流传着使用 volatile 关键字替代锁进行同步的常见认识误区。在接下来的第三章中,我们将深入剖析 volatile 在并发控制中的定义边界,并探讨它为什么无法为多线程环境提供实质性的正确性保护。

码字不易,欢迎大家点赞,关注,评论,谢谢!

相关推荐
天天代码码天天1 小时前
用 TensorRT 加速 PP-OCR:一套 C++ DLL + C# 调用的高性能 OCR 推理方案
c++·c#·ocr
guygg881 小时前
二维弹塑性有限元分析(von Mises 等向硬化)— MATLAB 实现
开发语言·人工智能·matlab
在放️1 小时前
Python 练习题讲解 2 · 循环计算
开发语言·python
我不是懒洋洋1 小时前
从零实现一个分布式链路追踪:TraceId与Span
c++
江华森2 小时前
高级 Bash 脚本编程指南 — 实战教程
开发语言·bash
森G2 小时前
78、框架分析------服务器源码解析----云视频服务项目
服务器·c++·qt
我不是懒洋洋2 小时前
【C++】string(string的成员变量、auto和范围for、string常用接口的说明、OJ题目、string的模拟实现)
c语言·开发语言·c++·visual studio
承渊政道2 小时前
飞算JavaAI 智能引导背后的多 Agent 协作机制解析:从老旧 Java 后台升级到可运行工程
java·开发语言·spring boot·安全·intellij-idea·软件工程·ai编程
Brilliantwxx2 小时前
【C++】 C++11 知识点梳理(中)
开发语言·c++