Using Swift’s concurrency system to run multiple tasks in parallel
Swift内置并发系统的一个好处是,它使并行执行多个异步任务变得更加容易,这反过来又使我们能够显著加快可分解为不同部分的操作。
在本文中,让我们看一看实现这一点的几种不同方法,以及每种技术何时特别有用。
1. From asynchronous to concurrent
首先,假设我们正在开发某种形式的显示各种产品的购物应用程序,并且我们已经实现了一个ProductLoader,它允许我们使用一系列异步API加载不同的产品集合,如下所示:
class ProductLoader {
...
func loadFeatured() async throws -> [Product] {
...
}
func loadFavorites() async throws -> [Product] {
...
}
func loadLatest() async throws -> [Product] {
...
}
}
尽管上述每种方法在大多数情况下都可能被单独调用,但假设在我们应用程序的某些部分中,我们还希望形成一个组合Recommendations模型,其中包含这三种ProductLoader方法的所有结果:
extension Product {
struct Recommendations {
var featured: [Product]
var favorites: [Product]
var latest: [Product]
}
}
一种方法是使用await关键字调用每个加载方法,然后使用这些调用的结果创建我们的Recommendations模型的实例,如下所示:
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
let featured = try await loadFeatured()
let favorites = try await loadFavorites()
let latest = try await loadLatest()
return Product.Recommendations(
featured: featured,
favorites: favorites,
latest: latest
)
}
}
上面的实现当然可以工作——但是,尽管我们的三个加载操作都是完全异步的,但它们目前是按顺序依次执行的。因此,尽管我们的顶级LoadRecommensions方法是相对于我们应用程序的其他代码并发执行的,但实际上它还没有利用并发来执行其内部操作集。
由于我们的产品加载方法在任何方面都不相互依赖,因此实际上没有理由按顺序执行它们,所以让我们看看如何使它们完全并发执行。
关于如何做到这一点的初步想法可能是将上述代码简化为一个表达式,这将使我们能够使用一个await关键字来等待每个操作完成:
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
try await Product.Recommendations(
featured: loadFeatured(),
favorites: loadFavorites(),
latest: loadLatest()
)
}
}
然而,即使我们的代码现在看起来是并发的,它实际上仍然会像以前一样完全按顺序执行。
相反,我们需要利用Swift的 async let绑定来告诉并发系统并行执行每个加载操作。使用该语法,我们可以在后台启动异步操作,而无需立即等待操作完成。
如果我们在实际使用加载的数据时(即,在形成建议模型时),将其与单个await关键字结合使用,那么我们将获得并行执行加载操作的所有好处,而不必担心状态管理或数据竞争:
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
async let featured = loadFeatured()
async let favorites = loadFavorites()
async let latest = loadLatest()
return try await Product.Recommendations(
featured: featured,
favorites: favorites,
latest: latest
)
}
}
非常整洁!因此,async let提供了一种内置方式,当我们有一组已知的、有限的任务要执行时,可以同时运行多个操作。但如果不是这样呢?
2. Task groups
现在让我们假设我们正在开发一个ImageLoader,它允许我们通过网络加载图像。要从给定URL加载单个图像,我们可以使用如下方法:
class ImageLoader {
...
func loadImage(from url: URL) async throws -> UIImage {
...
}
}
为了简化一次加载一系列图像的过程,我们还创建了一个方便的API,该API获取URL数组,并异步返回一个图像字典,这些图像由下载它们的URL键入:
extension ImageLoader {
func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
var images = [URL: UIImage]()
for url in urls {
images[url] = try await loadImage(from: url)
}
return images
}
}
现在让我们假设,就像之前处理ProductLoader时一样,我们希望使上面的loadImages方法并发执行,而不是按顺序下载每个图像(这是目前的情况,因为我们在for循环中调用loadImage时直接使用await)。
然而,这一次我们将无法使用async let,因为我们需要执行的任务数量在编译时是未知的。谢天谢地,Swift并发工具箱中还有一个工具,可以让我们在并行task groups中执行动态数量的任务。
要组成任务组,我们要么调用withTaskGroup,要么调用withThrowingTaskGroup,这取决于我们是否希望在任务中抛出错误。在本例中,我们将选择后者,因为我们的底层loadImage方法是用throws关键字标记的。
然后我们将迭代每个URL,就像以前一样,只是这次我们将把每个图像加载任务添加到组中,而不是直接等待它完成。相反,在添加每个任务后,我们将分别await组结果,这将允许图像加载操作完全并发执行:
extension ImageLoader {
func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
for url in urls {
group.addTask{
let image = try await self.loadImage(from: url)
return (url, image)
}
}
var images = [URL: UIImage]()
for try await (url, image) in group {
images[url] = image
}
return images
}
}
}
与使用async let时一样,以操作不会直接改变任何状态的方式编写并发代码的一个巨大好处是,这样做可以完全避免任何类型的数据争用问题,同时也不需要在混合中引入任何锁定或序列化代码。
因此,在可能的情况下,让每个并发操作返回一个完全独立的结果,然后依次等待这些结果以形成最终的数据集,这通常是一种很好的方法。