Please excuse my ignorance, but I am trying to wrap my head around the Swift concurrency.
In the evolution proposals related to concurrency, the term potential suspension point is used. Does this imply that not all suspension points are real. For example, in the following example where does the actual suspension of the task occur?
enum Test {
static func main () {
Task {
await f()
}
}
}
func f () async {
await g ()
}
func g () async {
await h ()
}
func h () async {
await Task.sleep (…)
}
The await keyword indicates a potential suspension point. It means the expression contains at least one function call that may suspend. However, there’s no obligation for the called function(s) to actually suspend. Consider the following (contrived) example.
The call to suspending() will suspend when it invokes maybeSuspend(), but movingOn() will not suspend at all because its invocation of maybeSuspend() does not itself suspend.
Yes, there is still no suspension in that case. It doesn’t matter how long the chain of async function invocations is; as long as none actually suspends, the original caller at the top of the chain will not suspend at that point either.
Btw, one fun (and insightful) way to verify such things in practice is by creating a custom executor and logging the calls to enqueue. Each true suspension results in a new job being enqueued.
Hi @ibex , this is covered in more detail in the WWDC Video, “Meet async/await in Swift”, where the particular term “might suspend” is used, explaining the concept of “potential suspension point”. So this just implies that a function call marked await might suspend there, or it might not, as the video explains.
One easy to understand category of potential suspension points are those related to isolation changes, where no suspension actually happens if you're already in the right isolation.
For example, if you have a function accessing Main Actor protected state, but the context doesn't require any particular isolation, you'll need to await to access that state, even though you could be in the main actor already:
@MainActor
struct SomeProtectedState {
static var foo: String = "foo" // <-- Main Actor protected
}
func printProtectedState(
isolation: isolated (any Actor)? = #isolation // <-- Gets the isolation from the caller
) async {
let state = await SomeProtectedState.foo // <-- Potential suspension point to access Main Actor protected state
print(state)
}
Now, if you create a detached task (to ensure the function isn't called from the Main Actor) it'll need to suspend at some point to switch actors and access the Main Actor isolated state:
Task.detached {
await printProtectedState() // <-- DOES actually suspend
}
However, if you call printProtectedState() from the main actor:
It won't actually suspend, because you're already in the Main Actor.
If I'm not mistaken, Swift doesn't suspend unless it hits a "real" suspension point. It doesn't suspend just because there's an await if execution can continue.
So in your example chain of async functions (Task { ... } -> f() -> g() -> h() -> Task.sleep):
Execution of await f() would synchronously enter the implementations of g() -> h()-> Task.sleep.
Then suspend inside the implementation of Task.sleep, which has a "real" suspension point (because it's continuation-based).
This leaves the program suspended in await f(), await g(), await h() and await Task.sleep, as neither of these functions have returned yet.
Eventually, Task.sleep's continuation is called, and Task.sleep -> h() -> g() -> f() all return.
Some answers here aren’t consistent with my experience as a concurrency user. Here’s some unofficial definitions:
A “suspension point” means the runtime will interrupt your function to see if there’s another task it can run.
A “real suspension” would be when you start an operation that is asynchronous from your program’s perspective, such as waiting for a response from an HTTP server. This only happens if your program (or a library it uses) uses one of the with*Continuation functions.
My experience using these made up definitions:
A “potential suspension point” means the task yields. It’s a full on suspension point. The runtime pushes it at the back of the task queue and it dequeues the one at the front to run next. The only thing “potential” about the suspension point is that if there was nothing else to run, your task resumes immediately (“uninterrupted” from its perspective).
In the case of a “real suspension”, the task is pushed at the end of the queue when the continuation is resumed.
All async functions have a built-in suspension point. At any point that you call a function that says async, even if it doesn’t need a “real” suspension, your program will yield at least once to the concurrency runtime to see if there’s anything else it could run.
This program will reliably print 1, 2, 3, 4, 5, even though there is no asynchronous operation in foo. That’s because foostarts with a built-in suspension point, just out of being an async function, and that gives the task a chance to run.
For correctness, you must assume that something else can run at any point that you see await. In fact:
await applies to its entire subexpression, so there could be multiple suspension points in the same awaited expression.
Now that defer can run async functions, you need to be a little careful that leaving a scope can suspend.
What settings are you running this with? This is generally not true, and very likely the reason you've observed that[1] is strictly because the top-level code runs on the main actor, while foo is non-isolated and runs off-main. Simply rewriting the code into
outputs 1 2 4 5 3 because both functions run on the global executor, so there's no hop.
Moreover, there are several language features that are designed to specifically ensure that there won't be any actor hop: Task.immediate as of recent + the somewhat older @isolated(any) and isolated (any Actor?) = #isolation parameters. For instance:
If we look at the source of swift_task_switch, we get exactly the promised semantics of continuing to synchronously run the function if there's no actor/executor hop required :
// If the current executor is compatible with running the new executor,
// we can just immediately continue running with the resume function
// we were passed in.