Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

Swift Task Lifecycles on macOS: Ownership, Cancellation, and Structured Work

Manage Swift tasks in macOS apps with explicit owners, cooperative cancellation, structured child work, stale-result guards, and actor-safe UI updates.

Swift concurrency makes asynchronous code easier to compose, but it does not remove the need to decide who owns a task and when its result is still relevant. A Task can start running immediately after creation. Dropping the reference does not automatically cancel the work; it only removes the ability to wait for or cancel that task through the reference. A view that disappears, a document that closes, and a user who presses Cancel are different ownership events and should not be conflated.

Structured concurrency keeps child tasks within a lexical lifetime, while unstructured tasks are useful for work that must outlive the immediate caller. Both models still rely on cooperative cancellation. Calling cancel() marks the task and triggers cancellation handlers, but arbitrary functions do not stop unless their code checks for cancellation or awaits an operation that responds to it.

Choose a task owner before creating work

For UI-triggered work, a model or coordinator should own the task handle. The owner defines whether a new request replaces the previous one, whether a view disappearing cancels it, and whether the task’s result can be applied after a document changes. Avoid launching anonymous tasks throughout button actions with no place to cancel or observe them.

import Foundation

struct SearchResult: Sendable {
    let identifier: String
}

struct SearchService: Sendable {
    func search(_ query: String) async throws -> [SearchResult] {
        [] // Replace with the real, cancellation-aware service call.
    }
}

@MainActor
final class SearchModel {
    private let service = SearchService()
    private var request: Task<Void, Never>?
    private(set) var results: [SearchResult] = []

    func search(_ query: String) {
        request?.cancel()
        let service = service
        request = Task { [weak self] in
            do {
                let value = try await service.search(query)
                try Task.checkCancellation()
                self?.results = value
            } catch is CancellationError {
                // Cancellation is an expected state transition.
            } catch {
                self?.results = []
            }
        }
    }

    func cancel() {
        request?.cancel()
        request = nil
    }
}

The service body is a placeholder. A real service must either call cancellation-aware APIs or check Task.isCancelled between bounded units of CPU work. The model is main-actor isolated so its visible result state is updated on the UI actor. If search completion must not update a newer query, compare a request generation or query identity before applying the result; cancellation can race with a completion that is already returning.

The weak capture prevents the task from keeping the model alive solely through a closure cycle. It does not cancel the underlying operation when the model deinitializes. If the owner disappears, call cancel() as part of a deterministic lifecycle or use a task API whose scope is tied to the view or parent. Every long-lived task should have an owner, a cancellation rule, and a completion path.

Cancellation is a signal, not a thread kill

Cancellation is cooperative and idempotent. It sets a flag, runs active cancellation handlers, and propagates to child tasks and task groups. It does not forcibly interrupt synchronous code, terminate a thread, undo a network request already accepted by a server, or roll back a file write. Code that ignores cancellation may continue normally and return a value after its caller no longer wants it.

Check cancellation at meaningful boundaries: before expensive work starts, between chunks, before publishing a result, and after an await when an external call may have completed after cancellation. Throw CancellationError when an operation should stop without producing its normal result. For functions that return partial work, document the partial-result contract instead of disguising cancellation as success.

Use withTaskCancellationHandler only when a non-async resource needs an explicit cancellation action, such as closing a custom stream or cancelling a callback-based operation. Its onCancel closure is synchronous and can race with the operation closure. Protect shared state and make cleanup idempotent. Do not block or await inside the cancellation handler; transfer cleanup to a safe executor if the resource API requires asynchronous teardown.

Use structured concurrency for child work

Use async let for a small, statically known number of independent operations and task groups for a dynamic set. Child tasks inherit the parent task’s priority and task-local values, and structured scopes wait for their children to finish before returning. This makes resource ownership easier to reason about than detached tasks, but it also means a slow or cancellation-ignoring child can delay the scope’s exit.

import Foundation

func loadDocuments(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask {
                try Task.checkCancellation()
                return try Data(contentsOf: url)
            }
        }

        var values: [Data] = []
        for try await value in group {
            values.append(value)
        }
        return values
    }
}

