How to determine where tasks and async functions run in Swift?
Published on: February 16, 2024Swift’s current concurrency model leverages tasks to encapsulate the asynchronous work that you’d like to perform. I wrote about the different kinds of tasks we have in Swift in the past. You can take a look at that post here. In this post, I’d like to explore the rules that Swift applies when it determines where your tasks and functions run. More specifically, I’d like to explore how we can determine whether a task or function will run on the main actor or not.
We’ll start this post by very briefly looking at tasks and how we can determine where they run. I’ll dig right into the details so if you’re not entirely up to date on the basics of Swift’s unstructured and detached tasks, I highly recommend that you catch up here.
After that, we’ll look at asynchronous functions and how we can reason about where these functions run.
To follow along with this post, it’s recommended that you’re somewhat up to date on Swift’s actors and how they work. Take a look at my post on actors if you want to make sure you’ve got the most important concepts down.
If you prefer to consume the contents of this post as a video, you can watch the video below.
Reasoning about where a Swift Task will run
In Swift, we have two kinds of tasks:
- Unstructured tasks
- Detached tasks
Each task type has its own rules regarding where the task will run its body.
When you create a detached task, this task will always run its body using the global executor. In practical terms this means that a detached task will always run on a background thread. You can create a detached task as follows:
Task.detached {
// this runs on the global executor
}
A detached task should hardly ever be used in practice because there are other ways to perform work in the background that don’t involve starting a new task (that doesn’t participate in structured concurrency).
The other way to start a new task is by creating an unstructured task. This looks as follows:
Task {
// this runs ... somewhere?
}
An unstructured task will inherit certain things from its context, like the current actor for example. It’s this current actor that determines where our unstructured task will run.
Sometimes it’s pretty obvious that we want a task to run on the main actor:
Task { @MainActor in
}
While this task inherits an actor from the current context, we’re overriding this by annotating our task body with MainActor
to make sure that our task’s body runs on the main actor.
Interesting sidenote: you can do the same with a detached task.
Additionally, we can create a new task that’s on the main actor like this:
@MainActor
struct MyView: View {
// body etc...
func startTask() {
Task {
// this task runs on the main actor
}
}
}
Our SwiftUI view in this example is annotated with @MainActor
. This means that every function and property that’s defined on MyView
will be executed on the main actor. Including our startTask
function. The Task
inherits the main actor from MyView
so it’s running its body on the main actor.
If we make one small change to the view, everything changes:
struct MyView: View {
// body etc...
func startTask() {
Task {
// where does this task run?
}
}
}
Instead of knowing that startTask
will run on the main actor, it's a bit trickier to reason about where our function will run exactly. Our view itself is not main actor bound which means that its functions can be called on any actor or executor. When we call startTask
, we'll find that the Task
that's created in its function body will not be main actor isolated. Not even if you call this function from a place that is main actor isolated. This seems to be related to startTask
being nonisolated by definition which means that it's never bound to a specific actor and runs on the global executor which results in unstructured Tasks being spawned on the global excutor too.
At runtime, we can use MainActor.assertIsolated(_:)
to perform a check and see whether we're on the main actor. If we're not, our app would crash during development which is perfectly fine. Especially when we're using this function as a tool to learn more about our code. Here's how you can use this function:
struct MyView: View {
// body etc...
func startTask() {
Task {
MainActor.assertIsolated("Not isolated!!")
}
}
}
When I ran this example on my device, it crashed every time which shows that the runtime behavior is not something that's random. We can already know at compile time that our code will not run on the main actor because neither the function, the view, nor the task are @MainActor
annotated.
As a rule of thumb you could say that a Task
will always run in the background if you’re not attached to any actors. This is the case when you create a new Task
from any object that’s not main actor annotated for example. When you create your task from a place that’s main actor annotated, you know your task will run on the main actor.
Unfortunately, this isn’t always straightforward to determine and Apple seems to want us to not worry too much about this. The key takeaway is that if you want something to run on the main actor, you have to annotate it with the @MainActor
annotation. The underlying system will make sure there are no extraneous thread hops and that there's no perfromance cost to having these annotations in place.
Luckily, the way async functions work in Swift can give us some confidence in making sure that we don’t block the main actor by accident.
Reasoning about where an async function runs in Swift
Whenever you want to call an async function in Swift, you have to do this from a task and you have to do this from within an existing asynchronous context. If you’re not yet in an async function you’ll usually create this asynchronous context by making a new Task
object.
From within that task you’ll call your async function and prefix the call with the await
keyword. It’s a common misconception that when you await
a function call the task you’re using the await
from will be blocked until the function you’re waiting for is completed. If this were true, you’d always want to make sure your tasks run away from the main actor to make sure you’re not blocking the main actor while you’re waiting for something like a network call to complete.
Luckily, awaiting something does not block the current actor. Instead, it sets aside all work that’s ongoing so that the actor you were on is free to perform other work. I gave a talk where I went into detail on this. You can watch the talk here:
Knowing all of this, let’s talk about how we can determine where an async function will run. Examine the following code:
struct MyView: View {
// body etc...
func performWork() async {
// Can we determine where this function runs?
}
}
The performWork
function is marked async
which means that we must call it from within an async context, and we have to await it.
A reasonable assumption would be to expect this function to run on the actor that we’ve called this function from.
For example, in the following situation you might expect performWork
to run on the main actor:
struct MyView: View {
var body: some View {
Text("Sample...")
.task {
await peformWork()
}
}
func performWork() async {
// Can we determine where this function runs?
}
}
Interestingly enough, peformWork
will not run on the main actor in this case. The reason for that is that in Swift, functions don’t just run on whatever actor they were called from. Instead, they run on the global executor unless instructed otherwise.
In practical terms, this means that your asynchronous functions will need to be either directly or indirectly annotated with the main actor if you want them to run on the main actor. In every other situation your function will run on the global executor.
While this rule is straightforward enough, it can be tricky to determine exactly whether or not your function is implicitly annotated with @MainActor
. This is usually the case when there’s inheritance involved.
A simpler example looks as follows:
@MainActor
struct MyView: View {
var body: some View {
Text("Sample...")
.task {
await peformWork()
}
}
func performWork() async {
// This function will run on the main actor
}
}
Because we’ve annotated our view with @MainActor
, the asynchronous performWork
function inherits the annotation and it will run on the main actor.
While the practice of reasoning about where an asynchronous function will run isn’t straightforward, I usually find this easier than reasoning about where my Task
will run but it’s still not trivial.
The key is always to look at the function itself first. If there’s no @MainActor
, you can look at the enclosing object’s definition. After that you can look at base classes and protocols to make sure there isn’t any main actor association there.
At runtime, you can use the MainActor.assertIsolated(_:)
function to see if your async function runs on the main actor. If it does, you’ll know that there’s some main actor annotation that’s applied to your asynchronous function. If you’re not running on the main actor, you can safely say that there’s no main actor annotation applied to your function.
In Summary
Swift Concurrency’s rules for determining where a task or function runs are relatively clear and specific. However, in practice things can get a little muddy for tasks because it’s not always trivial to reason about whether or that your task is created from a context that’s associated with the main actor. Note that running on the main thread is not the same as being associated with the main actor.
For async functions we can reason more locally which results in an easier mental modal but it’s still not trivial.
We can use MainActor.assertIsolated(_:)
to study whether our code is running on the main thread but once you fully understand and internalize the rules outlined in this post you shouldn't need this function to reason about where your code runs.
If you have any additions, questions, or comments on this article please don’t hesitate to reach out on X.