Why does Swift not resume an asynchronous function on the same thread it was started?
Asked Answered
T

2

6

In the introductory section of the Concurrency chapter of "The Swift Programming Language" I read:

When an asynchronous function resumes, Swift doesn’t make any guarantee about which thread that function will run on.

This surprised me. It seems odd, comparing for example with waiting on semaphore in pthreads, that execution can jump threads.

This leads me to the following questions:

  • Why doesn't Swift guarantee resuming on the same thread?

  • Are there any rules by which the resuming thread could be determined?

  • Are there ways to influence this behaviour, for example make sure it's resumed on the main thread?

EDIT: My study of Swift concurrency & subsequent questions above were triggered by finding that a Task started from code running on the main thread (in SwiftUI) was executing it's block on another thread.

Thermoluminescence answered 30/1, 2023 at 13:58 Comment(13)
Think of it the other way around: Why should it resume on the same thread? Suppose you have a suspended function that's ready to resume, but the thread it was last running on isn't available yet. If another thread in the cooperative thread pool frees up, should it take it, or keep waiting and insist on its original thread? As you see, making that guarantee doesn't come for free, so then you have to weigh it up against the benefit. What benefit is there to knowing it'll resume on the same thread?Schleswig
Here's another analogy to consider: Tasks are an abstraction of work over threads, similar to how threads are an abstraction of work over CPU cores. One might ask: "Why doesn't the OS Kernel guarantee resuming on the same core?" The vast majority of the time, it's just left up to the kernel's scheduler to automatically decide for you. There are reason to care sometimes (especially on NUMA systems, so there are still APIs for setting thread affinity, pinning threads to cores, etc.Schleswig
@Schleswig Swift should resume on the same thread to keep things easier to understand. Meaning matters a lot in programming; random behaviour like this doesn't help.Thermoluminescence
@Schleswig The core on which a piece of code runs almost never touches the area/level of the programmer's understanding. It therefore doesn't matter. Threading however is extremely important.Thermoluminescence
"easier to understand" is a bit subjective — can you elaborate on what you have in mind? To add a bit to what @Schleswig may be trying to express: a big notion in Swift Concurrency is that code should be isolated from the thread it's running on, such that you shouldn't need to think or examine "which thread am I on right now?", in the same way that you describe cores: "The thread on which a piece of code runs should never touch the area/level of the programmer's understanding [of that code]."Barretter
This is definitely a big departure from how we normally think about threading, I agree. To that end, is this causing actionable problems for you in some way which we can help resolve, or are you looking for an answer which helps provide clearer understanding?Barretter
@ItaiFerber Knowing the thread is essential when doing UI updates. For the remainder of situations, a Task can be assigned a certain priority, which indeed might be only thing else that may matter.Thermoluminescence
If you're referring to main thread vs. non-main-thread behavior, there are tools specifically for dealing with that, and we can elaborate on this — the question is, do you care about whether code is running on thread 5 vs. thread 7, and if so, why? If you're thinking purely of "main thread or not main thread?", then an answer is easy to give.Barretter
Specifically, tasks can be run on the @MainActor, guaranteed to run exclusively on the main thread. This makes working with UI updates trivial.Barretter
@ItaiFerber My question was indeed triggered by finding that a Task started from code running on the main thread (in SwiftUI) was executing it's block on another thread. Hence also my question "Are there ways to influence this behaviour ..."Thermoluminescence
Great! That helps tailor an answer, then. Working on one now.Barretter
Late to the party, but re: "The core on which a piece of code runs almost never touches the area/level of the programmer's understanding." That's a large goal and perk of Swift concurrency. It makes the thread as irrelevant to your understanding of the code as the cores. But of course, abstractions seem to always leak, so just how you can observe differences between CPU cores, you can probably observe differences between threads. On the happy path though, it shouldn't be relevant. Notice that even the main-thread vs non-main-thread distinction goes away. It's @MainActor, not @MainThread.Schleswig
And I'll add: even in cases where you're inter-operating with framworks that use threads or dispatch queues as their concurrency primitive, it's not a good idea two entangle the two together. E.g. don't assume anything about which threads a particular actor or dispatch queue runs on. Instead, make little islands that are self consistent, like a whole module that only uses threads, to work with a library that uses threads. Its interface should be exposed to the rest of the system using an actor, so the threading details stay encapsulatedSchleswig
B
15

It helps to approach Swift concurrency with some context: Swift concurrency attempts to provide a higher-level approach to working with concurrent code, and represents a departure from what you may already be used to with threading models, and low-level management of threads, concurrency primitives (locking, semaphores), and so on, so that you don't have to spend any time thinking about low-level management.

From the Actors section of TSPL, a little further down on the page from your quote:

You can use tasks to break up your program into isolated, concurrent pieces. Tasks are isolated from each other, which is what makes it safe for them to run at the same time…

