typelevel / cats-effect-cps

An incubator project for async/await syntax support for Cats Effect
Apache License 2.0
84 stars 12 forks source link

Different performance between Scala2 and Scala3 in `async[Resource[IO, *]]` #77

Open djx314 opened 2 years ago

djx314 commented 2 years ago
val resource1 = async[Resource[IO, *]] {
  r1.await
}
val resource2 = async[Resource[IO, *]] {
  r2.await
}
val http4sServerResource = for {
  r1 <- resource1
  r2 <- resource2
} yield r1.exec(r2)

def run(args: List[String]): IO[ExitCode] = http4sServerResource.useForever.as(ExitCode.Success)

It seems that resource1 and resource2 is already use when the block of async { } is ended. So when r1.exec(r2) the resource is closed.

And in Scala3 r1.exec(r2) can exec succeed.

djx314 commented 2 years ago

Success code

Issue code

djx314 commented 2 years ago

图片

djx314 commented 2 years ago

It seems that the h2 database closed before http4s start and after data inited.

async[Resources[IO, *]] works well in the same code on another Scala3 project.

Baccata commented 2 years ago

@djspiewak, here's a repro that eliminates other libraries from the equation.

I think it has to do with how Resource behaves conjointly with Dispatcher.

//> using lib "org.typelevel::cats-effect-cps:0.3.0"
//> using lib "org.typelevel::cats-effect:3.3.13"

// remove the directives below for Scala 3

//> using scala "2.13.8"
//> using options "-Xasync"

import cats.effect._
import cats.effect.kernel.Resource
import cats.effect.cps._
import cats.syntax.all._

object Main extends IOApp.Simple {
  type R[A] = Resource[IO, A]

  val r = Resource.make(IO.println("Hello"))(_ => IO.println("Goodbye"))

  val awaited = async[R] {
    r.await
    "I should appear in the middle"
  }

  def run = awaited.use(IO.println)

}
Baccata commented 2 years ago

@djx314 the mechanisms on top of which this library is implemented differ massively from Scala 2 to Scala 3. In Scala 3, the async/await block desugars (roughly) to a bunch of maps/flatMap calls, which gives you a behaviour close to the successful one you linked. In Scala 2, the code desugars to a dispatcher creation and a list of unsafeRun calls.

@djspiewak, here's another repro that removes cats-effect-cps from the equation and that showcases the intuition that I think many people (including myself) have when using Dispatcher conjointly with Resource.

//> using lib "org.typelevel::cats-effect-cps:0.3.0"
//> using lib "org.typelevel::cats-effect:3.3.13"

// remove the directives below for Scala 3

//> using scala "2.13.8"
//> using options "-Xasync"

import cats.effect._
import cats.effect.kernel.Resource
import cats.effect.cps._
import cats.syntax.all._
import cats.effect.std.Dispatcher
import scala.concurrent.duration._

object Main extends IOApp.Simple {
  type R[A] = Resource[IO, A]

  val r: R[Unit] =
    Resource.make(IO.println("Hello"))(_ => IO.println("Goodbye"))

  def run = Dispatcher[R]
    .use { d =>
      Sync[R]
        .delay {
          d.unsafeRunAndForget(r)
        }
        .as("I should appear in the middle")
    }
    .use(IO.println)
    .timeout(2.second)
    .attempt
    .void
}

The intuition could be summarised to

