PRE-SIP: Suspended functions and continuations

Pre-SIP: Suspended functions and continuations in Scala 3.

This Pre-SIP post proposes continuations as a new language feature in Scala 3.

It has been prepared by Jack Viers, Raul Raja, and reviewed by the Scala 3 team at 47 Degrees.
This doc is intended to be used as a way to gather community feedback and foster discussion.

Motivation

Our observation in the industry and among our peers is that most programming in Scala today that involves async or I/O-based programs targets a monadic indirect boxed style.
Most programs involve some form of async effects, and in that case, they largely depend on data types such as Future, or lazy IO types found in many community libraries.
These data types express dependent, parallel, asynchronous, or potentially erroneous computations as lazily evaluated values or thread-shifted eager computations.
They do so to maintain efficient parallelization or concurrent execution, error-handling properties, non-determinism, and simplified structured concurrency.
This indirect style allows the programmer to treat side-effects as if they were any other value.

Library-level combinators such as map, flatMap, and raiseError allow the composition of single monads to compose relatively freely and easily.
However, combining multiple side-effects often involves increasingly confusing methods and datatypes to separate program expression from execution and treat the program as a value.
This style requires knowledge of and strict adherence to complex algebraic laws. These laws take time and effort to absorb and understand.

In scala, where the execution of side-effects is not yet tracked at the language level, it takes great discipline to maintain reasonable guarantees of safety, composition, and correctness in constructing data types in concordance with these laws. The data structures required to maintain adherence to these laws in side-effecting programs do not generally compose. Complex attempts to unify the simplicity of function composition with monadic extensible effect/transformer systems increase the distance between programmer intent and program expression.

Concepts such as simple tail recursion, loops, and try/catch must be sacrificed to maintain safety, program throughput and reasonableness guarantees when adhering to a monadic style.

We would like to write scala programs in a direct style while maintaining the safety of the indirect monadic style. We would like to write these programs with a unified syntax, regardless of these programs being async or sync. We have experienced this programming style in Kotlin for the last few years
with suspend functions. We have found that these programs are easier to write and teach and generally perform better than those written in indirect style.

We think most of the features we need are already on Scala 3, but we lack a way to perform non-blocking async/sync IO in direct style.

Example

Given a model mixing a set of unrelated monadic datatypes such as Option, Either, and Future, we would like to access the country code given a Future[Person]

import scala.concurrent.Future

object NotFound

case class Country(code: Option[String])

case class Address(country: Option[Country])

case class Person(name: String, address: Either[NotFound.type, Address])

Instead of the encodings we see today based on map and flatMap (or equivalent for comprehensions) like the one below.

import scala.concurrent.Future

def getCountryCodeIndirect(futurePerson: Future[Person]): Future[Option[String]] =
  futurePerson.map { person =>
    person.address match
      case Right(address) =>
        address.country.flatMap(_.code)
      case Left(_) =>
        None
  }

We would like to be able to express the same program in a direct style where instead of
returning a Future[Option[String]] we return just String.

import scala.concurrent.Future

suspend def getCountryCodeDirect(futurePerson: Future[Person])
     (using Structured, Control[NotFound.type | None.type]): String =
  val person = futurePerson.bind
  val address = person.address.bind
  val country = address.country.bind
  country.code.bind

The program above is impossible to implement in a direct style today without blocking because a call to futurePerson.bind would have to use Await or similar.

The program in the example above uses the Control type to represent the possibility of failure.

Invoking getCountryCodeDirect returns a String but until Control is resolved it may also contain NotFound or None.

We can take it further and simplify if bind is defined as apply:

suspend def getCountryDirect2(futurePerson: Future[Person])
     (using Structured, Control[NotFound.type | None.type]): String =
  futurePerson().address().country().code()

Status Quo & Alternatives

In Scala, interleaving monadic data types in a direct style (including Future and lazy IO) is impossible.
Despite context functions and the upcoming capture checking system, Scala lacks an underlying system such as Kotlin continuations or Java LOOM, where functions can suspend and resume computations.

Other projects such as dotty-cps-async or Monadless
provide similar syntactic sugar for monads and do a great job about it.
We have enjoyed using these libraries, but after trying native language support for these features in Kotlin, we decided to propose a deeper integration that works over function declarations and not just expressions.

