Swift Concurrency (Part 04) — Group Task
In our previous article, we went through running tasks in a loop, one after the other. Now, we will try to run tasks simultaneously but efficiently using Task Groups.
Scenario — Let’s assume we have a list of students and we need to do some time-consuming tasks (like calculating their grades or fetching their data from a server) for each student asynchronously. To make it simple we mimicry the time-consuming task by writing a delay function which returns the delayed time after the delay.
public class Student {
var id: Int
var processingTime: Int
init(id: Int, processingTime: Int) {
self.id = id
self.processingTime = processingTime
}
}
var students = [
Student(id: 1, processingTime: 2),
Student(id: 2, processingTime: 5),
Student(id: 3, processingTime: 4),
Student(id: 4, processingTime: 1),
]
private func delayAfter(delay: Int) async throws -> Int {
if delay % 2 == 0 {
throw CustomError.delayError
}
try await Task.sleep(
until: .now + .seconds(delay),
clock: .suspending
)
return delay
}
In the above code snippet, we create a Student class with two variables id and processingTime. We call the delayAfter function for each student and delay time would be the processingTime.
func getProcessingTime(students: [Student]) async throws {
await withTaskGroup(of: Void.self, body: { group in
for student in students {
group.addTask {
do {
let processingTime = try await delayAfter(delay: student.processingTime)
print("processing Time \(processingTime) id \(student.id)")
} catch {
print("error id \(student.id)")
}
}
}
})
}
Task {
let startTime = DispatchTime.now()
try await getProcessingTime(students: students)
// let processingTimesWithId = try await getProcessingTimeWithReturn(students: students)
// print(processingTimesWithId)
let endTime = DispatchTime.now()
let elapsedTime = Double(endTime.uptimeNanoseconds - startTime.uptimeNanoseconds) / 1000000000
print("ElapsedTime \(elapsedTime)")
}
In the presented context, the function getProcessingTime
is an asynchronous operation designed to handle an array of student inputs and compute the processing time for each student. To efficiently manage these asynchronous tasks collectively, we usewithTaskGroup
. Void.self
indicates that the operation doesn't yield any specific return value. Alternatively, you can utilize withThrowingTaskGroup
for operations that may throw errors.
Inside this special task group area, we put all our async tasks together. When you run the code, it will give you the output we expect.
error id 1
error id 3
processing Time 1 id 4
processing Time 5 id 2
ElapsedTime 5.421702042
IDs 1 and 3 cause errors because their processing times are even, and the function delayAfter
doesn't like even numbers. IDs 4 and 2 run concurrently. Since ID 4 only takes 1 second, it finishes first, followed by the completion of ID 2.
Now let’s store the execution time needed for each student according to their id.
func getProcessingTime(students: [Student]) async throws {
var processingTimeList: [Int: Int] = [:]
await withTaskGroup(of: Void.self, body: { group in
for student in students {
group.addTask {
do {
let processingTime = try await delayAfter(delay: student.processingTime)
print("processing Time \(processingTime) id \(student.id)")
processingTimeList[student.id] = processingTime
} catch {
print("error id \(student.id)")
}
}
}
})
}
We added processingTimeList dictionary to store the processing time and student ID. But it will throw the following error.
Mutation of captured var 'processingTimeList' in concurrently-executing code
Simply this error says you can not update something from a concurrent environment that is outside of that concurrent environment. It can cause data race conditions. To solve this problem we refactored the previous getProcessingTime function like below.
func getProcessingTimeWithReturn(students: [Student]) async throws -> [Int: Int] {
var processingTimeList: [Int: Int] = [:]
try await withThrowingTaskGroup(of: (Int, Int).self, body: { group in
for student in students {
group.addTask {
do {
let processingTime = try await delayAfter(delay: student.processingTime)
print("processing Time \(processingTime) id \(student.id)")
return (student.id, processingTime)
}catch{
print("error id \(student.id)")
return (student.id, -1)
}
}
}
for try await (id, time) in group {
processingTimeList[id] = time
}
})
return processingTimeList
}
Task {
let startTime = DispatchTime.now()
let processingTimesWithId = try await getProcessingTimeWithReturn(students: students)
print(processingTimesWithId)
let endTime = DispatchTime.now()
let elapsedTime = Double(endTime.uptimeNanoseconds - startTime.uptimeNanoseconds) / 1000000000
print("ElapsedTime \(elapsedTime)")
}
Here withThrowingTaskGroup returns a tuple of two integers. If the async function delayAfter does not throw an error then it returns the processing time and id in a tuple. Otherwise, it returns -1. The output will be like below.
error id 1
error id 3
processing Time 1 id 4
processing Time 5 id 2
[4: 1, 2: 5, 3: -1, 1: -1]
ElapsedTime 5.3457555
The above output is self-explanatory and like the previous one. The only difference is, that it additionally prints the dictionary value containing the ID and execution time.
Check the first , second, and third parts.
Get the full code from GitHub.
Connect me on LinkedIn.