CppCon 2018 学习:Pessimistic Programming

发布于:2025-07-06 ⋅ 阅读:(14) ⋅ 点赞:(0)

这段内容主要讨论的是为什么在写程序时要保持“悲观”态度(pessimistic),尤其是考虑最坏情况(worst-case)的重要性。结合内容,我帮你总结和解释如下:

为什么要“悲观”编程?

1. 不是什么程序员都要关注最坏情况

  • 大多数程序员更关心平均性能或者整体执行时间。
  • 但是,如果程序有**低延迟(low-latency)**要求,比如实时系统、游戏帧率稳定、交互响应等,最坏情况表现就变得非常重要。
  • 低延迟场景中,响应必须稳定和可预测,一旦出现性能抖动,用户体验会很糟糕。

2. 例子说明——帧率和流畅度

  • 显示70帧/秒,但帧间隔不均匀,会让画面抖动,不流畅。
  • 反而50帧/秒,但每帧时间稳定,反而看起来更顺滑。
  • 这就是为什么需要利用每次循环的剩余时间,智能地处理任务,避免最坏情况影响表现。

3. 例子说明——异常处理(Exceptions)

  • 代码演示了异常和optional处理的时间开销差异:
    • 正常数据处理时,异常开销小甚至更快。
    • 异常发生时,异常处理开销大很多(成千上万倍差距)。
  • 这提醒我们,异常处理虽然平时开销低,但最坏情况下非常昂贵

4. 例子说明——容器性能

  • 使用vectordeque在不同操作(如开头插入)上的表现差异巨大:
    • 对于频繁的开头插入,deque快很多。
    • 仅仅在末尾插入时,vector更快。
  • 选择容器时,要考虑最坏情况是否可接受,而不仅仅是平均情况。

5. 总结

  • 如果你的程序必须保证响应时间的稳定和可预测,就必须考虑和优化最坏情况表现。
  • 对于大多数程序员,平均性能是主要关注点,但要知道偶尔的“悲观”思考能避免严重问题
  • 在实际开发中,选择合适的数据结构和错误处理机制时,应考虑最坏情况带来的影响。
  • 区分“最快”与“足够快”,有时稳定的“足够快”比平均最快更重要。

你提供的这段内容深入探讨了一个现实世界中的“最坏情况至关重要(When worst-case counts)”的场景,并以伪代码方式演示了为什么在某些系统中,必须优先考虑最坏情况表现。

下面是详细的理解与分析

主题核心:最坏情况性能在关键系统中可能比平均性能更重要

示例场景:自动驾驶/驾驶辅助系统

假设代码:

class CollisionRiskDetected {};
DrivingDirection drive(DrivingDirection current) {
    // 非常简化的实现
    if (all_clear(current)) return current;
    throw CollisionRiskDetected{};
}
// 主循环逻辑
auto dest = query_destination();
auto current = compute_direction(current_location(), dest);
while (current_location() != dest)
    try {
        current = drive(current);  // 执行驾驶
    } catch (CollisionRiskDetected&) {
        avoid_collision();  // 碰撞风险 -> 避免
    }

表面看上去还不错:

  • 在一切“顺利”的情况下(没有碰撞风险),执行 drive() 成功返回,快速推进。
  • 遇到碰撞风险时,使用异常处理 throw/catch 做出反应。
    这在代码结构上简洁,逻辑清晰,只在“出问题”时才做额外处理

但现实问题来了:异常可能太慢

  • 抛出异常在 C++ 中是性能代价极高的操作(尤其在最坏情况下,可能是数量级上的差异)。
  • 在关键系统(如自动驾驶、机器人控制、金融系统等)中,最坏情况的处理时间必须非常短,否则会造成灾难性后果(如碰撞、失败响应等)。

所以:这段代码结构在“响应时间极其关键”的系统中是有问题的

  • 它在“碰撞风险已发生”的最坏情况中,才真正激活 avoid_collision()
  • 但这一反应路径中用到了异常机制(throw/catch),而这是性能瓶颈。

更合理的思路应该是:

  • 避免使用异常处理做常规逻辑控制。
  • 将“碰撞检测”视为正常流程的一部分,而非“异常”。

改进示意(使用返回值而非异常):

std::optional<DrivingDirection> try_drive(DrivingDirection current) {
    if (all_clear(current))
        return current;
    return std::nullopt;
}
// ...
while (current_location() != dest) {
    if (auto maybe_new_dir = try_drive(current)) {
        current = *maybe_new_dir;
    } else {
        avoid_collision();  // 快速、轻量地应对
    }
}

这样可以:

  • 移除异常路径的性能开销
  • 让最坏情况变得可控、可测、低延迟

结论总结:

平时表现 最坏情况 是否适合高可靠系统?
使用异常处理 高性能开销
使用返回值分支 性能稳定

教训:

在关键性系统中(如自动驾驶、机器人控制、交易系统等):

  • 最坏情况的处理速度 必须被优先考虑
  • “优雅的代码结构”不如“最坏情况下的稳定性”重要。
  • 异常处理适合处理“真异常”,不适合控制流程或处理常见问题
    如你需要,我也可以帮你:
  • 写一段完整的对比代码(异常 vs. 返回值)
  • 或者分析你自己的系统设计是否存在类似的“最坏情况不合理”的风险

