Design of -Xasync

That’s right. The original Scala Async 1 already had support for different Future or Future-like types so you could support 3rd-party Future implementations and also have custom implementations for the unit tests. It requires more than just the basic monad functions to generate simpler and more efficient code.Simpler code was also the main goal of Async 2 (integrated into the compiler). Generating the state machine later keeps the AST smaller during the intermediate phases.

2 Likes

That doesn’t surprise me at all, but it’s important to understand that the approach chosen makes it impossible to support anything that isn’t Future or all-but identical to it. This is exceptionally restrictive. Monadless represents a relatively decent existence proof that a straightforward transformation is possible even with just the basic monad operators. Is there a more specific example of why such an approach was rejected?

I’m not trying to run down any of the excellent work done here. My hope is just that we can take this opportunity while it’s behind an -X flag to generalize the mechanism so that it can benefit more than just a very narrowly-defined slice of the ecosystem.

7 Likes

The FSM approach is flawed in its core:

I still don’t understand how it landed in an official language release without a broader discussion with the interested parties in the community. Considering the feedback on Twitter, several people would prefer a more generic solution like Monadless: @djspiewak (Cats), @alexandru (Monix), @jdegoes (Zio), and myself (Twitter Future).

Now, Monadless also has its own series of problems with this. It handles fewer constructs than scala-async does (partially for this reason)

@djspiewak could you elaborate? Monadless supports a superset of the constructs supported by scala-async. A few examples are short-circuiting boolean logic, try/catch, functions, classes, methods, and others. For more details see GitHub - monadless/monadless: Syntactic sugar for monad composition in Scala

and it doesn’t correctly deal with side effects

could you expand on this?

4 Likes

Okay to head off the firestorm a bit, there was broader discussion, it just wasn’t trumpeted super-loudly. Jason had a discussion with Alexandru and myself on public GitHub that started with a prototype of a scala-async adapter for Monix Task and ultimately included a prototype of an implementation for any Cats Effect. So there was discussion, it was just further under the radar than you might have expected. I gave much of this same feedback on that discussion, but I think Jason didn’t have time to address it. I certainly was surprised when this landed in the official compiler without any further comment; I don’t blame anyone, I just wish it was handled a bit differently.

:slight_smile: I’m getting out a bit over my skis here. Please correct me where I’m in err. My understanding is that scala-async was able to handle certain higher-order function cases that Monadless couldn’t (since not all things are Traverse).

could you expand on this?

Something I’ve been thinking about is the reason people want to use async/await. The answer to that is basically that they want to have an imperative control flow within an execution environment which is callback-oriented. -Xasync and Monadless both assume the CPS control flow is encoded by a monad (in the case of scala-async, specifically Future). But this means that they probably want to squeeze effects in here somewhere:

async {
  writeToFile(await(fa) + await(fb))
  println("done!")
  await(finalizeF)
}

I dunno. I’m making up examples here. The point being that I think the body of the async needs to be treated as a place wherein effects may need to be captured. Right now, I would assume that most of this with monadless falls into accidentally-lazy land within map and flatMap statements? The more correct thing to do, if we buy into my assumption, is to take a Sync instance when such effects may need to be wrapped. So rather than effectively turning writeToDatabase(...) into pure(writeToDatabase(...)), you would turn it into delay(writeToDatabase(...)).

This would also open the door to taking an Async and allowing for a third construct wherein people await a callback. I’m not sure if that’s a good idea or not; just spitballing.

@djspiewak thanks for the clarifications :slight_smile:

I’m getting out a bit over my skis here. Please correct me where I’m in err. My understanding is that scala-async was able to handle certain higher-order function cases that Monadless couldn’t (since not all things are Traverse ).

It’s been some time since I developed Monadless so my memory might be failing but afaik there isn’t a construct that is supported by scala-async that isn’t supported by Monadless.

The more correct thing to do, if we buy into my assumption, is to take a Sync instance when such effects may need to be wrapped. So rather than effectively turning writeToDatabase(...) into pure(writeToDatabase(...)) , you would turn it into delay(writeToDatabase(...)) .

I think it’s more a question of how you set up your Monadless instance. It should be possible to change the Cats integration in Monadless to behave differently if Sync is available in the implicit scope. I still don’t see any issues with how Monadless deals with side effects, though.

I find Monadless’ transformation code quite readable in case you want to understand how it works: monadless/monadless-core/src/main/scala/io/monadless/impl/Transformer.scala at master · monadless/monadless · GitHub

I think that whenever we discuss async/await-like programming or for-comprehension in Scala, we should take a step back and have a look at F#'s Computation Expressions.

They are a variant of Haskell’s do-notation or of Scala’s for-comprehension, but it’s much more generalized and customizable. It doesn’t work only with flatMap <- (as if “monadic” val x = ...) and pure yield, but also works with for/while loops, sequences, pattern matches and resource disposal and try-catch-finally analogs from the “imperative” world.

Here’s a hypothetical example to illustrate the point:

