Alternate async/await proposal

This proposal is a possible alternative to A proposal for async style sugar.

The syntax and usage of async and await would look very similar to that of JavaScript, e.g.

let doSomething = async (arg) => {
  let value1 = await asyncOp1(arg);
  ...
  await asyncOp2(value1);
}

This would compile to the following JavaScript:

let doSomething = async (arg) => {
  let value1 = await asyncOp1(arg);
  ...
  return await asyncOp2(value1);
}

In order to simplify the implementation, both async and await would be converted to “apply” nodes by the parser just like regular function calls. This allows us to leverage the existing type checker without introducing new node types to the parse tree. The parser would also be responsible for checking if await was being used outside of an async function.

Programs using async/await would have external declarations for these functions providing they types necessary for the program to be type checked, e.g.

@val external _await: (Js.Promise.t<'a>) => 'a = "await"

@val external _async0: (() => 'a) => (() => Js.Promise.t<'a>) = "async"
@val external _async1: (('a) => 'b) => (('a) => Js.Promise.t<'b>) = "async"
...

let doSomething = _async1((arg) => {
  let value1 = _await(asyncOp1(arg));
  ...
  _await(asyncOp2(value1));
}

The frontend would then be responsible for convert _async0, _async1, …, and _await calls back to JavaScript async/await syntax.

This is similar to the approach I used to implement tagged template support in https://github.com/kevinbarabash/rescript-compiler/pull/2, albeit a bit more complicated because of the introduction of new keywords.

One limitation of this approach is that it requires async functions to await any promise before returning its value so that the types work. As an optimization, we could convert return await foo; to return foo; since the generated output since JS does automatic promise coalescing with async functions.

Here’s a playground link that shows the intermediate type-checking in action.

6 Likes

Now that multicore ocaml is very close to landing along with typed algebraic effects I would love it if ReScript could add async/await not as syntax but as some Promise.await function that uses an alg effect to await the promise.

I’m thinking it’s possible to do this and compile to standard async/await, while also adding support for generalised effect handlers at the same time.

I’m one of those people that think async/await was a miss-step in JS language design and would love if ReScript didn’t inherit all of the warts from that feature.

4 Likes

Looking at Future of OCaml | OCamlverse it looks like algebraic effects is separate from multicore support. It sounds like algebraic effects will require changes to the type system. All of this seems quite far away especially since we’re still using version 4.06. It sounds like we made some customizations to the OCaml source. I wonder how difficult it will be to update.

What are some of the warts of JS’ async/await that a system based on typed algebraic effects avoid?

One of the big ones is the introduction of more exceptions, it’s now very common to see await foo without an accompanying catch in JS. An effect handler could enforce handling rejections without sacrificing readability as with monads. This is “typed” part of a typed effect system, these are new errors that can be caught at compile time.

Another one is the introduction of memory leaks with async programming in JS. It’s possible to introduce a leak in many different but subtle ways and an effect handler could help with this. A shotgun approach would be to simply cancel long running promises, with a handler the timeout could be set at a granularity that makes sense for the application (or there could be just a single timeout for the whole application). This is something that can be implemented in userland, but not without adding indirection and abstractions.

2 Likes

An effect handler could enforce handling rejections without sacrificing readability as with monads.

Would wrapping a whole bunch of awaits in a single try-catch be sufficient to satisfy the enforced condition? If so, that would be pretty sweet. If an async function has a try-catch around all of its awaits would callers of that async function be able to get away without a try-catch? e.g.

let foo = async () => {
  try {
     await bar();
  } catch {
     // handle but don't rethrow
  }
}

let main = async () => {
  await foo();
}

I spent some time looking through ocaml-effects-tutorial and found this example that implements async and await as functions using algebraic effects.

module type Scheduler = sig
  type 'a promise
  val async : (unit -> 'a) -> 'a promise
  val await : 'a promise -> 'a
  val yield : unit -> unit
  val run   : (unit -> 'a) -> unit
end

// actually define the Scheduler module

let main () =
  let task name () =
    Printf.printf "starting %s\n%!" name;
    let v = Random.int 100 in
    Printf.printf "yielding %s\n%!" name;
    yield ();
    Printf.printf "ending %s with %d\n%!" name v;
    v
  in
  let pa = async (task "a") in
  let pb = async (task "b") in
  let pc = async (fun () -> await pa + await pb) in
  Printf.printf "Sum is %d\n" (await pc);
  assert (await pa + await pb = await pc)

let _ = run main

It feels a bit clunky to use, in particular having to call async whenever you want to schedule an asynchronous function to run. The implementation of run appears to be left an exercise the reader which is unfortunate because I would’ve liked to have seen how complicated run actually is. I also wonder how this kind of system would interop with JS promises.

Rethinking things a bit we could make await be a function as you suggested and use a @async decorator on functions in place of an async keyword (or async function), e.g.

@async
let doSomething = (arg) => {
  let value1 = await(asyncOp1(arg));
  ...
  await(asyncOp2(value1));
}

This could be implemented today without waiting for algebraic effects, but could still be refactored later to use them if we want.

2 Likes

I would caution against making plans around algebraic effects. OCaml 5.0 does not plan to introduce actual AE syntax, only some library function helpers to help access the AE system. And typed effects are even further out. Talking about a ReScript async/await system in terms of AE is very premature.

1 Like

That’s fair. I would personally prefer to wait to see how it plays out instead of adding async/await syntax support on its own. I really don’t think it offers much benefit and comes with a whole host of problems (as described above)

One downside I just realized about making await a function is that it won’t work with the pipe operator. I think we’d probably want to support something like:

let response = request()
  ->await sendResponse()
  ->await updateDatabase()

I was also thinking about how this proposal might generalize to other things like generators, sorry about the contrived example:

type Js.Generator.t<'a> = { done: bool; next: () => 'a };

@generator
let triangleNumbers = (): Js.Generator.t<int> => {
  let plus = (a, b) => a + b;
  
  0->yield plus(1)
   ->yield plus(2)
   ->yield plus(3);
}

@generator and @async should also be able to stack, e.g.

@async @generator
let fetchComments = (url) => {
  let response = await request(url)
  yield response["comments"]
  let nextUrl = ref(response["nextUrl"])
  while nextUrl.contents {
     let response = await request(nextUrl.contents)
     yield response["comments"]
     nextUrl.contents = response["nextUrl"]
  }
}
1 Like

Bravo! I used to think about asynchronous flow in ReScript quite a lot because on the server-side the current state of the language causes a lot of pain in some scenarios. I never thought about simply binding to JS async and await just if they were regular functions. This is very smart!

One could try to use this right now, without waiting for new compiler or stdlib features :heart: Yep, async0, async1, async2 is not elegant, but we use such arity suffixes for other stuff anyway.

I like it this way. IIRC the async keyword in JavaScript is nothing more than a backward-compatibility protection in case some existing code uses await as a variable name, so async is a superfluous specifier which could be omitted if async/await were introduced in JS from the very beginning. If we need to keep async in ReScript as a necessary evil to simplify compiler, let it be a @async decorator which arguably better reflects the intention.

And await being a regular function is super-elegant. No new constructs to introduce, document, and maintain.

I see no big problem here. In JS you’d deal with it like this:

const response = await request()
await sendResponse(response)
await updateDatabase(response)

which straightly projects to ReScript:

let response = await(request())
let () = await(sendResponse(response))
let () = await(updateDatabase(response))

And when pipe-style processing makes more sense, one can always use Promise.then and Promise.thenResolve

With an await function you should also be able to pipe to ignore or bind to _ right?

await(sendResponse(response))->ignore
let _ = await(updateDatabase(response))

Even if we used function calls for everything, we’d still need compiler support to convert those calls to the async/await syntax in the JS output.

Or you can bind to a poor man’s algebraic effect implementation :sweat_smile:

1 Like

Interesting approach to algebraic effects. I was thinking of using generators for algebraic effects since I think they could be used to implement the delimited continuations described in ocaml-effects-tutorial which I linked in a previous comment. Just because OCaml uses delimited continuations under the hood doesn’t mean we have to. I’m curious how perf would differ between these two approaches. I worry about the quality of stack traces with both of these approaches.

The problem with generators for alg effects is that they pollute every function in the callstack, similar to async functions. The beauty of effect systems is that they exist orthogonal to the call stack, wrapping handlers around regions of the call tree.

You could maybe throw generator functions for the implementation though :thinking:

Any comment on custom let bindings like let% for this use case?

Would you want to use let% still if async/await was available? I’m assuming that’s what you’re looking for, and not just general monadic behavior.

Correct me if I’m wrong, but the let% operator can be custom defined inside a module, and async is only one possible use (see ocaml lib lwt). So it’s more powerful than the javascript async operator.

Oh, they use let*. Eh, no idea what let+ does… Source: Lwt now has let* syntax - Community - OCaml

If I would still use let% if async was available? Dunno. :slight_smile: I’d try to follow the idioms and style of the community, I guess. :grin:

1 Like