The End of my Detour: Unified Futures
This is a cross-post from www.ModernesCpp.com.
After the last post to executors, I can now finally write about the unified futures. I write in the post about the long past of the futures and end my detour from the C++ core guidelines.
The long past of promises and futures began in C++11.
C++11: The standardised futures
Tasks in the form of promises and futures have an ambivalent reputation in C++11. On the one hand, they are a lot easier to use than threads or condition variables; on the other hand, they have a significant deficiency. They cannot be composed. C++20/23 may overcome this deficiency. I have written about tasks in the form of std::async, std::packaged_task, or std::promise and std::future. For the details: read my posts to tasks. With C++20/23 we may get extended futures.
Concurrency TS: The extended futures
Because of the issues of futures, the ISO/IEC TS 19571:2016 added extensions to the futures. From the bird's eye perspective, they support composition. An extended future becomes ready, when its predecessor (then) becomes ready, when_any one of its predecessors becomes ready, or when_all of its predecessors become ready. They are available in the namespace std::experimental. In case you are curious, here are the details: std::future Extensions.
This was not the endpoint of a lengthy discussion. With the renaissance of the executors, the future of the futures changed.
Unified Futures
The paper P0701r1: Back to the std2::future Part II gives a great overview of the disadvantages of the existing and the extended futures.
Disadvantages of the Existing Futures
future/promise Should Not Be Coupled to std::thread Execution Agents
C++11 had only one executor: std::thread. Consequently, futures and std::thread were inseparable. This changed with C++17 and the parallel algorithms of the STL. This changes even more with the new executors which you can use to configure the future. For example, the future may run in a separate thread, or in a thread pool, or just sequentially.
Where are .then Continuations are Invoked?
Imagine, you have a simple continuation such as in the following example.
future f1 = async([]() { return 123; });
future f2 = f1.then([](future f) {
return to_string(f.get());
});
The question is: Where should the continuation run? There are a few possibilities today:
- Consumer Side: The consumer execution agent always executes the continuation.
- Producer Side: The producer execution agent always executes the continuation.
- Inline_executor semantics: If the shared state is ready when the continuation is set, the consumer thread executes the continuation. If the shared state is not ready when the continuation is set, the producer thread executes the continuation.
- thread_executor semantics: A new std::thread executes the continuation.
In particular, the first two possibilities have a significant drawback: they block. In the first case, the consumer blocks until the producer is ready. In the second case, the producer blocks, until the consumer is ready.
Here are a few nice use-cases of executor propagation from the document P0701r184:
auto i = std::async(thread_pool, f).then(g).then(h);
// f, g and h are executed on thread_pool.
auto i = std::async(thread_pool, f).then(g, gpu).then(h);
// f is executed on thread_pool, g and h are executed on gpu.
auto i = std::async(inline_executor, f).then(g).then(h);
// h(g(f())) are invoked in the calling execution agent.
Passing futures to .then Continuations is Unwieldy
Because the future is passed to the continuation and not its value, the syntax is quite complicated.
First, the correct but verbose version.
std::future f1 = std::async([]() { return 123; });
std::future f2 = f1.then([](std::future f) {
return std::to_string(f.get());
});
Now, I assume that I can pass the value because to_string is overloaded on std::future.
std::future f1 = std::async([]() { return 123; });
std::future f2 = f1.then(std::to_string);
when_all and when_any Return Types are Unwieldy
The post std::future Extensions shows the quite complicate usage of when_all and when_any.
Conditional Blocking in futures Destructor Must Go
Fire and forget futures look very promising but have a significant drawback. A future that is created by std::async waits on its destructor, until its promise is done. What seems to be concurrent runs sequentially. According to the document P0701r1, this is not acceptable and error-prone.
I describe the peculiar behaviour of fire and forget futures in the post The Special Futures.
Immediate Values and future Values Should Be Easy to Composable
In C++11, there is no convenient way to create a future. We have to start with a promise.
std::promise<std::string> p;
std::future<std::string> fut = p.get_future();
p.set_value("hello");
This may change with the function std::make_ready_future from the concurrency TS v1.
std::future<std::string> fut = make_ready_future("hello");
Using future and non-future arguments would make our job even more comfortable.
bool f(std::string, double, int);
std::future<std::string> a = /* ... */;
std::future<int> c = /* ... */;
std::future<bool> d1 = when_all(a, make_ready_future(3.14), c).then(f);
// f(a.get(), 3.14, c.get())
std::future<bool> d2 = when_all(a, 3.14, c).then(f);
// f(a.get(), 3.14, c.get())
Neither the syntactic form d1 nor the syntactic form d2 is possible with the concurrency TS.
Five New Concepts
There are five new concepts for futures and promises in the Proposal 1054R085 to unified futures.
- FutureContinuation, invocable objects that are called with the value or exception of a future as an argument.
- SemiFuture, which can be bound to an executor, an operation which produces a ContinuableFuture (f = sf.via(exec)).
- ContinuableFuture, which refines SemiFuture and instances can have one FutureContinuation c attached to them (f.then(c)), which is executed on the future associated executor when the future f becomes ready.
- SharedFuture, which refines ContinuableFuture and instances can have multiple FutureContinuations attached to them.
- Promise, each of which is associated with a future and makes the future with either a value or an exception ready.
The paper also provides the declaration of this new concepts:
template <typename T>
struct FutureContinuation
{
// At least one of these two overloads exists:auto operator()(T value);
auto operator()(exception_arg_t, exception_ptr exception);
};
template <typename T>
struct SemiFuture
{
template <typename Executor>
ContinuableFuture<Executor, T> via(Executor&& exec) &&;
};
template <typename Executor, typename T>
struct ContinuableFuture
{
template <typename RExecutor>
ContinuableFuture<RExecutor, T> via(RExecutor&& exec) &&;
template <typename Continuation>
ContinuableFuture<Executor, auto> then(Continuation&& c) &&;
};
template <typename Executor, typename T>
struct SharedFuture
{
template <typename RExecutor>
ContinuableFuture<RExecutor, auto> via(RExecutor&& exec);
template <typename Continuation>
SharedFuture<Executor, auto> then(Continuation&& c);
};
template <typename T>
struct Promise
{
void set_value(T value) &&;
template <typename Error>
void set_exception(Error exception) &&;
bool valid() const;
};
Based on the declaration of the concepts, here are a few observations:
- A FutureContinuation can be invoked with a value or with an exception.
- All futures (SemiFuture, ContinuableFuture, and SharedFuture) have a method via that excepts an executor and returns a ContinuableFuture. via allows it to convert from one future type to a different one by using a different executor.
- Only a ContinuableFuture or a SharedFuture have a then method for continuation. The then method takes a FutureContinuation and returns a ContinuableFuture.
- A Promise can set a value or an exception.
Future Work
The Proposal 1054R086 left a few questions open.
- Forward progress guarantees for futures and promises.
- Requirements on synchronization for use of futures and promises from non-concurrent execution agents.
- Interoperability with the standardised std::future and std::promise.
- Future unwrapping, both future<future> and more advanced forms. Future unwrapping should in the concrete case remove the outer future.
- Implementation of when_all, when_any, or when_n.
- Interoperability with std::async.
I promise I write about them in the future.
What's next?
My next post continues with my journey through the C++ core guidelines. This time I write about lock-free programming.