DEV Community

Nick Vasilev
Nick Vasilev

Posted on

Swift Concurrency: Part 2

Introduction

In the previous article we consider the foundation of Swift Concurrency: a multithreading technique which lies underlying Swift Concurrency, definition of the Task, the difference between Task and Task.detached, and managaning priorities. If you haven't read this, check it out here. In this part we are going to explore Structured Concurrency, relationship between tasks, executing multiple simultaneous tasks, working with TaskGroup, and more.

Lightweight Structured Concurrency

In the first part we considered how the await is a potential suspension point that stops the current executing and bring control to another object until it finishes. But if operations are independent and can be run simultaneously.

The Swift Concurrency provides with a simple solution for this case. Let's image that we need to perform multiple requests which aren't dependent on each other, for this we can use async let construction.

func loadData() async throws {
    async let profile = try await userService.profile()
    async let configuration = try await configurationService.configuration()

    dashboardService.load(await profile, await configuration)
}
Enter fullscreen mode Exit fullscreen mode

In this case, these two operations will run simultaneously. The dashboardService will wait for the results of the previous requests before proceeding.

Task Cancellation

One important aspect of working with concurrency is the ability to cancel operations. Swift Concurrency provides built-in support for cancelling tasks, allowing developers to efficiently manage resources and respond to changing program conditions. In this section, we will explore how task cancellation works in Swift.

If one of these operations runs into an error, all of them will be cancelled. If you want to manually cancel these operations, you can call the cancel() method on Task.

let task = Task { [weak self] in
  self?.loadData()
}
task.cancel()
Enter fullscreen mode Exit fullscreen mode

The cancel() method doesn’t immediately stop an operation. It works similarly to cancel in OperationQueue: it marks a task as cancelled, but you must handle this case manually.

Task {
    let task = Task {
        if Task.isCancelled {
            print("Task is cancelled, \(Thread.currentThread)")
        }

        print("Starting work on: \(Thread.currentThread)")
        try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
        print("Still running? Cancelled: \(Task.isCancelled)")
    }

    task.cancel()
}

// Task is cancelled, <_NSMainThread: 0x600000ff0580>{number = 1, name = main}
// Starting work on: <_NSMainThread: 0x600000ff0580>{number = 1, name = main}
// Still running? Cancelled: true
Enter fullscreen mode Exit fullscreen mode

To handle task cancellation, you can either check the Task.isCancelled property or call try Task.checkCancellation(), which throws a general cancellation error that can be propagated to the user.

Task {
  let task = Task {
    print("Task is cancelled: \(Task.isCancelled), \(Thread.current)")
    if Task.isCancelled {
      print("Task was cancelled sorry, \(Task.isCancelled)")
    } else {
      try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 1)
      print("Job done \(Thread.current)")
    }
  }

  task.cancel()

// Task is cancelled: true, <_NSMainThread: 0x600002164580>{number = 1, name = main}
// Task was cancelled sorry, true
}
Enter fullscreen mode Exit fullscreen mode
Task {
  let task = Task {
    print("Task is cancelled: \(Task.isCancelled), \(Thread.current)")
    do {
      try Task.checkCancellation()
      try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 1)
      print("Job done \(Thread.current)")
    } catch {
      print("\(error), \(Thread.current) ")
    }
    print("end of task \(Thread.current)")
  }

  task.cancel()

// Task is cancelled: true, <_NSMainThread: 0x600000ec4580>{number = 1, name = main}
// CancellationError(), <_NSMainThread: 0x600000ec4580>{number = 1, name = main} 
// end of task <_NSMainThread: 0x600000ec4580>{number = 1, name = main}
}
Enter fullscreen mode Exit fullscreen mode

In this example, the inner task does not automatically respond to cancellation:

Task {
  let parentTask = Task {
    print("Parent is cancelled: \(Task.isCancelled), \(Thread.currentThread)")

    Task {
      print("Nested task is cancelled: \(Task.isCancelled), \(Thread.currentThread)")
    }
  }

  parentTask.cancel()
}

