Is Task.detached still useful?

Hello everyone!

I’ve been diving a bit into the internals of Task.detached lately, since I’ve seen several people using it in their projects, but for reasons that were a bit unclear to me.

As a reminder, a Task is a unit of asynchronous work that inherits the context from where it’s created. By context, I mean 3 things:

  • globalActor isolation / SerialExecutor
  • Task priorities
  • TaskLocal

From the documentation:

The new task defaults to running with the same actor isolation, priority, and task-local state as the current task

Naturally, Task.detached doesn’t inherit any of that:

To create an unstructured task that’s more independent from the surrounding code, known more specifically as a detached task, call the Task.detached(name:priority:operation:) static method. The new task defaults to running without any actor isolation and doesn’t inherit the current task’s priority or task-local state.

Most people use Task.detached specifically to avoid inheriting the isolation / SerialExecutor of the MainActor.

However, since iOS 18, a new API called TaskExecutor has been introduced. Tasks now have a new parameter that allows us to specify the executor on which we want them to run. One of the options is the globalConcurrentExecutor. The documentation states:

You may pass this executor explicitly to a Task initializer as a task executor preference, in order to ensure and document that task be executed on the global executor, instead of e.g. inheriting the enclosing actor’s executor.

I really like this approach because it makes the intent much clearer: we explicitly declare that we prefer to run the task on a specific executor, namely, the Swift Concurrency background pool (the GCE).

So, if it’s recommended to use the globalConcurrentExecutor in the Task initializer to switch to the GCE, then Task.detached really only has two remaining uses

  • Not inheriting the task’s priority

  • Not inheriting TaskLocal values

For the first one, I still don’t quite understand the use case. If you want to change the priority while switching to the GCE, you can just do:

@MainActor
class MainActorClass {
    func mainActorMethod() {
        Task(executorPreference: globalConcurrentExecutor, priority: .medium) {
            
        }
    }
}

PS: The documentation notes that executorPreference won’t work if a custom executor is created, but these are very specific cases.

Also, I didn’t mention using Task.detached to run multiple tasks in parallel, since that’s exactly what structured concurrency is designed for, something Apple also points out in the documentation:

Don’t use a detached unstructured task if it’s possible to model the operation using structured concurrency features like child tasks. Child tasks inherit the parent task’s priority and task-local storage, and canceling a parent task automatically cancels all of its child tasks. You need to handle these considerations manually with a detached task.

So in conclusion, Task.detached is really only useful to avoid inheriting TaskLocal values.

Did I miss anything in my reasoning?

Yes, setting the task executor preference works to avoid inheriting the current isolation.

This is accomplished by the Task initializer not using @_inheritActorContext for the variants setting a task executor preference.

I think it's OK as far as signaling intentions go although not ideal since it's using a side effect of starting a task with a task executor preference and it prevents inheriting a custom task executor preference.

Another frequently used approach is starting the task from a nonisolated wrapper. That avoids having to set a task executor preference but needs some extra boilerplate.

@MainActor
func f() {
    startTask()
}

// assuming nonisolated default actor isolation
// the function would need to be nonisolated explicitly when using MainActor default actor isolation
func startTask() {
    Task {
        // not isolated to MainActor
    }
}

The "ideal" solution is part of the Closure Isolation pitch which allows you to explicitly avoid inheriting isolation.

// Today, you can already override the isolation explicitly:
Task { @MainActor in
    // starts on the MainActor, no matter the isolation we started the task from
}

// With the closure isolation pitch you could also specify nonisolated to make the closure explicitly nonisolated:
Task { nonisolated in
    // starts nonisolated on the task executor, no matter the isolation we started the task from
}

If you want to try it, you can enable the ClosureIsolation experimental feature, the nonisolated in part of the pitch is already implemented.

Personally I share your preference for not using Task.detached for this purpose. While it works, not inheriting all the other context like task locals should be an explicit choice. Using Task.detached just to avoid inheriting the current isolation is liable to bite you in the future.

On Apple platforms specifically, there is at least one more item that detached tasks don't inherit that I am aware of, the current activity. I would assume that detached tasks probably match detached DispatchWorkItems in general in this regard but I don't know if there are any context attributes beyond priority and activity.

2 Likes

Yeah task executors are great but they do more than you described here. They affect the entire task hierarchy of the task which has a task executor configured (!), since their purpose is to keep all those tasks executing on the same executor.

This is different than just “enqueue this one task on specific executor”.

The reason we don’t have Task(on: some serial executor) is because of the long term goal to spell this as Task { [isolated <some serial executor>] in } which would work better with compile time isolation checking. However, we don’t have this spelling because we’re missing this language feature closure isolation control.

So yes, detached captures many semantics, but we don’t have a real replacement for some of them yet.

3 Likes

I would like to add, not that this really matters here, that one should not think about TaskExecutor as something similar to an actor's executor, but rather an interface to manage work on the executor's owned execution resources. In my opinion, The TaskExecutor doc does a rather poor job describing what it actually is, as is the name itself, given that we have moved away from using the term “partial task“, I think a more appropriate term would have been JobExecutor, or maybe not.

From SE-417:

In a way, one should think of the SerialExecutor of the actor and TaskExecutor both being tracked and providing different semantics. The SerialExecutor guarantees mutual exclusion, and the TaskExecutor provides a source of threads.

The task executor can be seen as a "source of threads" for the execution, while the actor's serial executor is used to ensure the serial and isolated execution of the code.

2 Likes

Thanks for the clarification!

I'm really looking forward to closure isolation control being included. I went through the proposal and the [isolated self] syntax seems like it would solve a lot of these issues elegantly. The current workarounds withTask.detached or executorPreference feel like we're indeed using side effects to achieve what should be explicit!

1 Like