Swift内置的并发系统的好处之一是,它使我们更容易并行地执行多个异步任务,这反过来又能使我们大大加快那些可以分解成独立部分的操作。
在这篇文章中,让我们来看看几个不同的方法,以及这些技术中的每一个何时会特别有用。
从异步到并发
为了开始,我们假设我们正在开发某种形式的购物应用程序,显示各种产品,我们已经实现了一个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
)
}
}
上面的实现当然是可行的--然而,尽管我们的三个加载操作都是完全异步的,但它们目前是依次进行的,一个接一个。因此,尽管我们的顶层loadRecommendations 方法是相对于我们的应用程序的其他代码并发执行的,但它实际上还没有利用并发性来执行其内部操作集。
由于我们的产品加载方法不以任何方式相互依赖,所以真的没有理由按顺序执行它们,所以让我们看看如何使它们完全并发地执行。
一个关于如何做到这一点的初步想法可能是将上述代码缩减为一个单一的表达式,这将使我们能够使用一个单一的await 关键字来等待我们的每个操作完成:
extension ProductLoader {
func loadRecommendations() async throws -> Product.Recommendations {
try await Product.Recommendations(
featured: loadFeatured(),
favorites: loadFavorites(),
latest: loadLatest()
)
}
}
然而,尽管我们的代码现在看起来是并发的,但实际上它还是完全按顺序执行,就像以前一样。
相反,我们需要利用Swift的async let 绑定,以便告诉并发系统平行地执行我们的每个加载操作。使用这种语法使我们能够在后台启动一个异步操作,而不要求我们立即等待它完成。
如果我们在实际使用我们加载的数据时(也就是在形成我们的Recommendations 模型时)将其与一个单一的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 ,当我们有一个已知的、有限的任务集要执行时,提供了一个内置的方法来并发地运行多个操作。但如果情况不是这样呢?
任务组
现在让我们假设,我们正在开发一个ImageLoader ,让我们通过网络加载图像。为了从一个给定的URL ,加载一个单一的图像,我们可能会使用这样一个方法,看起来像这样:
class ImageLoader {
...
func loadImage(from url: URL) async throws -> UIImage {
...
}
}
为了简单地一次性加载一系列图片,我们还创建了一个方便的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并发工具箱中还有一个工具,可以让我们并行执行动态数量的任务--任务组。
要形成一个任务组,我们可以调用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 ,以一种操作不直接改变任何状态的方式来编写我们的并发代码的巨大好处是,这样做可以让我们完全避免任何类型的数据竞赛问题,同时也不要求我们在混合中引入任何锁定或序列化代码。
所以,在可能的情况下,让我们的每个并发操作都返回一个完全独立的结果,然后按顺序将这些结果await ,以形成我们的最终数据集,这通常是一个好的方法。
我们将在未来的文章中仔细研究避免数据竞赛的其他方法(例如通过使用Swift的新actor 类型)。
总结
重要的是要记住,仅仅因为一个给定的函数被标记为async ,并不一定意味着它在执行工作时是并发的。相反,如果我们想做到这一点,我们必须刻意使我们的任务并行运行,这实际上只有在执行一组可以独立运行的操作时才有意义。
另外,如果你觉得这篇文章很有用,那么如果你能查看上述赞助商,或者与朋友分享这篇文章,我将非常感激,因为这样做真的有助于支持我的工作。
谢谢你的阅读!