In Swift Concurrency, a Task represents an isolated bit of work which can be done concurrently, and the concept of isolation here is really important: when code is isolated from the context around it, it can do the work it needs to without having an effect on the outside world, or be affected by it. This means that in the ideal case, a truly isolated task can run on any thread, at any time, and be swapped across threads as needed, without having any measurable effect on the work being done (or the rest of the program).

As @Alexander mentions in comments above, this is a huge benefit, when done right: when work is isolated in this way, any available thread can pick up that work and execute it, giving your process the opportunity to get a lot more work done, instead of waiting for particular threads to be come available.

However: not all code can be so fully isolated that it runs in this manner; at some point, some code needs to interface with the outside world. In some cases, tasks need to interface with one another to get work done together; in others, like UI work, tasks need to coordinate with non-concurrent code to have that effect. Actors are the tool that Swift Concurrency provides to help with this coordination.

Actors help ensure that tasks run in a specific context, serially relative to other tasks which also need to run in that context. To continue the quote from above:

…which is what makes it safe for them to run at the same time, but sometimes you need to share some information between tasks. Actors let you safely share information between concurrent code.

… actors allow only one task to access their mutable state at a time, which makes it safe for code in multiple tasks to interact with the same instance of an actor.

Besides using Actors as isolated havens of state as the rest of that section shows, you can also create Tasks and explicitly annotate their bodies with the Actor within whose context they should run. For example, to use the TemperatureLogger example from TSPL, you could run a task within the context of TemperatureLogger as such:

Task { @TemperatureLogger in
    // This task is now isolated from all other tasks which run against
    // TemperatureLogger. It is guaranteed to run _only_ within the
    // context of TemperatureLogger.
}

The same goes for running against the MainActor:

Task { @MainActor in
    // This code is isolated to the main actor now, and won't run concurrently
    // with any other @MainActor code.
}

This approach works well for tasks which may need to access shared state, and need to be isolated from one another, but: if you test this out, you may notice that multiple tasks running against the same (non-main) actor may still run on multiple threads, or may resume on different threads. What gives?


Tasks and Actors are the high-level tools in Swift concurrency, and they're the tools that you interface with most as a developer, but let's get into implementation details:

  1. Tasks are actually not the low-level primitive of work in Swift concurrency; Jobs are. A Job represents the code in a Task between await statements, and you never write a Job yourself; the Swift compiler takes Tasks and creates Jobs out of them
  2. Jobs are not themselves run by Actors, but by Executors, and again, you never instantiate or use an Executor directly yourself. However, each Actor has an Executor associated with it, that actually runs the jobs submitted to that actor

This is where scheduling actually comes into play. At the moment there are two main executors in Swift concurrency:

  1. A cooperative, global executor, which schedules jobs on a cooperative thread pool, and
  2. A main executor, which schedules jobs exclusively on the main thread

All non-MainActor actors currently use the global executor for scheduling and executing jobs, and the MainActor uses the main executor for doing the same.

As a user of Swift concurrency, this means that:

  1. If you need a piece of code to run exclusively on the main thread, you can schedule it on the MainActor, and it will be guaranteed to run only on that thread
  2. If you create a task on any other Actor, it will run on one (or more) of the threads in the global cooperative thread pool
    • And if you run against a specific Actor, the Actor will manage locks and other concurrency primitives for you, so that tasks don't modify shared state concurrently

With all of this, to get to your questions:

Why doesn't Swift guarantee resuming on the same thread?

As mentioned in the comments above — because:

  1. It shouldn't be necessary (as tasks should be isolated in a way that the specifics of "which thread are we on?" don't matter), and
  2. Being able to use any one of the available cooperative threads means that you can continue making progress on all of your work much faster

However, the "main thread" is special in many ways, and as such, the @MainActor is bound to using only that thread. When you do need to ensure you're exclusively on the main thread, you use the main actor.

Are there any rules by which the resuming thread could be determined?

The only rule for non-@MainActor-annotated tasks are: the first available thread in the cooperative thread pool will pick up the work.

Changing this behavior would require writing and using your own Executor, which isn't quite possible yet (though there are some plans on making this possible).

Are there ways to influence this behaviour, for example make sure it's resumed on the main thread?

For arbitrary threads, no — you would need to provide your own executor to control that low-level detail.

However, for the main thread, you have several tools:

  1. When you create a Task using Task.init(priority:operation:), it defaults to inheriting from the current actor, whatever actor this happens to be. This means that if you're already running on the main actor, the task will continue using the current actor; but if you aren't, it will not. To explicitly annotate that you want the task to run on the main actor, you can annotate its operation explicitly:

    Task { @MainActor in
        // ...
    }
    

    This will ensure that regardless of what actor the Task was created on, the contained code will only run on the main actor.

  2. From within a Task: regardless of the actor you're currently on, you can always submit a job directly onto the main actor with MainActor.run(resultType:body:). The body closure is already annotated as @MainActor, and will guarantee execution on the main thread