深入探讨了在某些程序设计场景中,“平均偏差 (average deviation)”的重要性,尤其是在保持稳定吞吐率任务调度的确定性方面。

以下是逐步的中文解读和分析:

主题:当“平均偏差”比“最快速度”更重要时

背景说明

  • 有些系统(尤其是实时系统流处理系统)不追求“最快执行时间”,而是更重视稳定
    • 每个操作耗时波动小(平均偏差趋于 0)
    • 比如:每 16ms 处理一帧视频、每 5ms 执行一次 loop 等
    • 不需要最短时间,而是规律性强

示例代码(阻塞 I/O 情况):

class ConsumeError{};
[[noreturn]]
void processing_loop(std::istream& source) {
    for (Data data; source >> data;) {
        process(data);
    }
    throw ConsumeError{};
}

分析:

  • 这段代码会立即消费数据(如果 source 中有数据)。
  • 否则会阻塞在读取操作上,直到输入可用。
  • 这样能处理恒定速率的数据输入,只要:
    • process(data) 的时间是受限的
    • 数据抵达速度 > 处理速度

加入“辅助任务”后的问题:

[[noreturn]]
void processing_loop(std::istream& source) {
    for (;;) {
        if (Data data; source >> data) {
            process(data);
            accessory_tasks(); // 我们希望周期性运行的辅助任务
        } else {
            throw ConsumeError{};
        }
    }
}

关键问题:

accessory_tasks() 的运行频率是多少?

答案是:不确定

  • 因为 source >> data阻塞式 I/O,所以如果没有数据可读,accessory_tasks()永远不被调用
  • 比如:source “饿死”(starving),导致整个 loop 卡在 >> data 上,辅助任务就停了!

改进方案:使用非阻塞 I/O

std::optional<Data> try_consume(std::istream&); // 非阻塞读取
[[noreturn]]
void processing_loop(std::istream& source) {
    for (;;) {
        if (auto data = try_consume(source); data) {
            process(data.value());
        } else if (!source) {
            throw ConsumeError{};
        }
        accessory_tasks(); // 每次都能运行
    }
}

优点:

  • try_consume() 不阻塞,因此每次循环无论有无数据,accessory_tasks() 都会被执行
  • 可以提供如下频率保证
    • 如果 process(data) 时间受限
    • accessory_tasks() 每次循环都运行
    • 整体系统调度节奏更平稳、更可预测

小结:平均偏差之所以重要,是因为…

特性 阻塞I/O 非阻塞I/O
accessory_tasks() 调用可预测性 不可预测(依赖数据是否就绪) 每次循环都会执行
适合实时系统
稳定处理频率
平均偏差可控

延伸思考:

这与**事件驱动 vs 轮询模型(event-driven vs polling)**也密切相关:

  • 事件驱动(阻塞式):高效但难以控制任务频率,适合输入密集型系统。
  • 轮询(非阻塞):适合对节奏敏感的系统(如实时渲染、自动控制系统等),牺牲部分效率换取确定性。
    如果你在设计某个高稳定性要求的系统(如音视频处理、工业控制、实时反馈系统),那么:
  • 平均偏差比平均速度更重要
  • 非阻塞式处理、可预测的调度更关键

这部分内容深入探讨了“事件驱动(event-driven)”与“轮询(polling)”两种模式在处理平均偏差方面的差异。以下是逐页的中文解读与总结,帮助你理解其含义和背后的系统设计哲学。

核心主题:当“平均偏差”很重要时

