Reputation: 1723
I'm new to async/await in swift and am currently facing a two-part issue. My goal is to be able to fetch a bunch of Posts like this:
func fetchPosts(ids: [Int]) async throws -> [Post] {
return try await withThrowingTaskGroup(of: Post.self) { taskGroup in
var posts = [Post]()
for id in ids {
taskGroup.addTask { return try await self.fetchPost(id: id) }
}
for try await post in taskGroup {
posts.append(post)
}
return posts
}
}
func fetchPost(id: Int) async throws -> Post {
// Grabs a post and returns it or throws
}
The code works but it seems like a lot of code for a simple task, is there any way to simplify the code? The other issue is that I need the order of the posts to be consistent with the order in the ids array that I use to request them, how would I go about that?
Upvotes: 4
Views: 4622
Reputation: 32922
It doesn't get much shorter that that. You can take some shortcuts to make the code more concise, and reduce the number of lines, by taking advantage of some functional programming aspects:
func fetchPosts(ids: [Int]) async throws -> [Post] {
try await withThrowingTaskGroup(of: Post.self) { taskGroup in
ids.forEach { id in
taskGroup.addTask { try await self.fetchPost(id: id) }
}
return try await taskGroup.reduce(into: [Post]()) { $0.append($1) }
}
}
, however on the long run not sure how much this would help.
If you run often enough into needing to fetch data based on an array of ids, you could create a helper function to mitigate this:
func awaitAll<T, U>(inputs: [T], awaiter: @escaping (T) async throws -> U) async throws -> [U] {
try await withThrowingTaskGroup(of: U.self) { taskGroup in
for input in inputs {
taskGroup.addTask { try await awaiter(input) }
}
var results = [U]()
for try await result in taskGroup {
results.append(result)
}
return results
}
}
, which case fetchPosts
would reduce to something like this:
func fetchPosts(ids: [Int]) async throws -> [Post] {
try await awaitAll(inputs: ids, awaiter: self.fetchPost(id:))
}
If you need awaitAll
to return the results in the same order as the ids, then you can change its implementation to something like this:
func awaitAll<T, U>(inputs: [T], awaiter: @escaping (T) async throws -> U) async throws -> [U] {
try await withThrowingTaskGroup(of: (Int, U).self) { taskGroup in
for (i, input) in inputs.enumerated() {
taskGroup.addTask { try await (i, awaiter(input)) }
}
var results = [(Int, U)]()
for try await result in taskGroup {
results.append(result)
}
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
}
No changes are needed for the callers.
Upvotes: 1
Reputation: 12237
Great answer from @Rob, however a caveat should be added: a task group executes its tasks on separate threads (and that's the whole point of it) which can have some nasty unintended consequences if you are accessing unprotected static data. Within the SwiftUI context where @State
(and especially @StateObject
) properties are protected automatically, you will generally be safe, but in other situations and especially with accessing global static data you may run into crashes or race conditions at best.
Moreover, running lightweight stuff like network requests without any heavy computations concurrently usually makes little sense. Another solution to this problem is to use Task { ... }
instead, like so:
func fetchPosts(ids: [Post.ID]) async throws -> [Post.ID: Post] {
let tasks = ids.map {
Task {
try await self.fetchPost(id: id)
}
}
var result: [Post.ID: Post] = [:]
// Can't use functional style here since async throwing code can't
// be used within `.map()` and friends.
for i in ids.indices {
result[ids[i]] = try await tasks[i].value
}
return result
}
The advantage of this is that the tasks will run within the same context as the enclosing code except it will be asynchronous. Note that Swift still can fork the await
part into a different thread but the code within task's action
will be guaranteed to be run within the same context.
Upvotes: 1
Reputation: 438467
I agree with Matt, namely that you should consider returning a dictionary, which is order-independent, but offers O(1) retrieval of results. I might suggest a slightly more concise implementation:
func fetchPosts(ids: [Int]) async throws -> [Int: Post] {
try await withThrowingTaskGroup(of: (Int, Post).self) { group in
for id in ids {
group.addTask { try await (id, self.fetchPost(id: id)) }
}
return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
}
}
Or if Post
conformed to Identifiable
, and then the tuple kruft is no longer necessary:
func fetchPosts(ids: [Post.ID]) async throws -> [Post.ID: Post] {
try await withThrowingTaskGroup(of: Post.self) { group in
for id in ids {
group.addTask { try await self.fetchPost(id: id) }
}
return try await group.reduce(into: [:]) { $0[$1.id] = $1 }
}
}
And if you want to return [Post]
, just build the array from the dictionary:
func fetchPosts(ids: [Post.ID]) async throws -> [Post] {
try await withThrowingTaskGroup(of: Post.self) { group in
for id in ids {
group.addTask { try await self.fetchPost(id: id) }
}
let dictionary = try await group.reduce(into: [:]) { $0[$1.id] = $1 }
return ids.compactMap { dictionary[$0] }
}
}
Your implementation may vary, but hopefully, this illustrates another pattern.
By the way, if you do this a lot, you might define a Sequence
extension that does this for you, e.g.:
extension Sequence where Element: Sendable {
@inlinable public func throwingAsyncValues<T>(
of type: T.Type = T.self,
body: @escaping @Sendable (Element) async throws -> T
) async rethrows -> [T] {
try await withThrowingTaskGroup(of: (Int, T).self) { group in
for (index, value) in enumerated() {
group.addTask { try await (index, body(value)) }
}
let dictionary = try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
return enumerated().compactMap { dictionary[$0.0] }
}
}
}
Used as follows:
func fetchPosts(ids: [Post.ID]) async throws -> [Post] {
try await ids.throwingAsyncValues { id in
try await self.fetchPost(id: id)
}
}
Obviously, you can easily make a non-throwing
rendition, too, but hopefully this illustrates the basic idea of extensions to simplify the calling point.
Upvotes: 3
Reputation: 536027
Your code is the correct pattern for fetching multiple Posts simultaneously (concurrently). You don't have to do that; you could fetch them sequentially, i.e. one at a time. The code for that would be a lot simpler — but would take much longer to run, since each fetch must wait upon completion of the previous one:
func fetchPostSequentially(ids: [Int]) async throws -> [Post] {
var posts = [Post]()
for id in ids {
posts.append(try await self.fetchPost(id: id))
}
return posts
}
That will give you your Posts in the same order as the original ids
— but, as I say, it will be terribly slow and inefficient.
Assuming that you do want to fetch your Posts simultaneously, your code has a major weakness: you have lost the advantage of preserving the linkage between the id
(which is what you seem to have in advance) and the corresponding Post. As you rightly say, the results come back in no order. There is nothing you can do about that; the fetching is asynchronous and simultaneous, so individual fetches can complete in any order.
But it is not the order that is important, but rather the association of the original id
with its post. It would be better, therefore, rather than worrying about order, to form a dictionary of Post values keyed by id
:
func fetchPosts(ids: [Int]) async throws -> [Int:Post] {
try await withThrowingTaskGroup(of: [Int:Post].self) { taskGroup in
var posts = [Int:Post]()
for id in ids {
taskGroup.addTask { return [id: try await self.fetchPost(id: id)] }
}
for try await post in taskGroup {
posts.merge(post, uniquingKeysWith: {one, two in one})
}
return posts
}
}
That way, you can fetch all posts using an initially known list of id
values, and henceforth you have a perpetually useful dictionary where accessing a Post by its id
is instant.
As for your initial misgivings that "it seems like a lot of code for a simple task": No, it isn't, that's the pattern — which makes perfect sense as soon as you understand what a task group is. So just suck it up and get used to it. It's boilerplate so it's not difficult to do once you have the habit.
Upvotes: 5