Note that creating a detached task will never inherit from the current actor, so guaranteed that a detached task will be implicitly scheduled through the global executor instead.

My study of Swift concurrency & subsequent questions above were triggered by finding that a Task started from code running on the main thread (in SwiftUI) was executing it's block on another thread.

It would help to see specific code here to explain exactly what happened, but two possibilities:

  1. You created a non-explicitly @MainActor-annotated Task, and it happened to begin execution on the current thread. However, because you weren't bound to the main actor, it happened to get suspended and resumed by one of the cooperative threads
  2. You created a Task which contained other Tasks within it, which may have run on other actors, or were explicitly detached tasks — and that work continued on another thread

For even more insight into the specifics here, check out Swift concurrency: Behind the scenes from WWDC2021, which @Rob linked in a comment. There's a lot more to the specifics of what's going on, and it may be interesting to get an even lower-level view.

Barretter answered 30/1, 2023 at 16:4 Comment(8)
Wow, this is a specular answer. TIL about jobs. Thanks for taking the time to write this up!Schleswig
Thanks, @Alexander! That means a lot. :) Happy to have been able to share some insight.Barretter
@Rob That's a good point — I was being sloppy. By "global actor" I meant "global executor", and by "global threads" I meant the cooperative thread pool that belongs to the global executor. I've updated the answer to use the word "global" more accurately.Barretter
Thanks for this amazing answer. What is the purpose of setting a Task's priority? I assume that scheduling is a bit more complicated that just taking the first free thread to do work?Thermoluminescence
It's good to know that a Task's block is always executed on the current actor. Does this mean that it's guaranteed that a Task created in for example a SwiftUI onChange(of:) { } will always run on the main thread? Does this also apply to .task { } in SwiftUI?Thermoluminescence
The purpose of setting a task's priority is indeed to help the scheduler better decide which work to enqueue next: when more than one task is available to run, the scheduler will prioritize higher-priority tasks than lower-priority ones.Barretter
Yes, using the Task initializer mentioned in the answer, you'll always inherit the currently-active actor when running, which means that spawning a Task inside of a function that's guaranteed to be running on the main thread should always inherit the main actor. (Assuming SwiftUI's onChange(of:) guarantees this, it should be safe.) However: it doesn't hurt to explicitly annotate code with @MainActor — if you refactor code over time, and end up creating the Task from elsewhere, you may no longer run on the main thread!Barretter
As for SwiftUI's .task(priority:_:), someone with more direct SwiftUI knowledge may be able to chime in better. The documentation doesn't appear to make any promises about how the given work is run, and the action isn't otherwise annotated in any manner. If you want to be safe, annotating with @MainActor is the way to go.Barretter
C
5

If you want insights into the threading model underlying Swift concurrency, watch WWDC 2021 video Swift concurrency: Behind the scenes.

In answer to a few of your questions:

  1. Why doesn't Swift guarantee resuming on the same thread?

Because, as an optimization, it can often be more efficient to run it on some thread that is already running on a CPU core. As they say in that video:

When threads execute work under Swift concurrency they switch between continuations instead of performing a full thread context switch. This means that we now only pay the cost of a function call instead. …

You go on to ask:

  1. Are there any rules by which the resuming thread could be determined?

Other than the main actor, no, there are no assurances as to which thread it uses.

(As an aside, we’ve been living with this sort of environment for a long time. Notably, GCD dispatch queues, other than the main queue, make no such guarantee that two blocks dispatched to a particular serial queue will run on the same thread, either.)

  1. Are there ways to influence this behaviour, for example make sure it's resumed on the main thread?

If we need something to run on the main actor, we simply isolate that method to the main actor (with @MainActor designation on either the closure, method, or the enclosing class). Theoretically, one can also use MainActor.run {…}, but that is generally the wrong way to tackle it.

Caz answered 30/1, 2023 at 16:8 Comment(4)
Why do you think MainActor.run {…} is generally the wrong way?Thermoluminescence
Because in Swift concurrency, one of the motivating ideas is to get rid of that brittle GCD pattern where the caller frequently would have to know to which queue a call to some method needed to be dispatched. Instead, we now just mark to which actor some routine is isolated, and when we call it from an asynchronous context, the compiler will take care of it for us. See, for example, WWDC 2021 video Swift concurrency: Update a sample app, where they first suggest MainActor.run, but later show how it is not needed, and why.Caz
Thanks Rob, I'll watch that video. Do you know of any readable sources that explain Swift concurrency in depth (because I'm disappointed by TSPL and videos are bad for referencing later)?Thermoluminescence
No, I don’t. I personally rely heavily on the videos (and the transcripts are searchable), your criticism notwithstanding. The Swift Evolution proposals offer insights about the rationale and implementations. There are tons of user-generated articles online, but it can be a bit of a mixed bag.Caz

© 2022 - 2025 — McMap. All rights reserved.