Reasoning about actor re-entrancy/suspension for optional `await`s

Let's set a stage:

@MainActor class C {
    actor A { let data: String }
    let a: A

    func f() {
        Task.detached {
            await self.g( /*await*/ self.a.data )
        }
    }
    func g(_ data: String) {
    }
}

I find it difficult to reason about actor suspension and potential re-entrancy in the context of optional await keywords, such as on a line where a previous await already exists.

For instance: does the main actor get blocked immediately until the A actor resolves the data and passes it into g, or does self.a.data allow for a suspention in the detached task which allows the main actor to proceed with other tasks should the A actor need more time to resolve data?

I worry that the language's current syntax for optional awaits obfuscates how task suspension happens in certain cases. Has this been discussed before with conclusions drawn?

1 Like

It’s unfortunate that the compiler allows you to omit the second await here, but since Swift does not specify the order of evaluation of arguments, and there is no way to execute code between argument evaluation and function invocation, any suspension points in the evaluation of the argument list can’t be relied upon.

IMO this reasoning will break down when custom executors are added, because then it will be possible for the executor to provide a well-defined order for any two asynchronous reads encountered when evaluating the expression.

1 Like

Left-to-right evaluation of arguments is guaranteed, I believe.

1 Like

I think that post says the opposite?

(emphasis added)

1 Like

Hah :) admittedly I am not able to enumerate the exceptions implied by ‘most’ in John’s post, but it was my understanding that at least as far as function call arguments, users can rely on left-to-right evaluation, which has been referenced as far back as I can find (i.e. pre-Swift-evolution).

Actors are never suspended; Tasks are. That goes for the local actor A, and the globalActor @MainActor. Your detached task may be suspended twice:

  1. waiting for data from A, then
  2. waiting to switch back to the @MainActor so it can call g

During both task suspensions, both the main actor and A are free to process other tasks. await does not block. So in terms of reasoning about data, IMO it's really the fact that there is a discontinuity at all that's important, not whether there are 1/2/3 suspensions on the line, or which order they happen in.

Does that help?


But there's an additional nuance here - actually, suspension 1 won't actually happen in the example as written, because A.data is a let variable. The compiler will actually emit a warning if you try to write it:

await self.g(await self.a.data)
             ^^^^^ warning: no 'async' operations occur within 'await' expression

If you change data to a var, you may write the await for the first suspension (because now it may actually suspend). I assume that's what you meant.

1 Like

Interestingly, this warning is not emitted in a Swift 5.7 playground.

Hmm, I tested in Godbolt, and it shows on 5.5, 5.6, 5.7, and nightly. Must be a playgrounds thing.

Godbolt

Swift’s evaluation order is fully-specified, it’s just a little complicated when you add inout (and eventually borrowed) arguments because there are multiple phases of evaluation for storage reference expressions.

All storage reference expressions (“l-values”) are evaluated in three phases:

  • formal evaluation, which recursively performs formal evaluation on sub-expressions, if any
  • start of access, which initiates the actual access
  • end of access, which concludes the actual access
    What happens in each phase depends on the expression, the storage, and the kind of access.

So e.g. given a mutation of a subscript with a by-value Int argument on a value type with a mutating modify accessor:

  • Formal evaluation does a formal evaluation of the base expression and a complete evaluation of the index expression.
  • Start of access recursively starts the access to the base expression, then calls the modify accessor (until it yields).
  • End of access resumes the accessor following the yield, then recursively ends the access to the base expression.

If you just use a storage reference expression as an r-value, these three phases are triggered one immediately after another. But if you pass it as an inout argument, or apply a pointer argument conversion to it, then the phases can interleave with other evaluation, which can no longer be understood as strictly left-to-right. That is, arguments are evaluated left-to-right, but for these special arguments (which can only apply to storage references), that only includes formal evaluation; the actual accesses are started in a second phase of left-to-right evaluation immediately prior to the call and ended right-to-left immediately after the call.

9 Likes

this is just the tip of the iceberg, it’s even easier to shoot yourself in the foot with async let.

async
let task:Void = bar(x: await foo())

async let will “swallow” all of the awaits to the right of the = sign, so even if what you wanted was to wait for the call to foo to complete before kicking off the call to bar(x:) in the background (most likely, why the programmer wrote an explicit await there in the first place), it actually continues executing immediately. because async let has “autoclosure” semantics and the expression to its right is not really part of the local control flow.

test program:

func foo() async throws -> Int
{
    try await Task.sleep(for: .seconds(1))
    print("A")
    return 5
}

func bar(x:Int) async throws
{

}
@main
enum Main
{
    static
    func main() async throws
    {
        async
        let task:Void = bar(x: await foo())
        print("B")

        try await task
    }
}

// B
// A

if i were president of swift, i would make the compiler emit a warning for any await in the right-hand side of an async let.

2 Likes

That actually is surprising and is good to know.