Other communities and languages

Other communities and languages concerned about ergonomics and performance, like Kotlin and Java, are bringing the notion of native support for scoped continuations and structured concurrency.
These features allow for sync and async programming without boxed return types and indirect monadic style.

These languages implement such techniques in different ways. In the case of Kotlin, the compiler performs CPS transformations for suspend functions, eliminating the need for callbacks and simplifying function return types.
This simple native compiler-supported keyword allows other ecosystem libraries such as Spring, Android, and many other libraries and projects in the Kotlin ecosystem integrate with suspending functions natively.

JDK 19, the Java 19 hotspot runtime, and Project Loom include support for virtual threads and structured concurrency built on top of continuations

Proposal

We want to propose a native system for continuations in Scala.

Two possible implementations are included in this Pre-SIP Post:

  1. The addition of a new keyword, suspend.

    suspend def countryCode: String
    
  2. The use of compiler-desugared Suspend context functions or given/using evidence.

    def countryCode: Suspend ?=> String
    

Our intuition is that this could be part of the experimental Capture Checking and related to the experimental CanThrow capabilities, where the compiler performs special desugaring in the function body.

Potential implementation

If the compiler followed a model similar to Kotlin, suspended function and lambdas get to codegen with an additional parameter.

suspend def countryCode: String is desugared to a function that looks like in bytecode like def countryCode(continuation: Continuation[?]): ?.

The body of the suspended function desugars to a state machine where each state is labeled and associated with suspension points.
In the function countryCode, calls to bind are calls to suspended functions and are considered suspension points.
When a program reaches a suspension point, the underlying continuation may have suspended, performed some work, and resumed back to the original control flow when ready.
The continuation can perform this background work without blocking the caller.

Compiler requirements.

In order to implement continuations in Scala, the compiler would include the following:

  • A new keyword, suspend or a new contextual type Suspend. This can appear in functions and lambda declarations.
  • CPS transformation for suspend function bodies that desugars continuation state into a state machine.
  • A new intrinsic trait Continuation[?] for which the compiler synthesizes instances for each one of the compilation target platforms.

Std lib requirements.

The standard library would include a set of functions related to continuations such as continuation that are the minimal building blocks from which other abstractions can be built.
If we do this in a similar way as done in Kotlin, these functions would look like:

createContinuationUnintercepted
suspend def createContinuationUnintercepted[T](
  block: suspend () => T,
  completion: Continuation[T]
): Unit

This function creates a new, fresh instance of suspendable computation every time it is invoked.

startContinuationUninterceptedOrReturn
object ContinuationSuspended

suspend def startContinuationUninterceptedOrReturn[T](
  block: suspend () => T,
  completion: Continuation[T]
): T | ContinuationSuspended

Starts a continuation and executes it until its first suspension point. Returns the result of the computation or ContinuationSuspended if this continuation should remain in suspended state.
When the implementer returns ContinuationSuspended it invokes completion as the continuation computation completes.

suspendContinuationUninterceptedOrReturn
object ContinuationSuspended

suspend def suspendContinuationUninterceptedOrReturn[T](
  block: Continuation[T] => T | ContinuationSuspended.type
): T

Given a suspend function it gets its current continuation. Allows for suspension with ContinuationSuspended or returns an immediate result without suspension.

continuation
suspend def continuation[T](
  block: Continuation[T] => Unit
): T

Get the current continuation and suspend execution.

Use cases

Removing callbacks

In the example below, we can define bind, a function that returns A from a Future[A] without blocking.

extension [A](f: Future[A])(using ExecutionContext)
    suspend def bind: A =
      continuation[A] { cont: Continuation[A] =>
        f.onComplete {
          //naive impl does not look into cancellation wiring.
          _.fold(ex => cont.resumeWithException(ex), cont.resume)
        }
      }
      

We use continuation to create a continuation that suspends the current program and resumes it when the future completes.
continuation is only available when the user is inside the scope of a suspend function.
Continuations can be resumed with the expected value or an exception.

trait Continuation[A]:
  def resume(a: A): Unit
  def resumeWithException(e: Throwable): Unit

// compiler generated platform dependent implementation for Continuation
suspend def continuation[A](c: Continuation[A] => Unit): A =
  ???

Structured concurrency