forAll { ((fa: F[A]) => 
  Dispatcher[F].use(d => F.delay(fa.unsafeRunSync())) <-> fa 
}

The question is basically "is this wrong ?", and I believe I've been heavily puzzled by this in the past, and may have even suggested the creation of a Dispatchable abstraction that Resource would not implement. My memory is sh*t so I'm not 100% sure.

djx314 commented 2 years ago

And it seems that Scala2 can have cps transform with macro.

ThoughtWorksInc/each

djx314 commented 2 years ago

@djx314 the mechanisms on top of which this library is implemented differ massively from Scala 2 to Scala 3. In Scala 3, the async/await block desugars (roughly) to a bunch of maps/flatMap calls, which gives you a behaviour close to the successful one you linked. In Scala 2, the code desugars to a dispatcher creation and a list of unsafeRun calls.

@djspiewak, here's another repro that removes cats-effect-cps from the equation and that showcases the intuition that I think many people (including myself) have when using Dispatcher conjointly with Resource. ...

Is it seems that in async await it will do many unsafeRunxxx in fact, so it crosses the boundary of Resource.

djspiewak commented 2 years ago

So I would have to stare at this more, and also look at the output a bit, but I think the problem here is less about the unsafeRuns and more about the Dispatcher shutdown itself. The issue is that the management of the finalizers on submitted tasks ultimately delegates to the way that finalizers are handled on fibers. This has a pair of semantics. If the fiber completes and is joined, then the joiner inherits the finalizers. If the fiber completes and is never joined, the finalizers will be run when the parent fiber is finalized, or immediately if the parent fiber is already dead. Lifecycles are weird.

So then the interaction here which is confusing is the way that these hidden inner fibers (within dispatcher) interact with the outer resource. I haven't totally puzzled that through yet here because I still haven't had coffee, but my guess is that the interaction is such that the finalizers are sequenced in a strange place.

For starters, this may simply go away as a problem in 3.4 with the sequential dispatcher mode. However, more generally, I think this does illustrate the peril of the Scala 2 version of this library. I definitely feel like the Scala 3 version is a lot more intuitive, and it certainly imposes fewer trade offs. What sucks is the distinction between semantics. I wonder if rewriting the Scala 2 version on top of each or monadless would be worthwhile, or if the extra dependency would be a problem.

djx314 commented 2 years ago

Let me have a try with ThoughtWorksInc/dsl-domains-cats a few days later. It seems that the lib just dependent on cats.{Applicative, FlatMap, MonadError} It seems can be a workaround for async await on (scala2 + cats-effect3)

Baccata commented 2 years ago

However, more generally, I think this does illustrate the peril of the Scala 2 version of this library

I do agree that, though I'm sure Jason Zaugg had a good rationale for designing it this way, -Xasync is really rigid in terms of how it can be implemented, considering that it's been proven time and again that the direct-style UX can be implemented for virtually any monad. And that rigid nature makes the implementation of the cps-coroutine dance really awkward in pure-FP.

I do however think that the behaviour of Dispatcher when handling Resource values is just ... unintuitive.

If the fiber completes and is never joined, the finalizers will be run when the parent fiber is finalized, or immediately if the parent fiber is already dead.

Throwing shit at the wall, but could we maybe force the intuitive behaviour by relying on a supervisor ? IE, could this start call be run through a supervisor instead to regain some control over the lifecycle ? The supervisor's lifecycle would encompass the one of the dispatcher.

EDIT : disregard, it was indeed a shit idea

I wonder if rewriting the Scala 2 version on top of each or monadless would be worthwhile, or if the extra dependency would be a problem.

If it's impossible to align the semantics of the -Xasync-based implementation with the dotty-cps-async one, it should be considered : the behaviour violating the user's intuition is pretty bad.

It's also important to point out that if it was built on top of monadless, there would be strictly no point in keeping this library cats-effect specific, besides the familiarity of users with the "async/await" terminology.

djx314 commented 2 years ago

code

Find a workaround use the lib above with cats2 support only. Now it works as expected.

djx314 commented 1 year ago

@djspiewak @Baccata The desire to implement this function disappears since I only use scala implicit to implement the injection what it is more appropriate to named it wire. https://github.com/scalax/simple/tree/main/modules/main/simple-wire/jvm/src/main Just use two step wire is ok. The first step is create the Resource, and the second step is wire the service and other thing that haven't lifecycle. Then input the first step to the second step is OK. So the second step is just some constructor without F[_].

This impelement is also include something like by name injection, and test & production environment by just use 1 concept.

  1. A simple wire without Environment can be see as under a Id Environment -- Id[WireInstance]

  2. A wire with a Environment(F[_]) is also a simple wire F[WireInstance] without Environment. -- Id[F[WireInstance]]

  3. A wire with a Environment(F[_]) is also can dependent on another Environment. -- G[F[WireInstance]]

  4. A wire can dependent on the wire itself, so it's different from the Injection.

  5. An Environment(F[]) from a wire can dependent on another Environment from other wires. This can help us distinguish between production environment and test environment and stop the delivery of Environment when I want(Just use the simple java style and get unexpected good result). -- type G[F[]] = (F[WireInstance1], F[WireInstance2])