示例代码分析:注册表(Registry

optional<Event> next_event(); // 非阻塞式获取事件
class Registry {
    mutex m;
    vector<function<void(Event)>> to_call;
public:
    template <class F>
    void subscribe(F f) {
        lock_guard _{ m };
        to_call.emplace_back(f);
    }
    void callback(Event e) {
        lock_guard _{ m };
        for (auto& f : to_call)
            f(e); // 调用每个订阅者
    }
    void execute() {
        for (;;) {
            if (auto e = next_event(); e) {
                callback(e.value()); // 仅当有事件时回调
            }
        }
    }
};

注册事件响应者并异步处理事件

void reaction_to_event(Event); // 假设的事件响应函数
auto reg = make_shared<Registry>();
reg->subscribe(reaction_to_event);        // 线程安全的响应函数
reg->subscribe([](Event e){ /* ... */ }); // 另一个响应者
thread th{ [reg] { reg->execute(); } };   // 独立线程执行事件循环
th.detach(); // 后台运行

事件驱动的优点

  1. 仅在有事件时执行任务
    • 避免资源浪费
    • 比如:网络流量、CPU 使用率低
  2. 带来的“效率感”
    • 没有事件时静默等待
    • 很适合节能、带宽受限的环境(IoT、网络后端)

事件驱动的缺点

  1. 事件“突发性”处理带来的压力
    • 某一瞬间多个事件突然到来,导致计算高峰
    • 线程可能阻塞在多个回调上,变得不够“响应及时”
  2. 不能预测什么时候会处理事件
    • 回调函数虽然高效,但它们的执行频率是非确定性的
    • 如果你依赖每 X ms 执行某任务 —— 事件驱动模式不合适

与轮询的对比

特性 事件驱动(Event-Driven) 轮询(Polling)
资源占用 通常更低 通常更高(持续循环)
响应模式 被动等待,突发调用 主动检查,定时行为
吞吐压力控制 难控制(突发可能堆积) 可预期(每轮执行固定任务)
适用场景 网络 I/O、事件稀疏 实时系统、稳定频率控制
平均偏差 不可控 易控(循环结构固定)

作者的观点总结

  • 事件驱动的代码设计是节能、高效的,但并非适用于所有场景
  • 容易陷入一个误区:“事件驱动总是优于轮询”
    • 但如果你对系统调度频率、任务均匀性、实时性有强需求:
      • 轮询 + 非阻塞 I/O可能是更合适的选择

应用建议

  • 用事件驱动:当你关心资源效率系统空闲状态、或任务稀疏
  • 用轮询(或事件+定时):当你需要定时检测、周期性调度、可预测行为
  • 小心突发事件对处理延迟和线程稳定性的影响,尤其在多线程场景下
    如果你正在构建一个需要定时任务执行的系统(例如:游戏引擎帧同步、工业控制循环、定频数据采集等),你可能需要在事件驱动的基础上,混合定时任务或最小时间轮询策略来补齐平均偏差问题。

提供的这部分内容深入探讨了轮询(polling)方式下,为了控制平均偏差和突发性负载(bursts)而设计的策略。下面是详细的中文理解与设计理念分析

主旨:为什么当“平均偏差”重要时,要选择轮询?

背景

  • 事件驱动(event-driven):高效、省资源,但回调不可预测,可能导致处理集中爆发(bursts)
  • 轮询模型:资源使用更多,但可以细致控制每一帧/周期内的处理量,更平滑平均偏差更小

示例结构与说明

示例 1:事件缓冲 + 分段处理

deque<Event> to_process;       // 待处理事件队列
decltype(to_call.size()) pos{}; // 当前处理的订阅者位置
for (;;) {
    // 消费阶段:收集事件
    if (auto e = next_event(); e)
        to_process.push_back(e.value());
    // 处理阶段:分配时间处理
    while (enough_time_current_iteration() && !to_process.empty()) {
        if (pos != to_call.size()) {
            to_call[pos](to_process.front()); // 给每个订阅者分发
            ++pos;
        } else {
            to_process.pop_front(); // 当前事件处理完
            pos = {};               // 重置订阅者位置
        }
    }
    wait_for_next_cycle(); // 等待下一周期(可以是 sleep 或 yield)
}

关键设计点:

项目 说明
to_process 将事件“缓存”以避免突发处理
pos 控制每个事件分发给多个处理者
enough_time_current_iteration() 控制每一轮循环所用时间不超限
wait_for_next_cycle() 等待下一帧,稳定频率(例如 60fps)

示例 2:处理优先,消费其后

// 如果你希望每轮从“同一时间点”开始处理
// 你应该先处理,后消费
while (enough_time_current_iteration() && !to_process.empty()) {
    // 处理逻辑和前面相同
}
// 消费阶段
while (enough_time_current_iteration()) {
    if (auto e = next_event(); e)
        to_process.push_back(e.value());
}
wait_for_next_cycle();

优点

  • 更强的确定性
  • 处理“准时”开始,避免“消费”阶段拖慢当前周期

示例 3:分线程:一个专门收集,一个专门处理

concurrent_queue<Event> to_process;
// 消费线程
thread consumer{ [&] {
    for (;;)
        if (auto e = next_event(); e)
            to_process.add(e.value());
} };
// 处理线程
thread processing{[&] {
    decltype(to_call.size()) pos{};
    optional<Event> e = to_process.try_extract(); // 非阻塞获取事件
    for (;;) {
        while (enough_time_current_iteration()) {
            if (e && pos != to_call.size()) {
                to_call[pos](e.value());
                ++pos;
            } else if (pos == to_call.size()) {
                e = to_process.try_extract(); // 处理下一个事件
                pos = {};
            }
        }
        wait_for_next_cycle();
    }
}};

优点

  • 彻底解耦 event arrivalevent processing
  • 即便事件突然大量到来,消费线程负责压入缓冲区,处理线程仍然平滑地处理

总结:轮询的优势与适用场景

优点 说明
稳定处理频率 每一帧处理事件数量有限
平滑消耗资源 避免事件爆发导致系统卡顿
结构清晰 消费与处理明确区分,可异步/多线程

注意事项

问题 原因
资源利用偏高 循环占用 CPU,空转时浪费
多线程复杂度高 涉及线程安全、同步等问题
时间判断机制要可靠 enough_time_current_iteration() 的准确性很关键

总结一句话:

当你的系统追求稳定帧率平滑响应、或希望在任务执行频率上提供保障,轮询(polling)结合节奏控制是极其有效的技术手段,比事件驱动更适合对响应波动敏感的场景。

内容讲述的是在 悲观编程(Pessimistic Programming) 场景中,C++ 提供的一些 实用构造与工具(constructs and tools),特别是面对最坏情况(例如:异常、高延迟、突发任务等)时,如何写出更加稳健、可预测、低延迟的代码。

为什么要“悲观”?

在某些系统(如嵌入式、实时控制、金融高频交易、SG14关注的场景)中:

  • 你不能仅仅依赖平均情况
  • 必须保证在最差情况下也能及时响应
  • 尽可能减少不可预测的阻塞、等待或突发

关键理解点(你的内容解读)

编译器优化

  • 现代编译器很强大(如 GCC、Clang、MSVC)
  • 大部分场景下,“让它优化就好”
  • 但优化总是针对目标的,不能只看速度,还要考虑:
  • 错误发生后的快速处理
  • 保持系统处理频率稳定
  • 控制任务处理延迟的波动性(deviation)

不阻塞处理、由调用者掌控的机制

这些构造可以帮助你构建低延迟、最坏情况可控的逻辑。

工具 功能说明
std::atomic<T> 原子操作,不依赖锁,适用于基础同步逻辑。必须小心使用,避免ABA问题等。
try_lock() 尝试获取锁,失败就立刻返回,不阻塞线程
std::unique_lock<T> 灵活的锁管理器,支持 try_lock(), try_lock_for(), try_lock_until(),适合实时系统
lock(), lock_guard<T> 阻塞直到获得锁,不适合最坏情况敏感场景
scoped_lock<Ts...> 用于一次性锁多个互斥锁,避免死锁;但它也是阻塞型的

实战应用场景(悲观编程里这些怎么用)

1⃣ 非阻塞资源访问

std::mutex m;
if (m.try_lock()) {
    // 安全地访问资源
    // 快速完成,无阻塞
    m.unlock();
} else {
    // 立即放弃,执行 fallback 操作
}

2⃣ 带超时的尝试锁

std::timed_mutex m;
if (m.try_lock_for(std::chrono::milliseconds(5))) {
    // 在 5ms 内获得了锁
    m.unlock();
} else {
    // 超时未获得,进入其他处理分支
}

3⃣ 原子状态切换

std::atomic<bool> busy{false};
if (!busy.exchange(true, std::memory_order_acquire)) {
    // 成功设置为 busy,继续处理
    do_work();
    busy.store(false, std::memory_order_release);
} else {
    // 正在处理,跳过或重试
}

总结:悲观编程下的 C++ 工具使用建议

原则 建议
避免不必要的阻塞 使用 try_lock 和原子变量代替阻塞型锁
控制每一帧的时间预算 try_lock_for() 限定最长等待时间
在最坏情况也能有良好响应 减少不可预测分支,如异常/阻塞等待
编译器负责优化,程序员负责建模 明确你的目标是 延迟最小化响应及时,不是 吞吐最大化

你提供的内容进一步拓展了 悲观编程(Pessimistic Programming) 的实现思路,特别是强调 如何避免阻塞、实现时间可控、保持高响应性。以下是对你提供的两个重点部分的详细解析与理解:

示例代码理解:非阻塞数据接收线程

mutex m;
deque<Data> data;
thread th0{ [&] {
    vector<Data> v; // 本地缓冲区,避免频繁加锁
    for(;;) {
        // 一直接收数据
        v.emplace_back(receive_data()); // 非阻塞接收
        // 尝试加锁(非阻塞)
        if (m.try_lock()) {
            lock_guard _{ m, adopt_lock }; // adopt_lock:因为try_lock已获得锁
            data.insert(end(data), begin(v), end(v)); // 将本地缓存合并进共享队列
            v.clear(); // 清空本地缓存
        }
        // 如果无法加锁,当前线程继续接收数据并缓存
    }
}};

重点解释:

行为 目的
vector<Data> v 使用线程本地缓存减少锁竞争
receive_data() 不阻塞主线程获取输入
try_lock() + adopt_lock 非阻塞地尝试加锁,如果失败,不等待,继续干活
data.insert(...) 仅在成功加锁时更新共享数据结构
v.clear() 减少冗余内存并准备下一轮缓存

优点:

  • 保证 数据接收线程永不阻塞
  • 降低锁竞争概率
  • 如果主线程长时间持锁,也不会让接收线程停工 —— 它会一直收数据并缓存

不阻塞的编程工具(client-controlled progression)

1. future.then()

  • future可用时注册回调
  • 非阻塞式延迟计算
  • 常用于并发/异步流程(如 std::asyncstd::futurestd::promise
std::future<int> fut = async([]{ return 42; });
auto chained = fut.then([](std::future<int> f) {
    return f.get() + 1;
});

注意:目前 future.then() 不是 C++ 标准的一部分,但在一些库(如 Folly、Boost、Concurrencpp)中提供支持。

适用性

特性 适合
非阻塞
可组合链式操作
最坏延迟可控性 视库实现而定
稳定吞吐 依赖调度器,不一定悲观友好

2. Timed Wait Functions:wait_for() / wait_until()

使用方式(示例):
std::mutex m;
std::condition_variable cv;
bool ready = false;
std::unique_lock<std::mutex> lk(m);
if (cv.wait_for(lk, std::chrono::milliseconds(5), [&]{ return ready; })) {
    // 条件满足
} else {
    // 超时,进行fallback处理
}

优点:

功能 描述
有上限等待时间 可以设定最大等待时间,不会无限阻塞
支持条件检查 可以结合 lambda 条件判断
condition_variable 配合 控制线程激活时间,适合低延迟/可预测调度

小结:悲观编程的 C++ 工具核心总结

工具 是否推荐 原因
try_lock() 非阻塞加锁,适合实时
本地缓存 + 批处理 降低锁持有时间,提高并发性
future.then() 部分适合 用于延迟处理,响应快但依赖具体实现
wait_for() / wait_until() 提供延迟上限,适合周期性处理
condition_variable 搭配使用 控制等待频率,减少 busy waiting

提供的内容总结了在 悲观编程(Pessimistic Programming) 中,C++ 提供的一些非常实用的工具和写法,特别关注于 避免阻塞、保持可预测性、控制最坏情况延迟。下面是对这些技巧和代码片段的详细解读与应用建议:

1. condition_variable + wait_for() + 周期性任务

condition_variable cv;
mutex m;
bool ok = false;
thread th{ [&] {
    unique_lock lck{ m };
    while (!cv.wait_for(lck, 1ms, [&ok]{ return ok; })) {
        perform_auxiliary_tasks();  // 每毫秒做一次备用任务
    }
    perform_main_task();  // 被唤醒后执行主任务
}};

说明:

  • wait_for带时间限制的等待,避免线程长时间阻塞。
  • 1ms 超时后检查条件,不满足则执行辅助任务。
  • 一旦 ok == true 且被唤醒(cv.notify_one()),立即执行主任务。

优点:

  • 保持线程活跃性:线程不长时间挂起,能继续做有用工作。
  • 低延迟唤醒:达到条件就立即执行。
  • 适用于响应时间敏感任务(如嵌入式控制、实时数据处理等)。

2. optional<T> vs 异常

std::optional<T> my_function() {
    if (bad_condition) return std::nullopt;
    return some_value;
}

说明:

  • 相比异常(throw/catch),optional<T> 不涉及栈展开、无性能陷阱。
  • 更容易预测性能,不会出现“快的时候极快,坏时爆炸”的情况

总结:

用法 建议
optional<T> 推荐用于正常失败情况
exceptions 不适用于最坏情况规划

3. concurrent_queue<T> —— 线程安全的非阻塞队列(尝试式)

try_add() 示例:

bool try_add(T obj) {
    if (m.try_lock()) {
        lock_guard _{ m, adopt_lock };
        impl.emplace_back(obj);
        return true;
    }
    return false;
}

try_extract() 示例(不抛异常):

bool try_extract(T &res) {
    if (!m.try_lock()) return false;
    lock_guard _{ m, adopt_lock };
    if (impl.empty()) return false;
    res = impl.front();
    impl.pop_front();
    return true;
}

特点:

功能点 优点
try_lock() 非阻塞加锁,无需等待系统调度资源
adopt_lock 避免重复加锁,节省开销
optional<T> 版本 表达失败语义更清晰,支持链式结构
不抛异常 适合对“最坏情况”有严格要求的系统,避免栈展开开销

总结:C++ 悲观编程核心工具回顾

工具/构造 用途
condition_variable::wait_for() 定时等待,保证任务频率或辅助执行机会
optional<T> 替代异常,提供更可控的失败通路
try_lock() + adopt_lock 实现非阻塞锁操作,提高吞吐,降低延迟
非阻塞队列(try_add/try_extract 在并发系统中保证最大吞吐和最小抖动
future.then() 延迟计算(异步流中可选,但慎用)
如果你正在设计一套 实时或软实时系统,这些技术能帮助你实现:
  • 稳定性(constant deviation)
  • 可预测性(bounded latency)
  • 最坏情况控制(worst-case awareness)

内容是关于 C++20 中新增的 [[likely]][[unlikely]] 属性在 悲观编程(Pessimistic Programming) 中的应用。我们来逐条分析:

什么是悲观编程(Pessimistic Programming)?

是一种关注 最坏情况(worst-case)性能与行为 的编程风格。目标是:

  • 控制异常路径的代价
  • 维持低延迟
  • 避免性能抖动(jitter)
  • 保证响应性

C++20 的 [[likely]] / [[unlikely]]

这两个是 分支预测提示(branch prediction hints),用于告诉编译器某个分支在运行时更有可能(或更不可能)被执行,从而帮助 CPU 提前优化跳转路径。

示例:

if ([[likely]] status == OK) {
    fast_path();
} else {
    slow_path(); // 错误处理
}
if ([[unlikely]] error_occurred()) {
    handle_error();
}

使用目的与优势:

优点 说明
优化分支预测 编译器可以根据你的提示,将“可能走”的路径放在热路径上,提高指令缓存命中率
优化管线预取 CPU 更容易把可能执行的代码加载到流水线中,减少跳转惩罚
控制异常路径 在悲观编程中,可以明确标记 [[unlikely]] 的错误处理分支,使主路径更精简

编译器 vs 人类判断

“你可能会优化失败(pessimize)代码”

编译器擅长分支预测:

  • 基于 __builtin_expect 或历史运行分析(PGO)。
  • 通常比人类更善于判断“平均”行为。

人类误用的风险:

  • 若错误地标记 [[likely]] / [[unlikely]],可能会让 CPU 的预测失败 → 性能下降

什么时候这些提示是合理的?

情况 推荐
高频循环中的错误处理 给异常路径加上 [[unlikely]],避免破坏主路径缓存
少数错误需要迅速响应 明确标记 [[unlikely]],让主路径执行更快更流畅
人类比编译器更有上下文 如网络通信、金融交易系统的协议状态切换分支
设计关注“最坏情况延迟” 为保障最慢时也能控制在预算内,做分支预测优化是合理的

悲观编程中的使用建议

switch (packet.type) {
    case Data:
        [[likely]]
        handle_data(packet);
        break;
    case Heartbeat:
        handle_heartbeat(packet);
        break;
    case Error:
        [[unlikely]]
        handle_error(packet);
        break;
}
  • 主流程:handle_data 被标记为 [[likely]]
  • 错误流程:明确标记为 [[unlikely]]
  • 最终目标:不是提升峰值速度,而是降低最坏情况成本波动

总结建议:

做法 建议
清晰、频率已知的分支上使用 [[likely]]/[[unlikely]] 如主路径与异常路径明显不对称
将其用作“告知优化器你知道编译器不知道的事” 比如事件频率受业务约束
不要滥用这些属性 不要在没有测量/了解的情况下盲目加标签
不要指望它们一定能带来巨大提升 它们只是提示,不是强制行为
如果你在做低延迟系统、嵌入式系统、游戏主循环、通信协议状态机等控制分支路径代价至关重要的领域,这些 C++20 特性是非常有价值的。

> 在 悲观编程(pessimistic programming) 情境下,如何看待异常处理(try/catch)的性能表现,特别是当我们希望 异常路径反而是快速路径 时该怎么做。

背景

通常在 C++ 中:

  • 正常路径(try块)是优化目标
  • 异常路径(catch块)是罕见路径,所以编译器不会特别优化这部分。
    但在某些 实时/低延迟/安全关键系统 中,错误路径必须快速执行(即使它是“异常”的)。

示例代码解析

class CollisionRiskDetected{};
DrivingDirection drive(DrivingDirection current) {
    if (all_clear(current)) return current;
    throw CollisionRiskDetected{};
}
auto dest = query_destination();
auto current = compute_direction(current_location(), dest);
while(current_location() != dest)
try {
    current = drive(current); // 通常路径
}
[[likely]] catch(CollisionRiskDetected&) {
    avoid_collision();        // 错误路径必须快速反应
}

核心问题

catch 块是为异常情况设计的,默认并不优化。但如果:

  • 异常是罕见的,但出现时必须“非常快”响应(如碰撞检测)
  • 那么:我们就需要优化 catch 路径
    这正是作者提出的矛盾:
    | 正常设计 | 你需要的 |
    | --------------- | ----------------- |
    | try 是热路径(优化) | catch 是热路径(要优化) |
    | catch 被认为是慢路径 | 但你不能接受它慢! |

使用 [[likely]] 的尝试

[[likely]] catch(CollisionRiskDetected&) {
    avoid_collision();
}

语义上,它是 让编译器优化 catch 路径,假定它是“更常发生的”。
但注意:

这是一种违背常规优化逻辑的用法,编译器可能无法完全发挥效果,甚至反而 pessimize。

适用场景

你可以使用这种方式——只要你明白它的含义和代价

  • 实时系统:如自动驾驶控制循环、航空、机器人运动控制
  • 硬实时错误处理路径:如“必须立即停车”、“避障”等
  • 极低错误频率 + 超高响应需求场景

建议和理解总结

要点 建议
异常路径通常不快 catch 块在设计上就是慢的
[[likely]] 放在 catch 上意义有限 它提示优化器,但并不一定带来你想要的提升
如果 catch 必须快 尽量 避免异常机制,用显式错误码、optional、状态机
真要用 try/catch 并要求它快 那就像这里一样,清晰地表达意图,但要测量实际性能!

替代设计建议(更偏悲观/高性能)

enum class DriveResult { Ok, Collision };
DriveResult drive_safe(DrivingDirection current, DrivingDirection& next) {
    if (all_clear(current)) {
        next = current;
        return DriveResult::Ok;
    }
    return DriveResult::Collision;
}
// ...
DrivingDirection current = compute_direction(current_location(), dest);
while (current_location() != dest) {
    DrivingDirection next;
    if (drive_safe(current, next) == DriveResult::Collision) {
        avoid_collision(); // 快路径
    } else {
        current = next;
    }
}
  • 无异常机制,所有路径都有良好性能
  • 更容易 predict、profile、benchmark
  • 控制权完全在程序员手里,更适合实时系统

这部分内容讨论的是在计算可能超出时间预算时,如何设计程序。重点在于:

控制执行时间,尤其是在实时系统、游戏 AI、图搜索、路径规划等情境下,让任务可中断、可恢复,防止卡顿或响应不及时。

为什么“计算超时”值得关注?

现实中,很多计算(特别是 AI、路径规划、图搜索):

  • 运行时间与输入规模有关(不可预测)
  • 系统必须响应及时(不能卡顿)
    例子
  • 游戏 AI 决策必须在 16ms 内完成(60 FPS)
  • 实时控制系统一帧只能用 4ms 计算路径
  • 复杂数据流分析必须分阶段执行

解决思路:将大任务拆成小步骤

  1. 不要让一次函数调用执行整个计算任务
  2. 而是:将计算过程分解成一系列状态机步骤
  3. 每一步检查是否超时,必要时中断
  4. 下次继续执行

示例对比分析

【旧式】写法(不推荐)

bool long_computation() {
  static auto state = State::Init; // 静态保存状态
  switch(state) {
    case State::Init:
      // do something...
      state = State::StepA; [[fallthrough]];
    case State::StepA:
      // do step A...
      if (time_slot_exceeded()) return false; // 超时,退出
      state = State::StepB; [[fallthrough]];
    case State::StepB:
      // ...
  }
  return true;
}

问题:

  • 使用 static 保存状态 → 非线程安全、不可复用
  • 不清晰职责归属
  • 封装性差

【改进版】现代写法

void long_computation(State& state) {
  switch(state) {
    case State::Init:
      // Init step
      state = State::StepA; [[fallthrough]];
    case State::StepA:
      // ...
      if (time_slot_exceeded()) return;
      state = State::StepB; [[fallthrough]];
    case State::StepB:
      // ...
  }
}

优势:

  • 用参数传递 state,不依赖全局或静态变量
  • 随时中断
  • 易测试、易复用
  • 适合协作式调度(cooperative multitasking)

使用场景

场景 原因
游戏 AI 决策(如行为树、状态机) AI 不得阻塞主线程
图搜索算法(如 A*, Dijkstra) 搜索空间大、需打断与恢复
模拟计算/物理系统 每帧限制计算预算
多阶段数据处理管道 可拆分、渐进式处理大数据

time_slot_exceeded 示例实现

#include <chrono>
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(10);
bool time_slot_exceeded() {
    return std::chrono::steady_clock::now() > deadline;
}

总结技巧

技巧 说明
✂ 拆分函数 不要让函数做太多事
状态机驱动 enum State 明确流程阶段
检查时间预算 每一步都检查是否要中断
保存中间状态 保证每一步都是 可恢复、可重复
支持协作式调度 可集成到主循环中(如 game tick)

这组内容讲的是如何将大计算任务拆成“可中断、分段执行”的小任务,以避免单次执行时间过长,尤其适合实时系统或游戏这类对响应时间要求严格的场景。

1. 传统算法(全量执行)

template<class I, class O, class F>
void transform(I bi, I ei, O bo, F f) {
  for (; bi != ei; ++bi, ++bo)
    *bo = f(*bi);
}
  • 一次性处理完所有数据
  • 适合非实时或时间充裕场合
  • 可能导致不可控的长延迟

2. 分段执行版本

template<class I, class O, class F, class Pred>
auto segm_transform(I bi, I ei, O bo, F f, Pred pred) {
  for (; bi != ei && pred(); ++bi, ++bo)
    *bo = f(*bi);
  return std::pair(bi, bo);
}
  • 结合一个“继续执行条件”pred,只在允许时间内处理部分数据
  • 返回处理进度(输入迭代器、输出迭代器)
  • 可多次调用,实现任务的分段完成

3. 客户端代码示例

auto now = [] { return std::chrono::system_clock::now(); };
auto make_pred = [now] {
  auto deadline = now() + std::chrono::milliseconds(2);
  return [now, deadline] {
    return now() < deadline;
  };
};
std::vector<Data> in = gather_data();
std::vector<ProcessedData> out;
auto p = segm_transform(begin(in), end(in), back_inserter(out), f, make_pred());
while (p.first != end(in)) {
  p = segm_transform(p.first, end(in), back_inserter(out), f, make_pred());
}
  • 通过make_pred设定每次允许的时间窗口
  • 每次分段执行部分工作
  • 循环调用直到全部完成

4. 性能与适用性

  • 分段执行版本比一次性版本慢(多次函数调用开销)
  • 有时结果质量可能略差(如分段压缩算法)
  • 适合实时、时间紧张的系统,允许利用剩余时间做辅助任务
  • 保持主循环稳定,避免卡顿

5. 典型用法示意

for (;;) {
  critical_tasks();    // 必须及时完成的任务
  accessory_tasks();   // 可分段完成的辅助任务
  wait_for_next_iteration();
}
  • 关键任务先完成
  • 剩余时间尽量用来做分段辅助任务
  • 保证循环周期稳定

6. 实际应用举例

template <class Pred>
void long_term_planning(Pred pred) { /* 分段规划任务 */ }
auto iter_duration = std::chrono::milliseconds(1000 / Game::frame_rate());
for (auto cur = now(); Game::ongoing(); cur = now()) {
  display_scene();
  prepare_next_scene();
  long_term_planning([deadline = cur + iter_duration] {
    return now() < deadline;
  });
}
  • 游戏主循环中,长远规划任务被切分
  • 每帧限制规划执行时间,保证流畅体验

总结

技巧 说明
分段执行 大任务拆分为多个可中断部分
进度状态传递 函数返回迭代器/指针表示进度
时间预算判断 传入断言谓词控制执行时长
适合实时、低延迟系统 避免一次性长时间阻塞
允许多次调用逐步完成任务 灵活利用空闲时间

这部分内容讲了用**协程(coroutines)**来简化分段执行的状态管理问题。协程能让你写出“可挂起、可恢复”的函数,自动管理内部状态,避免客户端代码写复杂的状态追踪逻辑。

1. 为什么用协程?

  • 分段执行(subdivided functions)需要管理“执行进度”或“状态”,写起来麻烦且易错。
  • 协程天生支持“暂停”和“恢复”,内部状态自动保存。
  • 让客户端代码更简洁,更直观。

2. C++协程的现状

  • C++20开始标准支持协程,但还不完全普及。
  • 目前可以用std::experimental::generator(或第三方库)来模拟协程。
  • 协程函数内部用co_yield来“挂起”并返回值。

3. 代码示例讲解

#include <experimental/generator>
#include <iostream>
#include <chrono>
#include <vector>
using namespace std;
using namespace std::chrono;
using experimental::generator;
template <class Pred>
generator<vector<int>> even_integers(Pred pred) {
  vector<int> res;
  for (int n = 0; ; n += 2) {
    if (!pred()) {
      co_yield res;  // 挂起,返回当前结果
      res.clear();
    }
    res.emplace_back(n);
  }
}
int main() {
  auto deadline = system_clock::now() + 500us;
  auto pred = [&deadline] {
    return system_clock::now() < deadline;
  };
  for (auto batch : even_integers(pred)) {
    cout << "\nComputed " << batch.size() << " even integers\n";
    for (auto n : batch) cout << n << ' ';
    cout << "\n\n";
    deadline = system_clock::now() + 1ms;
  }
}
  • even_integers是一个协程,按条件pred()决定是否挂起并返回一批偶数。
  • 客户端代码用简单的for循环消费这些“批次”数据,无需管理状态。
  • co_yield自动保存函数执行位置和局部变量。

4. 优势总结

传统分段执行 使用协程
需显式管理迭代器和状态 状态由协程框架自动管理
代码复杂,难维护 代码简洁直观,易读易写
调用方控制执行粒度复杂 自然暂停/恢复,控制粒度灵活
逻辑分散 逻辑集中,状态隐式维护

这部分内容讲的是数据不变性编程(data-invariant programming),特别是在比较字符串时,如何避免“时间侧信道攻击”(timing side-channel attacks),即让函数执行时间只和输入长度有关,而不泄露输入内容差异的细节。

核心问题

传统的字符串比较代码:

bool compare(const char *p0, const char *p1) {
  if (!p0 || !p1)
    return !p0 && !p1;
  for(; *p0 && *p1 && *p0 == *p1; ++p0, ++p1);
  return *p0 == *p1;
}
  • 这个实现会在第一个不同字符处立即返回,因此执行时间取决于输入的相似度。
  • 攻击者可以通过测量函数响应时间,逐步“猜测”秘密字符串内容。

数据不变性(Data Invariance)

数据不变函数要求执行时间只和输入长度相关,而不是输入内容。

  • 目的是避免任何基于时间的副信道攻击。
  • C++标准对这方面尚无官方支持,但有相关提案(n4314)。

变成数据不变函数的思路

  • 不允许提前返回,必须走完整个循环。
  • 总是对固定长度的数据比较,避免长度差异暴露信息。
  • 不能用短路逻辑,必须处理所有元素。
    示例(带长度n,假设输入字符串都至少有n长度):
bool compare(const char *p0, const char *p1, size_t n) {
  bool result = true;
  for(size_t i = 0; i < n; ++i) {
    // 逐个比较,且不提前返回
    result &= (p0[i] == p1[i]);
  }
  return result;
}
  • 这里result &= ...保证无论何时都执行相同数量的比较。
  • 使用&=而不是&&避免短路,确保循环完整执行。

为什么之前的版本不数据不变?

  • 早期if (p0[i] != p1[i]) return false;提前退出。
  • result = false但循环继续,仍然会有不同代码路径优化。
  • 只有result &= (p0[i] == p1[i])这种写法,执行路径最一致。

结论

  • 编写数据不变函数非常棘手,需要特别注意避免任何依赖输入数据的执行时间差异。
  • 这对于安全领域尤其重要,防止秘密数据通过时间泄露。
  • 也是一种“悲观编程”风格——永远假设最坏情况,执行完整流程。