Whatâs the difference between a task and a detached task?
Whatâs the difference between a task and a detached task? êŽë š
Updated for Xcode 15
If you create a new task using the regular Task
initializer, your work starts running immediately and inherits the priority of the caller, any task local values, and its actor context. On the other hand, detached tasks also start work immediately, but do not inherit the priority or other information from the caller.
Iâm going to explain in more detail why these differences matter, but first I want to mention this very important quote from the Swift Evolution proposal for async let
: âTask.detached
most of the time should not be used at all.â Iâm getting that out of the way up front so you donât spend time learning about detached tasks, only to realize you probably shouldnât use them!
Still here? Okay, letâs dig in to our three differences: priority, task local values, and actor isolation.
The priority part is straightforward: if youâre inside a user-initiated task and create a new task, it will also have a priority of user-initiated, whereas creating a new detached task would give a nil priority unless you specifically asked for something.
The task local values part is a little more complex, but to be honest probably isnât going to be of interest to most people. Task local values allow us to share a specific value everywhere inside one specific task â they are like static properties on a type, except rather than everything sharing that property, each task has its own value. Detached tasks do not inherit the task local values of their parent because they do not have a parent.
The actor context part is more important and more complex. When you create a regular task from inside an actor it will be isolated to that actor, which means you can use other parts of the actor synchronously:
actor User {
func login() {
Task {
if authenticate(user: "taytay89", password: "n3wy0rk") {
print("Successfully logged in.")
} else {
print("Sorry, something went wrong.")
}
}
}
func authenticate(user: String, password: String) -> Bool {
// Complicated logic here
return true
}
}
let user = User()
await user.login()
In comparison, a detached task runs concurrently with all other code, including the actor that created it â it effectively has no parent, and therefore has greatly restricted access to the data inside the actor.
So, if we were to rewrite the previous actor to use a detached task, it would need to call authenticate()
like this:
actor User {
func login() {
Task.detached {
if await self.authenticate(user: "taytay89", password: "n3wy0rk") {
print("Successfully logged in.")
} else {
print("Sorry, something went wrong.")
}
}
}
func authenticate(user: String, password: String) -> Bool {
// Complicated logic here
return true
}
}
let user = User()
await user.login()
This distinction is particularly important when you are running on the main actor, which will be the case if youâre responding to a button click for example. The rules here might not be immediately obvious, so I want to show you some examples of what is allowed and what is not allowed, and more importantly explain why each is the case.
First, if youâre changing the value of an @State
property, you can do so using a regular task like this:
struct ContentView: View {
@State private var name = "Anonymous"
var body: some View {
VStack {
Text("Hello, \(name)!")
Button("Authenticate") {
Task {
name = "Taylor"
}
}
}
}
}
Note
The Task
here is of course not needed because weâre just setting a local value; Iâm just trying to illustrate how regular tasks and detached tasks are different.
In fact, because @State
guarantees itâs safe to change its value on any thread, we can use a detached task instead even though it wonât inherit actor isolation:
struct ContentView: View {
@State private var name = "Anonymous"
var body: some View {
VStack {
Text("Hello, \(name)!")
Button("Authenticate") {
Task.detached {
name = "Taylor"
}
}
}
}
}
Thatâs the easy part. The rules change when we switch to an observable object that publishes changes. As soon as you add any @ObservedObject
or @StateObject
property wrappers to a view, Swift will automatically infer that the whole view must also run on the main actor.
This makes sense if you think about it: changes published by observable objects must update the UI on the main thread, and because any part of the view might try to adjust your object the only safe approach is for the whole view to run on the main actor.
So, this means we can modify a view model from inside a task created inside a SwiftUI view:
class ViewModel: ObservableObject {
@Published var name = "Hello"
}
struct ContentView: View {
@StateObject private var model = ViewModel()
var body: some View {
VStack {
Text("Hello, \(model.name)!")
Button("Authenticate") {
Task {
model.name = "Taylor"
}
}
}
}
}
However, we cannot use Task.detached
here â Swift will throw up an error that a property isolated to global actor 'MainActor' can not be mutated from a non-isolated context. In simpler terms, our view model updates the UI and so must be on the main actor, but our detached task does not belong to that actor.
At this point, you might wonder why detached tasks would have any use. Well, consider this code:
class ViewModel: ObservableObject { }
struct ContentView: View {
@StateObject private var model = ViewModel()
var body: some View {
Button("Authenticate", action: doWork)
}
func doWork() {
Task {
for i in 1...10_000 {
print("In Task 1: \(i)")
}
}
Task {
for i in 1...10_000 {
print("In Task 2: \(i)")
}
}
}
}
Thatâs the simplest piece of code that demonstrates the usefulness of detached tasks: a SwiftUI view monitoring an empty view model, plus a button that launches a couple of tasks to print out text.
When that runs, youâll see âIn Task 1â printed 10,000 times, then âIn Task 2â printed 10,000 times â even though we have created two tasks, they are executing sequentially. This happens because our @StateObject
view model forces the entire view onto the main actor, meaning that it can only do one thing at a time.
In contrast, if you change both Task
initializers to Task.detached
, youâll see âIn Task 1â and âIn Task 2â get intermingled as both execute at the same time. Without any need for actor isolation, Swift can run those tasks concurrently â using a detached task has allowed us to shed our attachment to the main actor.
Although detached tasks do have very specific uses, generally I think they should be your last port of call â use them only if youâve tried both a regular task and async let
, and neither solved your problem.