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
Taskis 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
Utilize
Effect.run {}: Asynchronous code must be executed insideEffect.run.Do not mutate State directly: State updates should happen via an
Action.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 🙌