Skip to content

Swift Concurrency: Part 2

Published: at 10:36 AM

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.

Table of contents

Open Table of contents

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)
}

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()

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

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
}
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}
}

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("Child is cancelled: \(Task.isCancelled), \(Thread.currentThread)")
    }
  }

  parentTask.cancel()
}

// Parent is cancelled: true, <_NSMainThread: 0x600001960580>{number = 1, name = main}
// Child is cancelled: false, <_NSMainThread: 0x600001960580>{number = 1, name = main}

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 child 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}

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

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)

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.

Thanks for reading

If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.


avatar

Nikita Vasilev

A software engineer with over 8 years of experience in the industry. Writes this blog and builds open source frameworks.


Next Post
Swift Concurrency: Part 1