Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RTS class/builder/typeclass instead of interpreting to IO #230

Closed
jmcardon opened this issue May 18, 2018 · 14 comments
Closed

RTS class/builder/typeclass instead of interpreting to IO #230

jmcardon opened this issue May 18, 2018 · 14 comments

Comments

@jmcardon
Copy link

@jmcardon jmcardon commented May 18, 2018

So discussing this in the scalaz room as well as the cats-effect room, @jdegoes brought up an interesting point: Why are the cats-effect typeclasses interpreting to IO instead of carrying each's runtime system?

this is kind of where something like this may come in handy:

trait RTS[F[_]] {
  def unsafeRunSync(f: F[A]): A
  
  def unsafeRunAsync(f: F[A])(cb: Either[Throwable, A] => Unit): Unit

  def unsafeRunCancelable(f: F[A])(cb: Either[Throwable, A] => Unit): () => Unit
}

RTS is interesting to me because you don't even have to consider it a typeclass, it can just be a sort of immutable "builder" that can also carry the configuration you want in your runtime in the case that you happen to have a choice (like monix).

It cuts out the middleman: In places like http4s where we have to interface with impure code at the server backends it reduces the need to have to hop through IO for the same thing in the first place.

It's an interesting thing to consider. Then cats-effect typeclasses would have no need to have this middleman hop to express IO, you can just use them to express your F[A] behavior and use the runtime to control the unsafeRun semantics.

@jmcardon jmcardon changed the title RTS typeclass instead of interpreting to IO RTS class/builder/typeclass instead of interpreting to IO May 18, 2018
@jdegoes
Copy link

@jdegoes jdegoes commented May 18, 2018

👍 Giant improvement in performance, expressiveness, and library neutrality.

@mpilquist
Copy link
Member

@mpilquist mpilquist commented May 18, 2018

@jdegoes Can you elaborate on the benefits you refer to? We talked about this a bunch on Gitter and while I think it's a nice change, I don't see it being substantially different than what we have, though perhaps the details here lie in assumptions on how this would be implemented.

Effect and ConcurrentEffect barely rely on IO -- they do not require an effect to be interpreted in to IO. Rather, IO is used as a nicer version of () => Unit. If we had Coeval or BIO or some other type that represented guaranteed synchronous execution, we'd use that here instead of IO.

Regarding performance, returning IO(RTS.unsafePerformIO(fa.start)) isn't going to be substantially different than RTS.unsafePerformIOAsync(fa). The difference here is negligible.

I really see this proposal as a way to improve clarity -- I think it's confusing for people to look at a type class and see IO values returned, as it gives the impression that IO is somehow privileged in the hierarchy. We used to do things like F.liftIO(F.runAsync(...)) to start execution but that's been obsoleted by the introduction of Concurrent and the fiber model you proposed. Hence, the only remaining use-case for runAsync is FFI and RTS seems like it communicates this better.

I generally support this proposal, though I'd like to see it further developed -- specifically, a PR that answers questions like:

  • do we have any laws for RTS implementations?
  • do we need an RTS hierarchy representing different capabilities (synchronous (blocking) execution, async execution, cancelable execution)?
  • do we delete/deprecate Effect and ConcurrentEffect?
@johnynek
Copy link

@johnynek johnynek commented May 18, 2018

Yeah, I don't see the giant performance improvement here. Can you explain it in more detail?

@tpolecat
Copy link
Member

@tpolecat tpolecat commented May 18, 2018

There are are already a lot of people depending on cats-effect. I would prefer to get 1.0 out the door before considering foundational changes.

@jdegoes
Copy link

@jdegoes jdegoes commented May 18, 2018

Sorry, in my haste, I misspoke.

