async let vs Task.detached when work must escape actor isolation but keep tracing context
0 reputation · 15 Apr 2022, 20:23 UTC
0 reputation · 15 Apr 2022, 20:23 UTC
I have a Swift service where a method on an actor needs to kick off a background export. The work should not hold the actor's lock while it runs, but it does rely on a task-local tracing ID and should still stop if the caller's task is cancelled. I am weighing two documented options: a structured child task via async let, or an unstructured Task.detached.
My understanding is that async let inherits the enclosing actor isolation, priority, and task-local values, and is cancelled with the parent, while Task.detached drops all of that context and runs independently. That seems to put my two requirements in tension: escaping actor isolation appears to require detaching, but detaching appears to forfeit the task-local propagation and cancellation I depend on. A plain Task {} is a third option, but as an unstructured task its lifetime would need manual management.
Assume Swift 5.9 or later with strict concurrency checking enabled.
Is there a documented way to escape the actor's isolation for the heavy work while still inheriting task-local values and priority, or must I pick one side of this trade-off?
If I choose Task.detached and explicitly pass the tracing ID in as an argument, do I also lose cooperative cancellation from the original caller, or only automatic cancellation propagation?
Does the answer change depending on whether the export genuinely needs to outlive the calling scope versus merely needing to run off the actor?
31115 reputation · 15 Apr 2022, 23:46 UTC
To escape actor isolation while maintaining task-local values and cooperative cancellation, you should use async let (or a Task Group) in conjunction with a non-isolated function.
The misconception is that async let always keeps the work on the actor. In reality, async let inherits the actor isolation of the call site only if the function it calls is isolated to that actor. If you call a nonisolated function or a function in a different module that is not bound to your actor, the work is dispatched to the global concurrent pool, effectively escaping the actor's lock while remaining part of the structured concurrency tree.
| Feature | async let + nonisolated | Task.detached | Task { } |
|---|---|---|---|
| Escapes Actor | Yes | Yes | No (Inherits isolation) |
| Task-Locals | Inherited | Lost | Inherited |
| Cancellation | Automatic/Structured | Manual/Independent | Manual/Unstructured |
nonisolated function or a separate struct/class that is not an actor.async let from within your actor method.nonisolated func performExport(data: Data) async throws {
// This runs on the global concurrent pool
// Task-local tracing IDs are available here
try await Task.sleep(for: .seconds(5))
}
If you choose Task.detached, you lose automatic cancellation propagation. While the detached task is a top-level task, it does not monitor the cancellation state of the task that spawned it. To regain this, you would need to manually check Task.isCancelled within the detached block and manually link the parent's cancellation state to the child, which is redundant if structured concurrency is available.
The choice depends entirely on the desired lifetime:
async let + nonisolated.Task.detached and manually pass the tracing ID as a parameter. Be aware that you must manage the Task handle if you ever need to cancel it later.Diagnostic Detail Needed: Does the export process need to continue running if the user navigates away from the screen/service that initiated the request, or should it be terminated immediately?
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.