Skip to content

SIL.rst: Add documentation for async function representation. #33994

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

Merged
merged 4 commits into from
Sep 23, 2020

Conversation

jckarter
Copy link
Contributor

Unlike our existing coroutines, async functions run independently within an async coroutine context, and don't
directly yield values back and forth. They therefore mostly behave like normal functions with only an @async annotation
to indicate the presence of async suspend points. The withUnsafeContinuation primitive requires some instructions
to represent the operation that prepares the continuation to be resumed, which will be represented by
begin_async_continuation...await_async_continuation regions.

Unlike our existing coroutines, async functions run independently within an async coroutine context, and don't
directly yield values back and forth. They therefore mostly behave like normal functions with only an `@async` annotation
to indicate the presence of async suspend points. The `withUnsafeContinuation` primitive requires some instructions
to represent the operation that prepares the continuation to be resumed, which will be represented by
`begin_async_continuation`...`await_async_continuation` regions.
@jckarter jckarter requested review from rjmccall and atrick September 19, 2020 00:13
@atrick
Copy link
Contributor

atrick commented Sep 19, 2020

Thanks for writing these docs. At the SIL level, explaining async coroutines in terms of @async functions confuses me. Calling an @async function will require generating a coroutine, but the SIL design actually hides that. (I think most people reading this will initially assume that these instructions are used to create a coroutine for calling @async functions, which is backward).

Instead, here we're talking about any function that may suspend execution in some way that does not involve calling an @async function. It may be worth pointing out that calling an @async function implicitly generates a coroutine in a way that's hidden from SIL, while these instructions are for other kinds of suspension points that aren't naturally handled by an @async function call, namely withUnsafeContinuation. As a footnote, we can say that they are currently limited to @async function bodies for implementation reasons that are totally unrelated to SIL design.

In SIL, there's no reason that a synchronous function can't suspend. I think that will be important to support at the language-level when we can statically determine the executor. There will be some type annotation on such functions and additional type restrictions on its interface similar to the restrictions on async coroutines. So, if we needed some SIL function attribute to guard the use of these instructions, I would call it @maysuspend instead, but that doesn't seem useful.


Regarding the implications for SIL passes, a lot seems to be hidden in this statement:

It is possible for a continuation to be resumed before ``await_async_continuation``.
 In this case, the resume operation returns immediately, and
 ``await_async_continuation`` immediately transfers control to its ``resume``
 or ``error`` successor block, with the resume or error value that the
 continuation was resumed with.

We won't be able to allow side-effect free code motion across the await, which is something we've never had to content with. Do we need to add a constraint that values defined within the suspend region can't be used outside the suspend region?

We also, obviously can't reason about side effects across the await. The basically means that the await appears to write to the entire memory space, meaning we can't optimize anything across suspends.

@jckarter
Copy link
Contributor Author

jckarter commented Sep 21, 2020

Thanks for writing these docs. At the SIL level, explaining async coroutines in terms of @async functions confuses me. Calling an @async function will require generating a coroutine, but the SIL design actually hides that. (I think most people reading this will initially assume that these instructions are used to create a coroutine for calling @async functions, which is backward).

Maybe this is a matter of different perspective, whether you consider the "coroutine" to be the notional execution context that persists across async calls, or to be the mechanical LLVM coroutine lowering goop. I guess I'm speaking more in terms of the former than the latter—as I see it, the LLVM coroutine splitting is an implementation detail, whereas the Task that async functions run in is part of the programming model. An async function doesn't represent a whole Task/coroutine unto itself, but a piece of work that can run on that Task.

Instead, here we're talking about any function that may suspend execution in some way that does not involve calling an @async function. It may be worth pointing out that calling an @async function implicitly generates a coroutine in a way that's hidden from SIL, while these instructions are for other kinds of suspension points that aren't naturally handled by an @async function call, namely withUnsafeContinuation. As a footnote, we can say that they are currently limited to @async function bodies for implementation reasons that are totally unrelated to SIL design.

I guess it isn't clear, but the intent of these instructions was to give something for withUnsafeContinuation specifically to lower to, as John described in this forum post. I can add a more explicit example of how withUnsafeContinuation lowers to these SIL instructions. The hope is that withUnsafeContinuation's operation block can be inlined into the caller in most situations.

In SIL, there's no reason that a synchronous function can't suspend. I think that will be important to support at the language-level when we can statically determine the executor.

Could you give me an example of what you have in mind? As I understand it, anything associated with an executor that might suspend ought to be async in the language design. What differences would there be between @async and @maysuspend?

Regarding the implications for SIL passes, a lot seems to be hidden in this statement:

