Hi everyone,
I am playing around with Swift's concurrency and as an exercise, I wanted to make some kind of file downloader that could concurrently download multiple files.
I came up with a (surprisingly) working solution but I am unsure if this is a good solution or if there is any issue with what I've done.
Each download task is represented by the following (simplified) struct:
struct FileTask {
let url: URL
// ...
}
I then have the following functions that, given an array of tasks and an allowed number of concurrent downloads, will chunk the array and download the files using an async task group:
func download(tasks: [FileTask], maxConcurrent: Int = 0) async throws {
guard !tasks.isEmpty else {
return
}
let chunks: [[FileTask]]
if maxConcurrent == 0 {
chunks = [tasks]
} else {
chunks = stride(from: 0, to: tasks.count, by: maxConcurrent).map { index in
let upperLimit = min(index + maxConcurrent, tasks.count)
return Array(
tasks[index..<upperLimit]
)
}
}
for chunk in chunks {
await withThrowingTaskGroup(of: Void.self) { group in
for task in chunk {
group.addTask {
// function download(task: FileTask) -> async throws
// This function handles the network call and writes the file to disk
try await download(task: task)
}
}
}
}
}
I am very unsure about the for chunk in chunks with an await inside, is this an anti-pattern? Is there any limitation or caveat I should know about using this solution?
Very insightful response, thanks.
-
This was indeed the expected behaviour at first, I wasn't aware you could do for await _ in group, this makes a lot more sense
-
I was indeed wondering why try await withThrowingTaskGroup raised a warning, very interesting. I however fail to understand how / where an error thrown by a child task should be handled. My best guess is wrapping the try await download(task: task) in a do { } catch { }
ole
(Ole Begemann)
4
You can iterate over the group's results after the loop where you enqueue the tasks. Something like this:
// Now `try` is required here
try await withThrowingTaskGroup(of: Void.self) { group in
for task in chunk {
group.addTask {
try await download(task: task)
}
}
// Wrap this in do { } catch { } if you don't want
// child task errors to propagate:
for try await _ in group {
// Nothing to do here because the child tasks don't return anything.
}
}
Written like this, any error thrown by a child task will propagate to the outside, and the task group will automatically cancel other child tasks that are still in flight.
If that is not what you want, you can wrap the second loop in a do { } catch { } and handle errors locally. From the caller's perspective, this approach is more or less what you have now, but the code makes it explicit that child task errors aren't propagated.
Edit: code fixes
3 Likes
This makes a lot of sense, I prefer to not cancel the entire group but having the possibility to do so is great. Having played around with both solutions, I enjoy the possibilities they offer.
Thank you a lot for the explanations, they were very educational and insightful !
1 Like