SE-0533: Generating synchronous overloads of async functions with a macro

I don't have much to add other than to agree with @toph42. @Reasync provides zero indication to me that, when applied, will generate a non-async overload. I can't think of anything better than a verbose option like: @WithNonAsyncOverload

I find it very difficult to predict what will happen after annotating a function @Reasync. For example, because of the complexity of isolation, a simple enough higher ordered async function will be invalid when the "asyncness" is removed:

This is valid:

func a(_ block: @MainActor () async -> Void) async {
    await b(block)  // OK
}

func b(_ block: () async -> Void) async {}

But this is not:

func a(_ block: @MainActor () -> Void) {
    b(block)
}

func b(_ block: () -> Void) { }

From my perspective, the inherent complexity means that no matter how we design a general purpose reasync mechanism, we cannot achieve the same semantic robustness as rethrows. We should either limit reasync to nonisolated code only, or just not provide it.

I'm against this proposal. Whilst it solves a real issue, the bar to add something to the standard library is correctly high. Since this macro can live in an outside package and doesn't offer a 'complete' solution in the way that a full reasync would, I don't think it should be accepted as proposed.

I also disagree with the argument that it has to live in swiftlang as opposed to being a community package. If it's a useful tool, which it appears to be, there's nothing stopping the community from adopting it and using it. Fostering a healthy ecosystem outside of swiftlang should always be a goal, rather than just consolidating packages in one org. There is absolutely nothing wrong with having good packages outside the language org.

Finally I believe this proposal should at the very least be returned for revision straight away as the question of where this feature lives is not answered. An important decision like that shouldn't be left to outside the scope of the review as it fundamentally changes how the proposal is reviewed.

6 Likes

The @Reasync transformation is deterministic and fully specified. The table below, reproduced from the proposal's "Strict concurrency" section, is the entire transformation; apart from @Reasync itself, which does not carry over to the peer, everything not listed is preserved. That section explains each decision.

Annotation Rule Removal Scope
async, await Remove Everywhere
@isolated(any) Remove Closure parameter types (at any nesting depth)
nonisolated(nonsending) Remove Everywhere
@concurrent Remove Everywhere
@Sendable Remove Closure parameter types (at any nesting depth)
sending Preserve
Global actors Preserve
isolated parameters Preserve
Bare nonisolated Preserve

The second snippet is invalid Swift 6 code regardless of whether it is generated by @Reasync or written by hand, and the proposal's design rests on that fact.

The two snippets differ in whether the global actor can be erased from the closure's type. Erasing @MainActor from an async function type is sound: every call to an async function value is a potential suspension point, and the function hops to its actor on entry, so its isolation is honored after the annotation leaves the type. A synchronous call provides no suspension point at which to hop, so the same erasure on a synchronous function type would let the closure run outside its actor, and the compiler rejects the conversion. That difference is a property of the language, not of the macro.

The transformation, as above, is fixed; your example turns on whether the generated peer compiles. That is not always decidable at the attachment site, because the async-ness the macro removes can be doing two jobs at once: propagating a closure parameter's async-ness, and licensing isolation behavior that only async makes sound. In your example it does both: the async in block's type is what makes passing an @MainActor closure as an unannotated one legal. (A body consisting only of await block() fails the same way, with the await licensing the hop directly.) The peer is valid when the function's async-ness does nothing beyond propagate its parameters' async-ness, and invalid when it also pays for something else like an isolation change, as here, or use of an API with no synchronous form. Determining which of these is the case is a semantic fact the macro cannot see ("Diagnosing misuse syntactically at the expansion site" covers why no expansion-time check can draw this line), so enforcement sits with the compiler, on the generated peer. Per "Semantic validity":

If the body contains constructs that are inherently asynchronous, such as calls to actor-isolated methods or calls to async-only APIs, the generated overload will fail to compile, and the compiler will report the error at the site of the invalid expression in the expanded source.

There is no silent miscompilation. The failure lands exactly where the hand-written attempt fails, because the function it would produce cannot exist.

The proposal agrees. From the Language Steering Group's evaluation, quoted in "Applicability":

async code offers many more possibilities for semantic distinctions between synchronous and async variations of a function, such as different interactions with isolation and sendability, or different parallel execution strategies that aren't readily available in synchronous code, so it isn't as clear-cut that there is a one-size-fits-most solution like rethrows for async.

The design consequence follows: the macro does not claim rethrows-grade universality. It targets the mechanical-duplication case and "is not intended to express every possible relationship between an async function and its synchronous counterpart." A function like your example belongs to the hand-written population by design, where the author can choose the different signature or isolation the synchronous form actually requires, which is a choice no mechanical transformation should make silently.

The restriction doesn't hold under either reading of "nonisolated code."

If "nonisolated code" means nonisolated functions, it fails to exclude your own example. A function's isolation comes from its declaration and enclosing context (a global actor attribute, an isolated parameter, membership in an isolated type), not from the types of its parameters. The @MainActor annotation appears only inside block's type, where it constrains which closures may be passed; it does not isolate a(). Both a() and b() are nonisolated, in both snippets, so the restriction would not exclude the very case offered to motivate it.

If it means rejecting any isolation annotation anywhere in the signature, it rejects valid uses:

@Reasync
@MainActor
func run(_ body: @MainActor () async -> Void) async
{
    await body()
}

// Generated by @Reasync:
//
// @MainActor
// func run(_ body: @MainActor () -> Void)
// {
//     body()
// }

The generated peer is valid: the body calls the closure from matching isolation, and there is no hop to fund. A signature-level ban would reject this while its hand-written equivalent compiles.

To noodle a bit… reasync doesn't really make a load of sense as a term of art especially since (as noted) rethrows is going away. What if:

func f() async? -> Int {
  let value = await? g()
  return value.g()
}

i.e. instead of spelling it "reasync", spell it "async, maaaaybe?" Such a function could call any synchronous function or any other reasync? function. The compiler would have to emit two copies of the function, one synchronous and one asynchronous, but… honestly, I don't think we're ever going to avoid that requirement and should just accept it as necessary.

Just a thought.

2 Likes

Thanks to everyone for providing your feedback. The Language Steering Group has decided to reject this proposal for the time being.

1 Like