问题历史。
std::future 和 std::promise 机制在 C++11 中引入,旨在规范化线程之间的异步结果传输。早期的方法依赖于临时共享内存和手动同步,这使得线程之间的异常处理几乎变得不可能。标准化委员会要求一种机制能够捕获在工作线程中抛出的任何异常类型,并能够在等待线程中忠实地重现,而无需在存储时知道异常的静态类型。
问题。
异常对象是多态的,默认情况下是栈分配的,但它们必须存活于产生它们的 std::promise 的作用域之外。由于 std::future 只以结果类型为模板,而不是异常类型,因此共享状态无法包含一个类型化的异常成员。此外,消费者线程的生命周期可能会超出生产者线程,这要求异常在堆分配的存储中持久化,并具有共享所有权语义。
解决方案。
标准要求 std::promise 使用 std::exception_ptr 通过 std::current_exception() 捕获异常,该方法通过将异常复制到堆并存储一个类型擦除的句柄来执行隐式类型擦除。共享状态(一个引用计数的控制块)保留此 std::exception_ptr,允许 std::future::get() 检测异常并通过 std::rethrow_exception() 重新抛出异常。
std::promise<int> prom; auto fut = prom.get_future(); std::thread([&prom]{ try { throw std::runtime_error("工作失败"); } catch(...) { prom.set_exception(std::current_exception()); } }).detach(); try { int val = fut.get(); // 重新抛出 runtime_error } catch(const std::exception& e) { // 处理传递的异常 }
上下文。
一个分布式计算框架要求工作线程处理可能因 GPUOutOfMemory 或 CorruptInputData 异常而失败的图像分割任务。主线程需要接收这些特定的异常,以触发回退的 CPU 处理或数据重传。
问题描述。
最初的尝试使用 std::exception_ptr 手动处理,但由于异常在主线程的错误队列仍被引用时被销毁,导致了生命周期错误。开发人员还很难在单个结果容器中存储异构异常类型,而不在多态存储中切片或对象切片。
解决方案 1: 类型化异常队列。
团队考虑使用模板为每个异常类型维护单独的队列。这提供了类型安全,但需要在公共队列中使用 std::any 进行类型擦除,增加了显著的开销和复杂性。这也打破了在消费者线程中自然捕获得到异常的能力。
解决方案 2: 虚拟异常持有者。
他们实现了一个抽象的 ExceptionBase 类,并将模板派生类存储在 std::unique_ptr<ExceptionBase> 中。尽管这使得多态存储成为可能,但需要手动克隆逻辑以维护线程之间的共享所有权,并在重新抛出时引入了虚拟调度开销。自定义引用计数容易出错,并且自身难以实现异常安全。
选择的解决方案及原因。
团队采用了 std::packaged_task 和 std::future,它们在内部使用 std::promise/std::exception_ptr 机制。这消除了自定义类型擦除代码的需求,因为标准库自动处理异常捕获和共享状态的生命周期。这个选择基于零维护的异常安全性以及支持标准异常处理模式的需求,而无需自定义基类。
结果。
系统成功地在线程边界之间传播特定的异常类型,没有内存泄漏,即使在激烈的线程池调整过程中。主线程可以专门捕获 GPUOutOfMemory 异常,同时对于未知错误默认为 std::exception,保持了错误处理逻辑与线程同步之间的清晰分离。
问题: 为什么 std::current_exception() 会复制异常对象而不是存储对现有异常的指针?
回答。
在 catch 块中的异常对象通常是由运行时在栈展开期间创建的临时副本。存储一个原始指针将在 catch 块退出并且栈帧被销毁后创建一个悬空引用。通过将异常复制到堆中,std::current_exception() 确保该对象独立于抛出线程的栈而持续存在。这种复制操作还使得类型擦除机制成为可能,允许 std::exception_ptr 通过一个类型擦除的删除器来管理对象,同时保持稍后重新抛出确切原始类型的能力。
问题: std::promise 如何防止 set_value() 和 set_exception() 之间的竞争条件?
回答。
共享状态包含一个原子状态标志,跟踪承诺是否已满足。当调用 set_value() 或 set_exception() 时,实现会执行原子的比较并交换操作,将状态从 "未满足" 转换为 "已准备好"。如果状态已经准备好了,操作会抛出 std::future_error,并包含 promise_already_satisfied。这种原子转换确保观察到已准备状态的消费者线程看到的是一个完全构造的值或异常,防止在生产者和消费者之间的并发访问期间进行部分读取或写入。
问题: 为什么 std::exception_ptr 可以超出创建它的 std::promise 和 std::future 的生命周期?
回答。
std::exception_ptr 在异常对象本身上使用内嵌引用计数,与 std::future/std::promise 共享状态无关。这个设计允许异常处理代码在异步操作完成并其相关的 future/promise 对象被销毁后,将错误存储在长寿命的日志或错误处理程序中。引用计数确保只有当最后一个引用它的 std::exception_ptr 被销毁时,异常对象才会被销毁,支持延迟错误报告或跨多个异步操作的异常聚合等用例。