Once we have continuations available we can build structured blocks. These blocks guarantee asynchronous tasks spawned inside complete
before the block is exited either with a successful result or an exception.

The following example uses project LOOM dependencies with Scala 3 and wraps their structured concurrency implementation.
If we don’t depend on LOOM this example would be blocking each time the fibers are joined.
If continuations where available, we could use them to avoid blocking and have other impls outside of LOOM and the JVM.
Compiling and running this code requires VM args --add-modules jdk.incubator.concurrent and a build of JDK 19 with LOOM.

import jdk.incubator.concurrent.StructuredTaskScope
import scala.annotation.implicitNotFound
import java.util.concurrent.*

@implicitNotFound(
  "Structured concurrency requires capability:\n% Structured"
)
opaque type Structured = StructuredTaskScope[Any]

extension (s: Structured)
  private[fx] def forked[A](callable: Callable[A]): Future[A] =
    s.fork(callable)

inline def structured[B](f: Structured ?=> B): B =
  val scope = new StructuredTaskScope[Any]()
  given Structured = scope
  try f
  finally
    scope.join
    scope.close()

private[fx] inline def callableOf[A](f: () => A): Callable[A] =
  new Callable[A] { def call(): A = f() }

opaque type Fiber[A] = Future[A]

extension [A](fiber: Fiber[A])
  def join: A = fiber.get // this is non-blocking in LOOM
  def cancel(mayInterrupt: Boolean = true): Boolean =
     fiber.cancel(mayInterrupt)

def fork[B](f: () => B)(using structured: Structured): Fiber[B] =
   structured.forked(callableOf(f))

Structured blocks are resources that when they get closed, they join all fibers that were created within the block.

We can implement different policies with structured concurrency, such as:

  • Shutdown on failure
  • Shutdown on success
  • Control the number of fibers to join or parallelism level.

In the program below all fibers are joined before the structured block exits.

    val x: Control[Int] ?=> Structured ?=> String =
      val fa = fork[String](() => "Hello")
      val fb = fork[String](() => 0.shift)
      fa.join + fb.join

    val value: String | Int = run(structured(x)) 

Functional programming based on continuation folding

Many functional patterns such as safe error handling can be derived from continuations.

Control implements the classic Control/shift from continuations literature to demonstrate an application of continuations and exceptions for safe functional error handling.

We can think of a continuation as a program producing A or a Throwable, but when it’s using Control, it may be interrupted at any point with a value of R.
Control provides shift the operation that allows interruption analogous to the imperative throw but it’s not restricted to Throwable.

trait Control[-R]: //can potentially be implemented in terms of `canThrow`
  extension (r: R) 
    suspend def shift[A]: A // can throw or shift to R when otherwise expected A

All programs requiring Control are foldable and they interop with try/catch

import java.util.UUID
import java.util.concurrent.ExecutionException
import scala.annotation.tailrec
import scala.util.control.ControlThrowable

object Continuation:
  inline suspend def fold[R, A, B](
      inline program: suspend Control[R] ?=> A
  )(inline recover: suspend R => B, inline transform: suspend A => B): B = {
    var result: Any | Null = null
    implicit val control = new Control[R] {
      val token: String = UUID.randomUUID.toString

      extension (r: R)
        def shift[A]: A =
          throw ControlToken(token, r, recover.asInstanceOf[Any => Any])
    }
    try {
      result = transform(program(using control))
    } catch {
      case e: Throwable =>
        result = handleControl(control, e)
    }
    result.asInstanceOf[B]
  }

  @tailrec def handleControl(control: Control[_], e: Throwable): Any =
    e match
      case e: ExecutionException =>
        handleControl(control, e.getCause)
      case e @ ControlToken(token, shifted, recover) =>
        if (control.token == token)
          recover(shifted)
        else
          throw e
      case _ => throw e

  private case class ControlToken(
      token: String,
      shifted: Any,
      recover: Any => Any
  ) extends ControlThrowable

In the implementation above, program, recover and transform are all suspended functions.
We can try/catch over them because they are suspension points, and they guarantee control flow will return to the caller either with a result or an exception.
The work performed may go async, get scheduled, or sleep, all in a non-blocking way.

run and other similar operators that fold the program look like:

