Task group

The Task Group is a part of the new modern concurrency framework, and it allows us to run multiple asynchronous operations in parallel.

In this previous article, we explored how we can create parallel operations using async let syntax. This approach is fine if we are going to perform two or three operations, but what about if we need to perform 20 operations in parallel? Or if the number of operations is not predefined?

That’s where Task Groups come into play because we can dynamically create a set of asynchronous operations, which run in parallel, and we can collect the results as they come or when all the tasks are finished.

Syntax

We have two possibilities when creating a task group, depending on whether the group can throw errors or not.

If the group can throw errors, we use the following code, indicating the return type of each task and the returned type, which most of the time is an array of the elements the tasks return.

Swift
try await withThrowingTaskGroup(
      of: Sendable.Protocol,
      returning: GroupResult.Type,
      body: (inout ThrowingTaskGroup<Sendable, Error>) async throws -> GroupResult>
  )

In case the group will not throw errors, we use this version.

Swift
try await withTaskGroup(
    of: Sendable.Protocol,
    returning: GroupResult.Type,
    body: <#T##(inout TaskGroup<Sendable>) async -> GroupResult#>
)

View

As usual, we are going to explore Task Groups through an example. We will download four files from a public API and then display the text of each file in a view.

The view is straightforward; we display the text of the files, and if we encounter an error, we show an error message instead. Alongside the file, we also display the index. Since Task Group operations run in parallel, we can’t predict the order in which tasks will finish. To impose a specific order for the files, we can sort them based on the index.

Swift
struct ContentView: View {
    @StateObject var model = ContentModel()
    @State var displayError: Bool = false
    
    var body: some View {
        VStack {
            Text("List of files")
            
            if displayError {
                Text("Something went wrong")
            } else {
                VStack(alignment: .leading, spacing: 16) {
                    ForEach(model.sortedFiles) { file in
                        VStack(alignment: .leading) {
                            Text("File: \(file.index)")
                            Text(file.text)
                                .lineLimit(2)
                        }
                    }
                }
            }
            
            Spacer()
        }
        .task {
            do {
                try await model.downloadFiles()
            } catch {
                displayError = true
            }
        }
    }
}

ViewModel

In the ContentModel is where we perform all asynchronous operations. We download four files from fileSamples, incorporating a random delay to visualize how the view updates when each download finishes. Additionally, we have an update method executed on the main actor to add files to the property used by the view.

Swift
struct File: Identifiable {
    let index: Int
    let text: String
    var id: Int { index }
}

final class ContentModel: ObservableObject {

    let numberOfFiles = 4
    @Published var files: [File] = []
    
    var sortedFiles: [File] {
        files.sorted(by: {
            $0.index < $1.index
        })
    }
    
    func download(index: Int) async throws -> File {
        let baseURL = "https://filesamples.com/samples/document/csv/sample"
        let url = baseURL + "\(index).csv"
        try await Task.sleep(for: .seconds(Int.random(in: 1...5)))
        let data = try await URLSession.shared.data(from: URL(string: url)!).0
        let text = String(data: data, encoding: .utf8)!
        return File(index: index, text: text)
    }
    
    @MainActor
    func update(file: File) {
        files.append(file)
    }

Downloading files

Now, let’s explore how we can utilize Task Groups to download the four files in parallel. Each task returns a File, and the task result is an array of files. Notice that we use group.addTask to add each operation, and group.reduce to collect and return all the files. After all task groups have finished, the group returns the result, and we iterate over the result outside the task group.

Swift
func downloadFiles() async throws {
    let files = try await withThrowingTaskGroup(of: File.self, returning: [File].self) { group in
        
        for index in 1..<numberOfFiles + 1 {
            group.addTask {
                try await self.download(index: index)
            }
        }
        
        return try await group.reduce(into: [File]()) { acc, new in
            acc.append(new)
        }
    }
    
    for file in files {
        await update(file: file)
    }
}

Updating view after every download

When we execute the previous code, it becomes apparent that the view remains empty until all downloads are complete. To address this, we can update the view after each download is completed, rather than collecting and returning all the files from the Task Group.

By iterating inside the group and performing the view update after each download, the view reflects the progress as the tasks complete. The Task Group concludes once all tasks have finished.

Swift
func downloadFiles() async throws {
    try await withThrowingTaskGroup(of: File.self, returning: Void.self) { group in

        for index in 1..<numberOfFiles + 1 {
            group.addTask {
                try await self.download(index: index)
            }
        }
        
        for try await file in group {
            await update(file: file)
        }
    }
}

Controlling concurrent tasks

In our previous examples, we added all the tasks at the beginning of the group, and afterward, we iterated over the results. However, can we add more tasks later, or control how many tasks are executed simultaneously?

Certainly, we can add more tasks after any previous download is finished. This allows us to control that no more than one task is executed at the same time. When a download is finished, we have the flexibility to decide whether to add another one.

Swift
func downloadFiles() async throws {
        let batchSize = 1
        
        try await withThrowingTaskGroup(of: File.self, returning: Void.self) { group in
    
            for index in 1..<batchSize + 1 {
                group.addTask {
                    try await self.download(index: index)
                }
            }
            
            var index = batchSize + 1
            
            for try await file in group {
                await update(file: file)
                
                if index < numberOfFiles + 1 {
                    group.addTask { [index] in
                        try await self.download(index: index)
                    }
                    index += 1
                }
            }
        }
    }

Error handling

In our previous code, if any of the downloads encounters an error, the entire task group is canceled, and the view displays an error. To handle this differently, where a failed download simply results in ignoring that file while allowing the rest of the tasks to continue, we can ensure that every task group returns a result instead of throwing an error. This way, we avoid propagating errors to the task group.

Swift
func downloadFiles() async throws {
        await withTaskGroup(of: Result<File, Error>.self, returning: Void.self) { group in
            
            for index in 1..<numberOfFiles + 1 {
                group.addTask {
                    await self.downloadResult(index: index)
                }
            }
            
            for await result in group {
                switch result {
                case .success(let file):
                    await update(file: file)
                case .failure:
                    //Ignore error
                    break
                }
            }
        }
    }
    
    func downloadResult(index: Int) async -> Result<File, Error> {
        let baseURL = "https://filesamples.com/samples/document/csv/sample"
        let url = baseURL + "\(index).csv"
        
        do {
            try await Task.sleep(for: .seconds(Int.random(in: 1...5)))
            let data = try await URLSession.shared.data(from: URL(string: url)!).0
            let text = String(data: data, encoding: .utf8)!
            return .success(File(index: index, text: text))
        } catch {
            return .failure(error)
        }
    }

Additional APIS

We have additional functions and properties in the task group that we can leverage.

Swift
// Checking if the group is cancelled before adding a task
group.addTaskUnlessCancelled(priority: .medium) {
    try await self.download(index: index)
}

group.cancelAll() // Cancel all tasks group
group.isCancelled // Check if the group is cancelled
try await group.waitForAll() // Wait for all task to complete

Conclusion

We’ve explored how to use Task Groups to perform dynamically concurrent asynchronous operations, different methods for processing the results of tasks, and how to handle errors. See you in the next article!