lambda
lambda copied to clipboard
Resolve undefined IO throwing behavior in the presence of async and non-termination
Suppose we have the following IO
tree:
IO<Object> throwImmediately = IO.throwing(new IllegalStateException("head fell off"));
IO<Unit> parkForever = io((SideEffect) LockSupport::park);
throwImmediately.discardL(parkForever).unsafePerformAsyncIO().join();
What should the result be? Should it throw, or block?
Intuitively, it feels like it should probably throw immediately, and why wouldn't it? Well, because there is asynchrony, and since discardL
is just a sugary zip
call, and since zip
can only execute once both results have been computed, the backing future holding the thrown exception will never be interrogated, so the exception will never propagate.
Investigate whether or not a principled approach to parallel IO
can also easily immediately propagate exceptions under the context of asynchrony without violating correctness.
Apparently CompletableFuture.allOf
behaves the same way:
CompletableFuture.allOf(CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("kaboom");
}), CompletableFuture.supplyAsync(() -> {
LockSupport.park();
return null;
})).join(); // blocks forever, never throws
That code should probably wait forever.
The practical solution that comes to mind is to define a timeout for the IO unsafePerformAsyncIO(miliseconds(300))
.
There's certainly a good reason for it to wait forever (and that's currently what would happen), but there's no reason that the operational semantics couldn't be specified by the consumer either. For instance, I could certainly imagine a semantics for IO#zip
that, under async execution, forked each parallel branch into its own CompletableFuture
, and attached completion callbacks to each that, if fulfilled with an error, attempted to cancel all remaining running futures (potentially also aggregating all other thrown exceptions into the suppressed
side of the final Throwable
, since multiple async branches could throw simultaneously). At least this strategy would make a best-effort to yield as soon as feasible whilst also attempting to signal the other threads that they need not continue.
To do this properly would require being able to tap into the Executor
that an IO
may run with, which means it may finally be time to model an ExecutionPlatform
for IO
. This has been on my radar for years, but I'd like to probably pull IO
out of lambda first.
I see what you are saying. In a way this is similar to the short-circuit behavior... where you don't want to calculate everything if one of the calculations fails.
Would Execution Platform be an implementation detail?
Regarding pulling IO out of the library I don’t know why would that be necessary.
could it be the case that we could achieve both executions desired with two different IO classes?
IO (IOWaitAll) IOCancelAllOnError
Then, you can use a different execution strategy for that class, that allows cancelling all other tasks as soon as we throw on one of them.
I could give it a try for a poc implementation
Then the consumer would do:
cancellAllOnThrow(throwImmediately.discardL(parkForever)).unsafePerformAsyncIO().join();
@jnape I watched this talk recently, talking about IO and analogous implementations in Scala and how the cats-effect library solves a lot of the previous issues with a bunch of IO classes:
I am not sure it will completely solve the issue but it sounds really interesting:
https://www.youtube.com/watch?v=g_jP47HFpWA
I just heard this talk that gives an intro into Lawvere theory A categorical view of computational effects - Emily Riehl It seems to be a principled, mathematical way to think about running monads in parallel (sum) or combine (tensor product), etc. She made an important note that Lawvere theory only applies to finite monads and thus continuations monad won't work in that framework (and thus treated a little different than finite monads)