Design of -Xasync

– I can’t find port of scoverage to dotty, guess it not exists yet.

  • I prefer to say ‘applicable only in imperative scenarios’ instead ‘extremely dangerous’. Exists one case, where it’s not Future but have a high practical value: it’s a ‘something like Future,’ running inside one-threaded dispatch. When we know that we are always in the same thread, then we can avoid operations, that flush processor cache [volatile access, CAS… etc.] and, as a result - have some speedup over plain Future. [No tests for dotty-cps-async yet, but I remember experiments on 2.12 with something like 10-x difference]. Although I know that Future in 2.13.x also was optimized for a one-thread case, so maybe now the difference is not so promising. Another potential example is a Loom-monad when we will able to introduce after Loom will be available. Speed of such monad will be in-between Future and one-thread case: (Loom dispatch contains volatile access).
    Naming, of course, can be better.
  • currently, we assume that the developer can implement a ‘shifted’ version of the high-order method and bind one with object type via typeclass. And provide such shifted versions for the classes defined in the standard library. Also, it is possible to translate lambda-expressions automatically.
    We don’t provide rewrites of third-party high-order functions automatically now. We implement AsyncShifted[T] for Iterable[T], Collection[T] … etc. Monadless approach for transform can be represented as something like ‘generation of given Shifted[T] from Traverable[T]’. , which potentially can be implemented on top of dotty-cps-async, but this is a big separate direction.
    Also exists two potential other approaches, each with its own set of gains and drawbacks.

  • The third notable difference from ‘Monadless’ is ‘ghost interfaces.’ We run after the typing phase, so we should use real interfaces. If map/ flatMap can be defined as inline, we can hope that compiler will inline one, but I yet not played with this.
    Also, I have thought about passing ‘rewriters’ to macro, instead of interfaces, but faced with the impossibility to pass non-trivial argument (tree rewriter) from the context of macro application to macro.

I mean, fulfill really is dangerous, imperative or otherwise, because of the thread blockage. In fact, it’s even more dangerous in a single-threaded environment (like ScalaJS) since it will actually deadlock. Literally the only circumstances under which fulfill is not dangerous are either a) no use of Promise whatsoever in the entire application (i.e. so Future is basically Try in this scenario), or b) running under Loom.

Blocking functions are simply not viable. They are dangerous, regardless of whether the stylistic context is imperative or functional.

Ah I’ll have to look. That sounds a lot like Cats’ Traverse, which is basically what you would need to do something like this in any case (assuming the goal is to avoid blocking).

This is very nice to be sure, though this benefit disappears a bit the moment anything is abstracted. If you, for example, wrote a hypothetical layer which adapted dotty-cps-async to Cats Effect, certainly dotty-cps-async would not have any visible interfaces in the emitted bytecode, but Cats Effect would. This is exactly the same as Monadless. The only case where dotty-cps-async is able to inline things that Monadless (and similar) cannot is where the dotty-cps-async adapters (e.g. AsyncMonad) are directly implemented for a specific concrete type (e.g. Future).

Yes, it should be not called inside monad. In any IO you have something like runUnsafe and to actually run program, you should call this method. It can be hidden under something like IOApp, but in this case construction and evaluation of IOApp will play role of fulfill.

I.e. if we need the only transformation and always will run received program in a cats-effect, than you don’t need export you runUnsafe. But if we want that third-party to be able actually run program, not knowing that it come form cats-effect, than I can’t imagine API which not include something like ‘Await.run’, ‘runUnsafe’, ’ IOApp.run’, etc.

Hmm, … maybe exists way to specify implicit value, which saying 'we are not inside monad` to prevent dangerous use… Will think, but not sure that this is possible.

Well, the trick with IOApp is that it doesn’t give users the ability to run the IO… and then do something else. Running the IO is the whole program, so that avoids two problems: visibility of side-effects, and blocking of threads. A thread is blocked (main), but it’s the only one and it’s irrelevant since nothing happens after. The unsafeRun functions obviously don’t have that same kind of safety. They’re basically always dangerous, though the Async variants of them are safer (since they don’t block). fulfill corresponds to unsafeRunSync, which is the most dangerous variant, particularly on ScalaJS (where it really shouldn’t exist at all, since it almost always results in deadlock).