It is possible for a continuation to be resumed before await_async_continuation.
In this case, the resume operation returns immediately, and
await_async_continuation immediately transfers control to its resume
or error successor block, with the resume or error value that the
continuation was resumed with.
We won't be able to allow side-effect free code motion across the await, which is something we've never had to content with. Do we need to add a constraint that values defined within the suspend region can't be used outside the suspend region?

await_async_continuation shouldn't be any more opaque to optimization than a try_apply. Code outside of the task can't manipulate the function's local state, but can have arbitrary global side effects, like a synchronous call.

Reading this back, I see it sounds like it's implying that resuming a continuation before await_async_continuation immediately transfers control to the await_async_continuation successor, which isn't what I meant to say—the resume operation returns immediately to its caller after priming the await, and then later, when the await_async_continuation executes, it immediately goes to the resume/error successor using the value it was primed with. I'll reword it to clarify.

Thanks for taking a look and giving me your feedback, Andy!

docs/SIL.rst Outdated
A coroutine type may declare any number of *yielded values*, which is to
obey a strict stack discipline. Different kinds of coroutines in SIL have
different representations, reflecting the different capabilities and
structural constraints of different language-level features:
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it make sense to call async functions a kind of coroutine? They kind of aren't from SIL's perspective; they use mostly exactly the same SIL abstractions as normal functions. Feels like it's just a new dimension of function type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps trying to discuss them as a kind of coroutine only serves to confuse. Thinking of them as just another axis of function type might be easier to understand; I'll try that.

@jckarter
Copy link
Contributor Author

I revised the text a bit; this time I stop trying to discuss async functions as a kind of coroutine, and clarify the semantics of await_async_continuation when the continuation is resumed before it's awaited.

@atrick
Copy link
Contributor

atrick commented Sep 21, 2020

Thanks for writing these docs. At the SIL level, explaining async coroutines in terms of @async functions confuses me. Calling an @async function will require generating a coroutine, but the SIL design actually hides that. (I think most people reading this will initially assume that these instructions are used to create a coroutine for calling @async functions, which is backward).

Maybe this is a matter of different perspective, whether you consider the "coroutine" to be the notional execution context that persists across async calls, or to be the mechanical LLVM coroutine lowering goop. I guess I'm speaking more in terms of the former than the latter—as I see it, the LLVM coroutine splitting is an implementation detail, whereas the Task that async functions run in is part of the programming model. An async function doesn't represent a whole Task/coroutine unto itself, but a piece of work that can run on that Task.

It's a natural assumption when reading this introduction that the new async_continuation instructions are necessary for implementaing @async functions, which is quite misleading. We can say that the instructions only occur in @async functions, but from the point-of-view of SIL, that's an arbitrary restriction.

Instead, here we're talking about any function that may suspend execution in some way that does not involve calling an @async function. It may be worth pointing out that calling an @async function implicitly generates a coroutine in a way that's hidden from SIL, while these instructions are for other kinds of suspension points that aren't naturally handled by an @async function call, namely withUnsafeContinuation. As a footnote, we can say that they are currently limited to @async function bodies for implementation reasons that are totally unrelated to SIL design.

I guess it isn't clear, but the intent of these instructions was to give something for withUnsafeContinuation specifically to lower to, as John described in this forum post. I can add a more explicit example of how withUnsafeContinuation lowers to these SIL instructions. The hope is that withUnsafeContinuation's operation block can be inlined into the caller in most situations.

That's totally clear to me, I just don't think it was clear in the docs.

In SIL, there's no reason that a synchronous function can't suspend. I think that will be important to support at the language-level when we can statically determine the executor.

Could you give me an example of what you have in mind? As I understand it, anything associated with an executor that might suspend ought to be async in the language design. What differences would there be between @async and @maysuspend?

What about an @asyncHandler function?

Or, purely hypothetical:

// Must not return anything...
func spawnTask() -> () {
  Builtin.beginAsyncTask(on: globalExecutor)
  // Synchronous return after setting up a continuation
  let request = await asyncRequest()
  // Asynchronous continuation
  _ = request
}

Regarding the implications for SIL passes, a lot seems to be hidden in this statement:

It is possible for a continuation to be resumed before await_async_continuation.
In this case, the resume operation returns immediately, and
await_async_continuation immediately transfers control to its resume
or error successor block, with the resume or error value that the
continuation was resumed with.
We won't be able to allow side-effect free code motion across the await, which is something we've never had to content with. Do we need to add a constraint that values defined within the suspend region can't be used outside the suspend region?

await_async_continuation shouldn't be any more opaque to optimization than a try_apply. Code outside of the task can't manipulate the function's local state, but can have arbitrary global side effects, like a synchronous call.