Technically, it should be: an improvement to performance (because rewrapping and running in IO cuts performance in half in the worst case, i.e. it's 2x slower) and giant improvements in expressiveness and library neutrality.

Without IO possessing a privileged position at the bottom of the hierarchy, it means libraries no longer have the option to translate from a polymorphic effect type into a concrete effect type; they must remain polymorphic, until their end of the world.

This not only removes the possibility of translating from F -> IO -> F (which results in the loss of capabilities for F), but means that libraries do not need to depend on the cats-effect data type, only on type classes themselves, which means they become truly data type agnostic.

At this point, there is no need for the cats effect data type to be bundled with the type classes, but instead, every reason to separate it out into its own module, or as I have suggested elsewhere, deprecate it in favor of Monix Task, since Alexandru currently maintains both of them.

The overall approach of bundling dependencies (such as executors, scheduled executors, timers, etc.) into an RTS that is separated from data types will lead to cleaner and more user-friendly code, I think. Implementation details should not leak into type classes or public APIs.

do we have any laws for RTS implementations?

The laws for cats effect are not laws, more like bug finders, because equality is not definable on IO. I submit changing the terminology to avoid confusing people on the issue.

We could have bug finders for RTS implementations, at least for finding trivial bugs.

do we need an RTS hierarchy representing different capabilities (synchronous (blocking) execution, async execution, cancelable execution)?

RTS is at minimum the ability to do async evaluation. I think it makes sense to extend that with the ability to do sync evaluation on the JVM. The final extension—if necessary—might be the ability to submit work. The ability to cancel, however, belongs in pure code not in the RTS.

do we delete/deprecate Effect and ConcurrentEffect?

Are we talking 1.1 or 2.0? 😄

@johnynek
Copy link

@johnynek johnynek commented May 19, 2018

I still don't see the performance loss.

In practice, I guess no one will convert to IO in their apps. For instance, Monix users working with Task, even when generically working with Async[Task] don't convert to IO.

In the case they choose to use IOApp and convert their application Task[Int] => IO[ExitCode], that is not a deep translation, there is just an outer IO that wraps the running of the Task. Presumably all the expense of this app is inside, not actually triggering the outermost execution.

Next, if we have RTS[Task] that's strong enough to give you Task[A] => IO[A] in I think exactly the same way that toIO is on Effect and ConcurrentEffect.

I don't really see a practical difference except not using the cats IO type, I would be very surprised if there is a performance difference.

@jdegoes
Copy link

@jdegoes jdegoes commented May 19, 2018

@johnynek The worst case bound on F ~> IO ~ Identity is twice the bound on F ~> Identity. You'd see the worst case if you we're benchmarking this over IO.unit, for example.

Most libraries run effects everywhere due to interop with impure Java / Scala.

I'm not saying performance is the reason to make this change (it's not), but it's a nice...um...side-effect.

@mpilquist
Copy link
Member

@mpilquist mpilquist commented May 19, 2018

The laws for cats effect are not laws, more like bug finders, because equality is not definable on IO. I submit changing the terminology to avoid confusing people on the issue.

Let's call these bug finders "tests" ;) Terminology aside, the point is that middleware folks can rely on RTS implementations adhering to a specification of behavior.

RTS is at minimum the ability to do async evaluation. I think it makes sense to extend that with the ability to do sync evaluation on the JVM.

How about the ability to do synchronous execution on Scala.js if there's no async boundary? (I realize such an instance wouldn't be available for many effect types unless auto-shifts are disabled -- another good reason to keep this capability in a separate trait.) I've found the ability to call IO#unsafeRunSync to be very important when working in Scala.js and interfacing with framework code (e.g. React).

The ability to cancel, however, belongs in pure code not in the RTS.

So we wouldn't have unsafeRunCancelable like in the original proposal above, instead using something like start before calling unsafeRunAsync? That seems pretty reasonable to me.

Are we talking 1.1 or 2.0?

Not clear to me yet, but I think we can be pretty aggressive with 2.0. 3-6 months from 1.0 feels right to me, giving us time to work through things like UIO, BIO, SyncIO as well as more fixes to the type class hierarchy. The main reason cats-effect is in its own repo is to allow for an accelerated release cycle compared to cats. Let's use that to our advantage here.