extension [R, A](c: Control[R] ?=> A)

    def toEither: Either[R, A] =
      fold(c)(Left(_), Right(_))

    def toOption: Option[A] =
      fold(c)(_ => None, Some(_))

    def run: (R | A) = fold(c)(identity, identity)

For a full impl with more operators and abstractions,
see EffectScope the equivalent to Control and
fold impl in Arrow.

Once we have the ability to Control and shift we can implement monad bind for types like Either and Option.
Here monad bind has the shape F[A] => A. Once we have a function like bind, we can extract A
without needing to map over F. If we encounter a failure case at any point, we will not get A, and our program
short-circuits up to the nearest Control in the same way exceptions work.

extension [R, A](fa: Either[R, A]) 
  suspend def bind(using Control[R]): A = 
    fa.fold(_.shift, identity) //shifts on Left

extension [A](fa: Option[A])
  suspend def bind(using Control[None.type]): A = 
    fa.fold(None.shift)(identity) //shifts on None

We can safely compose unrelated types with bind in the same scope.
shift allows us to escape the continuation in case we encounter a Left or None.

With the implementations for bind we can express now countryCode in a direct, non-blocking style.

def getCountryCodeDirect(futurePerson: Future[Person])
    (using Structured, Control[NotFound.type | None.type]): String =
  val person = futurePerson.bind //throws if fails to complete (we don't want to control this)
  val address = person.address.bind //shifts on Left
  val country = address.country.bind //shifts on None
  country.code.bind //shifts on None

Monadic values compose in the same scope delegating their computation to the underlying continuation.
There is no need for wrapped return types, monad transformers, or stacked types to model a sequential computation composed of unrelated monadic types.

We don’t propose bind or Control as part of this proposal, just intrinsics for continuations such as the function continuation.

Finally, we have used using clauses to model functions with effects or context functions to model programs as values with
given effect requirements.

Is the answer Traverse?

In this model traverse can be simply defined as map + bind.

@main def program2 =
  val test: Structured ?=> Control[String] ?=> List[Int] =
    List(Right(1), Right(2), Left("oops")).map(x => x.bind)
  println(run(structured(test))) // oops

@main def program3 =
  val test: Structured ?=> Control[String] ?=> List[Int] =
    List(Right(1), Right(2), Right(3)).map(x => x.bind + 1)
  println(run(structured(test))) // List(2, 3, 4)

Non-blocking sleep

Since continuations don’t block, we can schedule their completion and resume them when needed.

private val executor = Executors.newSingleThreadScheduledExecutor((runnable: Runnable) => {
  val thread = Thread(runnable, "scheduler")
  thread.setDaemon(true)
  thread
})

suspend def sleepMillis(time: Long): Unit = continuation { c =>
  val task = new Runnable:
    override def run(): Unit = c.resume(())
  executor.schedule(task, time, TimeUnit.MILLISECONDS)
}

kotlin example

Generators

Operators such as yield are helpful in generators that suit stream processing.
In this model, only when the caller requests an element yield computes it and offers it back.

val fibonacci: LazyList[Int] = lazyList { //suspend lambda
    yield(1) // first Fibonacci number (suspension point)
    var cur = 1
    var next = 1
    while (true) do
      yield(next) // next Fibonacci number (suspension point)
      val tmp = cur + next
      cur = next
      next = tmp
}

kotlin example

Additional information

The text for this pre-sip and the code are available in this gist

Next steps

We believe that introducing continuations in Scala 3 coupled or not to the capture checking system or context function results in the following improvements:

  • Simplifies program description, eliminating wrapped return types for most use cases.
  • Helps inference and compile times due to reducing the usage of complex types.
  • Cooperates with the language control structures and produces smaller and faster programs that desugar suspension points efficiently in the stack.
  • Eases the learning curve to program async / effects-based applications and libraries in Scala.
  • Reduces indirection and allocations that arise through higher-order functions used extensively in map, flatMap, and others.
  • Can interop with other libraries and frameworks that offer custom fiber scheduling and cancellation strategies.

In addition to this proposal and in the hope that more people get to try this, the team at 47 Degrees has started working on the needed compiler changes, a compiler plugin and a library to bring this implementation to Scala 3.
We plan to release it in the near future based on feedback from the Scala community.

Looking forward to your thoughts, and thank you for reading this far! :pray:

