Skip to content

C++ 事件分发器:线程安全队列与死锁防护 ​

事件分发器(Dispatcher)是 GUI 框架、消息中间件客户端、游戏引擎里反复出现的构件:多个外部线程向队列注入事件,内部工作线程从队列取出事件分发给处理器。它是典型的生产者-消费者模型,线程安全与死锁防护都围绕共享队列展开。本文整理一套完整的设计要点,核心原则是"减少共享、同步有界、顺序一致",适合自己动手实现事件循环或消息队列的开发者参考。

基础:互斥锁 + 条件变量 ​

生产者端 ​

cpp
void Dispatcher::pushEvent(Event event) {
    {
        std::lock_guard<std::mutex> lock(m_queueMutex);
        m_eventQueue.push(std::move(event));
    }  // lock_guard 析构,自动释放锁
    m_queueCondVar.notify_one();  // 先释放锁,再通知
}

两个关键点:

  • RAII 加锁(std::lock_guard):即使 push 抛异常,锁也能安全释放,不会造成永久死锁;
  • 先释放锁再 notify_one:若先通知后释放,被唤醒的线程会立即尝试拿锁却拿不到(锁仍被当前线程持有),白白多一次上下文切换;先释放再通知,唤醒的线程可直接获得锁开始工作。

消费者端 ​

cpp
void Dispatcher::run() {
    while (m_isRunning) {  // atomic<bool>
        Event event;
        {
            std::unique_lock<std::mutex> lock(m_queueMutex);
            m_queueCondVar.wait(lock, [this] {
                return !m_eventQueue.empty() || !m_isRunning;
            });

            if (!m_isRunning && m_eventQueue.empty()) break;

            event = std::move(m_eventQueue.front());
            m_eventQueue.pop();
        }  // 锁在这里释放

        processEvent(event);  // 【关键】在锁外执行事件处理
    }
}

三个关键点:

  • 用 std::unique_lock:condition_variable::wait 需要在等待时解锁、唤醒后重新加锁,lock_guard 做不到;
  • 带谓词的 wait:防御虚假唤醒(spurious wakeup)——操作系统可能在没有 notify 的情况下唤醒等待线程,谓词重新检查条件,不满足就继续睡;
  • 锁外处理事件:临界区内只做"移动数据"这类轻量操作,processEvent 放在锁外。处理一个事件时,其他生产者线程可以继续 push,不被阻塞——极大缩短锁持有时间,是并发性能最重要的一处优化。

分场景选择同步原语 ​

原语 适用场景 说明
std::atomic 简单状态标志、计数器(如运行标志) 开销最小,配 memory_order_acquire/release 精确控制可见性
std::mutex + condition_variable 保护复杂结构的独占访问、线程间同步(任务队列) 通用选择
std::shared_mutex(C++17) 读多写少的资源(观察者/处理器注册列表) 读端 shared_lock 并发遍历,写端 unique_lock 独占
无锁结构 极致性能路径 不自研,用经过验证的第三方实现(如 moodycamel::ConcurrentQueue)或中间件内置结构

观察者列表是 shared_mutex 的典型场景:注册/注销(写)频率低,遍历通知(读)频率极高。用普通 mutex 会让多个读线程串行化,读写锁让读并发、只在写时短暂阻塞所有线程。

死锁防护的三条规则 ​

  1. 单一职责锁:一个 Dispatcher 只拥有一把互斥锁,且它只保护事件队列。类内部不存在多把锁,就不存在内部锁序死锁。

  2. 禁止在事件处理中回调 Dispatcher:processEvent() 绝不允许再调用任何要拿同一把锁的公共方法(如 pushEvent):

cpp
// 反例:处理事件时又去拿同一把锁 → 死锁
void processEvent(const Event& event) {
    if (event.type == "error") {
        pushEvent(Event{"shutdown"});  // 错误!
    }
}

确有需要时走异步路径:放入另一个无锁队列,由别的线程代为投递。

  1. 明确的关闭协议:
cpp
void Dispatcher::stop() {
    m_isRunning.store(false);         // 原子标志,无需锁
    m_queueCondVar.notify_all();      // 可能有多个工作线程
}

谓词里包含 || !m_isRunning,保证队列为空时线程也能被 notify_all 唤醒、看到退出信号并安全结束——避免关闭时线程永远睡在 wait 上。

两把锁必须按不同顺序获取的场景,可用 std::lock(mtx1, mtx2) 一次性加锁(内部用死锁避免算法)配合 std::scoped_lock 管理;多线程死锁已经发生时的通用调试手段,是用 GDB 的 thread apply all bt 抓全量调用栈,找出互相等待的锁。

小结 ​

这套设计的要点可以压缩成三句话:临界区内只搬数据、处理逻辑放锁外(缩短持锁时间);一把锁只管一件事、处理中不许回调(杜绝锁序死锁);等待用带谓词的 wait、退出靠原子标志加 notify_all(防御虚假唤醒与关不掉的线程)。在此基础上,按场景换用 shared_mutex 或经过验证的无锁结构,覆盖绝大多数事件分发的并发需求。

最近更新

基于 VitePress 构建