在并发编程中(尤其是“生产者-消费者”模型),我们经常会遇到一个极其核心但又容易被忽视的问题:当队列为空,或者对象池没有可用对象时,消费者线程到底该怎么“等”?

很多人拿到这个问题,第一反应就是:“这有什么难的?加个 std::mutexstd::condition_variable 挂起不就行了?”

如果你的系统对延迟要求不高,这么做确实没问题。但在诸如 Apollo CyberRT(自动驾驶中间件)、高频交易系统、或者是对实时性要求极高的游戏服务器中,这种“一刀切”的休眠策略可能会成为性能的致命瓶颈。

今天,我们将探讨如何通过策略模式(Strategy Pattern)多态机制,将“等待”这个动作抽离出来,设计一个灵活、优雅且高性能的等待策略层(WaitStrategy)


一、 为什么我们要把“等待”单独抽成一层?

在不同的业务场景下,我们对 “CPU 资源消耗”“响应延迟(Latency)” 的容忍度是完全不同的:

  1. 后台异步任务(如日志写盘):对延迟极度不敏感,希望 CPU 占用越低越好。
  2. 普通业务轮询(如配置更新):对 CPU 和响应时间只要求“折中”。
  3. 核心数据流(如自动驾驶感知数据下发):对延迟极度敏感(毫秒甚至纳秒级),宁愿牺牲一颗 CPU 核心的满负荷运转,也要换取最快的数据响应。

这就引出了一个矛盾:底层的无锁队列或对象池,根本不知道自己会被用在什么场景下。

如果不分离等待策略,代码会有多丑陋?

如果你把等待逻辑硬编码在队列的 Pop()Get() 函数里,你的代码很可能会变成一座巨大的“If-Else 屎山”:

