Executors for C++- A Long Story
其核心是在为 C++ 设计一个 基础的并发构建模块(Base Concurrency Building Block)时所遇到的困难和挑战。
- Executors 是并发编程中的一个概念,负责管理任务的调度和执行,解耦任务的提交和运行。
- C++社区和标准委员会一直在努力设计一个适合C++的、通用的、灵活的执行器(Executors)框架,作为并发编程的基础模块。
- 这个过程非常复杂、漫长,涉及到设计理念、性能要求、兼容性和易用性等多方面的权衡与挑战。
C++ 标准库的 std::async
来实现异步执行时遇到的局限性:
- 代码中启动了两个异步任务,分别打印 "Hello " 和 “World!\n”。
- 但是:
- 没有并发执行:两个任务可能不是同时执行的,甚至可能是顺序执行(取决于实现)。
- 无法控制执行环境:开发者无法指定任务运行在哪个线程或线程池(即“执行代理”)。
launch::async
(强制异步)和launch::deferred
(延迟执行)这两个执行策略,不能满足复杂的控制需求。
代码示例及分析
#include <iostream>
#include <future>
int main() {// 启动第一个异步任务std::async([]() {std::cout << "Hello ";});// 启动第二个异步任务std::async([]() {std::cout << "World!\n";});return 0;
}
分析
std::async
调用std::async
会启动一个任务,但标准并不保证任务一定异步执行。- 默认执行策略是
launch::async | launch::deferred
,即实现可以选择同步调用或者异步调用。
- 没有并发
- 两个任务不一定在不同线程并发执行,可能一个任务先完成,再执行下一个。
- 缺少执行者控制
- 开发者无法选择使用线程池、工作队列或其他调度策略。
- 只能依赖标准库实现。
launch::async
和launch::deferred
的不足launch::async
强制异步,但可能开销大(每次新建线程)。launch::deferred
推迟执行,直到调用future::get()
,这不是真正的并发。- 这两个策略不足以灵活应对复杂的异步并发场景。
总结
std::async
虽然方便,但:
- 并不能保证真正的并发执行。
- 不能灵活控制线程和执行策略。
- 在复杂异步任务调度时,表现有限。
Async 问题点
std::future
的析构函数通常不会阻塞,这意味着当future
对象销毁时,程序不会等待任务完成。- 但是,如果这个
future
是由std::async
创建的,析构函数会阻塞。
也就是说,当future
对象销毁时,会等待对应的异步任务完成再继续,这可能导致程序卡顿或性能问题。
重要点:生命周期管理
- C++ 对象的生命周期控制非常重要,尤其是资源管理与任务完成的协调。
- 对于
std::async
生成的异步任务,没有其他显式的机制去控制任务的生命周期,只能依赖future
对象本身。 - 这导致了析构函数行为的不一致和潜在的阻塞风险。
总结问题
std::future
的析构行为在普通情况下(如std::promise
生成的future
)与std::async
生成的future
不同。- 这种行为上的差异使得使用
std::async
时需要格外小心,避免析构时的隐式阻塞。 - 因此,需要更好的异步任务管理和生命周期控制机制,避免不期望的阻塞和性能下降。
Motivation: Pipelines(管道模型动机)
- 这是一个用管道(pipeline)方式表达任务流程的示例。
pipeline::plan restaurant(orders| pipeline::parallel(chef, 3) // 3个厨师并行处理订单| pipeline::parallel(waiter, 4) // 4个服务员并行处理厨师输出| end);
thread_pool pool;
pipeline::execution work(restaurant.run(&pool));
代码分析
orders
是输入任务流。- 通过
|
操作符,任务依次经过多个处理阶段:pipeline::parallel(chef, 3)
表示用 3 个厨师实例并行处理订单。- 紧接着
pipeline::parallel(waiter, 4)
用 4 个服务员实例并行处理厨师处理后的结果。
end
标记管道的终点。restaurant.run(&pool)
将这个管道任务提交到一个线程池pool
上执行。pipeline::execution work
代表这个执行的上下文。
关键点
- Executors 作为基础构建模块:这说明执行者(Executors)不仅负责简单的任务执行,还可以组合成更高级的抽象,如任务管道。
- 这种设计提供了更灵活且可组合的异步处理方案,支持并行度控制(如指定厨师和服务员的数量)。
- 通过流水线式的处理,把复杂的任务拆解成阶段,便于扩展和维护。
总结:
这段代码展示了基于执行者模型的流水线设计思路,强调了执行者作为更高级并发抽象构件的角色。
Executor Requirements(执行器的要求)
- Run tasks(执行任务)
执行器必须能够接收并运行任务(通常是函数对象、可调用对象)。 - Control some lifetime aspects(控制某些生命周期方面)
执行器需要管理任务的生命周期,比如什么时候启动、挂起或结束任务,确保资源合理分配和释放。
总结:
执行者不仅仅是简单地运行任务,还要在任务的执行生命周期管理上承担一定责任,确保任务的正确、有效、及时执行。
Original Executor Interface(原始执行者接口)
class executor {
public:virtual ~executor();virtual void add(function<void()> closure) = 0;virtual size_t uninitiated_task_count() const = 0;
};
- 虚析构函数
virtual ~executor()
:保证继承类对象通过基类指针销毁时,能够正确调用派生类析构函数,防止资源泄漏。 - 纯虚函数
add(function<void()> closure)
:接口中定义一个添加任务的方法,接收一个无返回值、无参数的函数(任务),由具体执行者实现如何执行该任务。 - 纯虚函数
uninitiated_task_count() const
:返回当前未启动的任务数量,用于监控执行者的负载或状态。
备注:
这并不是最初的接口设计,只是一个示意版,反映了执行者需要基本的“添加任务”和“查询任务状态”的功能。
Default Executor(默认执行者)
shared_ptr<executor> default_executor();
void set_default_executor(shared_ptr<executor> executor);
default_executor()
:获取当前系统或环境中默认的执行者实例,返回一个智能指针,方便共享和管理生命周期。set_default_executor(...)
:设置默认的执行者对象,允许用户替换或定制默认的执行行为。
作用:
这两个接口提供了一个全局默认执行者的访问和配置机制,方便程序中无需显式指定执行者时使用统一的线程池或任务执行策略。
这是几种具体的执行者(Concrete Executors)实现,常见于C++的异步任务调度框架中:
- thread_pool
使用线程池管理多个线程,能够并发执行多个任务,适合高并发场景,线程资源可复用。 - serial_executor
串行执行所有任务,任务按顺序一个接一个执行,保证无并发,适合不允许竞争的场景。 - loop_executor
在当前线程的事件循环中执行任务,通常用于事件驱动框架,任务放入消息循环里执行。 - inline_executor
任务直接在调用者线程中执行,不做调度,开销最低,但不异步。 - thread_executor
每提交一个任务就创建一个新线程执行,适合任务少且执行时间较长的场景,但线程开销大。
总结:
不同执行者针对不同需求设计,用户可以根据性能、并发要求选择合适的执行策略。
概念理解
这部分讲的是在 C++ 中用 async
和执行器(executor)搭配执行任务时的一些设计与行为问题,特别是:
- 如何让
async
使用一个自定义或默认的executor
。 - 默认执行器的生命周期控制问题(如何优雅地关闭它)。
std::future
的析构行为可能 阻塞主线程,这是实际使用中的一个陷阱。
分析代码
示例 1:
async(launch::executor, [](){ std::cout << "Hello!\n"; });
- 使用了
launch::executor
,意味着使用某个指定的执行器(executor
)。 - 执行器会从全局默认执行器
default_executor
中获取执行上下文。 - 所以实际使用的是:
shared_ptr<executor> exec = default_executor();
exec->add([](){ std::cout << "Hello!\n"; });
示例 2:
async([](){ std::cout << "Hello!\n"; });
- 没有指定
launch
策略。 - 推测实现可能会使用
default_executor()
来安排任务。 - 然而
async
返回的是一个std::future
,其析构函数会阻塞等待任务完成(如果未调用.get()
)。
关键问题:
默认执行器的生命周期不可控,
std::future
析构时可能导致 同步等待,这是违背异步初衷的。
问题小结
async
没有明确的控制执行上下文(executor)的方式,容易出现执行器生命周期不明确、无法 shutdown 的问题。- 即使用了
default_executor()
,也可能因std::future
的析构阻塞主线程(没有真正实现异步解耦)。 - 建议显式管理任务调度(如使用 executor + promise/future 或自定义调度框架)以避免这些问题。
建议用法
例如,使用自定义线程池和显式 .get()
控制:
auto exec = std::make_shared<thread_pool>(4);
set_default_executor(exec);
auto f = async([](){ std::cout << "Hello!\n"; });
// f.get(); // optional: block to wait or let f go out of scope
并手动控制 executor 的关闭时机:
exec->shutdown(); // 若支持该接口
C++ 异步任务执行的一种现代用法,通过显式传入一个 executor(执行器) 来调度任务执行。
代码:
thread_pool myPool;
async(myPool, [](){ std::cout << "Hello!\n"; });
理解要点:
1. thread_pool myPool;
- 创建一个线程池执行器,
myPool
是一个可以调度并并发执行多个任务的执行器。
2. async(myPool, [](){ std::cout << "Hello!\n"; });
- 使用线程池
myPool
来异步运行 lambda 表达式中的代码。 - 这 明确控制了任务的执行上下文 —— 任务将在
myPool
的某个线程中执行,而不是依赖std::async
的默认策略(如launch::deferred
或launch::async
)。 - 这样比
std::async
更可控、更灵活、更现代化。
为什么这样做?
标准的 std::async
有如下局限:
- 不一定并发执行(默认行为可能是 lazy evaluation)。
- 执行位置不明确(可能是在调用线程)。
- 不能控制调度策略(不能优雅控制线程池或 shutdown)。
future
析构阻塞问题(如果不显式.get()
,在析构时仍然会阻塞)。
而使用 executor 的方式(如thread_pool
):- 明确任务在哪执行。
- 可以并发调度多个任务。
- 便于集中管理资源、控制生命周期、提升性能。
总结
项目 | std::async | async(myPool, ...) (现代方式) |
---|---|---|
控制执行方式 | 差(不可控) | 好(通过 executor 控制) |
并发性 | 不保证 | 可控制线程并发 |
性能与资源管理 | 无法共享线程池 | 可复用线程池 |
生命周期/关闭控制 | 难以管理 | 显式 shutdown、可控 |
future 析构阻塞 | 是(可能阻塞) | 可规避(通过设计) |
这段代码的核心是在说明 C++ Executors 的动机之一:实现并发流水线(pipelines)模型,其中 executor
是构建高级抽象(如 pipeline)的基础。
代码结构解析:
pipeline::plan restaurant(orders| pipeline::parallel(chef, 3)| pipeline::parallel(waiter, 4)| end
);
thread_pool pool;
pipeline::execution work(restaurant.run(&pool));
每部分含义解释:
pipeline::plan restaurant(...)
创建了一个 pipeline 计划,模拟了一个餐厅的处理流程。它接收一个 orders
流,然后:
| pipeline::parallel(chef, 3)
- 并行使用 3 个 chef 来处理订单。
- 意味着
chef
这个函数或对象会被多个线程并发运行。 - 提高处理吞吐率。
| pipeline::parallel(waiter, 4)
- 接下来订单交给 4 个 waiter 并行处理,比如送菜、打包等。
| end
- 标记流水线终结。
这是一个函数式风格(类似 UNIX pipe 或 JavaScript 的链式调用)的组合方式。
- 标记流水线终结。
thread_pool pool;
- 创建一个通用的线程池,用于调度流水线中的任务。
- 所有的并发步骤(如 chef/waiter)都在这个线程池中调度执行。
pipeline::execution work(restaurant.run(&pool));
- 启动流水线执行,传入线程池。
- 返回一个表示 执行状态或控制器 的
work
对象,用于管理运行中的 pipeline。
为何重要(动机)
这个例子表明:
- C++ 需要一种统一的并发执行接口(即 executors),作为构建并发抽象(比如流水线)的基础。
- 使用
executor
(如thread_pool
)可以:- 控制并发粒度(chef 3 个、waiter 4 个)
- 重用线程资源,避免过度线程创建
- 易于扩展与组合
类比:现实中的餐厅流水线
流程阶段 | 并发数 | 执行单位 |
---|---|---|
厨师 | 3 | chef() 函数 |
服务员 | 4 | waiter() 函数 |
线程池就像后厨统一调度资源的平台。 |
总结
这个例子展示了:
- Executors 不只是“跑任务”的工具,它们可以支持构建高级模型(如 pipelines)。
- Executors 提供并发控制、资源管理、生命周期管理。
- 是设计现代异步系统、流处理框架等的基础。
C++ 中抽象基类(Abstract Base Class, ABC)在 Executors 设计中的利与弊。我们来逐点理解:
代码片段:
virtual void add(function<void()> closure) = 0;
这行代码定义了一个纯虚函数,是抽象基类中典型的写法,用于表示“某个任务将被执行”。
各点解释与理解:
1. No template concept
- 抽象基类通常不能是模板类,因为模板类本身不能直接实例化。
- C++ 的类型擦除(如
std::function<void()>
)是一种非模板方式,可以跨多个类型统一处理调用。
2. Not part of the type
- 这意味着:抽象基类接口不参与类型系统的泛型编程。
- 换句话说,用抽象基类实现的接口不是类型上的“概念”,不像
template<typename T> requires Executor<T>
这种写法明确表达“这个类型是个 Executor”。
3. Not really important for functions
- 对于仅仅执行函数(如
closure
)的 executor,接口简单,抽象基类够用了。 - 不需要额外的模板魔法或复杂类型系统支持。
4. Important for structures
- 如果 executor 需要包含状态(如线程池、事件循环),或做更多资源管理,抽象类可以统一暴露接口,隐藏具体实现细节。
- 尤其是需要跨平台或跨模块(比如跨
.so
/.dll
)时。
5. Can cross binary interfaces
- 抽象基类由于不使用模板,编译后的 ABI(应用二进制接口)是稳定的,因此适合跨模块调用。
- 模板类在不同编译器或不同编译设置下可能会有不同的符号实现,无法轻易跨 binary 边界传递。
6. Sometimes simply too costly
- 使用
std::function<void()>
+ 虚函数调度,有运行时开销(函数对象堆分配、vtable 跳转)。 - 对于极端高性能场景,可能会成为瓶颈。
- 相比之下,模板或 inline lambda 编译期展开会更快。
总结
特性 | 抽象基类优点 | 抽象基类缺点 |
---|---|---|
类型通用性 | 高(不需模板) | 类型检查不强,不是概念的一部分 |
ABI 兼容性 | 好(适合跨模块) | 无法内联优化 |
性能 | 可接受(但不是最优) | 存在堆分配、虚函数开销 |
可表达性 | 简单明确 | 不如现代模板 / concepts 灵活 |
结论:
抽象基类非常适合早期 executor 设计(或需要动态调度、插件化系统),但在现代 C++ 中,如果性能关键、需要类型系统集成,模板 & concepts 更具优势。
C++ 中 .then
continuation 的设计和执行模型 —— 特别是和 executor 的集成。下面是详细的理解和代码分析:
.then
是什么?
.then()
是一种 continuation(续操作) 的语法,表示:“当前的 future 执行完之后,接着执行某个操作”。
示例代码讲解:
auto f = std::async([](){std::cout << "Hello ";
});
f.then(myPool, [](){std::cout << "World\n";
});
分析:
- 第一行:
auto f = std::async([](){ std::cout << "Hello "; });
- 启动一个异步任务,输出
Hello
。 - 返回一个
std::future
对象。
- 启动一个异步任务,输出
- 第二行:
f.then(myPool, [](){ std::cout << "World\n"; });
- 在
f
完成之后,在myPool
所代表的 executor 上执行另一个 lambda,输出World\n
。 myPool
控制了后续代码的执行上下文。
- 在
核心问题:
“Without executor, how does
.then
know on which executor to run best?”
答案:
如果你不明确传递 executor,.then
无法准确知道应该在哪个线程/上下文执行后续代码,会有以下几种问题或行为:
情况 | 结果 |
---|---|
默认执行当前线程 | 导致阻塞或非并发执行 |
随机使用后台线程 | 不可控,可能导致性能不一致 |
不执行 / 抛出异常 | 如果设计强依赖 executor |
为什么要传递 executor?
传递 executor(如 myPool
)有以下好处:
优点 | 说明 |
---|---|
明确执行上下文 | 控制在哪个线程池/线程执行 |
性能优化 | 比如 IO 操作放 IO 线程池,CPU 操作放计算线程池 |
可组合性强 | 支持管道化、批处理、负载分担 |
跨平台一致行为 | 避免依赖默认实现行为 |
小结
.then()
是现代 C++ 并发设计中的核心模式之一,它和 executor
联用时:
- 能够精确控制后续执行的环境
- 避免依赖默认线程行为(可能导致性能或正确性问题)
- 增强了代码的组合性和模块性
这段代码与概念展示了一个 “数据集中器”(Data Concentrator)系统的设计,它采用 执行器(RTExecutor
) 来控制任务调度,并且类似于一个简化的 异步数据处理管线(pipeline)。我们来逐步理解它的含义。
示例代码解析
RTExecutor rtExec0(0); // 优先级 0 的执行器(低)
RTExecutor rtExec80(80); // 优先级 80 的执行器(高)
ConcentratorT dataConcentrator{wrap(rtExec80, ReadDev(1, in1)), // 高优先级读取设备 1wrap(rtExec0, ReadDev(2, in2)), // 低优先级读取设备 2wrap(rtExec0, StoreData(out)) // 低优先级写出数据
};
dataConcentrator.run();
各个组件含义
组件 | 含义 |
---|---|
RTExecutor | 一个支持 实时/优先级调度 的执行器类。构造函数参数表示优先级。 |
wrap(exec, task) | 将任务(例如 ReadDev , StoreData )包装在指定的 executor 下执行。 |
ReadDev(id, input) | 读取设备的数据,表示为一个数据生产者。 |
StoreData(out) | 存储处理结果的消费者。 |
ConcentratorT{...} | 一个集中器管道,连接多个“数据处理单元”。 |
dataConcentrator.run() | 启动整个管道流程:读取、处理、存储。 |
架构图示意
Input1 --[ReadDev(1)]--\---> [StoreData] --> Output
Input2 --[ReadDev(2)]--/
ReadDev(1)
:使用 高优先级执行器rtExec80
ReadDev(2)
:使用 低优先级执行器rtExec0
StoreData
:也使用 低优先级rtExec0
概念理解
“Concentrator” 像是一个异步流水线系统:
- 两个生产者(
ReadDev(1)
和ReadDev(2)
)并行地收集数据。 - 一个消费者(
StoreData
)负责统一地将数据保存或处理。 - 调度由 RTExecutor 控制优先级,实现资源合理分配。
“一个生产者有更高优先级” 是什么意思?
rtExec80
被赋予更高优先级 ⇒ 它调度的任务会更快获得 CPU 资源。- 这样保证关键数据(如来自设备 1)比次要数据更快被读取与处理。
- 优先级调度使得系统响应更加敏捷,尤其在资源紧张时。
总结
关键点 | 说明 |
---|---|
使用 RTExecutor | 控制任务的调度优先级 |
采用 wrap() | 将具体任务封装进调度器中 |
类似 pipeline | 多任务流→合并→统一处理 |
可扩展性强 | 可加入更多任务/设备,动态分配优先级 |
ASIO(Asynchronous Input/Output) 框架的核心设计理念与动机,下面是详细的理解与结构化说明:
什么是 ASIO?
ASIO 是一种跨平台的异步 I/O 库,广泛应用于网络编程,尤其是 C++ 中的 Boost.Asio 和 C++标准库中的网络提案(Networking TS)。
ASIO 的主要目标与动机
目标 | 解释 |
---|---|
在 OS I/O 返回的线程上继续执行(continuation) | 当一个异步操作完成(如 async_read() ),它希望在相同线程中继续运行后续逻辑,减少上下文切换。 |
支持并发或协作式执行(cooperative scheduling) | 可以并发处理任务,也可让任务主动放弃控制权,支持 lightweight coroutine。 |
避免使用 std::future 的开销 | std::future 等标准机制在调度和同步上有额外开销,不适合高频、低延迟 I/O 处理。 |
支持用户自定义 executor | 用户可以定义自己的调度器(执行器),来控制任务在哪个线程、如何执行。 |
系统相关异步事件支持 | 比如 Linux 的 epoll/kqueue,Windows 的 IOCP,信号、定时器、中断、邮箱等底层机制。 |
ASIO 特性汇总
特性 | 描述 |
---|---|
Zero-cost abstraction | 高性能设计,避免不必要的运行时开销。 |
异步模型 | 基于回调、handler、coroutine 的非阻塞处理。 |
定制执行器 | 用户可以决定任务在哪个线程执行。 |
多平台支持 | Windows、Linux、macOS 上运行良好。 |
不依赖 std::future | 自己的事件分发机制更适合低延迟网络编程。 |
长期经验积累 | 来源于真实项目需求与经验(如 Boost 社区、服务器网络栈等)。 |
与 Executors 的关系
ASIO 与本讨论的 Executors 系统有很多相似之处,但它是 特化的(ASIO specific):
- ASIO 的 handler(回调)系统可以与自定义的 executor 集成;
- 但它有自己的行为模型和事件循环,不完全等同于标准 proposal 中的 executor 架构;
- 它更强调“I/O驱动的调度模型”。
举个简单例子(使用 ASIO):
boost::asio::io_context io;
boost::asio::steady_timer timer(io, std::chrono::seconds(1));
timer.async_wait([](const boost::system::error_code&){std::cout << "Timer expired!" << std::endl;
});
io.run(); // 会阻塞直到所有事件处理完成
async_wait
表示异步等待计时器;- 等待结束后会在 同一个线程(事件循环所在线程) 中执行回调;
- 回调不使用
std::future
; - 如果你提供了自定义 executor,则会在那个 executor 上调度回调。
总结
核心点 | 说明 |
---|---|
ASIO 是一套异步 I/O 机制 | 不依赖标准 futures,而是基于事件驱动模型 |
支持自定义执行器 | 灵活调度回调执行位置 |
更贴近系统层面的异步事件处理 | 例如网络 I/O、定时器、信号等 |
更适合构建低延迟、高并发的系统 | 如服务器、嵌入式、RT 系统等 |
C++ Executors 提案(基于 ASIO) 的进一步解释,核心概念源于 N4046 提案(由 Chris Kohlhoff 提出),也是 Boost.Asio 的作者之一。
下面是详细理解与代码结构化说明:
背景:N4046 提案(Executors for C++)
N4046 是一个历史性的 C++ 提案,提出了在标准中引入 executor 执行模型 的框架,目标是提供统一、灵活、可组合的异步任务调度机制。这个提案后来影响了 C++ Networking TS 和标准 executor 架构的设计。
两个关键概念
1. executor
- 是一个 轻量级的句柄(handle);
- 表示一种调度能力:你可以通过它安排任务去执行;
- 它不拥有线程或任务本身,只是一个调度接口;
- 可以将任务提交给它:
executor.add(fn)
或post(executor, fn)
。
2. execution_context
- 是实际持有资源的实体(线程池、事件循环等);
- 管理着生命周期、线程、任务队列等;
- 可以通过
executor
间接访问它; - 可以关闭、等待、回收资源 —— 例如用于优雅关机。
// 概念化的关系
execution_context ctx;
executor exec = ctx.get_executor();
exec.add([]{ std::cout << "Task executed!\n"; });
ctx.run(); // 启动线程执行任务
提案中的具体 Executor 类型(Concrete Executors)
类型名称 | 类似/作用 | 描述 |
---|---|---|
system | 系统默认执行器 | 与全局线程池绑定,类似 Boost.Asio 的 io_context::executor_type |
executor (generic) | 类似 thread_executor | 可以是线程池或某种调度策略 |
strand | 类似 serial executor | 保证串行顺序执行任务(用于避免竞态) |
thread | 每次任务一个线程 | 低效但简单,类似 std::thread |
loop | 单线程事件循环 | 类似 GUI 中的事件循环或主线程任务调度 |
pool | 多线程线程池 | 最常用的,适合高并发任务 |
举个例子(ASIO风格 Executors):
boost::asio::io_context io;
boost::asio::executor_work_guard guard(io.get_executor());
// 创建一个线程池来跑 io_context
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i)threads.emplace_back([&io]{ io.run(); });
// 提交任务
boost::asio::post(io, [] {std::cout << "Running in executor!\n";
});
// 等待所有线程退出
for (auto& t : threads) t.join();
在这里:
io_context
是 execution_contextio.get_executor()
是 executorexecutor_work_guard
保持上下文不退出,直到我们完成所有任务post
用于提交异步任务
总结理解
概念 | 含义 |
---|---|
executor | 轻量句柄,负责调度任务 |
execution_context | 拥有执行资源,线程与队列的管理者 |
strand | 用于串行化执行,避免并发竞态 |
多种 executor 类型 | 适用于不同并发场景:串行、多线程、系统线程等 |
executor 和 async 紧密结合 | 可组合形成 pipeline、任务链、事件系统 |
C++ Executors 执行模型的可定制性(Customization Points) 的解释,关键在于支持多样的异步任务行为和调度策略。我们逐一来理解:
Customization Points(可定制接口)
这些是异步任务系统设计中的核心交互点,允许开发者控制任务如何被调度和执行:
1. Continuation Token(续延标记)
- 指的是任务完成后的回调机制。
- 类似
.then()
,允许你在任务完成后接续其他任务。 - 可定制:
- 是否在当前线程直接执行;
- 是否通过另一个 executor 执行;
- 是否延迟调度(例如
defer()
);
- 是连接异步操作的重要方式。
2. Direct Continuation on Same Thread(直接续延)
- 比如
asio::defer()
可选择是否在当前线程继续执行下一步; - 通常用于避免线程切换带来的性能损失;
- 也与 IO 回调线程模型(reactor)相匹配。
3. Synchronization Mechanism(同步机制)
- 用于控制任务执行的时机和顺序;
- 例如:
strand
(串行执行,避免竞态);mutex/condition_variable
等传统同步方式;
- 自定义执行器可实现自己的同步模型。
4. Concurrency Mechanism(并发机制)
- 控制任务调度的并发行为;
- 支持:
- 单线程串行(loop executor / strand)
- 多线程并发(thread_pool)
- 线程绑定或线程亲和性
Execution Interface(执行接口)
这些是任务被调度时调用的核心方法,定义了调度语义:
方法名 | 行为描述 |
---|---|
dispatch() | 立即执行任务(若可在当前上下文) |
post() | 异步调度(总是放入队列中,尽快执行) |
defer() | 延迟执行(有空再执行,可能和其他任务一起合批) |
这些 API 通常由 executor 提供,并根据上下文或策略决定执行方式。 |
例如:
executor exec = ...;
exec.dispatch([]{ std::cout << "Run now\n"; }); // 可能同步执行
exec.post([]{ std::cout << "Run soon\n"; }); // 放入队列中
exec.defer([]{ std::cout << "Run later\n"; }); // 延迟执行
5. get_associated_executor()
- 允许任务或操作对象关联一个 executor;
- 即使你调用
post(op)
,实际执行的是op
自己携带的 executor; - 这对于库开发者至关重要 —— 任务拥有自己的调度上下文。
应用场景举例
假设你写一个 async_read()
:
template<typename CompletionToken>
auto async_read(Socket& sock, Buffer buf, CompletionToken&& token);
token
可以是一个回调,也可以是一个 future。- 它内部会调用
get_associated_executor(token)
来获得调用方期望的执行器,确保回调在正确线程上执行。
总结
项目 | 作用 |
---|---|
Continuation Token | 控制任务后续的执行(如 .then() ) |
dispatch/post/defer() | 控制任务调度方式:同步 / 异步 / 延迟 |
get_associated_executor() | 允许任务携带自己的 executor |
Synchronization/Concurrency | 控制任务间的同步与并发策略 |
扩展性强 | 支持自定义调度模型、线程模型、生命周期管理等 |
这些 customization points 共同构成了现代 C++ 异步模型(例如 Boost.Asio / Networking TS / cppcoro / libunifex)的基础。 |
对 Executors and Schedulers R5 提案 中 executor 的现代接口形式的说明,特别强调了:
- 使用 模板(template)驱动 的接口;
- 引入 任务包装器 task_wrapper 来关联任务和 executor(执行器);
- 支持 类型擦除(type erasure),以实现更灵活的调度策略和抽象。
下面详细逐点分析和理解:
1. Executor 的新定义(R5 版本)
class executor {
public:template<class Func>void spawn(Func&& func);
};
意义:
spawn(Func&& func)
:接受任意可调用对象(函数、lambda、函数对象等);- 用于将任务调度到 executor 管理的上下文中执行;
- 是通用、轻量级任务提交接口,类似于
std::async
但控制权更强。
2. Template-based Concept(基于模板的概念)
- Executor 不一定要继承某个基类;
- 编译期泛型接口,靠概念或 duck typing 定义 “什么样的类是 executor”;
- 有利于性能优化、类型推导、零成本抽象;
- 可与
std::concept
(C++20)结合使用:
template<typename Exec>
concept Executor = requires(Exec e, std::function<void()> f) {e.spawn(f);
};
3. Type Erasure(类型擦除)
- 尽管基于模板可以提高灵活性和性能,但在某些场景(如容器、插件、ABI 边界)需要将任意 executor 封装为一个抽象类型。
- 所以仍然需要一种方式来进行类型擦除。
例如:
class executor_base {
public:virtual void spawn(std::function<void()>) = 0;virtual ~executor_base() = default;
};
using executor_ptr = std::shared_ptr<executor_base>;
4. task_wrapper:绑定任务与 executor
由于异步任务可能要延迟执行或在链式调用中执行(如 .then()
),需要一种机制来:
- 将任务和其希望运行的 executor 打包在一起;
- 携带调度策略直到任务实际被调用;
这就是task_wrapper
的作用。
举例:
struct task_wrapper {std::function<void()> task;executor_ptr exec;void run() {exec->spawn(task);}
};
应用场景:
executor_ptr myExec = get_executor();
auto task = []() { std::cout << "Run async\n"; };
task_wrapper wrapped{task, myExec};
wrapped.run(); // 实际通过 executor 调度执行
总结理解点:
项 | 内容 |
---|---|
spawn(Func&&) | 现代 executor 接口,调度任意任务 |
模板接口 | 零开销抽象,适合泛型库 |
类型擦除 | 用于跨 ABI、插件或动态策略等场景 |
task_wrapper | 把任务和其 executor 绑定在一起,便于延期调度或跨线程调用 |
接口灵活 | 同时支持编译期约束和运行时抽象,兼顾性能与通用性 |
这部分内容是关于Executor Traits,特别是来自提案 N4406(Integrating Executors with Parallel Algorithm Execution)的内容。它描述了如何通过 traits 来定义和区分 executor 的能力和行为语义,以及执行任务的方式。
Executor Traits 核心点
1. 通过 Traits 定义 Executor 的接口和语义
- Traits 是一组类型和常量,用于描述和约束 executor 的行为和能力;
- 通过 traits,算法和框架能根据 executor 特性选择最合适的执行策略。
2. Executor 的语义分类
- concurrent(并发执行)
Executor 能同时执行多个任务,任务间彼此独立,能够真正并发执行。 - parallel(并行执行)
更强的保证,可能要求任务之间同步或共同完成。 - weakly parallel(弱并行)
允许某些程度的并行,但没有强同步保证。
3. Future 类型
- Executor 应该定义或者能生成和关联的 future 类型,用于表示任务执行结果的异步状态;
- 这使得可以组合任务、等待完成等。
4. 任务启动方式
- 单任务启动:执行单个异步任务的能力;
- 批量任务启动(bulk task starting):能同时启动多个任务的能力,比如并行算法中的批量操作。
5. 执行接口的抽象
- 这些 traits 提供了执行器能力的抽象描述,使得并行算法可以适配不同的执行器(如线程池、GPU执行器等);
- 方便实现标准化接口,比如 C++ 标准库并行算法(
std::for_each
等)可以根据 executor 特性优化执行。
举例理解
template<typename Executor>
struct executor_traits {using future_type = /* executor-specific future type */;static constexpr bool is_concurrent = /* 是否支持并发 */;static constexpr bool is_parallel = /* 是否支持并行 */;static constexpr bool is_weakly_parallel = /* 是否弱并行 */;template<typename Func>static future_type async_execute(Executor& exec, Func&& f);template<typename Func>static void bulk_async_execute(Executor& exec, std::size_t n, Func&& f);
};
- 通过
executor_traits
,算法可以在编译期判断 executor 的能力,决定是否可以安全地并行执行任务。
总结
关键词 | 解释 |
---|---|
Traits | 描述 executor 的类型信息和行为能力的模板结构 |
concurrent | 允许真正的并发执行多个任务 |
parallel | 强同步并行执行保证 |
weakly parallel | 允许部分并行,无强同步要求 |
Future type | 用于异步结果管理的类型 |
Task starting | 启动单个任务的能力 |
Bulk task starting | 批量启动多个任务的能力,用于并行算法加速 |
- N4406 提案主要是针对并行算法的 Executor Traits 设计,而不是强制规定某个具体的 executor 接口标准。
- Traits 的作用是为执行器提供额外的抽象和能力描述,特别是一些执行器本身可能没有直接实现的接口,通过 traits 也可以“补足”。
- Traits 支持两种主要接口形式:
- bulk interface(批量任务接口):可以一次启动多个任务,方便并行算法高效执行。
- future based interface(基于 future 的接口):支持异步任务返回 future,用于任务结果的等待和组合。
总结就是:N4406 用 traits 来灵活描述执行器能力,方便并行算法适配各种执行器,而不限制执行器必须提供某种具体接口。
展示了系统的分层结构,层次关系如下:
用户层(User programs / Application)
- 最上层,是具体的应用程序,直接面向最终用户。
库层(Library components)
- Containers:数据结构容器,比如 vector、map 等。
- async:异步编程支持,比如异步任务启动。
- .then:任务继续操作,支持任务链式调用。
- Parallel Algorithms:并行算法库,用于多核并行计算。
- ASIO:异步输入输出库,支持网络和文件的异步操作。
- FlowGraph:数据流图模型,支持复杂任务流调度。
- pipeline:流水线模式,实现任务阶段性处理。
- Event:事件驱动机制。
- Loop:事件循环,调度任务和事件。
基础构建块(Building blocks)
- allocator:内存分配器,负责内存管理。
- executor:执行器,负责任务调度和执行。
这是一种典型的现代 C++ 并发和异步框架设计,将系统从底层的执行和内存管理,到中间的异步和并行支持,再到上层的应用程序组织起来,层次清晰,模块化强。
执行器(executor)在管理任务时,任务本身可能携带大量有用的信息,但这些信息不一定都要直接放在执行器接口里,而是需要有合适的机制去传递和利用这些信息。
具体来说:
- 任务可能包含的额外信息有:
- 任务与其生成任务(spawning task)的关系
- 任务是长时间运行还是短时间运行
- 任务是否会阻塞
- 任务是否会重复执行
- 任务优先级
- 任务的返回信息
- 等等……
- 这些信息通常是特定执行器或领域相关的,可能对不同执行器有不同的影响和处理方式。
- 并不需要所有信息都直接暴露在执行器的公共接口里,否则接口会变得复杂难用。
- 需要设计一种机制让这些信息能够在任务和执行器之间灵活传递,且只让必要的信息被“中间层”或执行器知道和使用。
总结就是:
执行器设计要灵活、可扩展,能够支持多样化的任务属性,但又不能让接口臃肿,要通过合理的“信息传递机制”来达到这个目的。
如果想要,我可以帮你分析这类机制可能的设计方案!