@alexandru
Copy link
Member

@alexandru alexandru commented May 19, 2018

@jdegoes

The ability to cancel, however, belongs in pure code not in the RTS.

I don't agree with that.

First of all, in our experience with Effect and ConcurrentEffect, what happens is that these type classes are required whenever any operation is needed that isn't described already and cannot be derived from the existing type class hierarchy. So Effect and ConcurrentEffect represents the last resort solution and you need the ability to cancel at the edge of the world. And that's valuable, because we cannot possibly think of everything that users will ever need.

Also you need runCancelable for conversions between effects. Right now the toIO operation for example is described on ConcurrentEffect like this:

def toIO[F[_], A](fa: F[A])(implicit F: ConcurrentEffect[F]): IO[A] =
  IO.cancelable { cb =>
    fa.runCancelable(r => IO(cb(r))).unsafeRunSync
  }

There's a nice symmetry here btw, between Concurrent#cancelable and ConcurrentEffect#runCancelable, or between Async#async and Effect#runAsync. That's not a coincidence.

More problematic however, if we'd want to clean the API of IO, is that a Concurrent#cancelable taking a function returning an F[Unit] instead of an IO[Unit] is very problematic. As @mpilquist said, our IO[Unit] values are only a nice way of expressing () => Unit. If we make those F[Unit] instead, things get a lot more complicated for no reason.

I'm not even sure if it's possible and I'm saying this because I tried making it F[Unit] and had problems to implement the instances for the monad transformers, e.g. EitherT, IndexedStateT, etc, although in such cases implementing anything for those is a pain in the ass. Having F.cancelable take a function returning IO[Unit] is at this point the least painful way to express it.

This is why for example I insist on keeping IO with a simple, understandable evaluation model that doesn't do many tricks. Because we need to rely on its execution model when used in the type classes. We need for certain operations to have an unsafeRunSync that works — although in such cases, to be honest, some sort of SyncIO would have been better, because in such instances we basically rely on conventions rather than types, but that's something to fix later, in a version 2.0, because it's not a burning issue.

I'm open to implementing a RTS, but we'll need to see how we're going to solve the problems that Effect and ConcurrentEffect are solving first.


@tpolecat

There are are already a lot of people depending on cats-effect. I would prefer to get 1.0 out the door before considering foundational changes.

I totally agree and I'm in that same boat. We don't have time for any foundational changes for 1.0, what we have is good anyway, so any grand, breaking changes will get delayed for a version 2.0 at least.

@jdegoes
Copy link

@jdegoes jdegoes commented May 19, 2018

@mpilquist

How about the ability to do synchronous execution on Scala.js if there's no async boundary?

I may be wrong here, but I do not think this belongs in RTS. I think you need the equivalent of the following in some type class:

type Result[A] = Either[Throwable, A] // or whatever

def evaluate[A](fa: F[A]): F[Result[A]]
def evaluateStep[A](fa: F[A]): G[Either[Result[A], F[A]]]

