The End of my Detour: Unified Futures
After the last post to executors, I can 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 standardized futures
Tasks in the form of promises and futures have an ambivalent reputation in C++11. Conversely, they are much easier to use than threads or condition variables; conversely, 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 on 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 becomes 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 an excellent 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 you can use to configure the future. For example, the future may run in a separate thread, in a thread pool, or just sequentially.
Modernes C++ Mentoring
Do you want to stay informed: Subscribe.
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 show the quite complicated usage of when_all and when_any.
Conditional Blocking in futures Destructor Must Go
Fire and forget futures look very promising but have significant drawbacks. A future created by std::async waits on its destructor until its promise is done. What seems to be concurrent runs sequentially. According to document P0701r1, this is not acceptable and error-prone.
I describe the peculiar behavior of fire and forget futures in the post The Special Futures.
Immediate Values and future Values Should Be Easily 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 Proposal 1054R085 to unified futures.
- FutureContinuation, is 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 that produces a ContinuableFuture (f = sf.via(exec)).
- ContinuableFuture, which refines SemiFuture and instances, can have one FutureContinuation c attached to them (f.then(c)), 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 prepares the future with either a value or an exception.
The paper also provides the declaration of these 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 that excepts an executor and returns a ContinuableFuture. via allows it to convert from one future type to a different one using a different executor.
- Only a ContinuableFuture or a SharedFuture has a then continuation method. The then method takes a FutureContinuation and returns a ContinuableFuture.
- A Promise can set a value or an exception.
Future Work
Proposal 1054R086 left a few questions open.
- Forward progress guarantees futures and promises.
- Requirements on synchronization for using futures and promises from non-concurrent execution agents.
- Interoperability with the standardized 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 will 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.
Thanks a lot to my Patreon Supporters: Matt Braun, Roman Postanciuc, Tobias Zindl, G Prvulovic, Reinhold Dröge, Abernitzke, Frank Grimm, Sakib, Broeserl, António Pina, Sergey Agafyin, Андрей Бурмистров, Jake, GS, Lawton Shoemake, Jozo Leko, John Breland, Venkat Nandam, Jose Francisco, Douglas Tinkham, Kuchlong Kuchlong, Robert Blanch, Truels Wissneth, Mario Luoni, Friedrich Huber, lennonli, Pramod Tikare Muralidhara, Peter Ware, Daniel Hufschläger, Alessandro Pezzato, Bob Perry, Satish Vangipuram, Andi Ireland, Richard Ohnemus, Michael Dunsky, Leo Goodstadt, John Wiederhirn, Yacob Cohen-Arazi, Florian Tischler, Robin Furness, Michael Young, Holger Detering, Bernd Mühlhaus, Stephen Kelley, Kyle Dean, Tusar Palauri, Juan Dent, George Liao, Daniel Ceperley, Jon T Hess, Stephen Totten, Wolfgang Fütterer, Matthias Grün, Phillip Diekmann, Ben Atakora, Ann Shatoff, Rob North, Bhavith C Achar, Marco Parri Empoli, Philipp Lenk, Charles-Jianye Chen, Keith Jeffery,and Matt Godbolt.
Thanks, in particular, to Jon Hess, Lakshman, Christian Wittenhorst, Sherhy Pyton, Dendi Suhubdy, Sudhakar Belagurusamy, Richard Sargeant, Rusty Fleming, John Nebel, Mipko, Alicja Kaminska, Slavko Radman, and David Poole.
My special thanks to Embarcadero | |
My special thanks to PVS-Studio | |
My special thanks to Tipi.build | |
My special thanks to Take Up Code | |
My special thanks to SHAVEDYAKS |
Modernes C++ GmbH
Modernes C++ Mentoring (English)
Rainer Grimm
Yalovastraße 20
72108 Rottenburg
Mail: schulung@ModernesCpp.de
Mentoring: www.ModernesCpp.org
Modernes C++ Mentoring,
Leave a Reply
Want to join the discussion?Feel free to contribute!