Description
I've spent many hours in the past trying to solve this, and never quite tied up all the loose ends, but I think I've done it this time.
Related Proposals:
- add "select" syntax to the language to await the first function in a given set that completes add "select" syntax to the language to await the first function in a given set that completes #5263
- a way to check async frame liveness Proposal: a way to check async frame liveness #3164
- ability to annotate functions which allocate resources, with a way to deallocate the returned resources ability to annotate functions which allocate resources, with a way to deallocate the returned resources #782
Problem 1: Error Handling & Resource Management
Typical async await usage when multiple async functions are "in-flight", written naively, looks like this:
fn asyncAwaitTypicalUsage(allocator: *Allocator) !void {
var download_frame = async fetchUrl(allocator, "https://example.com/");
var file_frame = async readFile(allocator, "something.txt");
const download_text = try await download_frame; // NO GOOD!!!
defer allocator.free(download_text);
const file_text = try await file_frame;
defer allocator.free(file_text);
}
Spot the problem? If the first try
returns an error, the in-flight file_frame
becomes invalid memory while the readFile
function is still using the memory. This is nasty undefined behavior. It's too easy to do this on accident.
Problem 2: The Await Result Location
Function calls directly write their return values into the result locations. This is important for pinned memory, and will become more noticeable when these are implemented:
- result location: ability to refer to the return result location before the
return
statement result location: ability to refer to the return result location before thereturn
statement #2765 - result locations: unwrap optional and error unions so that the payload can be non-copied result locations: unwrap optional and error unions so that the payload can be non-copied #2761
- Ability to mark a struct field as "pinned" in memory Ability to mark a struct field as "pinned" in memory #3803
- make aggregate types non-copyable by default; provide copyable attribute make aggregate types non-copyable by default; provide copyable attribute #3804
However this breaks when using async
and await
. It is possible to use the advanced builtin @asyncCall
and pass a result location pointer to async
, but there is not a way to do it with await
. The duality is messy, and a function that relies on pinning its return value will have its guarantees broken when it becomes an async function.
Solution
I've tried a bunch of other ideas before, but nothing could quite give us good enough semantics. But now I've got something that solves both problems. The key insight was making obtaining a result location pointer for the return
statement of an async
function, implicitly a suspend point. This suspends the async function at the return
statement, to be resumed by the await
site, which will pass it a result location pointer. The crucial point here is that it also provides a suspension point that can be used for cancelawait
to activate. If an async function is cancelled, then it resumes, but instead of returning a value, it runs the errdefer
and defer
expressions that are in scope. So - async functions will simply have to retain the property that idiomatic code already has, which is that all the cleanup that possibly needs to be done is in scope in a defer at a return
statement.
I think this is the best of both worlds, between automatically running a function up to the first suspend point, and what e.g. Rust does, not running a function until await
is called. A function can introduce an intentional copy of the result data, if it wishes to run the logic in the return expression before an await
result pointer is available. It means async function frames can get smaller, because they no longer need the return value in the frame.
Now this leaves the problem of blocking functions which are used with async
/await
, and what cancelawait
does to them. The proposal #782 is open for that purpose, but it has a lot of flaws. Again, here, the key insight of await
working properly with result location pointers was the answer. If we move the function call of non-suspending functions used with async/await to happen at the await site instead of the async site, then cancelawait
becomes a no-op. async
will simply copy the parameters into the frame, and await
would do the actual function call. Note that function parameters must be copied anyway for all function calls, so this comes at no penalty, and in fact should be better all around because we don't have "undoing" of allocated resources but we have simply not doing extra work in the first place.
Example code:
fn asyncAwaitTypicalUsage(allocator: *Allocator) !void {
var download_frame = async fetchUrl(allocator, "https://example.com/");
errdefer cancelawait download_frame;
var file_frame = async readFile(allocator, "something.txt");
errdefer cancelawait file_frame;
const download_text = try await download_frame;
defer allocator.free(download_text);
const file_text = try await file_frame;
defer allocator.free(file_text);
}
Now, calling an async function looks like any resource allocation that needs to be cleaned up when returning an error. It works like await
in that it is a suspend point, however, it discards the return value, and it atomically sets a flag in the function's frame which is observable from within.
Cancellation tokens and propagating whether an async function has been cancelled I think can be out of scope of this proposal. It's possible to build higher level cancellation abstractions on top of this primitive. For example, #5263 (comment) could be improved with the availability of cancelawait
. But more importantly, cancelawait
makes it possible to casually use async
/await
on arbitrary functions in a maintainable and correct way.