Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
Tasks are the unit of async work in Swift Concurrency. Creating a Task is the simplest way to introduce an async context to your code:
func setupView() {
Task {
self.data = await fetchData()
}
}
Using a Task, we can call await
and fetch data asynchronously even though setupView()
itself is not marked async
.
Because we’re initialising them with a closure, the standard* behaviour of creating a Task isn’t immediately obvious: they’ll run on the same thread they’re created on.
Let’s explore the implications this has on our code.
*I’ll come back to this, I promise.
Blocking Synchronous Workloads
Recently, I helped debug a weird hang — a UI freeze — that showed up from a viewDidLoad
method (remember those, kids?).
The culprit was a synchronous database call which performed a complex read query and processed the results. This blocked our UI, since the processing had to complete before the screen was presented.
@MainActor
override func viewDidLoad() {
super.viewDidLoad()
self.data = fetchDataFromDatabase()
}
Let’s try the first obvious solution: get this code off the main thread. The naïve approach is to create a Task and call it a day.
@MainActor
override func viewDidLoad() {
super.viewDidLoad()
Task {
self.data = fetchDataFromDatabase()
}
}
Remember that a Task will normally run on the thread it was instantiated from: unless you explicitly allow a context switch with await
, execution will be bound to the thread of the actor it was called from.
Therefore, since this function is on the @MainActor
, the contents of the Task will still run on the main thread — the original UI hang isn’t fixed.
We can create our own sample code to see this principle in action.
Using breakpoints, we can inspect the active threads during our function in the Xcode debug navigator. The Task is running on Thread 1, the main thread where all UI work is performed.
Clearly Task isn’t a silver bullet — it won’t magically get a slow sync workload to run on a background thread.
Jumping Between Threads
A cooperative thread pool (similar to Grand Central Dispatch) is used to aim for optimal parallelisation — one thread running per CPU core. This means that workloads running inside a Task can be scheduled on different threads in the pool.
While Task allows us to create this async context, the Swift Concurrency runtime won’t actually jump between threads unless the code inside the Task is broken up by an await
.
await
marks a suspension point. Code which calls the async
method is stored in the heap on an async frame and can be resumed afterwards to continue doing work.
We can illustrate this suspension using the debug navigator — let’s run a real asynchronous workload by fetching some data from the internet.
This Task begins on the main thread, but the URLSession
data task jumps to Thread 9, a background thread, to wait for the network response. After fetching, the remainder of the work is performed back on Thread 1.
When using a Task from the main actor, it can still jump between various threads to perform async work and avoid blocking the UI thread with waits.
For functions not marked @MainActor
, you can ensure a Task runs on the main thread by initialising it like this:
Task { @MainActor in
// ...
}
Back to our blocking synchronous workload in viewDidLoad()
: we could convert the database query and time-consuming processing into an async
function. This can get the processing off the main thread while the Task await
s the results.
Detached Tasks
There’s one more approach which helps illustrate what’s going on with the actual threads. Let’s see what happens when we create a detached Task on the @MainActor
function.
With a detached Task, it’s not constrained to any existing Task hierarchy or thread. When we set a breakpoint, we find this runs its contents in Thread 11, a background thread.
To populate the UI, we also need to put the results back on the main actor with await MainActor.run { }
to avoid updating our UI from a background thread:
Task.detached { [weak self] in
let data = self?.fetchDataSynchronously()
await MainActor.run {
self?.data = data
}
}
Detached tasks are added to the global executor — a little like the global queue in Grand Central Dispatch. While they don’t block the UI thread, the workload might not be run immediately — we can set priority level (e.g. .low
, .medium
, or .high
) to nudge the system in the right direction.
Why did you only say the “standard” behaviour?
I’m glad you noticed.
So in, probably, 99.9% of the cases where you instantiate a Task, it will be run on the thread on which it was created.
But with thread pooling, the system gets to be the arbiter of what work is scheduled where, and technically the work on a task is able to be scheduled on another thread. You simply need to put a huge amount of threading load on the system.
In High Performance Swift Apps, I was making improvements to my 2FA app. It calculated millions of TOTP codes into the future to send push notifications when you got cool numbers like 012345. By using more efficient number-matching algorithms and introducing parallelism for the calculations, its cryptographic number crunching became 20x faster.
This also offered a perfect place to demonstrate when the runtime does NOT use standard behaviour.
Here, as in our original example, we are running a function on the main thread. Our original print statement is on Thread 1 as expected.
But our Task actually runs on Thread 14. The runtime has scheduled it to run on a background thread, because there was so much work being performed.
The context?
Blocking up the CPU with many parallel tasks calculating 2FA codes.
But if you aren’t simultaneously scheduling a ton of work, you can reasonably expect the standard behaviour.
This might be the best heuristic:
“My Task is probably going to run on the thread it’s created on, however I shouldn’t use any unsafe operations such as
assumeIsolated
based on this assumption.”
In Summary
Tasks are extremely useful for running async work, but they can appear to have strange limitations when we don’t fully understand their behaviour.
Task
s normally start running straight away on the thread on which they are defined. This means if they are started on the main thread, they should run on the main thread.await
functions act as a suspension point, where work might leave the main actor and execute on another thread.Task.detached
passes the work to the global executor to be scheduled on any available global thread.
Using the debug navigator with breakpoints helps us see through the high-level abstractions of the Swift Concurrency runtime to view the underlying POSIX threads that are actually running our work.
While this Task behaviour is the standard, it’s possible to overwhelm our system and observe something else. Therefore, we should probably avoid performing unsafe operations based on this assumption.
I hope now you’ve got a better intuition for how Tasks work in Swift Concurrency, and what’s really going on under the hood in your async workloads.
Unstructured tasks inherit the actor context they’re created in. In your example, since the task is created inside a UIViewController, it runs on the MainActor, meaning any code in the task will be executed on the main thread.
Apple covers this in WWDC 2021 Session 10134 - https://developer.apple.com/videos/play/wwdc2021/10134/?time=1273.
If you don’t want a task to inherit the actor context, you can use Task.detached. Just be careful—this can lead to issues like running UIKit code on a background thread, which isn’t safe.
In your example of the 2 prints on main, I assume the first comes from a method (or class) that is MainActor bound? If so, can’t you still use Task { someMainActorBoundEntity.syncMethod() } and this will be guaranteed to run on main? Is it the fact that the only enclosed content is a print statement mean an optimization is made to not require main execution?