let fetchAndDownload url =
  async {                                         // marks the start of the computational expression, like `for` in Scala, but async is an object which defines how it's all wired up together
    let urlStripped = strip url                   // usual variable binding; it's nice you can do that even as the first thing -- you can't do that in Scala
    let! data = downloadData urlStripped          // `let! x = y` is like `x <- y` in Scala, but it's much visually closer to its imperative cousin, which in Scala would be `val x = y`
    let processedData = processData data          // another usual variable binding
    use! monitor = createMonitor processedData    // like `let! x = ...`, but the resource is disposed of at the end of the (otherwise asynchronous) block, think of cats-effect's `Resource.use` (`use x = ...` is F# normal resource acquisition)
    do! notifyMonitor                             // like `let! () = x`, but nicer syntax than `_ <- x` which is what Scala forces you to do
    return processedData                          // like `pure`, serves the same purpose as Scala's `yield` block
  }

The most important part (even before the generality and customizability) is that the syntax is intentionally made similar to the “imperative counterparts”. There’s just ! added at the end of the keyword!. This results in easy learning of the concept and then in fluent writing and reading of the code.
Playing with other syntax constructs programmers are familiar from the imperative world, like resource acquisition/disposal or exceptions (try-catch-finally) is also handy in practice.

The other useful thing to have, besides looking like imperative code, would be debugging like imperative code, where one can nicely see the stacktrace as is logically expected (even though that’s not how it actually is).

And OCaml has recently gained syntax even for applicative composition.

Whichever path Scala takes, be it the improvement of for-comprehension based on callbacks or this new async/await with state machines transformation, I hope it will align well with the rest of Scala’s syntax and that it will be more general and work with more than just Future‘s. Currently, this is Scala’s weak spot, but we can learn from other languages’ successes.

More info and examples on Computation Expressions

3 Likes