// Parent is cancelled: true, <_NSMainThread: 0x600001960580>{number = 1, name = main}
// Nested task is cancelled: false, <_NSMainThread: 0x600001960580>{number = 1, name = main}
Enter fullscreen mode Exit fullscreen mode

Here, we create a task and launch another Task inside it. When we call task.cancel(), the outer task is marked as cancelled, but the inner task continues to run. This happens because cancellation does not automatically propagate to nested tasks.

Task {
    let task = Task {
        await withTaskCancellationHandler {
            try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 1)
            print("Print job done: \(Task.isCancelled), \(Thread.currentThread)")
        } onCancel: {
            print("Task cancelled: \(Task.isCancelled), \(Thread.currentThread)")
        }
    }

    task.cancel()
}

// Task cancelled: true, <_NSMainThread: 0x600003d80580>{number = 1, name = main}
// Print job done: true, <_NSMainThread: 0x600003d80580>{number = 1, name = main}
Enter fullscreen mode Exit fullscreen mode

TaskLocal

Task-local values allow you to store and access data within the scope of specific tasks. This can be useful when you need to propagate contextual information — for example, tracking a user role, request ID, or configuration setting across a chain of tasks without explicitly passing it as a parameter.

Just like task priorities, detached tasks do not inherit the task context. That’s why task-local values revert to their default state when accessed from a detached task.

private enum Context {
  @TaskLocal static var locale: String = "en_US"
}

func performTask() async {
  print("Outer locale: \(Context.locale)")

  Task {
    print("Before change: \(Context.locale)")

    Context.$locale.withValue("fr_FR") {
      print("Within withValue: \(Context.locale)")

      Task.detached {
        print("Detached locale: \(Context.locale)")
      }
    }
  }
}

// Outer locale: en_US
// Before change: en_US
// Within withValue: fr_FR
// Detached locale: en_US
Enter fullscreen mode Exit fullscreen mode

In this example, the task-local variable locale initially has the value "en_US".

When the value is temporarily overridden with "fr_FR" using withValue(_:), the new value is visible only within that specific task hierarchy. However, when a detached task is created, it doesn’t inherit this context and therefore prints the default "en_US" role again.

Task Group

Task groups are useful when dealing with a dynamic number of tasks.

Unlike async let, which is designed for a fixed number of concurrent tasks known at compile time, task groups allow you to create and manage tasks dynamically — for example, when processing a collection of items or running parallel network requests of unknown count.

Conceptually, a TaskGroup is similar to a DispatchGroup, but with native support for Swift concurrency features such as structured cancellation, error propagation, and task priorities.
All child tasks in the group inherit the priority of their parent task, unless explicitly overridden.

Task(priority: .background) {
  await withTaskGroup(of: Void.self) { group in
    for i in 0..<5 {
      let p: TaskPriority = i % 2 == 0 ? .high : .low

      group.addTask(priority: p) {
        print("\(i), p: \(Task.currentPriority), base: \(Task.basePriority)")
      }
    }
  }
}

// 0, p: TaskPriority.background, base: Optional(TaskPriority.high)
// 1, p: TaskPriority.background, base: Optional(TaskPriority.low)
// 2, p: TaskPriority.background, base: Optional(TaskPriority.high)
// 3, p: TaskPriority.background, base: Optional(TaskPriority.low)
// 4, p: TaskPriority.background, base: Optional(TaskPriority.high)
Enter fullscreen mode Exit fullscreen mode

In this example, a parent task with .background priority creates several child tasks inside a group.
Each child task explicitly sets its own priority (.high or .low), which can be observed through Task.currentPriority and Task.basePriority.

This demonstrates how task groups provide flexible and dynamic control over concurrent workloads, while maintaining structured task management and cancellation behavior.

Conclusion

In this article, we explored the core ideas behind Structured Concurrency in Swift from lightweight parallelism with async let, to advanced techniques like TaskGroup, TaskLocal, and cooperative cancellation.

Swift’s concurrency model provides not only performance and safety, but also a clean, declarative way to reason about concurrent code. Each feature — whether it’s Task, TaskGroup, or @TaskLocal — is designed to work seamlessly together under the same structured model, ensuring that your asynchronous operations remain predictable and maintainable.

Top comments (0)