Reading this back, I see it sounds like it's implying that resuming a continuation before await_async_continuation immediately transfers control to the await_async_continuation successor, which isn't what I meant to say—the resume operation returns immediately to its caller after priming the await, and then later, when the await_async_continuation executes, it immediately goes to the resume/error successor using the value it was primed with. I'll reword it to clarify.

Oh, phew, I completely misread that statement. What I really need to know is that the region of code inside begin/await_async_continuation must run synchronously with the continuation's resume block. I'd need to think hard about how that's implemented. My mental model is that the body of the continuation is actually the resume block. So, once that continuation is added to a dispatch queue, there's nothing preventing it from running before execution within the begin/await region reaches the await. I must not be clear on how it's implemented in IRGen yet.

I just reread John's forum post (which you linked above). It's clear that we'll have some magic that blocks the continuation until the begin/await region (operation) complete. I see now that's the whole point of consuming the UnsafeContinuation in the await_async_continuation. That's great! No concerns here.

Copy link
Contributor

@gottesmm gottesmm left a comment

Choose a reason for hiding this comment

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

Some quick comments. I probably have some more.

docs/SIL.rst Outdated

sil-instruction ::= 'begin_async_continuation' '[throws]'? sil-type

%0 = begin_async_continuation $T
Copy link
Contributor

Choose a reason for hiding this comment

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

@jckarter to maintain future flexibility, did you consider making this have potentially multiple instruction results? Not saying it has it today, but if we want to add something this will ensure that we do not run into problems ala apply site.

Copy link
Contributor

Choose a reason for hiding this comment

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

begin_async_continuation doesn't traffic in user-provided values and so doesn't need multiple results. await_async_continuation does traffic in user-provided values, but I think the usefulness of producing separated values is probably tied to the usefulness of allowing reabstraction of the continuation result — which is to say, per the conversion I had above with Joe, not very useful.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it isn't useful today, but we should be cognizant that requirements can change in the future. It is easy to treat a multiple result instruction as a single result instruction. Hard to go the other way due to source incompatibility. That being said if we are /really/ sure there is no case in the future where that will be needed. I am cool. Just be aware that we are boxing in the design and potentially (if requirements change) requiring painful refactoring.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How hard would it be to add multiple results later if we decide it's useful? For now, YAGNI seems prudent.

docs/SIL.rst Outdated

sil-instruction ::= 'begin_async_continuation_addr' '[throws]'? sil-type ',' sil-operand

%1 = begin_async_continuation_addr $T, %0 : $*T
Copy link
Contributor

Choose a reason for hiding this comment

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

Same question about multiple result values here.

docs/SIL.rst Outdated
await_async_continuation %0 : $UnsafeContinuation<T>, resume bb1
await_async_continuation %0 : $UnsafeThrowingContinuation<T>, resume bb1, error bb2

bb1(%1 : $T):
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the ownership of T here? Owned? Can I have something that is guaranteed? It would be good to call this out explicitly.

Copy link
Contributor

Choose a reason for hiding this comment

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

It does always need to be owned, yeah.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I said in the text: " The value of the resume argument is owned by the current function." Is there a way I can make it clearer?

Copy link
Contributor

Choose a reason for hiding this comment

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

Mark it as @owned like it is in ossa.

Copy link
Contributor

Choose a reason for hiding this comment

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

bb1(%1 : @owned $T)

@rjmccall
Copy link
Contributor

I revised the text a bit; this time I stop trying to discuss async functions as a kind of coroutine, and clarify the semantics of await_async_continuation when the continuation is resumed before it's awaited.

I like the new text, thanks!

I wonder if begin_async_continuation is misleading as an instruction name — like Andy says, the region described by the instructions isn't actually the continuation, it's an operation that works with the continuation. Maybe get_async_continuation for the opener? The risk is that someone reading that might not understand that it's a region, but I think that's okay.

I really like await_async_continuation for the closing instruction, it captures the sense of what's happening from the function's perspective perfectly.

@jckarter
Copy link
Contributor Author

get_async_continuation seems reasonable. Hopefully the verifier can enforce the constraints well enough to help people understand what's going on if it isn't evident from the instruction names themselves

@jckarter
Copy link
Contributor Author

@swift-ci Please smoke test

@jckarter
Copy link
Contributor Author

Alright, I revised begin to get_async_continuation, and clarified the ownership of the bbargs. Any other concerns?

Copy link
Contributor

@atrick atrick left a comment

Choose a reason for hiding this comment

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

Nice!

@jckarter jckarter changed the base branch from master to main September 23, 2020 14:45
@jckarter jckarter merged commit 13f8641 into swiftlang:main Sep 23, 2020
Copy link
Contributor

@rjmccall rjmccall left a comment

Choose a reason for hiding this comment

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

No, this looks great to me, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants