Swift — Concurrency(1)

Tom Tung
10 min readMar 21, 2023

--

Photo by
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讓應用程序在圖片準備好時再繼續運行。

以下是一種可能的執行順序:

  1. 代碼從第一行開始運行,一直運行到第一行await。它調用該listPhotos(inGallery:)函數並在等待該函數返回時暫停執行。
  2. 當此代碼的執行暫停時,同一程序中的其他並發代碼會繼續運行。例如,一個長時間運行的後台任務可能會繼續更新新照片庫的列表。
  3. listPhotos(inGallery:)返回後,此代碼從該點開始繼續執行。它將返回的值分配給photoNames
  4. 定義sortedNamesname為一般同步代碼,所以沒有任何暫停點。
  5. 下一個downloadPhoto(named:)函數的調用有await標記。此代碼再次暫停執行,直到該函數返回,從而使其他並發代碼有機會運行。
  6. 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)執行的工作。
  • awaitasync-let 都允許其他代碼在掛起(suspended)時運行。
  • 在這兩種情況下,您都用 await 標記可能的暫停點,以指示執行將暫停(如果需要),直到異步函數返回。

可以在同一代碼中混合使用這兩種方法。

--

--