Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?

Why not both?

With the recent announcement of cats-effect, a relevant question from the past resurfaces: why does IO, which is otherwise quite Task-like, not define both or race? To be clear, the type signatures of these functions would be as follows:

object IO {
  def both[A, B](ioa: IO[A], iob: IO[B])(implicit EC: ExecutionContext): IO[(A, B)] = ???
  def race[A, B](ioa: IO[A], iob: IO[B])(implicit EC: ExecutionContext): IO[Either[A, B]] = ???
}

These functions are basically duals, and they both represent the notion of taking a pair of IO actions and running them in parallel. Without them, IO doesn't really have even the most basic of concurrency primitives, and essentially represents only the notion of an effect. Outside of API surface area and subjective design constraints, the question is simply: why doesn't it have these functions?

The answer is this: they are unsafe. And what's more, they are fundamentally unsafe. You cannot define concurrency machinery solely in terms of IO without sacrificing either safety or practicality. You need something more powerful, such as fs2.Stream.

The problem comes back to the fact that all practical concurrency use is going to involve two things: resource acquisition, and errors. Errors are just a fact of life any time you're fiddling around with side-effects. At a basic level, imagine you're trying to acquire a resource (like a network socket) and that resource simply isn't available. You will get an effectful error sequenced in IO. That makes sense, and IO handles this case correctly, but you start to see issues when you combine this scenario with the resource acquisition itself.

Any effectful code is going to deal with resources at some level. Again, looping back to the network socket question, you need to close the socket once you're done with it! Sockets, like many things, represent a resource with a linear lifecycle: you acquire it, you use it, and then you release it, and you don't use it after you release it. If you fail to release the resource exactly once, for any reason, then the resource has leaked, meaning that it won't be released until the program exits. This is a terrible, terrible situation to be in, and it basically universally represents a bug.

The problem is that IO not only doesn't help you avoid this situation, it makes it actively impossible to avoid this situation if you are doing concurrency! A minimal example:

IO.race(IO.pure("annoyingly fast computation"), IO(openSocket()))

The "annoyingly fast computation" action is almost guaranteed to complete before openSocket(), meaning that it will "win" the race and its result will be produced. As the types indicate, the result of openSocket() will just… disappear, meaning that you now have a resource leak that you cannot do anything about.

This seems like an obvious problem, so maybe race is bad but both is safe, right? Well, the duality of Pair and Either indicates the flaw in this logic. If we can break race with pure, then we should be able to break both using a dual of pure. And indeed, this is the case:

IO.both(IO.fail(new Exception), IO(openSocket()))

The results of this IO must be IO.fail. There is no other way to safely implement both. But the problem then is this: what happens to the results of openSocket()? Even if you don't implement short-circuit semantics for both – which you don't have to! – you still lose the result of that effect. So once again we have a resource leak, and there is no way to prevent it.

Ultimately, you simply cannot get around this issue. On a fundamental level, IO represents either zero or one results: you either have an error (zero), or you have a value (the effect completed). This is represented very clearly in the types, which encode Either[Throwable, A]. However, to properly handle the notion of preemption (which is to say, concurrent errors), your algebra must be capable of representing zero, one, or two: an error, a value, or both. IO's algebra simply isn't rich enough to represent that state, and so any time you have both, you have unsafety and resource leaks.

This is why streaming models are fundamental to functional concurrency, even when your application doesn't otherwise have any notion of a data pipeline or stream-like thing. Streams by definition represent cardinalities beyond 1, and thus they are able to naturally encode safe finalizers with semantics that make sense and are guaranteed even in the event of errors and preemption.

And so while you could encode concurrency primitives on top of just IO, you shouldn't.

eulerfx commented Apr 19, 2017

Have you looked at Concurrent ML or Transactional Events (https://www.cs.rit.edu/~mtf/research/tx-events/ICFP06/icfp06.pdf)? Might provide some ideas in terms of giving these operations suitable semantics. See also: https://github.com/polytypic/JoinCML

Hi @djspiewak would an IO type storing finalizers work? (I understand why you wouldn't want to add it though).

fanf commented Apr 21, 2017

For reference (and for not-so-literate people like me, trying to follow the cats-effects/IO/monix/fs2 story), monix Task can do it safelly because of Cancellable, see: typelevel/cats#1617 (comment) (in part: "Resource Safety & Concurrency")

Owner

djspiewak commented Apr 29, 2017

@etorreborre Sorry, gist comments don't give notifications because github hates human beings…

An IO type can store finalizers, and this is precisely what Monix's Task (and likely scalaz 7.3's Task) do. However, as you expand the algebra of Task to support these sorts of constructions, you're pushing Task away from "a unit of work" and more into the realm of "a single element stream". I think that's a conflation of abstractions. I would rather have a precise, narrowly-defined unit of work, and then use that unitary abstraction to define higher level constructs such as fs2.Stream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment