← ALL ARTICLES

线程同步

全库目录221
本文目录11

一个后台线程把任务放进队列,另一个线程醒来取走它,看起来只需要一个通知。但消费者醒来时,队列仍可能为空;程序停止时,空队列里的线程又可能永远睡着。通知、共享数据和线程寿命分别解决什么问题,决定了这个队列是否可靠。

我们先让同一个队列支持生产与消费,再加入第二个消费者和停止操作。沿着这些交错,可以看清互斥锁保护什么、条件变量等待什么,以及原子操作为什么不能自动替代整个协议。

共享状态与互斥

设队列最初有任务A。消费者甲读取队首,消费者乙也读取队首,随后两者分别删除队首。如果读取和删除没有共同保护,两者可能重复执行A,第二次删除还可能访问已经无效的元素。即使把“任务数量”改成原子整数,队列内部的指针和元素移动仍在同时发生。

所以先把“检查队列、取得任务、移除任务”作为一个不可被其他消费者穿插的临界区。甲持有队列锁时,乙等待;甲移除A并释放锁以后,乙才重新检查队列。锁还建立可见性:同一互斥锁上的解锁与随后取得所有权的加锁,使受保护的数据写入对后续持锁访问可见。

保护范围跟着数据不变量走。队列数量与队列内容必须一致,因此两者共同受锁保护;任务执行可以耗时很长,却已不再需要修改队列,因此应在解锁后执行。否则甲执行A的整个期间,生产者也无法加入B。

锁只保护遵守同一协议的访问。若调试界面在不持锁时读取队列大小,生产者仍可能同时修改它。在C++中,对同一存储位置的冲突访问至少一个非原子且没有先行发生关系,会构成数据竞争;结果不能按“偶尔读到旧值”理解。对象寿命的开始与结束也可能参与这种冲突。

条件变量与等待谓词

队列为空时,消费者可以不断加锁检查,但它会占用CPU,却没有任务可做。条件变量让消费者暂时睡眠,生产者加入任务后再通知它重新检查。

这里真正需要等待的是“队列非空”,通知只改变等待状态。假设甲、乙都在等待,生产者只加入一个任务A并通知全部消费者。甲先取得锁并拿走A;乙随后取得锁时,队列已经为空。乙确实收到了通知,却没有取得任务的资格。因此唤醒以后必须重新检查共享条件。

检查与入睡的交接

单独执行“发现队列为空、解锁、睡眠”会留下缝隙:消费者解锁后,生产者入队并发出通知;消费者随后才睡下。通知没有保存在条件变量里,若再无任务到来,它可能一直等待。

condition_variable::wait把释放互斥锁与进入等待作为原子的交接步骤;生产者又必须持有同一把锁才能修改队列。生产者若先取得锁并入队,消费者检查时就会看见非空;消费者若先检查到空并进入wait,生产者随后才能取得锁、入队和通知。这个协议封住了检查与等待之间的空隙。

谓词版本的wait(lock, pred)等价于在锁内循环检查pred,为假才继续等待。它同时处理其他消费者先取走任务,以及等待本身允许的伪唤醒。通知次数与任务数量没有一一对应关系;需要保存许可数量时,信号量提供另一种计数模型。

入队、取出与停止

下面是C++17机制样例,任务以整数ID表示,pop返回空值表示队列已关闭且任务已排空。所有调用线程必须在队列析构前结束。它采用无界队列,内存分配失败通过标准异常交给调用者;真实任务执行与异常策略由调用者负责。

cpp
#include <condition_variable>
#include <deque>
#include <mutex>
#include <optional>

class Queue {
    std::mutex mutex_;
    std::condition_variable ready_;
    std::deque<int> jobs_;
    bool closed_ = false;
public:
    bool push(int id) {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            if (closed_) return false;
            jobs_.push_back(id);
        }
        ready_.notify_one();
        return true;
    }

    std::optional<int> pop() {
        std::unique_lock<std::mutex> lock(mutex_);
        ready_.wait(lock, [&] { return closed_ || !jobs_.empty(); });
        if (jobs_.empty()) return std::nullopt;
        int id = jobs_.front();
        jobs_.pop_front();
        return id; // unique_lock析构释放锁,调用者随后执行任务
    }

    void close() {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            closed_ = true;
        }
        ready_.notify_all();
    }
};

closed_与队列使用同一把锁,谓词因此能在一个一致的状态下回答“现在可以继续吗”。关闭时使用notify_all:所有空闲消费者都需要退出,单次通知不能保证它们全部醒来。关闭后拒绝新任务,已经入队的任务继续被取出;当最后一个任务取完,下次pop返回空值。

持锁时看到的状态pop的动作
未关闭、队列空继续等待
未关闭、队列非空取出一个任务
已关闭、队列非空继续取出已有任务
已关闭、队列空返回空值并由调用者退出循环

这张表也定义了关闭与入队竞争的结果。push先取得锁并加入A,则A属于需要排空的任务;close先取得锁,则随后的push失败。程序无需猜测两条线程谁“同时”发生,锁内状态变化给出了可检查的先后关系。

调用者先停止提交任务,再调用close,最后对所有消费者执行join,才可以销毁队列。close只是改变状态并通知;它并不等待消费者退出。若任务内部还要向同一队列提交后续任务,关闭时机就必须由更高层任务生命周期决定,不能直接采用这个停止顺序。

原子发布与内存顺序

队列锁同时保护多个字段。若只需把一份写完以后不再修改的数据交给另一线程,可以建立更小的一次性发布协议。