where G[_] is an effect type guaranteeing synchronicity (see @LukaJCB's work on MonadBlunder), which could even be a package-private newtype over IO, or Coeval, etc.

The first one, evaluate is independently useful in formulating certain laws (as well as implementing sandboxes for untrusted code), and already exists in the proposed Scalaz 8 type class hierarchy.

You can then achieve unsafeRunSync functionality in a safe way (well, as "safe" as unsafePerformIO), by combining these independent features, with the additional guarantee that the running of G[_] will be synchronous, so it may fail only for exception.

(I realize such an instance wouldn't be available for many effect types unless auto-shifts are disabled -- another good reason to keep this capability in a separate trait.)

I think this is easy to provide. However, I'm very curious about the use cases. You do not have any compile-time guarantee about how much of the IO will be executed synchronously, so it seems that to rely on this function for interop, you have to be willing to accept the compiler will not help you in ensuring code that needs to be executed synchronously actually is synchronous.

It seems like a synchronous effect type that can guarantee unsafePerformIO and has the ability to be lifted into an asynchronous effect type might provide stronger guarantees...at the cost of some API complexity?

So we wouldn't have unsafeRunCancelable like in the original proposal above, instead using something like start before calling unsafeRunAsync? That seems pretty reasonable to me.

Yes, exactly, you can get this functionality without baking it into RTS.

The main reason cats-effect is in its own repo is to allow for an accelerated release cycle compared to cats. Let's use that to our advantage here.

👍

@jdegoes
Copy link

@jdegoes jdegoes commented May 19, 2018

So Effect and ConcurrentEffect represents the last resort solution and you need the ability to cancel at the edge of the world. And that's valuable, because we cannot possibly think of everything that users will ever need.

If you have the ability to cancel purely, and you have the ability to do run an effect impurely, then you already have the ability to cancel at the end of the world—by composition of the above orthogonal features. So you don't lose anything in expressiveness and you gain in a more minimal surface area.

As @mpilquist said, our IO[Unit] values are only a nice way of expressing () => Unit. If we make those F[Unit] instead, things get a lot more complicated for no reason.

In that case, they should be () => Unit. Both pure (by which I mean F[_]) and impure (() => Unit) cancellation operations are required for pure code and interop with legacy code.

This is why for example I insist on keeping IO with a simple, understandable evaluation model that doesn't do many tricks. Because we need to rely on its execution model when used in the type classes.

If the type classes defining what it means to be an effect monad themselves require a concrete effect data type in order to fully specify semantics, then this is a gigantic failure of abstraction.

Or a sign that abstraction over effects is impossible and the project's goals must necessarily fail.

@mpilquist
Copy link
Member

@mpilquist mpilquist commented May 19, 2018

I think you need the equivalent of the following in some type class

This satisfies same use case as https://github.com/typelevel/cats-effect/blob/master/core/shared/src/main/scala/cats/effect/Effect.scala#L74, right? E.g., if IO was replaced there with G.

You do not have any compile-time guarantee about how much of the IO will be executed synchronously, so it seems that to rely on this function for interop, you have to be willing to accept the compiler will not help you in ensuring code that needs to be executed synchronously actually is synchronous.

Agreed, what I really want in such use cases is an effect type and associated type class that guarantees synchronous execution, along with an associated mechanism to run it at FFI boundaries. I can't safely write such code with the current type classes or with IO directly, though it's at least possible with IO, I just don't get any help from the type system. I want type system help here. :)

@jdegoes
Copy link

@jdegoes jdegoes commented May 19, 2018

Agreed, what I really want in such use cases is an effect type and associated type class that guarantees synchronous execution, along with an associated mechanism to run it at FFI boundaries. I can't safely write such code with the current type classes or with IO directly, though it's at least possible with IO, I just don't get any help from the type system. I want type system help here. :)

This seems sane, and it's worthwhile highlighting that a similar dichotomy emerged in the PureScript ecosystem between Eff and Aff, with the relationship between them specified by type classes.

I'll incorporate these ideas into the Scalaz 8 type class hierarchy and perhaps that can inspire some revisions in cats-effect 2.0.

@djspiewak
Copy link
Member

@djspiewak djspiewak commented Jul 26, 2019

Closing because this discussion has been mostly obsoleted by the more contemporary concepts of what we're going to do in CE 3. I'm also strongly opposed to abstracting over the evaluation of effects in this fashion, as it cannot be done safely or lawfully and will ultimately only constrain downstream implementors. Ultimately, it is also unnecessary, as the practical downsides to the current runAsync approach are being rather drastically overstated, and in any case all of the downsides (stated here and elsewhere) are resolved by moving to an IO-agnostic Effect#to approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
7 participants