Understanding unstructured and detached tasks in Swift

Published on: April 13, 2023

When you just start out with learning Swift Concurrency you’ll find that there are several ways to create new tasks. One approach creates a parent / child relationship between tasks, another creates tasks that are unstructured but do inherit some context and there’s an approach that creates tasks that are completely detached from all context.

In this post, I will focus on unstructured and detached tasks. If you’re interested in learning more about child tasks, I highly recommend that you read the following posts:

These two posts go in depth on the relationship between parent and child tasks in Swift Concurrency, how cancellation propagates between tasks, and more.

This post assumes that you understand the basics of structured concurrency which you can learn more about in this post. You don’t have to have mastered the topic of structured concurrency, but having some sense of what structured concurrency is all about will help you understand this post much better.

Creating unstructured tasks with Task.init

The most common way in which you’ll be creating tasks in Swift will be with Task.init which you’ll probably write as follows without spelling out the .init:

Task {
  // perform work
}

An unstructured task is a task that has no parent / child relationship with the place it called from, so it doesn’t participate in structured concurrency. Instead, we create a completely new island of concurrency with its own scopes and lifecycle.

However, that doesn’t mean an unstructured task is created entirely independent from everything else.

An unstructured task will inherit two pieces of context from where it’s created:

  • The actor we’re currently running on (if any)
  • Task local values

The first point means that any tasks that we create inside of an actor will participate in actor isolation for that specific actor. For example, we can safely access an actor’s methods and properties from within a task that’s created inside of an actor:

actor SampleActor {
  var someCounter = 0

  func incrementCounter() {
    Task {
      someCounter += 1
    }
  }
}

If we were to mutate someCounter from a context that is not running on this specific actor we’d have to prefix our someCounter += 1 line with an await since we might have to wait for the actor to be available.

This is not the case for an unstructured task that we’ve created from within an actor.

Note that our task does not have to complete before the incrementCounter() method returns. That shows us that the unstructured task that we created isn’t participating in structured concurrency. If it were, incrementCounter() would not be able to complete before our task completed.

Similarly, if we spawn a new unstructured task from a context that is annotated with @MainActor, the task will run its body on the main actor:

@MainActor
func fetchData() {
  Task {
    // this task runs its body on the main actor
    let data = await fetcher.getData()

    // self.models is updated on the main actor
    self.models = data
  }
}

It’s important to note that the await fetcher.getData() line does not block the main actor. We’re calling getData() from a context that’s running on the main actor but that does not mean that getData() itself will run its body on the main actor. Unless getData() is explicitly associated with the main actor it will always run on a background thread.

However, the task does run its body on the main actor so once we’re no longer waiting for the result of getData(), our task resumes and self.models is updated on the main actor.

Note that while we await something, our task is suspended which allows the main actor to do other work while we wait. We don’t block the main actor by having an await on it. It’s really quite the opposite.

When to use unstructured tasks

You will most commonly create unstructured tasks when you want to call an async annotated function from a place in your code that is not yet async. For example, you might want to fetch some data in a viewDidLoad method, or you might want to start iterating over a couple of async sequences from within a single place.

Another reason to create an unstructured task might be if you want to perform a piece of work independently of the function you’re in. This could be useful when you’re implementing a fire-and-forget style logging function for example. The log might need to be sent off to a server, but as a caller of the log function I’m not interested in waiting for that operation to complete.

func log(_ string: String) {
  print("LOG", string)
  Task {
    await uploadMessage(string)
    print("message uploaded")
  }
}

We could have made the method above async but then we wouldn’t be able to return from that method until the log message was uploaded. By putting the upload in its own unstructured task we allow log(_:) to return while the upload is still ongoing.

Creating detached tasks with Task.detached

Detached tasks are in many ways similar to unstructured tasks. They don’t create a parent / child relationship, they don’t participate in structured concurrency and they create a brand new island of concurrency that we can work with.

The key difference is that a detached task will not inherit anything from the context that it was created in. This means that a detached task will not inherit the current actor, and it will not inherit task local values.

Consider the example you saw earlier:

actor SampleActor {
  var someCounter = 0

  func incrementCounter() {
    Task {
      someCounter += 1
    }
  }
}

Because we used a unstructed task in this example, we were able to interact with our actor’s mutable state without awaiting it.

Now let’s see what happens when we make a detached task instead:

actor SampleActor {
  var someCounter = 0

  func incrementCounter() {
    Task.detached {
      // Actor-isolated property 'someCounter' can not be mutated from a Sendable closure
      // Reference to property 'someCounter' in closure requires explicit use of 'self' to make capture semantics explicit
      someCounter += 1
    }
  }
}

The compiler now sees that we’re no longer on the SampleActor inside of our detached task. This means that we have to interact with the actor by calling its methods and properties with an await.

Similarly, if we create a detached task from an @MainActor annotated method, the detached task will not run its body on the main actor:

@MainActor
func fetchData() {
  Task.detached {
    // this task runs its body on a background thread
    let data = await fetcher.getData()

    // self.models is updated on a background thread
    self.models = data
  }
}

Note that detaching our task has no impact at all on where getData() executed. Since getData() is an async function it will always run on a background thread unless the method was explicitly annotated with an @MainActor annotation. This is true regardless of which actor or thread we call getData() from. It’s not the callsite that decides where a function runs. It’s the function itself.

When to use detached tasks

Using a detached task only makes sense when you’re performing work inside of the task body that you want to run away from any actors no matter what. If you’re awaiting something inside of the detached task to make sure the awaited thing runs in the background, a detached task is not the tool you should be using.

Even if you only have a slow for loop inside of a detached task, or you're encoding a large amount of JSON, it might make more sense to put that work in an async function so you can get the benefits of structured concurrency (the work must complete before we can return from the calling function) as well as the benefits of running in the background (async functions run in the background by default).

So a detached task really only makes sense if the work you’re doing should be away from the main thread, doesn’t involve awaiting a bunch of functions, and the work you’re doing should not participate in structured concurrency.

As a rule of thumb I avoid detached tasks until I find that I really need one. Which is only very sporadically.

In Summary

In this post you learned about the differences between detached tasks and unstructured tasks. You learned that unstructured tasks inherit context while detached tasks do not. You also learned that neither a detached task nor an unstructured task becomes a child task of their context because they don’t participate in structured concurrency.

You learned that unstructured tasks are the preferred way to create new tasks. You saw how unstructured tasks inherit the actor they are created from, and you learned that awaiting something from within a task does not ensure that the awaited thing runs on the same actor as your task.

After that, you learned how detached tasks are unstructured, but they don’t inherit any context from when they are created. In practice this means that they always run their bodies in the background. However, this does not ensure that awaited functions also run in the background. An @MainActor annotated function will always run on the main actor, and any async method that’s not constrained to the main actor will run in the background. This behavior makes detached tasks a tool that should only be used when no other tool solves the problem you’re solving.

Subscribe to my newsletter