I prototyped an integration with monix.Task.


The implementation could be more efficient if monix.Task exposed a way to query if a value is immediately available. In the analagous case for scala Futures, scala-async doesn’t occur a thread-switch.

The Task returned by this async is non-strict and can be run more than once (e.g within a retry combinator).

2 Likes

The machinery inside -Xasync is:

  • The Finite State Machine (FSM) transform, which effectively flattens all the behaviour and state capture in the lambdas you would get with use of map/flatMap into a single method. This transform is possible because we know that a execution of the FSM is sequential.
  • The Selective A-Normal Form transform (ANF), a precursor to FSM transform that ensures that the execution order of the code is not accidentally changed by subsequent rewrites. (I noted that Monadless, as currently implemented, does change the execution order in some cases)
  • Analysis to detect when intermediate captured state will no longer be required by the FSM which nulls this out, reducing footprint of in-progress computations.
  • An API for different future data types to participate.

The new implementation is more efficient, correct and IMO more maintainable because a) trees are easier to work with post-erasure b) we with full access to the compiler API. Because we generalized it enough to work with a variety of Future/Task types with only a small adapter, it seemed worthwhile to ship as a compiler phase rather than as a separate compiler plugin.

-Xasync itself may be evolvable based on what out there in Kotlin / C# / F#. But shipping the first version also doesn’t prevent these extensions – the facade API is currently purely structural (we don’t ship the StateMachine interface in scala-library) so a future version can support an extended or alternate version of this.

Some ideas for extension are:

An effect DSL based on Applicative/Traverse, like monadless, is a different prospect. While the user experience may be quite similar, the requirements it places on the data types and the compiler implementation different. If there are opportunities to share code with the implementation -Xasync all the better! If not, the existence of one DSL does not preclude the other.

6 Likes

Thanks for the Monix example. If there’s no need to have an executor/scheduler in scope, then Xasync still lets us write libraries with methods that produce tasks and leave it to someone else to choose how/where/when to run the returned task.

The integration seems intense though: the integration code needs a macro, mutable vars that start out null, required methods with dollar signs, AnyRef + asInstanceOf, and calls to seemingly internal apis (c.internal). I’d be worried about asking my team to maintain this for our own integrations, especially having to ramp up on scala.reflect.macros.

Integrating a custom task type with monadless was straightforward by comparison:

trait MonadlessTask extends Monadless[Task] {
  def apply[T](value: T): M[T] =
    Task.successful(value)

  def collect[T](list: List[Task[T]]): Task[List[T]] =
    Task.sequence(list)

  def rescue[T](task: Task[T])(pf: PartialFunction[Throwable, Task[T]]): Task[T] =
    task.recoverWith(pf)

  def ensure[T](task: Task[T])(f: => Unit): Task[T] =
    task.andThen { case _ => f }
}

object MonadlessTask extends MonadlessTask
1 Like

Looks like there’s a prototype for IO at Async/Await (Experimental) · Cats Effect! Curious if anyone has tried this on bigger code bases?

1 Like

Note that although the Scala 2 version of cats-effect-cps is based off -XAsync, the Scala 3 version depends on dotty-cps-async, and is arguably better.

As implementor of the Scala 2 version of cats-effect-cps, I can attest integrating cats-effect with the -XAsync machinery was very hard, partially due to lack of documentation, partially due to how the design of -XAsync really doesn’t work well with anything that doesn’t have Future-like semantics (in particular, it simply can’t work with cats.effect.Resource). It kinda works for cats.effect’s IO (and some monad transformers) due to the Dispatcher construct provided by cats-effect, which allows some form of interoperability between pure and impure code, but it’s easy to find monads that wouldn’t work well with -XAsync (Free monads, for instance)

Not saying that -XAsync has no reason to be in its current form, but I very much think it’s incorrect to advertise it as the blessed way for providing direct-style for any monad.

3 Likes