Swift Concurrency Part 1: Exploring async/await, Task, and TaskGroup

async/await, Task, TaskGroup
DelightRoom's avatar
Feb 14, 2025
Swift Concurrency Part 1: Exploring async/await, Task, and TaskGroup

To support concurrency programming, Swift provides features like async/await, Task, and TaskGroup. When used correctly, concurrency programming can optimize your app's performance and prevent data race conditions.

In this post, we'll dive into Swift's concurrency concepts and explore how to apply them in real-world scenarios.

What is async/await?

Introduced in Swift 5.5, async/await allows you to write asynchronous code as easily as synchronous code. It is far more intuitive than the traditional completion handler pattern and significantly improves code readability.

The Traditional Completion Handler Approach

func fetchData(completion: @escaping (String) -> Void) {
    DispatchQueue.global().async {
        sleep(2) // 네트워크 요청을 가정
        completion("데이터 로드 완료")
    }
}

fetchData { result in
    print(result)
}

This approach introduces a few problems.
[ex) Callback hell, difficulty in error handling, non-intuitive code flow, memory management issues (Strong Reference Cycle)]
However, these aren't the main focus of this post, so we'll skip them. Swift introduced async/await exactly to solve these issues.

The async/await Approach

func fetchData() async -> String {
    await Task.sleep(2 * 1_000_000_000) // 2초 대기
    return "데이터 로드 완료"
}

Task {
    let result = await fetchData()
    print(result)
}

You can use the async keyword to explicitly declare an asynchronous function, and await to wait for its result. This makes the code much cleaner and more efficient when chaining multiple asynchronous calls.

Task: Starting a New Asynchronous Job

Task is the fundamental way to execute work in a new asynchronous context. Inside a Task {} block, you can use await to run asynchronous operations.

Task {
    let message = await fetchData()
    print(message)
}

Key Features of Task

  • When you create a Task, it automatically runs in the background.

  • You can execute asynchronous operations using await.

  • A Task is cancellable, meaning you can halt the running operation if a cancellation request is made.

let task = Task {
    let data = await fetchData()
    print(data)
}
task.cancel() // 실행 중인 Task를 취소할 수 있음

Using Task with SwiftUI + ViewModel

One important thing to note when using a Task in SwiftUI is that you must cancel the running Task when the View disappears. Otherwise, it can lead to unnecessary resource consumption.

Using Task in View + ViewModel

@MainActor
class MyViewModel: ObservableObject {
    @Published var data: String = ""
    private var tasks: [Task<Void, Never>] = [] // 실행 중인 여러 개의 Task를 저장
    func fetchData() {
        let task = Task {
            let result = await fetchData()
            await MainActor.run { self.data = result }
        }
        tasks.append(task)
    }
    
    func cancelTasks() {
        tasks.forEach { $0.cancel() }
        tasks.removeAll()
    }
}

struct ContentView: View {
  @StateObject private var viewModel = MyViewModel()

  var body: some View {
    VStack {
      Text(viewModel.data)
      Button("Load Data") {
        viewModel.fetchData()
      }
    }
    .onDisappear {
      viewModel.cancelTasks() // View가 사라질 때 Task 취소
    }
  }
}

Things to Keep in Mind When Using Task

1. Cancel the Task when the View disappears
Call Task.cancel() inside onDisappear to prevent resource waste.

2. **@MainActor** to guarantee UI updates
Use MainActor.run {} inside the Task to execute UI updates.

3. Store the Task as a property to manage it
Save the Task in a variable so you can cancel it when needed.

Using Task in TCA

You can also utilize Task to handle asynchronous work in TCA (The Composable Architecture). Below is a simple example of performing a fetchData operation.

struct AppState: Equatable {
    var data: String = ""
}
enum AppAction: Equatable {
    case fetchData
    case fetchDataResponse(String)
}
struct AppEnvironment {
    var fetchData: () async -> String = {
        await Task.sleep(2 * 1_000_000_000) // 2초 대기
        return "데이터 로드 완료"
    }
}
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
    switch action {
    case .fetchData:
        return .run { send in
            let result = await environment.fetchData()
            await send(.fetchDataResponse(result))
        }
    case .fetchDataResponse(let result):
        state.data = result
        return .none
    }
}

struct ContentView: View {
    let store: Store<AppState, AppAction>
    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                Text(viewStore.data)
                Button("Load Data") {
                    viewStore.send(.fetchData)
                }
            }
        }
    }
}

Things to Keep in Mind When Using Task in TCA

  1. Utilize Effect.run {}: Asynchronous code must be executed inside Effect.run.

  2. Do not mutate State directly: State updates should happen via an Action.

  3. Handling job cancellation: Use .cancellable(id:) to cancel the Task if needed.

TaskGroup: Managing Multiple Asynchronous Jobs

Using TaskGroup allows you to bundle and manage multiple asynchronous jobs as a group. For instance, it's highly useful when you need to execute several API requests concurrently, wait for all their results, and merge them.

func fetchMultipleData() async -> [String] {
    await withTaskGroup(of: String.self) { group in
        var results: [String] = []

        group.addTask { await fetchData() }
        group.addTask { await fetchData() }
        
        for await result in group {
            results.append(result)
        }
        return results
    }
}

Task {
    let data = await fetchMultipleData()
    print(data)
}

Key Features of TaskGroup

  • You can execute multiple Tasks in parallel.

  • Instead of waiting for every individual Task to complete first, you can process results in the order they finish.

  • It provides an internally optimized concurrent execution model to maximize performance.

Task.detached: Executing Independent Asynchronous Jobs

By default, a Task inherits the context of its parent Task. However, you can use Task.detached to spin up an independent Task.

Task.detached {
    let data = await fetchData()
    print("Detached Task: \(data)")
}

Key Features of Task.detached

Task {
    let priority = Task.currentPriority
    print("부모 Task의 우선순위: \(priority)")
    
    Task.detached {
        let detachedPriority = Task.currentPriority
        print("Detached Task의 우선순위: \(detachedPriority)")
    }
}
  • Does not inherit the parent Task's context (priority, local storage).

  • Operates in an entirely new execution context.

  • Is unaffected by the parent Task's cancellation state.

When Should I Use Task.detached?

  • When performing independent background work.

  • When a job must run regardless of the parent Task (e.g., background data processing, network requests, etc.).

⚠️ However, overusing Task.detached can be dangerous. Since it executes without considering the parent Task's cancellation state, it might lead to unnecessary resource usage.

Swift Concurrency Concepts Recap

async/await: A way to express asynchronous function execution more intuitively.
Task: A context that creates an independent asynchronous execution unit.
TaskGroup : Can manage multiple asynchronous jobs and collect their results.Task.detached : An asynchronous Task that runs independently of its parent context.

Wrapping Up

Swift Concurrency has made concurrency programming much easier by leveraging features like async/await, Task, TaskGroup, and Task.detached. Using them appropriately will boost both the readability and safety of your code.

In the next post, we'll take a deep dive into how to guarantee safe data sharing using Sendable. 🚀

⏰ We are looking for people to transform mornings with Alarmy at DelightRoom 🙌

Share article