9 Likes

I see that you say foo.map(x => x.bind) will work, but it’s not clear to me how your proposa allows that to happen.

  • Will it work for every higher-order function?
  • Do the HOFs need to be specifically annotated for them to work?
  • What about HOF’s defined earlier and already compiled?
  • What about HOF’s defined in Java? Or Javascript for Scala.js?

The interaction with HOFs has been the achilles heel of every async/cps transformation I’ve seen. While languages have a finite set of features that you can hard-code support for, the variation in user-defined HOFs are limitless. Even the same HOF can have diffeerent interpretations: e.g.if I call listOfItems.map(item => Future(doThing(item)).bind), how does the compiler know if the user wants the doThing calls to happen async-sequentially or async-concurrently?

Different implementations taking different approaches, e.g. the old Scala-CPS plugin required HOFs to be compiled with a specific annotation IIRC, while scala-async doesn’t support HOFs at all. The same issues even apply to other languages, with Kotlin’s suspend functions, C# async, Python async, JS async all having different approaches to HOFs

HOFs are much more common in Scala than in any other language, and even are used for what in other languages are typically baked-in language features: loops, context managers, try-with-resource, etc. This problem which is a edge-case in other languages is a core problem front-and-center in Scala. This proposal needs more clarity on how it approaches HOFs: how it works, what works, what doesn’t, and what tradeoffs it chooses to make around them.

14 Likes

This must be a soft keyword. There is a very high chance that a ‘suspend’ name is already used throughout many codebases.

5 Likes
  1. Are you have looked at variants, where each fun. is suspended. by default?
    In a typical Kotlin project, nearly all functions become suspended with time.

  2. What will be the type of the suspended function? Is my intuition that suspended function type hierarchy will be parallel to the functional type hierarchy?
    I.e. we will have

 SuspendedFunction0[Y]. ... SuspendedFunction1[X,Y], .... etc   

and converting SuspendedFunction into Function will be possible only in a specific context,
(where SuspendCapacity is available)?

  1. It will be good to have an API, which converts between suspended and unsuspended function representations, to allow passing suspended functions as first-citizen objects.
    It can be something like
def.  unsuspend1[X,Y](f :  suspended  X => Y):   X => SuspendMonad[Y]

(assuming that we have some SuspendMonad)

  1. In my first understanding, looks, like it will be impossible to pass the suspended function over the collection.map. Is it true?

[
after seen collection.map(x => x.bind) – map should accept suspended function here ?
will collection.map(x => x+1) work ?
]

If yes, potentially this can be solved as in dotty-cps-async, in one of the next ways:
- having a second definition of a map, which accepts suspended functions.
- transform all HOF order functions into functions that accept suspended form only and insert a suspension point into the HOF argument.

This means, that you should transform not only your code, but also all code that can call you functions, or introduce some restrictions. If we want to use suspensions inside the loop – we should change the standard library. It will be interesting to see List or Option. signature with suspension support.

Some problems involving HOF functions is not trivial (for example - fusing filter and map calls for collections, to achieve the same behavior as in a standard library)

  1. Pushing cps-transform deeper can solve the ‘coloring problem’ for platforms without the support of runtime continuations in a more natural way.

Hello, sorry for the incoherent reply, but why move backwards 5-10 years and introduce coloring and replicate kotlin?
Why this needs to be in the compiler as this is way worse than having monads. Why not push the branch of cc-checking further and try to leverage effect tracking with loom and continuation primops to get something truly useful and ergonomic a la koka/frank/eff? Looking at the proposal it looks like way less generic and primitive implementation of algebraic effects

15 Likes

Because some of us don’t think monads everywhere are the answer, and that looking past that may bring us 5-10 years into the future.

4 Likes

I think this is a very intriguing proposal: while I haven’t done enough with this style in other languages to be fully comfortable with it myself yet, it’s clearly very popular, and a good implementation might well help broaden Scala’s appeal. I suspect I’d wind up using it at least a fair amount, if it provides good boilerplate reduction.

The most important thing I don’t fully grok yet is what this would look like when interoperating with monadically-wrapped code. If I have an existing codebase or collection of libraries written using cats-effect or ZIO and want to build a program using suspend (or vice versa), what do the interfaces between those look like? I suspect that it can be done (and hope that it’s straightforward), but don’t have the intuition for it yet. My guess is that clean interfaces here would be crucial for adoption, given the amount of investment folks have in the monadic approach.