下面片段假设payload与ready在启动线程前已经构造,只有一个生产者和一个消费者。两者结束后才销毁数据;消费者的循环只演示内存关系,实际长时间等待宜使用阻塞机制。

cpp
#include <atomic>

int payload = 0;
std::atomic<bool> ready{false};

// 生产者
payload = 42;
ready.store(true, std::memory_order_release);

// 消费者
while (!ready.load(std::memory_order_acquire)) {
    // 等待;此处不访问payload
}
int value = payload; // 在这份一次性协议中应为42

消费者读到初始false时,没有取得生产者的发布,继续等待且不能读取正在写入的payload。它读到生产者release写入的true以后,这次acquire与该发布建立同步;发布前的普通写入先行发生于随后的普通读取,因此可以读到42。关键是取得对应发布的值,代码中仅仅出现release与acquire两个单词并不够。

把两个原子操作都改成relaxed,标志本身仍是原子的,但这份协议没有为普通payload的写入与读取建立同步,因而不能保证安全读取。顺序一致的原子操作提供更强的顺序约束,也仍需正确设计被保护数据的访问与寿命。

多轮复用与回收

一次发布完成后,如果生产者立即把payload改成43,消费者可能还在读取42。ready保持为真无法说明上一轮已经消费完;随意把它写回假,也没有自动产生完整的双向交接。可采用队列、双方确认的状态机或具有明确所有权转移的槽位,每一轮都需要解释谁有权读写。

同样,原子指针读取成功只说明取得了一个指针值。消费者读取非空指针后,生产者仍可能删除对象,导致解引用访问已经结束寿命的存储。锁可以覆盖“取指针到使用结束”的整个范围;引用所有权或延迟回收协议则用另一种方式保留这段寿命。具体机制见 对象生命周期。

无锁进展保证描述系统整体能否持续完成操作;它不自动保证单个线程不会饥饿,也不保证对象回收已经安全。把指针改为原子,距离一个正确的无锁容器仍有很长的协议路径。

锁的选择与等待依赖

工作队列的临界区只移动少量数据,大部分时间在锁外执行任务。选择原语时先看锁持有者在等待期间能否运行,再看读写比例与实际竞争。

自旋锁通过反复尝试取得锁,省去一次睡眠与唤醒的交接,但持锁线程若被抢占,等待线程只是在消耗执行资源。持锁期间做IO或等待另一个锁时,等待可能远超一次短临界区。读写锁允许多个读者共享访问,收益取决于读临界区是否足够长、写入是否稀少,以及具体实现的开销与公平策略。递归锁允许同线程重复获取,也需要为每层获取正确释放,不能替代对调用依赖的梳理。

双锁死锁

假设任务甲已持有队列锁,接着请求资产锁;任务乙已持有资产锁,接着请求队列锁。甲等待乙释放资产,乙等待甲释放队列,两者都无法继续。这里的环就是故障原因,延长超时时间无法消除它。

统一约定先资产锁、后队列锁,可以消除这一对锁的循环顺序。另一种设计是在一把锁内只复制所需数据,释放后再进入另一模块,但必须确认复制期间保存的不变量足以支撑后续操作。需要同时拥有两把锁时,C++17的std::scoped_lock可使用避免死锁的多锁获取算法;其他代码路径仍须遵守所有权和寿命要求。

持锁调用未知回调也会隐藏这类环:回调可能重新进入队列,或者取得另一个模块的锁。因此队列样例把任务移出以后才执行。锁图记录“持有A时请求B”的边,出现环时就有具体的顺序需要调查。

死锁中参与者不再推进;活锁中双方可能不断释放、重试,仍然谁都无法完成;饥饿则可能只有一个线程长期得不到机会。它们需要分别观察等待链、重试次数和每个线程的完成情况,不能只看总CPU占用。

推演与验证

先手工改变一条交错,再用运行实验寻找实现错误。对队列至少检查以下路径:

输入或交错应当得到的结果主要暴露的问题
队列空时关闭所有消费者退出停止谓词与notify_all
两消费者竞争一个任务只执行一次,另一消费者继续等待唤醒后重新检查
任务已入队再启动消费者消费者直接取走任务状态具有持久性,通知无需积累
关闭前已入队多个任务每个任务恰好取出一次,再退出排空策略与互斥范围
关闭后再次提交push返回false停止与入队的线性化位置

并发测试需要超时,以免错误实现把测试进程永久挂住;超时只能发现未完成,不能单独解释死锁原因。竞态检测器能发现实际执行中被观察到的竞争,压力测试也只能覆盖有限交错。正确性仍需回到共享状态、同步边和对象寿命逐项推理。

从仓库根目录运行python -B 知识库/examples/mechanism_depth/threading/test_reference.py,可检查锁内状态转换的Python参考模型。2026年10月23日执行5项检查通过,覆盖两个消费者依次取值、关闭排空、四种谓词状态和一次性发布关系分类。模型把持锁操作视为串行步骤,不模拟真实线程调度或C++弱内存行为;发布分类仅检查本文限定的协议条件。

2026年10月24日使用Clang以C++17编译正文抽取样例,并运行4类队列场景及100次release/acquire交接,入口为宿主检查。队列检查覆盖关闭排空、四消费者退出、1000项恰好消费一次,以及生产与关闭竞争;子进程设置20秒超时。尚未运行竞态检测器,也未穷举弱内存交错。2026年10月23日核读C++工作草案的条件变量等待、原子发布与互斥锁条款,支持上述协议解释;标准条款不能替代样例的编译与运行证据。

相关主题

参考资料