This sample demonstrates child task ownership, not a production file loader. It intentionally uses a synchronous file API to show why cancellation checks alone do not make blocking I/O interruptible. For large files, use an asynchronous or chunked reader and bound how many operations run at once. A task group is concurrent, not automatically rate-limited; adding thousands of children can create pressure even though the code is structured.

If one child throws out of a throwing task group body, the group cancels remaining children, then waits for them to finish. That wait is required for structured lifetime safety. If siblings ignore cancellation, error propagation can appear slow. Make children cancellation-aware and avoid external side effects that cannot be safely abandoned or reconciled.

Detached tasks need a stronger contract

Task.detached creates unstructured work that does not inherit the surrounding actor context or task-local values in the same way as a child task. It can be appropriate for independent work with an explicit lifetime, but it should not be used as a generic escape hatch for compiler isolation warnings. Retain its handle, define how it is cancelled, make captured values safe to share, and return results to an actor deliberately.

A detached task is not automatically a background-priority task, a background execution entitlement, or a durable job. It may be cancelled by application logic, process termination, or system lifecycle. If a task must survive relaunch, persist a job record and use a platform mechanism designed for that workload rather than expecting an in-memory task to be durable.

Prevent stale results and duplicate side effects

Cancellation is a resource-management signal, not a correctness proof. A request can finish just before cancellation is observed. Use request IDs, document revision numbers, or model versions to reject stale results at the commit point. For remote mutations, send a stable idempotency key or query the server before retrying an ambiguous operation. For local edits, apply results only if the source revision still matches the one the task read.

Keep intermediate work separate from publication. Write a download to a temporary file, validate it, then atomically replace the visible artifact. Compute a search index off to the side, then swap it into the model only if the indexing generation is current. A cancelled task can have performed partial work; its cleanup path must remove or mark that intermediate state rather than presenting it as complete.

Actor isolation and AppKit boundaries

Use @MainActor for UI-facing state, not as a blanket annotation for all work. CPU-heavy transforms should not run synchronously on the main actor. A task created from a main-actor context may inherit actor isolation, so identify suspension points and move expensive computation to a suitable non-actor-isolated function or dedicated actor. Avoid accessing NSView and mutable AppKit objects from background tasks unless the API explicitly allows it.

When a task calls back from an API with a different isolation context, hop to the owning actor before changing the UI. Prefer typed Sendable results between actors. Avoid marking mutable reference types @unchecked Sendable merely to silence diagnostics; establish and document their synchronization invariant first.

Cancellation handlers, timeouts, and retries

A timeout is not a cancellation policy by itself. When a deadline expires, cancel the child operation and still await its termination if it is structured work. If an underlying callback API may never call back after cancellation, wrap it so the continuation resumes exactly once and resource cleanup happens on all paths. Test callback-versus-cancel races rather than assuming the framework always chooses one deterministic winner.

Retries should be bounded and apply only to idempotent or reconcilable work. A cancellation can arrive after a mutation reached the server but before the client received the response. Repeating that mutation may duplicate it. Preserve an operation identifier and ask the server for its state or use an idempotency contract before retrying. Exponential backoff belongs to the retry owner, not a tight loop in a task that ignores user cancellation.

Acceptance tests and diagnostics

Test cancellation before task start, during a suspension point, during synchronous CPU work, while a network request is returning, after the result is computed but before UI publication, and while a child task fails. Assert that canceled work does not publish stale results, task groups do not outlive their scope, resources close exactly once, and replacement requests cannot be overwritten by the earlier generation.

Record task type, owner identifier, request generation, cancellation reason, duration, and final outcome. Avoid logging document contents, tokens, or full URLs with sensitive query parameters. Measure cancellation latency for long operations; a request that takes minutes to acknowledge cancellation is an operational defect even if it eventually exits.

Swift tasks provide composable asynchronous execution and structured child lifetimes. The application still owns the task handle, interruption behavior, stale-result policy, durable side effects, and actor boundaries. Production quality comes from making those contracts explicit rather than treating cancel() as an undo button.

Related:

Sources:

Comments