// 反面教材:硬编码的屎山代码
T Pop(WaitMode mode, int timeout_ms = 0) {
    if (mode == WAIT_BLOCK) {
        std::unique_lock<std::mutex> lock(mutex_);
        cv_.wait(lock, []{ return !queue_.empty(); });
    } else if (mode == WAIT_SLEEP) {
        while (queue_.empty()) {
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
    } else if (mode == WAIT_YIELD) {
        while (queue_.empty()) {
            std::this_thread::yield();
        }
    } else if (mode == WAIT_BUSY) {
        while (queue_.empty()) {
            // 纯粹的死循环空转
        }
    } ...
    
    // 终于可以取数据了...
}

这种设计不仅长得丑,而且严重违反了“开闭原则(OCP)”。一旦未来想加一种新的自旋回退策略(Spin-Backoff),你就必须去改底层队列的源码。

优雅的解法:面向接口编程

我们应该做到:底层队列只管生产和消费数据,至于空的时候怎么等,交给外面传进来的“策略”决定。
我们定义一个纯虚基类 WaitStrategy

class WaitStrategy {
public:
    virtual void NotifyOne() {}       // 唤醒一个等待者(如果有阻塞)
    virtual void BreakAllWait() {}    // 打断所有等待(比如系统退出时)
    virtual bool EmptyWait() = 0;     // 核心接口:队列为空时,具体执行什么等待操作
    virtual ~WaitStrategy() {}
};

这样,底层的队列只需持有一个 WaitStrategy* 指针。当队列为空时,直接调用 strategy->EmptyWait() 即可,实现了完美的解耦。


二、 五大核心等待策略详解与实现

接下来,我们来看看基于 WaitStrategy 派生出的五种常见等待策略,它们分别对应了不同的性能折中方案。

1. 阻塞等待:BlockWaitStrategy

这最正统的等待方式,交由操作系统内核进行线程调度。

  • 实现原理:使用 std::mutexstd::condition_variable 将线程完全挂起(置为休眠态)。
  • 优点:等待期间 真正的零 CPU 占用
  • 缺点:唤醒时,操作系统需要进行昂贵的线程上下文切换(Context Switch),延迟最高(微秒~毫秒级波动)。
  • 适用场景:对时延不敏感的离线/异步非核心任务。
class BlockWaitStrategy : public WaitStrategy {
public:
    void NotifyOne() override { cv_.notify_one(); }
    void BreakAllWait() override { cv_.notify_all(); }
    
    bool EmptyWait() override {
        std::unique_lock<std::mutex> lock(mutex_);
        cv_.wait(lock);
        return true;
    }
private:
    std::mutex mutex_;
    std::condition_variable cv_;
};

2. 超时阻塞等待:TimeoutBlockWaitStrategy

带有超时保护机制的阻塞等待,是日常工程中最推荐的默认兜底方案。

  • 实现原理:使用 cv_.wait_for
  • 优点/场景:当生产者意外崩溃或出现死锁断流时,消费者不会一直被挂死(俗称被“饿死”),超时后可以醒来进行错误恢复或日志打印。
class TimeoutBlockWaitStrategy : public WaitStrategy {
public:
    explicit TimeoutBlockWaitStrategy(uint64_t timeout_ms) : timeout_ms_(timeout_ms) {}
    
    void NotifyOne() override { cv_.notify_one(); }
    void BreakAllWait() override { cv_.notify_all(); }

    bool EmptyWait() override {
        std::unique_lock<std::mutex> lock(mutex_);
        // 如果超时没被唤醒,返回 false;如果被唤醒,返回 true
        return cv_.wait_for(lock, std::chrono::milliseconds(timeout_ms_)) == std::cv_status::no_timeout;
    }
private:
    std::mutex mutex_;
    std::condition_variable cv_;
    uint64_t timeout_ms_;
};

3. 休眠等待:SleepWaitStrategy

相比起重量级的互斥锁,有时候我们只想让线程轻度“打个盹”。

  • 实现原理:直接调用 std::this_thread::sleep_for 睡眠一段固定的时间(比如 1ms)。
  • 优点:省去了加解互斥锁的操作,减去了内核抢占的开销。
  • 缺点:即使生产者刚塞入数据,你也必须睡满这段时间才能醒,延迟极不可控。
  • 适用场景:对 CPU 和响应时间要求不高,且不想写锁逻辑的普通轮询场景。
class SleepWaitStrategy : public WaitStrategy {
public:
    explicit SleepWaitStrategy(uint64_t sleep_time_us) : sleep_time_us_(sleep_time_us) {}

    bool EmptyWait() override {
        std::this_thread::sleep_for(std::chrono::microseconds(sleep_time_us_));
        return true;
    }
private:
    uint64_t sleep_time_us_;
};

4. 调度让步等待:YieldWaitStrategy

  • 实现原理:调用 std::this_thread::yield()。这句代码的意思是告诉操作系统:“当前时间片我不跑了,把这颗 CPU 核心让给其它就绪状态的线程”。
  • 优点:如果系统满载,它能平滑让出 CPU 防止卡顿;如果此时只有它需要 CPU,它会立刻再次获得执行权(极速重试)。延迟介于 Block 和 Busy 之间。
  • 适用场景:系统的线程数多于 CPU 核心数,且预期“生产者几乎马上就会产生数据”的高吞吐量场景。
class YieldWaitStrategy : public WaitStrategy {
public:
    bool EmptyWait() override {
        std::this_thread::yield();
        return true;
    }
};

5. 忙等待(自旋):BusyWaitStrategy

延迟的极致追求者,通过消耗硬件资源换取绝对的速度。

  • 实现原理:空操作,放到外层的 while 循环里就是一个纯粹的死循环(自旋)。
  • 优点:当前线程永远处于活跃态(甚至直接挂靠在 CPU L1 缓存上),彻底消除操作系统的上下文切换。只要数据一到,下一条 CPU 指令立刻就能处理,延迟降到纳秒级别
  • 缺点:灾难性的 CPU 占用率,直接将一颗核心打满至 100%。如果竞争激烈,还会引发严重的缓存一致性流量(MESI 协议)浪费。
  • 适用场景:高频交易(HFT)、自动驾驶核心链路(通常配合“绑核/CPU affinity”技术使用)。
class BusyWaitStrategy : public WaitStrategy {
public:
    // 什么都不做,直接返回 true,配合外层的 while(empty) 形成死循环
    bool EmptyWait() override { return true; } 
};

三、 补充建议与进阶预告

在实际的工业级框架(如 Disruptor 或者是某些高性能 RPC 框架)中,往往还会使用 组合策略(PhasedBackoffWaitStrategy)
比如:先尝试 BusyWait 自旋 100 次,如果还是空,降级为 YieldWait 让出 100 次时间片,最后实在没办法了,再降级为 BlockWait 挂起。这种自适应的退让机制,往往能兼得“低延迟”与“低 CPU 占用”。

为什么我们要在今天讲这个?

我们花费巨大笔墨把 WaitStrategy 抽离出来跑通,是为了一个更大的目标做铺垫。

在后续的文章中,我们将基于这个“策略层”,手写一个工业级的无锁环形队列(Lock-Free RingBuffer Queue)。到时候你就会发现:“底层死磕 CAS 解决 ABA 问题,上层像插拔 U 盘一样动态配置 WaitStrategy”,这种架构设计写起来会有多爽!

敬请期待!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