Home > Software engineering >  Is there a more compact way of combining multiple async calls
Is there a more compact way of combining multiple async calls

Time:02-18

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?

CodePudding user response:

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.

CodePudding user response:

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.

  • Related