These are chat archives for kirkshoop/kirkshoop.github.io

22nd
Jul 2017
Sean Parent
@sean-parent
Jul 22 2017 04:32

Reading through the Single description, it isn't clear to me how one would build in task cancellation. A future is both a way to transport a value as well as a handle to control the task that generates it. Splitting out consume_on() and execute_on() would seem to make it impossible to change the execution context without causing a task to be scheduled twice. Also, you mention "some will poll on a thread" - I hope no continuations actually do this. The then() callback should be scheduled for execution directly from the context of of the task, but in what context it is scheduled to execute in needs to be defaultable from the promise and overridable from the code attaching the continuation.

I very much like the idea of trying to split out concepts and algorithms - but I do not see how to do so. For example, the requirements of get() are:

The associated task is guaranteed to be scheduled on a context other than the calling context, or can be promoted to execute in the calling context. To be promotable a task must either be fully resolved (a nullary function) or all arguments must be supplied by promotable tasks, and must not be required to be serialized with other tasks or dependent on a particular execution context.

So is this a concept? A precondition? In practice, using get() or wait() within a tasking system is incredibly error prone. Even if we introduce the notion of a "getable" future what restriction do we put on it to guarantee the above? If we want to support systems without threads then it has to be promotable, but if instead we want to use get() as synchronization primitive to replace condition variables, then we absolutely don't want that requirement.

We clearly have too many "things" mixed-up in the idea of a future, and though I'm sure a Single is a useful construct and might make a better basis type for some constructs, my initial impression is that it is of limited use.

Kirk Shoop
@kirkshoop
Jul 22 2017 05:12
Lifetime::stop() cancels the SingleDeferred::subscribe()
While I agree with you, I believe that Bryce saw polling as a desirable implementation. He would do better to describe why.
Kirk Shoop
@kirkshoop
Jul 22 2017 05:21
'produce_on()' would call the 'SingleDeferred::subscribe()' from a scheduled task. The subscribe would run the produce lambda. If the produce lambda called 'Single::value()' directly then the consumer lambda would run in that same task.
Kirk Shoop
@kirkshoop
Jul 22 2017 05:30
In the case where the producer lambda itself is calling an is API that takes a callback, perhaps an IO read. produce_on() is wasteful and can be omitted. However, consume_on() would schedule a task to call Single::value()
In the case where the producer lambda is calling an API that takes a callback, perhaps a UX event registration. All the work is on the UX thread and both produce_on() and consume_on() would be omitted. Unless a consume_on() was used to move the UX event value to a background task.
Kirk Shoop
@kirkshoop
Jul 22 2017 05:37
The power of consume_on() and produce_on() is to put scheduling in the users control.
http.get() | consume_on(UX) | tap((j){label.text = j["title"];}) | then(http.get()) | consume_on() . . .
Kirk Shoop
@kirkshoop
Jul 22 2017 05:47
get() is just an algorithm where subscribe() creates a mutex and condition_variable and calls the next subscribe() in the chain passing a Single that will trigger the condition after saving the value or error then waits on the condition and returns the saved value or throws the saved error when the condition is triggered.
I am not sure what promotion means in this context. I will wait to address those concerns.
I believe that Single represents a superset of functionality from the promises the you and David and Bryce have written. I hope that, given time I can show that in future posts.
Thank you for the response!
Kirk Shoop
@kirkshoop
Jul 22 2017 05:54
I reread your comment on get() and I believe that we agree on this as well. While get() is a valid algorithm, it has limited use. I would primarily use it to prevent main from exiting. Mostly in example code. And rarely more than once in any app.
Sean Parent
@sean-parent
Jul 22 2017 07:15
Thanks for the additional info - regarding lifetime::stop() - if I understand correctly this goes the wrong direction. It is forcing a broken promise as opposed to informing upstream tasks that a value is no longer necessary. Regarding consume_on() - let's say I have processes which is producing values that generally should be consumed on the UI thread. So I return a single as return http.get() | consume_on(UX);, this works for most cases, however, the client wants to attach a performance critical continuation that could be scheduled for immediate execution. So the have something like result | consume_on(immediate_scheduler) so the entire pipe looks like: http.get() | consume_on(UX) | consume_on(immediate) with the intent that the second consume_on() replaces the first. Defaulting to immediate execution, is error prone. See my writeup here. get() and wait() are "just an algorithm" - but a very vexing to use correctly. Implemented as a condition variable they are an immediate deadlock if you are not in a threaded environment. Used within a thread pool they can create very difficult to diagnose deadlocks... My understanding is Microsoft goes through considerable lengths to be able to promote tasks created from std::async() so they can execute those tasks within a thread pool and not deadlock, however, that doesn't work in the presence of continuations (at last there is no reasonable way to implement it that I'm aware of). This is why get() and wait() were not included in our proposal, but from the notes from the standard meeting there is a desire for having them available to be used as one would a condition variable as a lower level synchronization mechanism... I can implement that but I'm really not certain it is a good idea.
Kirk Shoop
@kirkshoop
Jul 22 2017 15:22

Lifetime::stop() is bound to the consumer. The stop signal travels from the consumer to the producer.

In normal operation the SingleSubscription enforces the scope contract by calling lifetime.stop() after destination.value() or destination.error() returns.

Cancelation from an algorithm (e.g. take_until(SingleDeferred) & timeout(duration)) or from user code calling lifetime.stop() performs a well-defined race to propagate the stop signal from the consumer to the producer.

When there are multiple algorithms, there can be multiple nested Lifetime ‘scopes’. Each lifetime will

  • end the current scope
  • propagate the signal to the next scope

The take_until(SingleDeferred) algorithm that receives the value() from the other SingleDeferred will call destination.error() which then calls lifetime.stop() when it returns.

the entire pipe looks like: http.get() | consume_on(UX) | consume_on(immediate) with the intent that the second consume_on() replaces the first.
Kirk Shoop
@kirkshoop
Jul 22 2017 15:31

consume_on() does not override, in the above the | consume_on(immediate) would be always be a noop because the immediate context is a noop. The value would still be moved onto the UX thread.

Similar to try/catch, consume_on() should be used at the largest scope possible. Only introduce queueing when required.

return http.get() | consume_on(UX);

I would not recomend this pattern. A function that is returning a stream should leave it on its natural context. only the caller knows if the context needs to be shifted.

Kirk Shoop
@kirkshoop
Jul 22 2017 15:48

get() and wait() are "just an algorithm" - but a very vexing to use correctly.

agreed.

get() and wait() were not included in our proposal, but from the notes from the standard meeting there is a desire for having them available to be used as one would a condition variable as a lower level synchronization mechanism... I can implement that but I'm really not certain it is a good idea.

I think that using concepts to extract the algorithms makes get() and wait() more palatable to include.

Perhaps using awkward names similar to the reinterpret_cast<>() that would make their usage stand out in code. get() -> stop_the_thread_until_value_arrives() :)

Kirk Shoop
@kirkshoop
Jul 22 2017 15:56

Defaulting to immediate execution, is error prone.

The power of the concepts is that this tradeoff can be explored in different implementations.

C++ usually favors designs where the default is risky but performant (STL containers are not thread-safe). However, std::shared_ptr always uses interlock ref-counts, which is slower.

In this case, promises that default to immediate & trampoline & task can all coexist and compose using the same algorithms. Over time, this will result in guidelines and perhaps tooling (GSL) that will use the best strategy for the task at hand.

The reactivex community started with trampoline as the default (.Net, JS & C++) and then Java decided to make immediate the default. The queueing overhead was too high for the scenarios for which netflix was using reactivex.