【问题标题】:Is there a more compact way of combining multiple async calls是否有更紧凑的方式来组合多个异步调用
【发布时间】:2023-01-09 06:06:50
【问题描述】:

我是 async/await in swift 的新手,目前面临一个由两部分组成的问题。 我的目标是能够像这样获取一堆帖子:

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
}

该代码有效,但似乎有很多代码用于一个简单的任务,有什么方法可以简化代码吗? 另一个问题是我需要帖子的顺序与我用来请求它们的 ids 数组中的顺序一致,我将如何处理?

【问题讨论】:

  • Post数据中有没有什么可以用来接收后排序的?
  • @Fogmeister 不,不幸的是,订单应该来自 id 的列表

标签: swift async-await


【解决方案1】:

我同意 Matt 的观点,即您应该考虑返回一个字典,它与顺序无关,但提供 O(1) 的结果检索。我可能会建议一个稍微更简洁的实现:

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 }
    }
}

或者,如果 Post 符合 Identifiable,则不再需要元组 kruft:

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 }
    }
}

如果你想返回[Post],只需从字典中构建数组:

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] }
    }
}

您的实施可能会有所不同,但希望这说明了另一种模式。


顺便说一句,如果你经常这样做,你可以定义一个 Sequence 扩展来为你做这件事,例如:

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] }
        }
    }
}

用法如下:

func fetchPosts(ids: [Post.ID]) async throws -> [Post] {
    try await ids.throwingAsyncValues { id in
        try await self.fetchPost(id: id)
    }
}

显然,您也可以轻松制作非throwing 再现,但希望这能说明扩展的基本思想,以简化调用点。

【讨论】:

  • 酷啊拉玛!我总是忘记异步序列确实有一些类似序列的方法。
【解决方案2】:

您的代码是获取多个帖子的正确模式同时(同时)。你不必那样做;你可以拿来顺序地,即一次一个。这样做的代码会简单得多——但运行时间会长得多,因为每次提取都必须等待前一个提取完成:

func fetchPostSequentially(ids: [Int]) async throws -> [Post] {
    var posts = [Post]()
    for id in ids {
        posts.append(try await self.fetchPost(id: id))
    }
    return posts
}

这将按照与原始 ids 相同的顺序为您提供您的帖子 — 但是,正如我所说,这将非常缓慢且效率低下。

假设你想要同时获取您的帖子,您的代码有一个主要弱点:您失去了保留 id(这似乎是您事先拥有的)和相应帖子之间的联系的优势。正如您所说的那样,结果无序返回。你对此无能为力;提取是异步和同步的,因此单个提取可以按任何顺序完成。

但它不是命令这很重要,而是原始id与其帖子的关联。因此,与其担心顺序,不如形成一个由 id 键入的 Post 值字典会更好:

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
    }
}

这样,您就可以使用最初已知的 id 值列表来获取所有帖子,从今以后,您将拥有一个永久有用的字典,通过它的 id 访问帖子是即时的。

至于您最初的疑虑,即“一个简单的任务似乎有很多代码”:不,事实并非如此,这就是模式——一旦您理解了任务组是什么,这就非常有意义了.所以,忍住并习惯它。这是样板文件,所以一旦养成习惯就不难做到。

【讨论】:

  • 谢谢你。这真的很有帮助。
  • 简化了第一个例子。
  • 对于我前几天提出类似问题的问题,这将是一个有用的答案。它还沿用了 taskGroup 路线,这看起来确实比使用 Combine 复杂得多。 ?
  • 哦,我的错。我认为您的第一个 sn-p 代码是一个答案,但我现在可以看到它不是。与异步等待相比,将 Combine 用于类似的事情似乎仍然是一个更好的选择。直到 async await 有了类似 JavaScript 的 Promise.all
  • @Fogmeister - 通过明智地使用 reduce 可以轻松实现(有序结果)。如果你不想用这种逻辑阻碍调用点,你可以轻松地编写一个扩展来将它包装在一个漂亮、方便的方法中(例如,参见throwingAsyncValues方法here)。
【解决方案3】:

@Rob 给出了很好的答案,但是应该添加一个警告:一个任务组在单独的线程上执行其任务(这就是它的全部意义),如果您正在访问未受保护的静态数据,这可能会产生一些令人讨厌的意外后果。在自动保护 @State(尤其是 @StateObject)属性的 SwiftUI 上下文中,您通常是安全的,但在其他情况下,尤其是在访问全局静态数据时,您最多可能会遇到崩溃或竞争条件。

此外,在没有任何繁重计算的情况下同时运行网络请求等轻量级内容通常意义不大。此问题的另一种解决方案是改用Task { ... },如下所示:

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
}

这样做的好处是任务将在与封闭代码相同的上下文中运行,除了它是异步的。请注意,Swift 仍然可以将 await 部分分叉到不同的线程中,但任务的 action 中的代码将保证在相同的上下文中运行。

【讨论】:

    猜你喜欢
    • 2010-10-26
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-09-15
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多