Understanding the concept of "immediacy" in Swift concurrency

I had a question about the concept of "immediacy" that @mattie introduces in one of his concurrency recipes. Specifically, I was wondering why "immediacy" is guaranteed in this example.

My understanding of what he's saying is that, given the following example:

final class DemoViewController: UIViewController {
    func run() async {
        // My understanding of "immediacy" is that it is guaranteed that `beforeWorkBegins()` will be called before the current Task suspends
        await doWork()
    }

    // hazard 1: async virality. Can you reasonably change all callsites to `async`?
    func doWork() async {
        beforeWorkBegins()
        let result = await asyncExpensiveWork(arguments)
        afterWorkIsDone(result)
    }

   (…)
}

when run() performs await doWork() it is guaranteed that beforeWorkBegins() will be called before the current Task suspends.

I was hoping to understand better why this is the case. Please could somebody point me towards some relevant documentation or tell me, in Swift concurrency nomenclature, what are the relevant concepts here?

Probably the proposal introducing async/await is useful here. The point is that much like with try, which indicates a potential error to be thrown, await signals that there is a potential suspension point ahead when calling an asynchronous function. So in your example, beforeWorkBegins() will always be called before the potential suspension of doWork(), but it might happen that the current task doesn't suspend at all if asyncExpensiveWork() doesn't suspend. I think "immediacy" is something the author calls this behavior.

Thanks for your reply. I understand that await indicates a potential suspension point. My question is about how, in the case where await represents an actual suspension point, we can reason about what is guaranteed to occur before the suspension.

The linked proposal will explain in details how, but might take time to digest. I recommend give some time to read it thoroughly, if you want deeply understand mechanics. It will explain how synchronous functions differ from asynchronous in Swift.

As immediate explanation, await in that case is simply marking some asynchronous work, you can rewrite sample in a closure-based world:

beforeWork()
myWork {
    afterWork()
}

In which you are down to simple function calls right here right now.

But, I think @mattie example with async is either a bit ahead of the current compiler state or missing one important bit: that’s true if isolation (actor) remains the same. In the example there is a main actor-isolated method, so beforeWork will be called without any suspension only when also called from main actor. In other cases, despite beforeWork being synchronous, enclosing method is not, and will require a hop to another executor, and therefore suspension.

Looking for the await operator is useful as a way of understanding when suspensions can occur statically. Dynamically, suspensions only actually happen at dynamic suspension points, which only arise when:

  • control moves between contexts with different dynamic actor isolation or
  • the task performs some other inherently suspending operation, such as
    • calling Task.value on a task that hasn't yet completed or
    • exiting the function passed to with*Continuation before resume has been called on the continuation value.

This dynamic concept of suspension point is tied to the static concept of potential suspension point: a dynamic suspension always occurs as part of some static potential suspension point, which is then marked in some way by await. For example, all of the inherently suspending operations mentioned above are exposed as async APIs, and calls to such APIs must be covered by an await operator.

7 Likes

If we limit ourselves to considering only doWork (as Mattie was), and ignore your run method, yes, the only suspension point in doWork is to the call of asyncExpensiveWork. So, when doWork starts running, the beforeWorkBegins is called synchronously and therefore runs immediately on the current thread. The task used by doWork will not be suspended until it hits its first await, namely its call to asyncExpensiveWork. The code in doWork after the await of asyncExpensiveWork is a separate task, known as a “continuation”. It is in that continuation that we call afterWorkIsDone.

“Immediacy” is not a term that we generally use. You will not will find it in the Swift Evolution proposals, nor The Swift Programming Language: Concurrency. Generally we talk about “suspension points” and “continuations”. Every await is a potential suspension point. The code after you return from the await is a separate task, called a “continuation”. Where the referenced doc says “immediacy”, it merely means “no suspension point”.

If you watch Swift concurrency: Behind the scenes, it discusses the relevance of “suspension points” and “continuations”.


Reading some of your other comments, I now gather you understand these basic dynamics. I get the impression that your question wasn’t how synchronous methods differ from from async methods (where the await introduced a potential suspension point, whereas the synchronous call has no suspension point; which was the point of that “immediacy” discussion), but rather:

OK, if that’s the real question, then the answer is that you don’t generally have any guarantees. For example, SE-0306 says:

In my experience, it generally runs tasks in order of (a) priority; and (b) then largely in the order you await them, but not necessarily so. When you need strict FIFO sort of behaviors (sacrificing prioritization, etc.), then we reach for other patterns (AsyncChannel, custom actor executors such as one backed by GCD queue), each with their own tradeoffs.