Since it seems directly relevant, the slightly different syntax for using
an object with an asynchronous dispose in C# is await using (<expression>)
, as opposed to simply using (<expression>)
.
Not sure if I should post here with an overly half baked comment - but this seemed like an interesting question to ponder ... -- and hopefully the comments of a less experienced perspective are not super negative to the conversation ...
Maybe the async let
declaration syntax could be expanded to allow declaring logic that is intended to run when the declaration leaves scope?
Perhaps an initializer expression could take a keyword closure ...
<scope-effect> let x = <initializer expression> trailingKeywordInlineClosure: { <scope-effect args> in ... <closure body> }
Something like this for async let:
// ok: no suspension at scope exit
{
async let x = doWork(0)
await x
}
// error: async declaration x missing await on some path, async suspension effect must be acknowledged on scope exit path at try
{
async let x = doWork(0)
try failableWork()
await x
}
// ok: scope exit suspension effect acknowledged on every path via onAsyncLetScopeExit: argument to async let declaration ....
async let x = doWork(0) onAsyncLetScopeExit: { ackSuspension in await ackSuspension(x) }
try failableWork()
await x
In this sketch the onAsyncLetScopeExit:
keyword arg to the initializer expression would be made legal by the async
in front of the let ... and then that closure would be executed from within the guts of the logic associated with the effectful-let-declaration leaving scope ...
I have no idea what kind of restrictions would need to exist on that closure but I expect there'd need to be a bunch of them ... but being able to put logic into that point in the computation at all might provide a convenient place to put a breakpoint ... as being able to stop just before/just after the logic associated with an effectful variable's lifetime ending might prove a generally useful point in the computation to make observations of program state from ...
I’d say in the case not all async let
s are awaited explicitly, just nest the block into another block and prefix the inner block with (try) await
.
That doesn’t sound like such a blocking issue that requires the creation of a new “async defer
” to solve. Presumably defer
s from async
functions would be able to await on things (and similarly for throwing).
Hmm, this discussion has unlocked something for me (though apologies if it was already mentioned up-thread, or if this is what you were getting at @Karl ): we already have
defer
as a spelling for “do this along all exit paths from the scope,” so would it not be a solution to allow defer
to suspend and then tell users that if the value doesn’t get await
ed along all paths naturally, they should wrap it in a defer
:
async let x = …
defer { await x }
?
I had this same thought, but then I was wondering if it would be too strange since in theory, a defer
block would be executed after all code paths, even ones where x
had already been awaited. Or am I missing other circumstances where await
could really mean ‘await if not already awaited’?
Presumably we'd have the same situation with a setup like this:
async let x = ...
if specialCondition() {
specialSetup(await x)
}
standardPath(await x)
While I wanted to answer this, I found this interesting bug:
It seems that it is currently impossible to access an async let
twice.
When I try it this way
async let x = foo()
bar(await x)
baz(await x)
I get the error Immutable value 'x' may only be initialized once
on the first line. If I change the code to this instead
async let x = foo()
bar(await x)
baz(x)
Then I get the error Expression is 'async' but is not marked with 'await'
on the third line. This is on the latest Xcode beta btw.
Yeah, that's a known bug with async let
as currently implemented.
Yeah, so the thing about this is that I think it's too much noise.
One of (if not the) main benefits of structured concurrency is the ability to use regular control flow - throwing errors, conditionals, early returns (e.g. guard
), etc, so I think we should keep that as simple as possible, except if doing so is actually confusing or harmful.
When it comes to implicit await
s, I don't think they're all equally harmful. If the implicit await
happens because the function is over and you're leaving its scope, it's probably fine and we don't need to force developers to spell it out.
Implicit await
s within a function are potentially more insidious. But maybe not, I'm not sure. I guess it's possible you might write code which wants to reason about external state and carefully manage its suspension points. Generally speaking, async
functions should refrain from trying to reason about external state though, and anything they do need to reason about should be captured in a local variable.
So you really want to write code like this, with all your intermediate state being stored local to the function, rather than part of some struct or class which the function is a member of (and actors don't really help with that, because of reentrancy):
func processImageData() async throws -> Image {
let dataResource = try await loadWebResource("dataprofile.txt")
let imageResource = try await loadWebResource("imagedata.dat")
let imageTmp = try await decodeImage(dataResource, imageResource)
let imageResult = try await dewarpAndCleanupImage(imageTmp)
return imageResult
}
When you look at async/await code in other languages, it looks exactly like this.
So I think there's an argument that we're maybe exaggerating how important it is to explicitly call out each and every suspension point. For code that really embraces this pattern and only tries to reason about local variables, they won't really care if there are more suspension points that aren't immediately visible.
It seems like actors are a pretty direct counterexample to this. Their entire purpose is to provide a first-class construct for managing and reasoning about some external state, and reentrancy means that awareness of all suspension points is pretty important—implicit suspension points could lead to broken invariants across suspensions.
I wonder if it would be a feasible rule to say implicit suspension is not allowed within actor-isolated methods, but okay for 'normal' async
functions which aren't isolated to an actor.
True, but this is why reentrancy is such a problem. Implicit or explicit, any suspension completely invalidates anything you know about the actor state. Actors give you little islands of reasoning, but they are really small islands, and you shouldn't try to make a home there. Big functions with lots of suspension points mixed in-between reads/writes to actor state will be a recipe for bugs. I'd wager most actors will end up being small wrappers around an Array or Dictionary, with a bunch of methods that:
- Copy any relevant actor state in to locals, or set some kind of flag to say a transaction is in process and try to prevent anybody interfering with what you're about to do (e.g. inserting a task handle in to a dictionary).
- Start a transaction using that copied state (e.g. the download and process image example above, taken from the async/await proposal), being careful that the transaction itself doesn't directly access the actor state.
- Once the transaction is finished, merge its results back in to the actor state.
That's my take on it, anyway.
I quite like this idea. It would discourage people from trying to reason about actor state between suspension points by making them write a bunch of awkward boilerplate which they could avoid by writing their code a different way. It's not user-friendly, but it's ultimately for their own good.
TLDR: I think implicit suspension points are fine if our tools surface them and provide information about where they came from.
Over time, I've started to think about try
and await
more as granting permission to called functions rather than labeling source code, and I think this distinction is valuable when considering the async let
problem. The main benefit of these markers, as I see it, has to do with maintenance: the absence of these markers means that changes in the called function won't change the behavior of the calling function without the compiler telling you about it. For me, granting permission is great, but labelling things I already understand is what feels onerous. I'd wager that even as a learning exercise "write this word here to show you understand" is not a positive experience for the user.
The novel part about async let
is that the callee has the ability to potentially change behavior away from the call-site. Forcing users to label the places where this change could happen feels onerous to me, especially since they've already granted the callee permission to change the caller's behavior with async let
. From this perspective, the fact that the suspension point is implicit is good... the bigger problem is that it is hidden and thus may be surprising.
This is not the only potentially surprising thing that may happen in Swift. For instance, it may be surprising when calling count
on a collection is not O(1). Luckily, Swift already solves this with tooling: API docs mention this and they can be surfaced during autocomplete or in the help pane, and API that does this should call out this behavior. Without the tooling, this would not be a great situation (i.e. if folks had to go find the source file where count
was declared and check that it was O(1)).
Perhaps then, tooling is the right solution to our async let
problem. If Xcode (and sourcekit-lsp) highlighted potential implicit suspension points (maybe with an affordance which on hover pointed to the async let
s which cause them), this would not be an issue.
Corrolary: In "On the proliferation of try..." affordances like try do
and await do
were discussed as an improvement for try-or-await-heavy code. The granting permission mental model allows for this since you are granting permission to a scope to throw, suspend, or both. Without tooling, this would come at the cost of decreased visibility about where behavior might change based on a callees behavior. With tooling, we would get the best of both worlds: the ability to grant permissions at a less granular level and visual indications about which API might cause changes in behavior (and where). In fact, such a thing might already be useful for more complex expressions (for instance a function call where some of the arguments might throw
).
I agree with this. If there's too much ceremony, people may just refactor it into a function, defeating the purpose of async let
to be lightweight.
Though I think async defer
is still too heavy for the sole purpose of await
ing on all paths given that'd also unlock other async usage on defer path. It's already tricky identifying suspension points glancing at the code top-to-bottom, now we need to do it in reverse.
I have some questions related to this: Is there any case where code that modifies or just reads from actor mutable state in between await points can be correct? If there are cases like this, could that mutable state be better represented as immutable (a let
), making the answer to the first question a "No"?
Assuming the answer to both questions is "No" and "Yes" respectively, could the compiler disallow any access to mutable state inside an actor function in between await points, even implicit ones? The compiler would show an error on the line where the actor mutable property is being accessed, and in the error notes it would point out where the two related await points are (and highlight them in the source). It would allow access to mutable actor state before the first await point(s) and after the last await point(s).
If the issue is that with async let
the programmer might be unaware of the implicit await points, and knowing where the await points are in an actor function is important because you should not mess with actor mutable state between them, then I think this could solve the issue mentioned in this thread without any syntax changes, with only one rule to learn, and with static enforcement that reentrancy cannot be a problem, for actors at least.
Now, I hope I am not overlooking some obvious use cases for needing to touch mutable state in between await points within an actor function, but if that is the case and it cannot be solved by making that mutable state immutable in any way, then could we provide an escape hatch for more advanced use cases (maybe on a per actor method granularity)?
Some examples I thought through that the compiler should be able to handle
// labels:
// ✅ can access mutable state here
// ❌ can't access mutable state here
func actorMethod1() async {
... // ✅
if self.someBoolean { // mutable actor state
... // ✅
await someAsyncFunction()
... // ❌
} else {
... // ✅
}
... // ❌ since it is enforced statically
await anotherAsyncFunction()
... // ❌
await lastAsyncFunction()
// ✅
return
}
func actorMethod2() async {
... // ✅
if self.someBoolean { // mutable actor state
... // ✅
await someAsyncFunction()
... // ❌
await someAsyncFunction2()
... // ✅
return
} else {
... // ✅
}
... // ✅ since it is guaranteed that the function
// returns from the first if-else branch
await anotherAsyncFunction()
... // ❌
await lastAsyncFunction()
// ✅
return
}
func actorMethod3() async {
... // ✅
// there should be no issue with allowing
// someArray to be actor mutable state here
for _ in someArray {
... // ❌
await someAsyncFunction()
... // ❌
}
... // ❌
await lastAsyncFunction()
... // ✅
}
func actorMethod4() async {
... // ✅
async let x = ...
... // ✅
if condition {
... // ✅
someFunc(await x)
... // ❌
}
... // ❌
await someAsyncMethod()
... // ❌
} // <- compiler would point out this implicit
// await point due to the `async let` when `condition == false`
I really hope I'm not making a fool of myself with this post (at least I hope something in my post helps in any way).
How often in practice do we expect "awaiting on implicit cancellation of tasks at scope end" to lead to a task that never becomes runnable again ... say some async let created task calls into native code to integrate with a random external library and then that library code blocks forever ... tasks blocking forever on cancellation is what terrifies me most about implicit suspension -- i imagine it will be easy to produce code where the child task hangs forever on cancellation when some unrelated code throws an exception ...
async let first = someWorkThatBlocksInSomeExternalCLibrary
()
try somethingFailable()
someWorkThatUnblocksExternalCLibraryWhenRun()
With this kind of code, whenever somethingFailable() throws an error the task just hangs forever and the Error never even actually bubbles up into a loggable context? And if you could somehow attach the debugger on this task -- where would you put the breakpoint to confidently block just prior to cancellation to identify the actual trigger that leads to the task blockage ...
I worry about this kind of thing based on my limited experience with async/await in python -- which was a synchronous-everything ecosystem that tried to add async/await -- (vs javascript that had async only from tbr beginning). In my experience there were a lot of rough edges and difficult to reason about problem surface area associated with calling into (intentionally or unintentionally) large parts of the rest of the ecosystem that thought blocking was normal and fine and to be expected ...
The example you provide is pretty much the same as without async await, and is even solved nicer with it — since that „unblock” is necessary to run always… put it in a defer block: defer { someWorkThatUnblocksExternalCLibraryWhenRun() }
right after kicking off the work.
It not only is the right „style” to do so, it also ensures it’ll always run — even with an early return or throw after all.
I want to kind of rephrase my previous comment, because I think it maybe wasn't clear, or at least I thought of a clearer way of expressing it: actually, I don't think actors are a first-class construct for managing and reasoning about some external state. By all means, somebody correct me if I'm wrong.
Actors are very limited: they give you a box of resources that you can read or mutate without introducing data races. That's it. AFAICT, that's all they do.
If your code has suspension points, you still can't reason about how non-local variables change across those suspensions regardless of whether they are part of an actor.
You can use that race-free mutable state to try and develop higher-level (actor-level) reasoning - e.g. storing a task handle in a dictionary, so your image-cache knows if a request is currently executing, so it doesn't even call an async function; but they don't help you reason about state across suspension points at the function level - e.g. a processImage
function won't know whether any new requests started while it was awaiting an image to be decoded.
(And just to reiterate in case anybody is lost - this is all relevant to the topic because we're trying to figure out if it's even reasonable for developers to micro-manage their suspension points, or at what level it becomes un-reasonable, and hence whether implicit suspensions are a big deal or not)
I am really worried about unmarked suspension points. I think they would lead to many hard to find bugs and race conditions.
I am in favor of requiring to explicitly await all async let
s in all possible branches. The reason is that we can always drop this requirement while remaining source compatible if we find a better solution later.
I also like the idea by @michelf:
I do hope we don't put too much faith in tooling for something this seemingly universally important, and that missing it could easily be a cause of serious and "racy" bug. Then again, I don't immediately see what kind of tooling could help with this.