Also: what are the implications for mixed code? I have multiple large projects that have evolved over time, such that they use both Future and IO/ZIO engines. Given that type classes are clearly important here, what would it look like when you need to interface between those different async models? I’m a little concerned that that could get messy at the call sites, but hoping that it wouldn’t be too hard to write clean adapters.

1 Like
2 Likes

Well, I agree. On the flipside though if this sip gets accepted we finally will get some tech debt and points for scala 4 to remove cruft from scala 3

1 Like

Note, that technically monadic reflection can be built over any continuation system, including proposed, if the Continuations interface will be available. [Ironically, the latest version of Loom does not expose Continuation directly to the userland, so, monadic reflection over Loom continuation in such form is not possible with the latest JVM version]

Exposing some form of continuation is good.

On another side, I’m uncomfortable with the suspended part, because I have a feeling that exists a better way that will produce fewer integration problems.
Why: because embedding a call of (code with continuation = suspended) into (code without continuations = non-suspendable) is relatively hard: if you have a higher-order function that accepts 'non-suspendable code`, then when you receive suspended functions, you can do nothing.

But embedding non-suspendable code into suspendable is trivial: just wrap it into an interface with a suspension point.

Therefore, the right continuations solutions should have an interface where a higher-order function can accept by default suspendable functions. This will allow us not to have two implementations of the HO method for suspended and resuspended arguments. For strict interfaces without continuations, we have (will have) pure functions.

So, instead of having a parallel hierarchy of functions, I prefer to have Continuation capacity in the library, which assumes that the code which uses this capacity is cps-transformed.

In such a way, the HO problem can be solved. Two other problems (integration with external libraries and coloring) become a little harder, but I think this is an acceptable cost for HO support.

1 Like

Yes, we are not strictly proposing function coloring. I believe the other way where we make Suspend and implicit requirement with context functions can potentially be used to automatically CPS transform functions that carry that requirement. This approach would be similar to the ergonomics of LOOM, where you get non-blocking CPS underneath through the VirtualThread or the previous Continuation interface.

The map(_.bind) case in our examples already works non-blocking with our implementation based on LOOM, Context functions with requirements seem to work with map. We could potentially piggyback on LOOM for the JVM if we figure out a way to have a different runtime in native and js.

Another alternative is to create a LOOM style runtime for just the continuation disregarding virtual threads and things we leave abstract to ExecutionContext but that is more or less the impl of what we proposed above.

1 Like

If we are relying on Loom, I would expect to be able to implement the .bind example or the LazyList generator example just using “blocking” of Loom virtual threads, without needing any Scala language changes at all. In fact, I believe that is the whole motivation of Loom in the first place: to be able to write direct-style async code without needing to worry about compile-time transformations or function coloring, by pushing the complexity into the JVM runtime.

If this proposal relies on Loom, what is the additional value of this proposal that justifies the addition of new Scala syntax and keywords? If we believe the proposa can be used without Loom, how do we expect to solve the HOF issue?

7 Likes

how does the compiler know if the user wants the doThing calls to happen async-sequentially or async-concurrently?

That would depend on the implementation of map and the ExecutionContext, Scheduler, or surrounding policies for structured concurrency.

Our tests in our internal library with LOOM show that HOF work already using just context functions and assuming a Structured scope that forks and tracks the fibers. Maybe we can figure a way to not color the functions and just use a Suspend or similar as context parameter while still performing the CPS transformation where needed.

The proposal exclusively seeks a direct style. Maybe we can do that with colored functions or context types. LOOM is JVM only, but Scala is multi-platform. LOOM currently does not provide functions like continuation, and it’s focused on virtual threads. We think depending on LOOM for this feature is fine if we have alternatives for Scala JS and Scala Native from which we can construct functions like continuation.

If the impl is not dependent on LOOM, we can solve the HOF problem by having a contextual type Suspend that works alongside the implicit system. The compiler could then treat lambdas and function bodies with this context type as candidates for CPS. This would probably require instrumenting both declarations and call sites, and not sure of the implications on bin compact but it would not require an additional suspend keyword.

Would it work If instead of a suspend keyword we have just Suspend ?=> A. Context functions carry implicits in a way that composition with HOFs and others work as is today. The missing part is CPS over the body to create the suspension points state machine and loops

class Suspend

val x : Suspend ?=> Int = 1

val res = List(1, 2).map(_ + x) // this is ok

given Suspend = new Suspend

println(res)  //List(2, 3)

I think such integration may look like this https://github.com/47deg/TBD/pull/30

There are better integrations for IO or any data type if its ADT has support for the particular case of suspension. In that case, it could also suspend instead of requiring the integration to run unsafeToCompletableFuture or a similar terminal function.

I think overall, what is missing from this proposal is a clear description of:

  1. What does this provide to a user that Loom does not? All your user facing examples can be implemented with Loom with no compiler support. Are there user-facing code snippets that cannot work on Loom, that you can get working with this proposal? The internal machinery like the continuation function isn’t that interesting, because unless we can show improvements to user-facing code, it’s just a new syntax for library authors to implement the same thing they could already implement

  2. What’s the plan for Scala.js and Native? You repeatedly list being “cross platform” is an advantage of your proposal over Loom, but AFAICT the only current implementation relies on Loom, and the whole cross-platform design is hand-waved away as “we’ll figure something out”. If you want to claim that being cross platform is an advantage of your design, you actually have to provide a design that works cross-platform. Loom is probably more than a person-decade of work at this point, so figuring out an equivalent for Scala.js and Native is not something we can take for granted

  3. What are the limitations of this proposal? Every other implementation of this in any language has significant user-facing limitations. Even hardcoded-into-the-runtime implementations like Loom or Go’s lightweight threading hit issues with FFI calls into C code or OS-level blocking syscalls. We need a clear picture of what trade-offs this proposal makes, and where in the solution space this fits.

I’m fully in support of the stated motivation: direct-style code is what everyone learns in programming 101. If we can express monads and stuff in a direct style, without all this map/flatMap inner-platform-effect noise, while preserving all the other benefits of using monads, that’s great. But it’s a tough problem to crack

15 Likes

TLDR: This pre-SIP proposal to add “suspended functions and continuations” to Scala 3 needlessly imports problemmatic legacy Kotlin approaches to solving a problem that Loom already solves in a theoretically more performant and user-friendly fashion.

In this discussion, I’m going to ignore “side-effects” since it is my firm belief that “side-effect tracking” is not commercially relevant, and if it were (which it’s not), it would have another solution that is not connected to the challenges around async programming.

Kotlin attempted to solve the async problem by introducing language-level coroutines, which are implemented by specially translating functions marked by suspend into computations that may asynchronously suspend through (essentially) “saving” JVM stack to the heap.

This solution allows Kotlin to support async programming without the level of ceremony and boilerplate common in “reactive” solutions, including those based on monads. More importantly, it allows Kotlin to support direct imperative style, which is familiar to nearly all programmers and requires no training.

Unfortunately, what was once an advantage for Kotlin has become a liability and legacy decision: Kotlin chose to solve the async problem by modifying the Kotlin language, rather than the Kotlin runtime.

This means that information on which functions are async leaks into the programming language syntax itself. Kotlin has re-invented the colored functions problem, with all of its well-known drawbacks.

An alternative solution to the async problem would have been to bake async into the Kotlin runtime, rather than the language, essentially by reinventing virtual threads, which would have allowed Kotlin developers to write Kotlin code that is obvlious to whether it is async or sync.

Unfortunately for Kotlin, the JVM innovated at a rapid pace that few could predict, and now Loom has given us full async programming in a direct imperative style, without the need to mark any functions. Loom also transparently works with all legacy code, making it fully modern and asynchronous (with a few exceptions such as synchronzied and file IO).

Kotlin is now in a weird situation where its (at the time) forward-looking support for async programming has become legacy–a solution that is inferior in terms of maximum theoretical performance, as well as user-friendliness, to the virtual threading solution powered by the Loom advancement of the JVM.

Scala 3 can already support fully async programming with no additional syntax or compiler passes, merely by running on a post-Loom JVM! In this world, we are able to write fully asynchronous code in a direct imperative style, without introducing introducing the two-colored functions problem, or importing Kotlin’s (now legacy) solution to the problem.

Now, in the spirit of fairness, I should point out that advancements in Loom do not help Scala.js or Scala Native. For all of the good that Loom does, its benefits are restricted to the JVM. Currently, the vast majority of all commercially relevant Scala development occurs on the JVM. While I love Scala.js and Scala Native and support their development, these markets are tiny and will remain so for the foreseeable future. The JVM is where all the commercial dollars are going and where Scala’s strengths remain.

In my view, the Scala.js and Scala Native markets are insufficient to justify any language-level changes to the async problem. If those markets were commercially relevant, then that might be an argument to invest resources in solving the problem, but it should be solved in the way that Loom solved it, which is a substantially and objectively superior solution (transparent runtime support) to the way Kotlin solved it (two-colored functions). Under no circumstances would I recommend that Scala 3 copy the Kotlin model to support the Scala.js and Scala Native communities.

In summary, there is no reason to pollute the Scala 3 language with two-colored functions when Loom already solves the async problem in a way that is significantly better than the way Kotlin solved it.

Notes on Proposal

  • Addition of continuations is not necessary or helpful to achieve structured concurrency; indeed, the JDK itself will be getting structured concurrency primitives
  • Being able to eliminate Either is a non-goal given Scala 3 can leverage exceptions, which are compatible with direct imperative style
  • Union types and flow typing provide a nice way to reduce the need of Option and keep things simple
  • Non-blocking sleep and generators are already free with Loom
17 Likes

Implementing continuations at the compiler-level, as you noted, benefits all compiler targets, such as Scala.js and Scala Native.

Personally, I don’t care about native compilation (even as I realize that many other people do), but I care about JavaScript — it is my opinion that, given the trends, JavaScript will dominate the market for scripting. And it’s already giving the JVM a run for its money. The appeal of languages like Scala or Clojure is the benefit one gets from using a mature runtime and ecosystem of libraries, such as those of the JVM or JavaScript/Node.js. And if the language is designed to leach off a host, might as well target multiple hosts. Because it’s awesome to use the same language and the same base libraries on both the server and the client (the major appeal of JavaScript, actually). Also, Java++ will always be the next Java.

There’s also something to be said about Java’s timeline. The next LTS may support Loom, but many companies are still on Java 8 (2014), and I predict another 2 decades at least for the Scala language and libraries to be able to comfortably target and rely on Loom.


Not sure what you mean by two-colored functions being undesirable — isn’t that the whole point of types and effect systems? What are IO data types, if not much needed coloring? And why can’t Loom be used as an optimization only?

Personally, I like Kotlin’s continuations due to Arrow Fx.

There are downsides to usage of suspend in Kotlin, versus IO, such that IO is no longer a data structure with a custom interpreter, which reduces its compossibility (via flatMap). However, suspend does suspend side effects, and has better performance, and I can’t see why it couldn’t interface with our IO data types. But in spite of all that, I believe it has a better UX in the context of Scala.

Truth be told, if the compiler did this, then our IO data types become a tougher sell for your average programmer.

6 Likes

I agree that Javascript has become hugely important, including for scripting, but the bulk of backend development occurs in traditionally server-side languages such as Python, Java, C#, C++, etc.

The Javascript ecosystem is quite well-established and mature, and features (via Typescript) an extremely capable and industry-supported solution for static typing.

In my view, it would be a mistake to bet Scala’s future on the possibility of “winning” the unproven Javascript market. If anything, we should double down on what’s working: bread & butter APIs (REST + GraphQL), microservices, and data engineering.

In any case, even if we all agreed that Javascript is the future of Scala (which I do not ascent to, I have no confidence in that whatsoever), I would strongly advocate for taking Loom’s approach to async programming rather than Kotlin’s approach, which would involve zero language-level changes to Scala 3. Rather, it would be an internal implementation detail in Scala.js (actually, a rather large number of them, which would ultimately mean Scala.js could support all the java.util.concurrent.* machinery).

That proposal (which is not this proposal), I could certainly get behind (assuming I were not footing the bill, because quite honestly, and in my opinion, there are much more pressing concerns to solve than async on Javascript!), because it would solve the async problem in the correct way, without permanently marring Scala 3 language syntax and semantics on account of niche platform-specific limitations.

7 Likes