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.
2
Answers
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:
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.
Actor
s 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:Besides using
Actor
s as isolated havens of state as the rest of that section shows, you can also createTask
s and explicitly annotate their bodies with theActor
within whose context they should run. For example, to use theTemperatureLogger
example from TSPL, you could run a task within the context ofTemperatureLogger
as such:The same goes for running against the
MainActor
: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?
Task
s andActor
s 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:Task
s are actually not the low-level primitive of work in Swift concurrency;Job
s are. AJob
represents the code in aTask
betweenawait
statements, and you never write aJob
yourself; the Swift compiler takesTask
s and createsJob
s out of themJob
s are not themselves run byActor
s, but byExecutor
s, and again, you never instantiate or use anExecutor
directly yourself. However, eachActor
has anExecutor
associated with it, that actually runs the jobs submitted to that actorThis is where scheduling actually comes into play. At the moment there are two main executors in Swift concurrency:
All non-
MainActor
actors currently use the global executor for scheduling and executing jobs, and theMainActor
uses the main executor for doing the same.As a user of Swift concurrency, this means that:
MainActor
, and it will be guaranteed to run only on that threadActor
, it will run on one (or more) of the threads in the global cooperative thread poolActor
, theActor
will manage locks and other concurrency primitives for you, so that tasks don’t modify shared state concurrentlyWith all of this, to get to your questions:
As mentioned in the comments above — because:
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.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).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:
When you create a
Task
usingTask.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:This will ensure that regardless of what actor the
Task
was created on, the contained code will only run on the main actor.From within a
Task
: regardless of the actor you’re currently on, you can always submit a job directly onto the main actor withMainActor.run(resultType:body:)
. Thebody
closure is already annotated as@MainActor
, and will guarantee execution on the main threadNote 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.
It would help to see specific code here to explain exactly what happened, but two possibilities:
@MainActor
-annotatedTask
, 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 threadsTask
which contained otherTask
s within it, which may have run on other actors, or were explicitly detached tasks — and that work continued on another threadFor 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.
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:
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:
You go on to ask:
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.)
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 useMainActor.run {…}
, but that is generally the wrong way to tackle it.