这段内容主要讨论的是为什么在写程序时要保持“悲观”态度(pessimistic),尤其是考虑最坏情况(worst-case)的重要性。结合内容,我帮你总结和解释如下:
为什么要“悲观”编程?
1. 不是什么程序员都要关注最坏情况
- 大多数程序员更关心平均性能或者整体执行时间。
- 但是,如果程序有**低延迟(low-latency)**要求,比如实时系统、游戏帧率稳定、交互响应等,最坏情况表现就变得非常重要。
- 低延迟场景中,响应必须稳定和可预测,一旦出现性能抖动,用户体验会很糟糕。
2. 例子说明——帧率和流畅度
- 显示70帧/秒,但帧间隔不均匀,会让画面抖动,不流畅。
- 反而50帧/秒,但每帧时间稳定,反而看起来更顺滑。
- 这就是为什么需要利用每次循环的剩余时间,智能地处理任务,避免最坏情况影响表现。
3. 例子说明——异常处理(Exceptions)
- 代码演示了异常和
optional
处理的时间开销差异:- 正常数据处理时,异常开销小甚至更快。
- 异常发生时,异常处理开销大很多(成千上万倍差距)。
- 这提醒我们,异常处理虽然平时开销低,但最坏情况下非常昂贵。
4. 例子说明——容器性能
- 使用
vector
和deque
在不同操作(如开头插入)上的表现差异巨大:- 对于频繁的开头插入,
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(); // 后台运行
事件驱动的优点
- 仅在有事件时执行任务
- 避免资源浪费
- 比如:网络流量、CPU 使用率低
- 带来的“效率感”
- 没有事件时静默等待
- 很适合节能、带宽受限的环境(IoT、网络后端)
事件驱动的缺点
- 事件“突发性”处理带来的压力
- 某一瞬间多个事件突然到来,导致计算高峰
- 线程可能阻塞在多个回调上,变得不够“响应及时”
- 不能预测什么时候会处理事件
- 回调函数虽然高效,但它们的执行频率是非确定性的
- 如果你依赖每 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 arrival
与event 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::async
、std::future
、std::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 计算路径
- 复杂数据流分析必须分阶段执行
解决思路:将大任务拆成小步骤
- 不要让一次函数调用执行整个计算任务
- 而是:将计算过程分解成一系列状态机步骤
- 每一步检查是否超时,必要时中断
- 下次继续执行
示例对比分析
【旧式】写法(不推荐)
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])
这种写法,执行路径最一致。
结论
- 编写数据不变函数非常棘手,需要特别注意避免任何依赖输入数据的执行时间差异。
- 这对于安全领域尤其重要,防止秘密数据通过时间泄露。
- 也是一种“悲观编程”风格——永远假设最坏情况,执行完整流程。