使用 Swift 的并發系統并行運行多個任務
前言
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綁定來告訴并發系統并行執行我們的每個加載操作。使用該語法使我們能夠在后臺啟動異步操作,而無需我們立即等待它完成。
await如果我們在實際使用加載的數據時(即形成模型時)將其與單個關鍵字組合Recommendations,那么我們將獲得并行執行加載操作的所有好處,而無需擔心狀態管理或數據競爭之類的事情:
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方法并發執行,而不是按順序下載每個圖像(目前是這種情況,因為我們await在調用時直接使用loadImage我們的for環形)。
但是,這次我們將無法使用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
}
}
}
要了解有關上述for try await語法和一般異步序列的更多信息,請查看“異步序列、流和組合”。
就像使用 時一樣async let,以我們的操作不會直接改變任何狀態的方式編寫并發代碼的一個巨大好處是,這樣做可以讓我們完全避免任何類型的數據競爭問題,同時也不需要我們引入任何鎖定或序列化代碼混合在一起。
await因此,在可能的情況下,讓我們的每個并發操作返回一個完全獨立的結果,然后依次返回這些結果以形成我們的最終數據集,這通常是一種很好的方法。
在以后的文章中,我們將更仔細地研究避免數據競爭的其他方法(例如通過使用 Swift 的新actor類型)。
結論
重要的是要記住,僅僅因為給定的函數被標記為async并不一定意味著它同時執行它的工作。相反,如果這是我們想要做的,我們必須故意讓我們的任務并行運行,這只有在執行一組可以獨立運行的操作時才有意義。