Skip to content

cancelawait keyword to abort an async function call #5913

Open
@andrewrk

Description

@andrewrk

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:

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    proposalThis issue suggests modifications. If it also has the "accepted" label then it is planned.

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions