Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

async-await RFC #143

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions text/0000-async-await.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
- Feature Name: Support async / await syntax sugar on top of promises
- Start Date: 2019-03-02
- RFC PR: (leave this empty)
- Pony Issue: (leave this empty)

# Summary

Promises are very useful to facilitate the management of asynchronous behaviour, but working with them can quickly become tedious and error-prone.

Many programming language communities (JavaScript, Dart, Rust, C#, F#,Python, Scala, Kotlin) have recognized this problem and converged on the async/await construct.

# Motivation

Pony is quite different from most programming languages that are widely used by today's programmers. For this reason, Pony already offers many difficult challenges which may put off a large amount of programmers who could otherwise become active members of the community and contribute to the language's success.

One of the difficulties in Pony is no doubt working with asynchronous behaviours. Promises help a lot in that regard, but their usage can easily degenerate into what some people have termed the "callback hell", which makes it much harder to reason about the flow of a program.

The async / await pattern makes working with asynchronous code, and specially reasoning about it, much easier. Given that many programmers are already familiar with the concept, introducing it to Pony should help ease the learning curve of the language quite significantly without impacting negatively its overall design and performance.

# Detailed design

The async / await pattern can be implemented as syntax sugar by the compiler because the Pony Programming Language already supports:

* lambdas which can capture some of the scope surrounding it (explicitly).
* asynchronous computation that uses lambdas to continue the flow of execution (as promises do in Pony) at some point in the future.

The reason why these pre-conditions are enought is that they allow the automated translation of code as follows, (assuming `async fun` are functions that can use behaviours to return a value computed asynchronously, in the form of a `Promise`):

```
async fun lambdaV(t: T): Promise[V] => ...
async fun lambdaW(v: V): Promise[W] => ...

async fun async_fun(): Promise[W] =>
let promise: Promise[T] = /* obtained from an async call */
let t: T = await promise
let v: V = await lambdaV(t)
let w: W = await lambdaW(v)
// from the point of view of the caller,
this is a Promise[W] because this "fun" is marked as "async"
w
```

To:

```pony
async fun lambdaV(t: T): Promise[V] => ...
async fun lambdaW(v: V): Promise[W] => ...

fun async_fun(): Promise[W] =>
let promise: Promise[T] = /* obtained from an async call */
let result: Promise[W] =
promise.next[V]({(t: T) => lambdaV(t)})
.next[W]({(v: V) => lambdaW(v)})
result
```
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please show full desugaring. I can't evaluate what desugaring is supposed to be based on this partial desugared example.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't the compiler do that with the above code once you fill in the blanks?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please show full desugaring.


As the example above shows, each `await` call can be translated automatically into a promise chain. This translation would only occur in functions marked `async`, as above.

`async` functions are **not** functions that run, themselves, in another `Actor`, but they would presumably call an Actor's behaviour to perform asynchronus computation, returning a `Promise` instance passed to the called behaviour(s), as in the following example:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this point needs to be elaborated. "presumably" needs to be fleshed out into actual mechanics.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One may declare an async function and still not use await on anything, or just not ask for an Actor to fulfill the promise, but that would be pointless. If you don't need to have a promise fulfilled by a behaviour, why would you use one? Maybe that should be disallowed? So presumably would become certainly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of async as a keyword then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can only call something marked as async from within an await? Is that the point of the async keyword?


```pony
Actor Foo
be compute(p: Promise[T]) =>
let t: T = /* compute a value */
p(t)

class Bar
async fun bar(): Promise[V] =>
let p = Promise[T]
Foo.compute(p)
let t: T = await p
create_v(t)

fun create_v(t): V =>
let v: V = /* compute V */
v
```

> Future work might look at allowing behaviours to return `Promise` instances to make the above pattern and async / await even more friendly.

There are some challenges regarding the capture of the lexical scope by the lambdas, but they can be solved by following the same rules as for lambdas: all captures must be explicit.

Therefore, in the case of `await` calls, a capture would look similar to lambda calls:

```pony
class Foo
new create(env: Env, p: Promise[T]) =>
let t: T = await(env) p
// env can be used here as it was capture above
t.use(env)
```

De-sugared to Promises and lambdas, this would become:

```pony
class Foo
new create(env: Env, p: Promise[T]) =>
p.next[None]({(t: T)(env) =>
// env can be used here as it was capture above
t.use(env)
})
```

The implementation of this feature, for the above reasons, can and should be limited to the compiler's de-sugaring phase, without any other changes required.

## Limitations

`async` should be limited to function bodies only, not including constructors. Even though the single abstraction of asynchronous computation will continue to be Actor's behaviours, this limitation is necessary because:

* the feature remains opt-in, so programmers have a choice on whehter they want to use it.
* it is necessary to enforce that `async` functions return a `Promise` and no other types are allowed.
* it should help avoid slowing down the compiler as most functions presumably will not be marked `async`.
* behaviours cannot currently return values, so this feature doesn't make sense for them.

# How We Teach This

Because several popular languages have already introduced this pattern, the terminology used to describe it is pretty well established already. However, some of the terminology does not apply in Pony as well as it does in other languages.

For example, async / await is commonly described as follows (taken from the [Wikipedia article](https://en.wikipedia.org/wiki/Async/await)):

```
... the async/await pattern is a syntactic feature of many programming languages that allows an asynchronous, non-blocking function to be structured in a way similar to an ordinary synchronous function.
```

what "blocking" and "non-blocking" mean may not be clear to everyone in the context of the Pony runtime. It may be best to avoid using such language, preferring only synchronous VS asynchronous execution.

A call to `await` absolutely does not mean to block, it simply means to give up execution to another actor, a `Promise`, until it calls back (or errors, or even never) and executes the remaining of the body of the function (which is really, just a lambda inside the body of the function). This is why `async` functions are not allowed to return anything but a `Promise`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to the actor that "gives up execution"? Is its stack saved? Does it continue processing messages? If yes, how do you reconcile the stack that was saved with the context of the might have changed (fields for example) due to processing of additional messages.

"give up execution to another actor" is rather vague.

"it simply means to give up execution to another actor, a Promise, until it calls back (or errors, or even never) and executes the remaining of the body of the function (which is really, just a lambda inside the body of the function). This is why async functions are not allowed to return anything but a Promise."

isn't really how Pony works.

I don't think this is purely sugar. With a Promise, I give it a function to execute later when the promise is fulfilled and there is no local state that is saved for me to return to.

Are you suggesting that as part of the sugar, that all state on the stack is captured in a lambda that will be executed when the promise is fulfilled? But, with some sort of partial application?

Imagine for example:

let a: U64 = 1
let b: U64 = 2
let c: U64 = await ... something...
a + b +c

How does the above work?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From one part of this it might be that I need to do this? It's not clear to me:

let p: Promise[U64] = ... create some promise...
let a: U64 = 1
let b: U64 = 2
let c: U64 = await(a, b) p
a + b + c

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to the actor that "gives up execution"? Is its stack saved? Does it continue processing messages?

I don't understand these questions in light of my proposal.
Let me ask the same questions considering how Pony exists today:

  • What happens to an actor that calls next on a Promise, "giving up execution" (because the behaviour ends there) until the lambda it passed into next is called? It its stack saved? Does it continue processing messages?

The answers with my proposal are the same as today.

let a: U64 = 1
let b: U64 = 2
let c: U64 = await ... something...
a + b +c

You need to capture a and b if you need to use them after await (just like today when you create a lambda):

let c: U64 = await(a, b) .... something
a + b + c

Then the whole thing becomes:

let a: U64 = 1
let b: U64 = 2
(some promise).next[T]({(c: U64)(a, b) =>
    a + b + c
})

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to an actor that calls next on a Promise

A promise is an actor. next is an asynchronous message send to it and function execution continues as per normal like it would after any other message send.


It may help to have a tool, perhaps in the compiler itself, to expand async functions into their de-sugared version, similarly to how there are tools that expand macros in languages that support them.

No other parts of the language are affected by this change except the assumption that the body of a function always runs to completion before any other behaviour or function can execute in the same class or Actor. However, as the assumption still holds in the de-sugared case, explaining this exception in the documentation regarding only this feature should be enough to avoid confusion.

# How We Test This

The changes required to implement this feature, being restricted to the de-sugaring logic of the compiler, is fairly easy to test as it does not even require executing compiled programs.

Tests should include:

* `await` must not be recognized as a keyword outside of `async` functions.
* only functions may be marked with `async`.
* `async` functions must have `Promise` as a return type.
* the return value of the function is always automatically wrapped into a `Promise`.
This implies that if an `async` function returns a value of type `Promise[T]`, its return type must be `Promise[Promise[T]].
* compiler error messages in sugared code should take into account the fact that the error won't match the source.
* nested promises can be handled (e.g. `let v: V = await await p` is valid).

# Drawbacks

This feature is quite simple to use, but some people may face issues related to reasoning about which part of the code is run sequentially, and which part is not. This is already somewhat problematic in Pony since the introduction of lambdas, but the difficulty seems to be low.

The introduction of a lexical scope which is not separated visually from the surrounding scope (because of the introduction of implicit lambdas) is at the same time a big drawback and essential to make asynchronous code easier to read and write. As the Wikipedia description of the pattern says, the pattern makes it possible to structure asynchronous code similarly to ordinary synchronous code. The latter is without doubt easier to reason about. Due to the fact that reading and writing asynchronous code is central to Pony's proposition, making it easier for programmers should take precedence over comparatively minor comprehension issues (such as understanding the lexical scopes changes).

No existing code should break due to this feature because `async` functions do not yet exist.

# Alternatives

Composing asynchronous computations with Promises or Futures in a way that is easier to read and write than by using callbacks explicitly has been attempted previously by other languages.

For example, Go channels offer a different solution, though they could not be transferred to Pony due to the fact that receiving messages from channels necessarily blocks the caller's execution.

An older and similar technique is continuation passing, but experience shows that they tend to become even harder to read than commonly used promises today.

Some patterns have emerged within the Pony community that actually solve some problems with composing asynchronous computation. For example, it is possible to use an aggregator `Actor` to collect results from many parallel computations, then calling back when all results have been received, without using promises at all.

The problem is that, even though this kind of pattern may work well in many circumstances, it doesn't actually help with composing promises sequentially, only concurrently, and even then arguably in a way that is not nearly as easy to read as sequential async / await instructions.

# Unresolved questions

In order to make this pattern more easily usable in Pony, there would be a need to integrate the main asynchronous construct in Pony, Actor's behaviours, with Promises, so that behaviours would be able to return a value to a caller via a Promise (rather than having to be given the Promise to fullfill by the caller). This would be, however, a much bigger change that would involve runtime changes as well as a restructuration of the compiler, hence this was deemed to be out of scope for this RFC.

There may be some issues related to how the translation from sequential await calls to promise chains should be done, but due to the explicit nature of types declarations and lexical scope captures in Pony, these should not be very problematic.