What is a potential suspension point?

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 (…)
}

1 Like

you should consider any await keyword a potential suspension point and make no assumptions…

4 Likes

Thank you!

That’s easy to do, but why? :slight_smile:

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.

func maybeSuspend(_ shouldSuspend: Bool) async {
  if shouldSuspend {
    await Task.yield()
  }
  else {
    print("life goes on")
  }
}

func suspending() async {
  await maybeSuspend(true)
}

func movingOn() async {
  await maybeSuspend(false)
}

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.

5 Likes

Thank you, @Avi

I have made a small change to your example.

func maybeSuspend(_ shouldSuspend: Bool) async {
  if shouldSuspend {
    await Task.yield()
  }
  else {
    await say ("life goes on")
  }
}

func suspending() async {
  await maybeSuspend(true)
}

func movingOn() async {
  await maybeSuspend(false)
}

func say (_ s: String) async {
  // note pretending to be async
  print (s)
}
   

With that change in place, does movingOn() still not suspend because maybeSuspend (false) is now invoking the pretend-async function say?

1 Like

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.

4 Likes

Got it now. Thank you, @Avi

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.

8 Likes

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.

1 Like

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:

Task { @MainActor in
    await printProtectedState() // <-- DOESN'T actually suspend
}

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.
3 Likes

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.

For instance:

import Darwin

@inline(never) func foo() async {
	puts("4")
}

Task { puts("3") }
puts("1")
puts("2")
await foo()
puts("5")

This program will reliably print 1, 2, 3, 4, 5, even though there is no asynchronous operation in foo. That’s because foo starts 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.
6 Likes

One way around this is inlining, fwiw. This is how AsyncBytes is able to iterate byte by byte without being incredibly slow.

3 Likes

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

@inline(never)
func foo() async {
    puts("4")
}

func test() async {
    Task { puts("3") }
    puts("1")
    puts("2")
    await foo()
    puts("5")
}

await test()

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:

actor MyActor {
    var count = 0
    
    @inline(never)
    func foo() async {
        count += 1
    }
    
    @inline(never)
    func bar() {
        count -= 1
    }
    
    @inline(never)
    func makeFooClosure() -> @isolated(any) () async -> Void {
        return {
            await self.foo()
        }
    }
    
    @inline(never)
    func doSmth(work: @isolated(any) () async -> Void) async {
        count = 0
        await work()
        assert(count == 1)
    }
}

let act = MyActor()

for _ in 0..<100 {
    Task {
        await act.doSmth(work: act.makeFooClosure())
    }

    Task {
        await act.bar()
    }
}

try await Task.sleep(for: .seconds(1))

never hits the assert, which means that it always executes this specific sequence:

count = 0
count += 1 // from `foo` closure
assert(count == 1)

without interleaving bar in-between.


Edit: this is the case for me even without @isolated(any) FWIW.


  1. Which is not the case for me even when I run it as given FWIW ↩︎

6 Likes

This is Swift 6.2.1 as shipped in Xcode 26.1.1 (17B100) and built with swiftc -O test.swift. foo() starts with swift_task_switch:

_$s4test3fooyyYaF: ; test.foo() async -> ()
    adrp       x0, #0x100000000 ; _$s4test3fooyyYaFTY0_@PAGE
    add        x0, x0, #0x8d8   ; _$s4test3fooyyYaFTY0_@PAGEOFF
    mov        x1, #0x0
    mov        x2, #0x0
    b          _swift_task_switch

_$s4test3fooyyYaFTY0_: ; suspend resume partial function for test.foo() async -> ()
    orr        fp, fp, #0x1000000000000000
    sub        sp, sp, #0x20
    stp        fp, lr, [sp, #0x10]
    str        x22, [sp, #0x10 + var_8]
    add        fp, sp, #0x10
    adrp       x0, #0x100000000
    add        x0, x0, #0x9d0
    bl         _puts
    ldr        x0, [x22, #0x8]
    ldp        fp, lr, [sp, #0x10]
    and        fp, fp, #0xefffffffffffffff
    add        sp, sp, #0x20
    br         x0

I get the same results you have if I wrap the top level code in a function. You’re likely more correct than me on the details.

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 :upside_down_face::

// 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.
4 Likes