always push async values
The goal is of this post is to agree that; Single, future.then()
and yes, even callbacks, all push async values from a producer to a consumer. After that, the claim - always push async values - should seem rather humdrum.
This is motivated by the discussion with Sean Parent about promise is a concept with many implementations
In the post I teased a set of concepts (Single) that represent all the implementations of a promise.
The discussion exposed that some more groundwork was needed to connect and contrast Single with other Concepts and libraries.
Single
For reference:
template <typename L>
concept bool Lifetime() {
return requires(const L& l) {
{ l.is_stopped() } -> bool;
{ l.stop() } -> void;
};
}
template <typename S>
concept bool Single() {
return requires(const S& s) {
{ s.value(auto) } -> void;
{ s.error(auto) } -> void;
};
}
template <typename S>
concept bool SingleSubscription() {
return requires(const S& s) {
requires Lifetime<s.lifetime>;
requires Single<s.destination>;
};
}
template <typename S, typename D>
concept bool SingleDeferred() {
return requires(const S& s, const D& d) {
requires Lifetime<s.subscribe(d)>;
};
}
struct alifetime
{
bool is_stopped() const;
void stop() const;
};
static_assert(Lifetime<alifetime>(), "not a Lifetime");
struct asingle
{
template<typename T>
void value(T&& t) const;
template<typename E>
void error(E&& e) const;
};
static_assert(Single<asingle>(), "not a Single");
struct asinglesubscription
{
alifetime lifetime;
asingle destination;
};
static_assert(SingleSubscription<asinglesubscription>(), "not a SingleSubscription");
struct asingledeferred
{
Lifetime subscribe(SingleSubscription) const;
};
static_assert(SingleDeferred<asingledeferred>(), "not a SingleDeferred");
Push and Pull
push models call a function on the consumer passing the value as a parameter. Single, Legion and Transducer are push models. every future/promise with then()
is also a push model. even callbacks are a push model.
pull models return a value from a function on the producer. Range and Iterator are pull models.
All the push and pull models above are modeling an iteration over 0..N
values. All the push and pull models above provide a surface that supports the application of algorithms to arbitrary values (e.g. POD)
The async property model, that Sean Parent describes in this paper, appears to model a graph of dynamic values (they change over time). This seems similar to the FRP model of Behaviors, which is a pull model.
As an analogy - If a system was built with the async property model the result would be like a DOM. If the same system was built with a push model I would expect the result to be like a V-DOM (React, Cycle.js) or direct-mode (ImGui).
Personal Note: I prefer to structure operations on values as algorithms iterating over values rather than methods on values (e.g. a DirectXDraw algorithm passed a graph rather than a DirectXDraw method on a graph or an asPng algorithm passed a graph instead of an asPng method on a graph). the algorithm model also makes better extensibility tradeoffs IMO. Thus I prefer an async model for iteration over an async property model.
Push is the mathmatical dual of Pull
This has been explored repeatedly in the reactivex community. Subject/Observer is Dual to Iterator by Erik Meijer (PDF) states that the math exists and in this video, Erik walks through the math for Enumerable and Observable. this post seems to do a good job showing the steps from Iterable to Observable in python.
Here, I will show the same progression for Single.
Without getting hung up on the math, the intent is to show that Single is the inverse of a function returning a value.
to focus on the arrows better, the math will
- omit the concepts for lifetime scope and cancellation.
- assume that
producer()
&subscribe()
do not throw.
notation | pull value | push value |
---|---|---|
usage example |
try { auto v = producer.make().get(); } catch (exception e) {}
|
producer.subscribe( [](auto v){}, [](auto e){});
|
step 1 reverse arrows |
() -> ( () -> (value | error) )
|
() <- ( () <- (value | error) )
|
step 2 rewrite with right-arrows |
() -> ( () -> (value | error) )
|
( (value | error) -> () ) -> ()
|
define producer |
struct producer { result make(); }; struct result { value get(); };
|
struct single_deferred { void subscribe(single); };
|
define consumer |
_ |
struct single { void next(auto); void error(auto); };
|
the
producer.make().get()
pattern looks odd here, but it replicates*producer.begin()
for one value instead of many.make()
returns aresult
that represents both the value and the error.
this table demonstrates that Single and a Result, both represent the same Concept with the roles of consumer and producer reversed. Result is implemented on the producer. Single is implemented on the consumer.
Iterator Concepts
The properties of Iterator in C++ are well known. Since Single and Legion are the same as Iterator with the arrows reversed how to the different Iterator Concepts apply to Single and Legion?
Iterator | applicability to Single & Legion |
---|---|
RandomAccessIterator | indexing support could be added to the push model Concepts. However, what does it mean to random-access values distributed in time? An async producer would never be able to implement random-access. |
BidirectionalIterator | decrement support could be added to the push model Concepts. However, decrement generally requires accumulation of previously produced values and is better represented by an Iterator on the Container that has stored the accumulated values. Note: use ranges TS for values distributed in space. |
ForwardIterator | this is supported. this is almost the same as a COLD producer. each call to subscribe() calls the producer to create a new result. |
InputIterator | this is supported. this is almost the same as a HOT producer. the producer is run once, each call to subscribe() copies the result. |
OutputIterator | this is supported. The other supported Iterator are supported by SingleDeferred (producers). This one is supported by Single (consumers). subscribe() can be thought of as binding an input to an output Iterator with std::copy(producer.begin(), producer.end(), consumer.begin()) . |
Push and Pull for asynchronous producers
I reproduced this table from slides made by Erik Meijer (video)
One | Many | |
---|---|---|
sync | T |
Enumerable <T>
|
async |
Future <T>
|
Observable <T>
|
The sync types are pull models, the async types are push models (when Future has then()
)
push and pull producers can be implemented sync. (e.g. a push model subscribe()
would directly call value()
or error()
and return).
pull model is great for sync values distributed in space, but push model works well too.
pull model is not a good fit for async producers of values distributed in time, it requires blocking the consumption context.
push is a very common pattern used for async producers. for example, callbacks are very common, but suffer from a lack of composibility. The usability and composibility are poor, because the callback contract varies from producer to producer. creating a formal set of push model Concepts for producers and consumers, provides a stable contract that allows; composition, shared algorithms and extensibility.
coroutines
coroutines rewrite code to make a push producer appear to be pull. In doing so, coroutines serialize async work.
auto x = co_await produce();
auto y = co_await produce();
the async work to produce y will not start until x is produced and delivered to the consumer. As a result, it is quite challenging to implement when_any()
or when_all()
using coroutines.
its a wrap
- push is dual to pull
- push and pull allow composable algorithms to operate on POD values.
- The Single Concepts support Forward, Input and Output iteration implementations.
- Single is push for 1 async value
- use push for async values
- use Single to implement
promise
continued soon
hopefully with the code promised last time.