Just to clarify, async/await (and also the F# syntax) are not directly comparable to for-comprehensions. for-comprehensions are special syntax for certain function calls, whereas async/await (and similar) change the semantics of existing constructs. Critically, they change the semantics of constructs which have no bearing on each other in normal syntax. For example:

async {
  val a = await(fa)
  val b = await(fb)
  ...
}

// vs

for {
  a <- fa
  b <- fb
  ...
} yield ...

The critical example here is reordering:

async {
  val b = await(fb)
  val a = await(fa)
  ...
}

Referential transparency says that in all cases, you can reorder independent expressions and the resulting program will be the same. Note that reordering for-comprehensions isn’t reordering independent expressions, because it’s flatMaps under the surface, and the syntax is very explicitly different so as to convey that.

With async/await, though, pure expressions get restructured to be non-independent, since a and b are related by a flatMap. In other words, you get all the perils of imperative code tangling within a lexical scope, but applied to monads. This is actually the whole point of the construct (imperative logic, with all the familiarity and dangers), but it also means that it’s 100% not something that everyone will want, and definitely not something that is always applicable. Thus, not something that will replace for-comprehensions.

Though with that said, I really wish for-comprehensions were improved. By like a lot. Better-monadic-for helps a lot in Scala 2, and Scala 3 is getting some improvements (e.g. Guillaume is looking at removing trailing identity maps in the case that it doesn’t change the type), but even still it’s not what it could be as a construct.

3 Likes

I’ve not run across Computation Expressions before and will need to check it out.

As for myself, I would rather that Scala get coroutines, ala Kotlin, rather than things hyper-specialized to particular Monads, or Monads in general.

From my perspective, coroutines are a much more general and useful construct. Coroutines can work with Monads/Effects but without being locked into that structure.

For example, coroutines can express async/await, which is how Kotlin implements these notions as I understand. The POC from a few years back (http://storm-enroute.com/coroutines/) has an example implementing async/await via coroutines. Best I can tell the FSM transform that scala-async does is probably already 90% of what coroutines would need.

Coroutines are usually viewed as living on the imperative side of divide, but I think their applicability to FP is understated. One perspective is that for-comprehensions and async/await are effect generators, but without the regular control flow statements that we normally use. A coroutine can yield effects, but have access to all the same control flow statements.

Obviously coroutines have their own set of challenges. I would hope these could be dealt with.

Coroutines do not compose in the way that effects do. You cannot enrich coroutines with dependency injection, or separate error channels, or tracing semantics, or alternative resource management. This is basically the argument for monads abstracting over coroutines, which is exactly what IO is. Once you have a coroutine monad, you can compose it in a general way, which gives you considerably more power. for-comprehensions and async/await recover the syntactic side of things, bringing the imperative usability of monadic interfaces on par with coroutines, while leaving intact the advantages of a monadic API from the standpoint of enabling combinators and abstraction.

6 Likes

Here’s maybe a better example:

async {
  val a = await(fa)
  throw new RuntimeException(a.toString)
}

What happens here? Where does the exception go? Do you require a MonadError[F, Throwable]? Do you just throw it and hope for the best? Cats Effect guarantees that exceptions are caught in Sync#delay, but it makes no guarantees about flatMap/map (and it is in fact a violation of the functor laws to catch within those combinators, though in practice I think all of the practical IOs do it anyway).

2 Likes

I’m not arguing that coroutines compose in the way that effects do. They obviously don’t, but for my purposes that is tangential.

Instead, I’m arguing that the same code transform that scala-async does is almost exactly what would be necessary to implement coroutines, with only suspend/yield as the missing piece. So instead of limiting that transformation logic to transforming Futures/IOs why not go further, support coroutines, and then implement async/await in terms of the coroutine?

Then we get two spiffy new tools instead of one.

Of course that only works if the semantics of async/await don’t need further enrichment beyond what a coroutine could do. I can see Computation Expressions as being like that.

What happens here? Where does the exception go? Do you require a MonadError[F, Throwable] ? Do you just throw it and hope for the best? Cats Effect guarantees that exceptions are caught in Sync#delay , but it makes no guarantees about flatMap / map (and it is in fact a violation of the functor laws to catch within those combinators, though in practice I think all of the practical IO s do it anyway).

That’s just syntactic sugar for:

fa.map(a => throw new RuntimeException(a.toString))

The behavior depends on the implementation, as expected.

Yeah so that’s a good example of something that’s being handled incorrectly. throw within map is straight-up undefined, and while most implementations of IO-like things will sort of handle it correctly, it isn’t at all guaranteed, and you can get very complicated consequences if you’re composing transformers and such. However, if it were wrapped as fa.flatMap(a => Sync.delay(throw new RuntimeException(a.toString))), then it would be guaranteed to work every time.

I realize that this probably isn’t solvable in general in a layer like Monadless, since really making it work properly (without giving up on pure Applicative/Monad) would require require implementing an effect type system. My suggestion would be to spin off separate syntax for anyone who wants the pure version of async. So something like:

asyncPure {
  val a = await(fa)
  throw new RuntimeException(a.toString)   // undefined, because it's just map
}
async {
  val a = await(fa)
  throw new RuntimeException(a.toString)   // wrapped in Sync
}

The downside is this would mean that the async syntax would require Cats Effect. An alternative solution would be to have an effectful combinator which replaces async and has the Sync wrapping (so, same idea but in reverse). I believe Monadless supports this right now.

Anyway, this discussion is relevant to scala-async because these are the kinds of cases that need to be thought out and handled correctly.

Yeah so that’s a good example of something that’s being handled incorrectly.

How so? It’s just syntactic sugar similarly to for-comprehensions. It’s as wrong as if someone throws in a map function or in an for-comprehension.

I realize that this probably isn’t solvable in general in a layer like Monadless, since really making it work properly (without giving up on pure Applicative / Monad ) would require require implementing an effect type system. My suggestion would be to spin off separate syntax for anyone who wants the pure version of async

That’s interesting, I don’t think it’s as complicated as you describe. Monadless currently doesn’t do any special handling of pure trees but it could be easily modified to wrap them with a method call.

2 Likes

It would be more obviously wrong in for-comprehensions:

for {
  a <- fa
  _ = throw new RuntimeException(a.toString)
} yield a

// or

for {
  a <- fa
} yield throw new RuntimeException(a.toString)

Either one is pretty obviously strange. The point is that async/await syntax encourages people to use imperative patterns within code which is actually monadic. That’s good! That’s the point of the syntax, but it means that things like this are inherent to the problem space.

To clarify further: when I say it’s being “handled incorrectly” I mean “it’s really not going to behave well, or even consistently, even on the same types”.

What I mean is you can’t really detect pure vs impure code, so if you want to handle this properly, you have to assume that all expressions are impure. You would basically never use pure anymore unless looking at a literal. In general this turns into effect types, which are very clearly out of scope for monadless (or -Xasync).

For the record, F#'s Computation Expressions are precisely that. Just like do-notation, it’s a syntactic sugar for using certain functions that avoids nesting. But it’s a very good one, generalized (beyond what Haskell or Scala currently provide) and a very polished one.

It’s so good, that only blindly copying F# in this regard would improve Scala’s situation immensely. Imagine then what we could achieve, if we put our mind to it! And now’s the best time to do it, since we’re in the 2.x → 3 transition. As I’ve written above, Scala can improve a lot by taking inspiration from other programming languages.

3 Likes

There’s a whole new GraalVM-oriented language focused on making asynchronous and parallel programming easy and concise: https://github.com/yatta-lang/yatta
Blog post: https://functional.blog/2020/05/25/designing-a-functional-programming-language-yatta/

Is it possible to use -Xasync with Monix tasks or Trane.io tasks without providing an execution context to either the async or the await call sites?

Is there any way we can get some thoughts from the upstream team? Particularly @retronym would be great to hear from. Basically what I’d like to find out is what the general thoughts are on this situation. Is it something that is considered worthwhile to resolve, or is the API behind -Xasync considered relatively final?

3 Likes

I believe that this post from another thread is quite relevant in here as well:

1 Like