- A Unified Executors Proposal for C++">A Unified Executors Proposal for C++
- 1 设计文档(Design Document)
- 2 提案细则(Proposed Wording)
A Unified Executors Proposal for C++
Title: | A Unified Executors Proposal for C++ |
---|---|
Authors: | Jared Hoberock, \<jhoberock@nvidia.com> |
Michael Garland, \<mgarland@nvidia.com> | |
Chris Kohlhoff, \<chris@kohlhoff.com> | |
Chris Mysen, \<mysen@google.com> | |
Carter Edwards, \<hcedwar@sandia.gov> | |
Gordon Brown, \<gordon@codeplay.com> | |
David Hollman, \<dshollm@sandia.gov> | |
Lee Howes, \<lwh@fb.com> | |
Kirk Shoop, \<kirkshoop@fb.com> | |
Lewis Baker, \<lbaker@fb.com> | |
Eric Niebler, \<eniebler@fb.com> | |
Other Contributors: | Hans Boehm, \<hboehm@google.com> |
Thomas Heller, \<thom.heller@gmail.com> | |
Bryce Lelbach, \<brycelelbach@gmail.com> | |
Hartmut Kaiser, \<hartmut.kaiser@gmail.com> | |
Bryce Lelbach, \<brycelelbach@gmail.com> | |
Gor Nishanov, \<gorn@microsoft.com> | |
Thomas Rodgers, \<rodgert@twrodgers.com> | |
Michael Wong, \<michael@codeplay.com> | |
Document Number: | P0443R12 |
Date: | 2020-01-13 |
Audience: | SG1 - Concurrency and Parallelism, LEWG |
Reply-to: | \<sg1-exec@googlegroups.com> |
Abstract: | This paper proposes a programming model for executors, which are modular components for creating execution, and senders, which are lazy descriptions of execution. |
1 设计文档(Design Document)
1.1 动机(Motivation)
当我们想象C++编程的未来时,我们在期待着一种能够由各种不同的硬件,从微型移动设备到巨型超级计算机,来加速网络和异步并行计算的优雅艺术组合。在当下,硬件愈发的丰富多彩,但C++的程序员们却难以针对这些场景找到趁手的并行编程工具。工业级强度的并发原语,比如std::thread
和std::atomic
,强大而危险;std::async
和std::future
,有着一些众所周知的问题;而标准算法库(standard algorithms library)呢,尽管并行化了,也还是不够灵活不可组合。
为了应对这些暂时的挑战,来更好的面向未来,C++必须为程序的执行控制(controlling program execution)打下一个坚实的基础。首先,C++必须提供一组弹性的设施来控制何地(where)及何时(when)执行工作。本文提出了一个此类设施的设计方案。经过了大量的磋商和协作,在2019年的科隆会议(the Cologne meeting)上,SG1通过了这份达成了共识的设计。
1.2 使用示例(Usage Example)
本提案为执行器(execution)定义了两个关键的组成部分:
- 工作的执行接口(execution interface)
- 工作及工作之间相互关系的描述
它们分别是executors
,以及senders
和receivers
:
// make P0443 APIs in namespace std::execution available
using namespace std::execution;
// get an executor from somewhere, e.g. a thread pool
std::static_thread_pool pool(16);
executor auto ex = pool.executor();
// use the executor to describe where some high-level library should execute its work
perform_business_logic(ex);
// alternatively, use primitive P0443 APIs directly
// immediately submit work to the pool
execute(ex, []{ std::cout << "Hello world from the thread pool!"; });
// immediately submit work to the pool and require this thread to block until completion
execute(std::require(ex, blocking.always), foo);
// describe a chain of dependent work to submit later
sender auto begin = schedule(ex);
sender auto hi_again = then(begin, []{ std::cout << "Hi again! Have an int."; return 13; });
sender auto work = then(hi_again, [](int arg) { return arg + 42; });
// prints the final result
receiver auto print_result = as_receiver([](int arg) { std::cout << "Received " << std::endl; });
// submit the work for execution on the pool by combining with the receiver
submit(work, print_result);
// Blue: proposed by P0443. Teal: possible extensions.
1.3 通过executors
执行工作(Executors Execute Work)
作为一组轻量级的句柄(handles),executors
强制统一了执行上下文的访问接口。
executors
通过抽象实际执行工作的底层资源,为工作创建提供了统一的接口。前面代码示例中的底层资源就是线程池。另外的例子包括SIMD
单元,GPU
运行时,或单纯的当前线程。一般地,我们称这些资源为执行上下文(execution contexts)。作为轻量级句柄,executors
强制对执行上下文进行统一访问。统一访问的一致性可以有效控制工作执行的地点,哪怕是通过库接口被间接地执行。
基本的执行器接口是execute
函数,用户通过它来执行一个work
:
// obtain an executor
executor auto ex = ...
// define our work as a nullary invocable
invocable auto work = []{ cout << "My work" << endl; };
// execute our work via the execute customization point
execute(ex, work);
就其本身而言,execute
函数是一种典型的“即发即弃(fire-and-forget)”型接口。它接受单一的无参可调用体(nullary invocable)并创建一个work
,但在这之后不返回任何可以用来标识,或者操作该work
的句柄。它使用这种方式来以便捷换普适。因此,就结论而言,我们希望大多数程序员通过更方便的高级别库与executors
交互,我们所设想的异步STL
就是这样一个例子。
比如,如何通过executors
来拓展std::async
,使其能够具备让调用者控制执行调度的能力:
template<class Executor, class F, class Args...>
future<invoke_result_t<F,Args...>> async(const Executor& ex, F&& f, Args&&... args) {
// package up the work
packaged_task work(forward<F>(f), forward<Args>(args)...);
// get the future
auto result = work.get_future();
// execute work on the given executor
execution::execute(ex, move(work));
return result;
}
这种拓展的好处是,我们只需要提供一个对应的executor
,调用者就可以从多个线程池中精确地控制并选择让std::async
使用哪个池。打包和提交工作(work)的麻烦将成为库的一个职责。
编写执行器(Authoring executors)。程序员们将通过定义一个具有execute
函数的类型来编写和实现他们自定义的执行器executor
。比如说,实现一个能够内联(inline
)执行用户工作的executor
:
struct inline_executor {
// define execute
template<class F>
void execute(F&& f) const noexcept {
std::invoke(std::forward<F>(f));
}
// enable comparisons
auto operator<=>(const inline_executor&) const = default;
};
在此之上,比较函数用来确定两个executor
对象之间是否引用了相同的底层资源,从而以相同的语义执行。executor
和executor_of
这两个概念(concepts )描述了这些需求。前者单独确认executor
的有效性,后者则同时确认了executor
和work
。
自定义executor
能够加速执行过程,或者引入一些新的行为。之前的示例演示了如何以executor
类型为粒度做执行的自定义,但同时我们还可以通过另外一些技术做到更细,或更粗的自定义粒度。它们分别是执行器属性(executor properties)和控制结构(control structures)。
执行器属性(executor properties)在execute
函数的最小契约之外传达了可选的行为需求,本提案指定了这样一些属性。原则上来说,可选的动态数据成员或函数参数能够用来传达这些需求,但C++
要求具备编译期引入定制的能力。另外,可选参数引入了过多的函数变体组合。
相反,静态可操作属性(statically-actionable properties)考虑了这些需求,从而避免了executor API
的组合爆炸。例如,考虑指定优先级执行阻塞工作的需求。不可伸缩的设计可能通过将单个因素乘以单独的函数来将这些选项嵌入到execute
接口中:execute
,blocking_execute
,execute_with_priority
,blocking_execute_with_priority
,等等。
executor
通过采用P1393
的基于require
和prefer
的属性设计来避免这种不可扩展的情况:
// obtain an executor
executor auto ex = ...;
// require the execute operation to block
executor auto blocking_ex = std::require(ex, execution::blocking.always);
// prefer to execute with a particular priority p
executor auto blocking_ex_with_priority = std::prefer(blocking_ex, execution::priority(p));
// execute my blocking, possibly prioritized work
execution::execute(blocking_ex_with_priority, work);
require
和prefer
的每次使用都将一个executor
变换为具备了所请求属性的另一种形式。在上面这个例子里,如果ex
不能被转换为一个堵塞的executor
,那么require
的调用将无法通过编译。prefer
is a weaker request used to communicate hints and consequently always succeeds because it may ignore the request.
考虑一个决不可能堵塞调用者的std::async
版本:
template<executor E, class F, class... Args>
auto really_async(const E& ex, F&& f, Args&&... args) {
using namespace execution;
// package up the work
packaged_task work(forward<F>(f), forward<Args>(args)...);
// get the future
auto result = work.get_future();
// execute the nonblocking work on the given executor
execute(require(ex, blocking.never), move(work));
return result;
}
这样的增强能够解决std::async
的一个众所周知的风险:
// confusingly, always blocks in the returned but discarded future's destructor
std::async(foo);
// *never* blocks
really_async(foo);
控制结构(control structures)允许我们通过executor
钩住(hook)它们,从而实现在更高层次抽象上的自定义。这对于期望在一个特定的执行上下文中定制一个高效的实现是非常有用的。本提案定义的第一个控制结构是bulk_execute
,它能够用来在单个操作中创建一组函数调用。这个模式允许非常广泛的高效实现,对于C++
程序和标准库来说是至关重要的。
默认情况下,bulk_execute
会重复地调用execute
,但这显然是低效的。因此,在很多平台上都会提供高效执行批量工作的API
。在这些情况下,平台特定的bulk_execute
将通过直接调用这些批处理API
来避免低效的交互,同时优化标量API
的使用。
bulk_execute
接受一个可调用体,以及一个调用计数。如下是一个可能的实现:
struct simd_executor : inline_executor { // first, satisfy executor requirements via inheritance
template<class F>
simd_sender bulk_execute(F f, size_t n) const {
#pragma simd
for(size_t i = 0; i != n; ++i) {
std::invoke(f, i);
}
return {};
}
};
simd_executor
使用SIMD
循环来加速bulk_execute
。
bulk_execute
应该被用于能够一次执行多个相同工作片段的情况:
template<class Executor, class F, class Range>
void my_for_each(const Executor& ex, F f, Range rng) {
// request bulk execution, receive a sender
sender auto s = execution::bulk_execute(ex, [=](size_t i) {
f(rng[i]);
});
// initiate execution and wait for it to complete
execution::sync_wait(s);
}
simd_executor
中的bulk_execute
实现是“饥饿的(eagerly)”,但就bulk_execute
的语义来说,并没有要求这一点。如my_for_each
所示,不同于execute
,bulk_execute
是一个可以被可选地延迟的“惰性(lazy)”操作示例。bulk_execute
返回了一个示例sender
的token
,用户能够通过它启动执行,或与工作交互。例如,通过在sender
上调用sync_wait
能够确保批处理任务在此处全部完成。senders
和receivers
是我们下一节的主题。
1.4 通过senders
和receivers
来描绘工作(Senders and Receivers Represent Work)
executor
的概念解决了在指定的执行上下文中执行单个操作的基本需求。然而executor
的表达能力是有限的:因为execute
返回的是void
而不是刚刚调度的工作的句柄,executor
的抽象中没有提供串联操作的通用方法来向下传播值、错误和取消信号;无法处理在工作提交和执行之间发生的调度错误;而且没有方便的方法来控制与操作相关状态的分配和生存期。
没有这些控制能力,我们就没有办法定义泛化的异步并行算法,这些算法可以有效地与合理的默认实现组合在一起。为了填补这一空白,本文提出了两个相关的抽象概念:senders
和receivers
,具体如下。
1.4.1 泛型异步算法示例(Generic async algorithm example): retry
retry
是一类由senders
和receivers
使能的泛型算法。它的语义很简单:在一个执行上下文上调度工作,如果执行成功,则完毕;否则,如果用户请求取消任务,则完毕;否则,如果一个调度错误发生,则重试。
template<invocable Fn>
void retry(executor_of<Fn> auto ex, Fn fn) {
// ???
}
执行器单独禁止泛型实现,因为它们缺乏一种可移植的方法来拦截和响应调度错误。稍后,我们将展示使用senders
和receivers
实现该算法的样子。
1.4.2 目标:异步的STL
(Goal: an asynchronous STL)
恰当地选择概念来驱动如retry
这样的泛型算法的定义,能够简化高效的异步调用工作图的创建。下面是我们所设想的异步程序的示例语法(来自P1897):
sender auto s = just(3) | // produce '3' immediately
via(scheduler1) | // transition context
then([](int a){return a+1;}) | // chain continuation
then([](int a){return a*2;}) | // chain another continuation
via(scheduler2) | // transition context
handle_error([](auto e){return just(3);}); // with default value on errors
int r = sync_wait(s); // wait for the result
我们当然可以使用另一个异步API
来替换掉上面的just(3)
,只要它的返回值满足了正确的概念(concept),并能保证这段程序的正确性。形如when_all
和when_any
这样的泛型算法允许用户在他们的调用图(DAG)中对并发调用清晰地表达“fork/join”。与STL
的迭代器iterator
抽象一样,我们在满足这些概念需求上的开销,被大型算法库获得的可重用性和可组合性带来的表达能力所抵消了。
1.4.3 现有的技术(Current techniques)
有很多用于创建相互依赖的异步调用执行链的技术。多年来,朴素的回调函数在C++
及其他领域里都取得了成功。而现代的代码基已向着能够支持延续性(continuation)概念的future
抽象及其变体(e.g., std::experimental::future::then
)所演变。在C++20
及以后,我们能够预想到协程(coroutines)的标准化,将能够触发一个异步操作并返回一个awaitable
。这些方式各有千秋。
Futures,正如经典实现一样,它需要共享状态的动态分配和管理,需要同步,并且通常需要对工作本身和延续性特征做类型擦除。这些开销中有许多是由于“future
”的本质——作为已调度执行的操作的句柄(as a handle to an operation that is already scheduled for execution)所固有的。这些开销使得future
抽象在很多场景里被排除在选项之外,并且使它很难作为一个通用泛型机制的基础。
协程(Coroutines)遇到了许多相同的问题,但是在链接依赖的工作时可以避免同步,因为它们通常在开始时是挂起的。不过在很多情况下,协程帧(coroutine frames)不可避免地需要动态分配,因此,嵌入式或异构环境中的协程需要非常注意细节。可取消性对协程来说也不太友好,因为并没有什么令人满意的解决方案能够安全地提前终止协程调度。一方面,异常在许多环境中是低效的且不被允许的;另外,笨拙的临时(ad hoc)机制(通过co_yield
返回状态码)也会妨碍正确性。P1662里有关于这些内容的完整讨论。
回调(Callbacks)是创建工作链的最简单、最强大和最有效的机制,但它们当然也存在许多问题。回调必须在其上传递错误或值,这一简单的要求导致了各种不同的接口可能性,但却缺乏规范以至于难以进行通用的泛化设计。此外,当用户请求上游停止工作并清理时,这些接口设计中很少能容纳取消信号。
1.5 Receiver, sender, and scheduler
以上述内容为动机,我们引入原语(primitives)来解决泛型异步编程存在的值(value)、错误(error)和传播取消(cancellation propagation)的需求。
1.5.1 Receiver
receiver
只是一个具有特定接口和语义的回调。但与传统意义上的回调不同,uses function-call syntax and a single signature handling both success and error cases,receiver
具有三个独立的通道(channels),分别处理值(value)、错误(error)和完成(done,相当于取消)。
这些通道被指定为用户的自定义点,and a type R
modeling receiver_of<R,Ts...>
supports them:
std::execution::set_value(r, ts...); // signal success, but set_value itself may fail
std::execution::set_error(r, ep); // signal error (ep is std::exception_ptr), never fails
std::execution::set_done(r); // signal stopped, never fails
在receiver
销毁之前,必须恰好调用这三个函数中的一个。这些接口中的任何一个都被认为是“终结”,也就是说,一个特定的receiver
可能会假定如果其中一个被调用了,那么其它的都不会再被调用。这里有一个例外,就是当set_value
由于异常而结束时,当前的receiver
还尚未完成。因此这种情况下必须在它销毁之前调用其它的某个接口函数。在set_value
失败的情况下,正确的做法是紧接着调用set_error
或者set_done
——receiver
并不需要保证第二次调用set_value
是良构的(well-formed)。总体来说,以上这些需求就是所谓的“接收者契约(receiver contract)”。
虽然receiver
的接口第一眼看上去很奇特,它仍然只是一个回调。而且,当我们认识到std::promise
的set_value
和set_exception
从本质上来说提供了相同的接口,receiver
的奇特感就消失了。这种接口和语义的选择,连同sender
的设计,促进了许多有用的异步算法的泛型实现,比如retry
。
1.5.2 Sender
一个sender
代表一个尚未被调度执行的工作。它必须接续一个接收者receiver
,然后被“触发(launch)”,或者进入执行队列以等待执行。sender
对其连接上的receiver
的职责,是履行接收者契约(receiver contract)—— 通过确保上文所说的三个receiver
函数中的一个正常返回。
本提案目前将这两个操作融合在一个单独的操作submit
里:附加一个“延续(continuation)”,和触发一个执行。提案P2006建议将submit
拆分为connect
和start
,前者将sender
和receiver
打包为一个可执行状态;后者将可执行状态放入执行队列。
// P0443R12
std::execution::submit(snd, rec);
// P0443R12 + P2006R0
auto state = std::execution::connect(snd, rec);
// ... later
std::execution::start(state);
这样的分离设计为优化提供了一些有趣的机会,并且可以让sender
和协程结合得更协调。
sender
的概念对其工作执行时的执行上下文没有任何要求。与之相反,对将被调用的receiver
方法的上下文,sender
概念下的特定模型可能会提供更强的保证。这对于由一个scheduler
所创建的sender
来说更是如此。
1.5.3 Scheduler
许多泛型的异步算法在同一个执行上下文中创建多个执行代理。因此,是不足以用一个“single-shot”的sender
在已知的上下文中参数化这些算法的。或者说,it makes sense to pass these algorithms a factory of single-shot senders.像这样的工厂被称为scheduler
,它由一个单独的基础操作:schedule
。
sender auto s = std::execution::schedule(sched);
// OK, s is a single-shot sender of void that completes in sched's execution context