Abbas Tehrani on Unsplash
#前言
Swift 支持以結構化的方式編寫異步與並行代碼。
- 異步代碼(Asynchronous):可以實現暫停和恢復效果,儘管一次只執行一段程序,那什麼時候會用到呢?
例如:更新UI,同時繼續處理長期運行的操作,比如通過網絡獲取數據或解析文件 - 並行代碼(Parallel):意味著多段代碼同時運行,例如:四核處理器的計算機可以同時運行四段代碼,每個核執行一項任務。
使用並行和異步代碼的程序一次執行多個操作;它暫停正在等待外部系統的操作,並使以內存安全的方式編寫此代碼更容易。
並行或異步代碼的額外調度靈活性也伴隨著複雜性增加的成本,甚至可能使代碼更難調試。但是, Swift 可以幫助在編譯時發現問題。
以下使用並發(concurrency)取代異步和並行代碼的這種常見組合。
如果之前編寫過並發代碼,可能習慣於使用線程。Swift 中的並發model是建立在線程之上的,但你並不直接與它們交互。Swift 中的異步函數可以放棄它正在運行的線程,然後讓另一個異步函數在該線程上運行,而第一個函數暫停。當異步函數恢復時,Swift 不保證該函數將在哪個線程上運行。
簡而言之:在一段線程中,暫停現有任務(A)去處理其他任務(B),等任務(A)恢復時可能是在其他線程了,不一定會是在原有的線程。
儘管可以在不使用 Swift 語言支持的情況下編寫並發代碼,但該代碼往往更難閱讀。
例如,下載照片名稱列表後,下載該列表中的第一張照片,並將該照片顯示給用戶:
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
即使在這種簡單的情況下,由於必須等待前一個階段完成,才能處理之後的部分,最終會編寫成嵌套閉包。在這種風格中,具有深度嵌套的更複雜的代碼很快就會變得笨拙、難處理,最後變成 callback 地獄。
#定義及呼叫異步函數 (Defining and Calling Asynchronous Functions)
異步函數是一種特殊的函數,可以在執行過程中暫停。與普通的同步函數不同,後者的請況可能是運行完成、拋出錯誤,不然就是永遠不會返回。異步函數一樣會執行這三件事,但它可以暫停,等待某事完成。在異步函數中,使用者可以標記暫停執行的每個位置。
為了表明一個函數是異步的,需要在它的參數後加上關鍵字async
,類似於throws
用來標記一個拋出函數的方法。如果函數返回一個值,async
則在返回箭頭 (->
) 之前寫。
例如,以下是獲取圖庫中照片名稱的方法:
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
對於既是異步又是拋出的函數,可以在async
之後加上throws
:
func listPhotos(inGallery name: String) async throws -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
調用異步函數時,執行會暫停,直到該函數返回。在調用前寫下標記(await
)可能的暫停點。就像是使用try
一樣。
例如,以下是獲取圖庫中所有圖片的名稱,然後顯示第一張圖片:
let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
因為listPhotos(inGallery:)
和downloadPhoto(named:)
函數都需要發出網絡請求,所以它們可能需要相對較長的時間才能完成。使用async
讓應用程序在圖片準備好時再繼續運行。
以下是一種可能的執行順序:
- 代碼從第一行開始運行,一直運行到第一行
await
。它調用該listPhotos(inGallery:)
函數並在等待該函數返回時暫停執行。 - 當此代碼的執行暫停時,同一程序中的其他並發代碼會繼續運行。例如,一個長時間運行的後台任務可能會繼續更新新照片庫的列表。
listPhotos(inGallery:)
返回後,此代碼從該點開始繼續執行。它將返回的值分配給photoNames
。- 定義
sortedNames
和name
為一般同步代碼,所以沒有任何暫停點。 - 下一個
downloadPhoto(named:)
函數的調用有await
標記。此代碼再次暫停執行,直到該函數返回,從而使其他並發代碼有機會運行。 downloadPhoto(named:)
返回後,將其返回值賦值給photo
,然後在調用時作為參數傳遞給show(_:)
。
代碼中可能的暫停點用await
表示當前代碼段可能會在等待異步函數返回時暫停執行,這也稱為產生線程。因為在底層,Swift 會暫停當前線程上代碼的執行,並在該線程上運行一些其他代碼。因為await
需要能夠暫停執行,所以只有程序中的某些地方可以調用異步函數:
- 在異步函數、方法、屬性內部的代碼
- 標記了
@main
的struct、class、enum在static方法main()
的代碼 - 在非結構化的子任務中的代碼
在暫停點之間的代碼會按順序運行,不會被其他並發代碼中斷。
例如,以下將圖片從一個畫廊移動到另一個畫廊:
let firstPhoto = await listPhotos(inGallery: "Summer Vacation")[0]
add(firstPhoto toGallery: "Road Trip")
// At this point, firstPhoto is temporarily in both galleries.
remove(firstPhoto fromGallery: "Summer Vacation")
在調用 add(:toGallery:)
和 remove(:fromGallery:)
之間,其他代碼無法運行。在這期間,第一張照片出現在兩個畫廊中,暫時打破了應用程序的一個不變量。
為了更清楚地說明這段代碼以後一定不能添加 await
,可以將該代碼重構為一個同步函數:
func move(_ photoName: String, from source: String, to destination: String) {
add(photoName, to: destination)
remove(photoName, from: source)
}
// ...
let firstPhoto = await listPhotos(inGallery: "Summer Vacation")[0]
move(firstPhoto, from: "Summer Vacation", to: "Road Trip")
在上面的例子中,因為 move(_:from:to:)
函數是同步的。如果嘗試在這函數中添加 concurrent code,引入一個可能的掛起點,你會得到 compile-time error 而不是 introducing a bug。
#異步序列 (Asynchronous Sequences)
上一節中的 listPhotos(inGallery:)
函數在數組的所有元素都準備好之後,一次異步返回整個數組。另一種方法是使用異步序列一次等待一個元素。以下是對異步序列的範例:
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
在for-in
中加入await
表示可能的暫停點,for-await-in
循環可能會在每次迭代開始時暫停執行,此時它正在等待下一個元素可用。
#並行調用異步函數(Calling Asynchronous Functions in Parallel)
使用 await
調用異步函數一次只運行一段代碼。在異步代碼運行時,調用者會等待該代碼完成,然後再繼續運行下一行代碼。
例如,要從圖庫中獲取前三張照片,可以等待對 downloadPhoto(named:)
函數的三次調用:
let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])
let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
這有個嚴重的缺點:儘管下載是異步的,並且在下載過程中允許其他工作發生,但一次只能運行一次對 downloadPhoto(named:) 的調用。在下一張開始下載前,必須等前一張照片完全下載好。但是,這些操作無需等待 — — 每張照片都可以獨立下載,甚至可以同時下載。
為了要調用異步函數並使他們並行(parallel)運行。在定義常量時,在 let
前面寫 async
,然後使用時寫下 await
。
async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])
let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
在此範例中,三個調用 downloadPhoto(named:)
都在不等待前一個完成的情況下開始。如果有足夠的可用系統資源,它們可以同時運行。這些函數調用都沒有標記為 await
,因為代碼不會掛起等待函數的結果。相反,執行會一直持續到定義 photos 的那一行 — — 此時,程序需要這些異步調用的結果,因此您編寫 await
來暫停執行,直到所有三張照片都完成下載。
可以通過以下方式思考這兩種方法之間的區別:
- 當下一行的 code 會使用到此時的函數結果時,使用
await
調用異步函數(asynchronous functions)。這創建了按順序執行的工作。 - 當稍後才需要使用到此時的函數結果時,使用
async-let
調用異步函數(asynchronous functions)。這創建了可以並行(parallel)執行的工作。 await
和async-let
都允許其他代碼在掛起(suspended)時運行。- 在這兩種情況下,您都用
await
標記可能的暫停點,以指示執行將暫停(如果需要),直到異步函數返回。
可以在同一代碼中混合使用這兩種方法。