前言
學如逆水行舟,不進則退。共勉!!今天主要是分享一篇關于Swift并發(fā)初步的文章。
同步和異步
在我們說到線程的執(zhí)行方式時,同步 (synchronous) 和異步 (asynchronous) 是這個話題中最基本的一組概念。同步操作意味著在操作完成之前,運行這個操作的線程都將被占用,直到函數(shù)最終被拋出或者返回。Swift 5.5 之前,所有的函數(shù)都是同步函數(shù),我們簡單地使用 func 關鍵字來聲明這樣一個同步函數(shù):
var results: [String] = []
func addAppending(_ value: String, to string: String) {
results.append(value.appending(string))
}
addAppending 是一個同步函數(shù),在它返回之前,運行它的線程將無法執(zhí)行其他操作,或者說它不能被用來運行其他函數(shù),必須等待當前函數(shù)執(zhí)行完成后這個線程才能做其他事情。

在 iOS 開發(fā)中,我們使用的 UI 開發(fā)框架,也就是 UIKit 或者 SwiftUI,不是線程安全的:對用戶輸入的處理和 UI 的繪制,必須在與主線程綁定的 main runloop 中進行。假設我們希望用戶界面以每秒 60 幀的速率運行,那么主線程中每兩次繪制之間,所能允許的處理時間最多只有 16 毫秒 (1 / 60s)。當主線程中要同步處理的其他操作耗時很少時 (比如我們的 addAppending,可能耗時只有幾十納秒),這不會造成什么問題。但是,如果這個同步操作耗時過長的話,主線程將被阻塞。它不能接受用戶輸入,也無法向 GPU 提交請求去繪制新的 UI,這將導致用戶界面掉幀甚至卡死。這種“長耗時”的操作,其實是很常見的:比如從網(wǎng)絡請求中獲取數(shù)據(jù),從磁盤加載一個大文件,或者進行某些非常復雜的加解密運算等。
下面的 loadSignature 從某個網(wǎng)絡 URL 讀取字符串:如果這個操作發(fā)生在主線程,且耗時超過 16ms (這是很可能發(fā)生的,因為通過握手協(xié)議建立網(wǎng)絡連接,以及接收數(shù)據(jù),都是一系列復雜操作),那么主線程將無法處理其他任何操作,UI 將不會刷新。
// 從網(wǎng)絡讀取一個字符串
func loadSignature() throws -> String? {
// someURL 是遠程 URL,比如 https://example.com
let data = try Data(contentsOf: someURL)
return String(data: data, encoding: .utf8)
}

loadSignature 最終的耗時超過 16 ms,對 UI 的刷新或操作的處理不得不被延后。在用戶觀感上,將表現(xiàn)為掉幀或者整個界面卡住。這是客戶端開發(fā)中絕對需要避免的問題之一。
Swift 5.5 之前,要解決這個問題,最常見的做法是將耗時的同步操作轉換為異步操作:把實際長時間執(zhí)行的任務放到另外的線程 (或者叫做后臺線程) 運行,然后在操作結束時提供運行在主線程的回調,以供 UI 操作之用:
func loadSignature(
_ completion: @escaping (String?, Error?) -> Void
)
{
DispatchQueue.global().async {
do {
let d = try Data(contentsOf: someURL)
DispatchQueue.main.async {
completion(String(data: d, encoding: .utf8), nil)
}
} catch {
DispatchQueue.main.async {
completion(nil, error)
}
}
}
}

DispatchQueue.global 負責將任務添加到全局后臺派發(fā)隊列。在底層,GCD 庫 (Grand Central Dispatch) 會進行線程調度,為實際耗時繁重的 Data.init(contentsOf:) 分配合適的線程。耗時任務在主線程外進行處理,完成后再由 DispatchQueue.main 派發(fā)回主線程,并按照結果調用 completion 回調方法。這樣一來,主線程不再承擔耗時任務,UI 刷新和用戶事件處理可以得到保障。
異步操作雖然可以避免卡頓,但是使用起來存在不少問題,最主要包括:
- 錯誤處理隱藏在回調函數(shù)的參數(shù)中,無法用
throw的方式明確地告知并強制調用側去進行錯誤處理。 - 對回調函數(shù)的調用沒有編譯器保證,開發(fā)者可能會忘記調用
completion,或者多次調用completion。 - 通過
DispatchQueue進行線程調度很快會使代碼復雜化。特別是如果線程調度的操作被隱藏在被調用的方法中的時候,不查看源碼的話,在 (調用側的) 回調函數(shù)中,幾乎無法確定代碼當前運行的線程狀態(tài)。 - 對于正在執(zhí)行的任務,沒有很好的取消機制。
除此之外,還有其他一些沒有列舉的問題。它們都可能成為我們程序中潛在 bug 的溫床,在之后關于異步函數(shù)的章節(jié)里,我們會再回顧這個例子,并仔細探討這些問題的細節(jié)。
需要進行說明的是,雖然我們將運行在后臺線程加載數(shù)據(jù)的行為稱為異步操作,但是接受回調函數(shù)作為參數(shù)的 loadSignature(_:) 方法,其本身依然是一個同步函數(shù)。這個方法在返回前仍舊會占據(jù)主線程,只不過它現(xiàn)在的執(zhí)行時間非常短,UI 相關的操作不再受影響。
Swift 5.5 之前,Swift 語言中并沒有真正異步函數(shù)的概念,我們稍后會看到使用 async 修飾的異步函數(shù)是如何簡化上面的代碼的。
串行和并行
另外一組重要的概念是串行和并行。對于通過同步方法執(zhí)行的同步操作來說,這些操作一定是以串行方式在同一線程中發(fā)生的?!白鐾暌患?,然后再進行下一件事”,是最常見的、也是我們人類最容易理解的代碼執(zhí)行方式:
if let signature = try loadSignature() {
addAppending(signature, to: "some data")
}
print(results)
loadSignature,addAppending 和 print 被順次調用,它們在同一線程中按嚴格的先后順序發(fā)生。這種執(zhí)行方式,我們將它稱為串行 (serial)。


同步方法執(zhí)行的同步操作,是串行的充分但非必要條件。異步操作也可能會以串行方式執(zhí)行。假設除了 loadSignature(_:) 以外,我們還有一個從數(shù)據(jù)庫里讀取一系列數(shù)據(jù)的函數(shù),它使用類似的方法,把具體工作放到其他線程異步執(zhí)行:
func loadFromDatabase(
_ completion: @escaping ([String]?, Error?) -> Void
)
{
//
}
如果我們先從數(shù)據(jù)庫中讀取數(shù)據(jù),在完成后再使用 loadSignature 從網(wǎng)絡獲取簽名,最后將簽名附加到每一條數(shù)據(jù)庫中取出的字符串上,可以這么寫:
loadFromDatabase { (strings, error) in
if let strings = strings {
loadSignature { signature, error in
if let signature = signature {
strings.forEach {
strings.forEach {
strings.forEach {
addAppending(signature, to: $0)
}
} else {
print("Error")
}
}
} else {
print("Error.")
}
}
雖然這些操作是異步的,但是它們 (從數(shù)據(jù)庫讀取 [String],從網(wǎng)絡下載簽名,最后將簽名添加到每條數(shù)據(jù)中) 依然是串行的,加載簽名必定發(fā)生在讀取數(shù)據(jù)庫完成之后,而最后的 addAppending 也必然發(fā)生在 loadSignature 之后:

雖然圖中把 loadFromDatabase 和 loadSignature 畫在了同一個線程里,但事實上它們有可能是在不同線程執(zhí)行的。不過在上面代碼的情況下,它們的先后次序依然是嚴格不變的。
事實上,雖然最后的 addAppending 任務同時需要原始數(shù)據(jù)和簽名才能進行,但 loadFromDatabase 和 loadSignature 之間其實并沒有依賴關系。如果它們能夠一起執(zhí)行的話,我們的程序有很大機率能變得更快。這時候,我們會需要更多的線程,來同時執(zhí)行兩個操作:
// loadFromDatabase { (strings, error) in
// ...
// loadSignature { signature, error in {
// ...
// 可以將串行調用替換為:
loadFromDatabase { (strings, error) in
//...
}
loadSignature { signature, error in
//
}
為了確保在 addAppending 執(zhí)行時,從數(shù)據(jù)庫加載的內容和從網(wǎng)絡下載的簽名都已經(jīng)準備好,我們需要某種手段來確保這些數(shù)據(jù)的可用性。在 GCD 中,通??梢允褂?DispatchGroup 或者 DispatchSemaphore 來實現(xiàn)這一點。但是我們并不是一本探討 GCD 的書籍,所以這部分內容就略過了。
兩個 load 方法同時開始工作,理論上資源充足的話 (足夠的 CPU,網(wǎng)絡帶寬等),現(xiàn)在它們所消耗的時間會小于串行時的兩者之和:

這時候,
loadFromDatabase 和 loadSignature 這兩個異步操作,在不同的線程中同時執(zhí)行。對于這種擁有多套資源同時執(zhí)行的方式,我們就將它稱為并行 (parallel)。
Swift 并發(fā)是什么
在有了這些基本概念后,最后可以談談關于并發(fā) (concurrency) 這個名詞了。在計算機科學中,并發(fā)指的是多個計算同時執(zhí)行的特性。并發(fā)計算中涉及的同時執(zhí)行,主要是若干個操作的開始和結束時間之間存在重疊。它并不關心具體的執(zhí)行方式:我們可以把同一個線程中的多個操作交替運行 (這需要這類操作能夠暫時被置于暫停狀態(tài)) 叫做并發(fā),這幾個操作將會是分時運行的;我們也可以把在不同處理器核心中運行的任務叫做并發(fā),此時這些任務必定是并行的。
而當 Apple 在定義“Swift 并發(fā)”是什么的時候,和上面這個經(jīng)典的計算機科學中的定義實質上沒有太多不同。Swift 官方文檔給出了這樣的解釋:
Swift 提供內建的支持,讓開發(fā)者能以結構化的方式書寫異步和并行的代碼,… 并發(fā)這個術語,指的是異步和并行這一常見組合。
所以在提到 Swift 并發(fā)時,它指的就是異步和并行代碼的組合。這在語義上,其實是傳統(tǒng)并發(fā)的一個子集:它限制了實現(xiàn)并發(fā)的手段就是異步代碼,這個限定降低了我們理解并發(fā)的難度。在本書中,如果沒有特別說明,我們在提到 Swift 并發(fā)時,指的都是“異步和并行代碼的組合”這個簡化版的意義,或者專指 Swift 5.5 中引入的這一套處理并發(fā)的語法和框架。
除了定義方式稍有不同之外,Swift 并發(fā)和其他編程語言在處理同樣問題時所面臨的挑戰(zhàn)幾乎一樣。從戴克斯特拉 (Edsger W. Dijkstra) 提出信號量 (semaphore) 的概念起,到東尼?霍爾爵士 (Tony Hoare) 使用 CSP 描述和嘗試解決哲學家就餐問題,再到 actor 模型或者通道模型 (channel model) 的提出,并發(fā)編程最大的困難,以及這些工具所要解決的問題大致上只有兩個:
- 如何確保不同運算運行步驟之間的交互或通信可以按照正確的順序執(zhí)行
- 如何確保運算資源在不同運算之間被安全地共享、訪問和傳遞
第一個問題負責并發(fā)的邏輯正確,第二個問題負責并發(fā)的內存安全。在以前,開發(fā)者在使用 GCD 編寫并發(fā)代碼時往往需要很多經(jīng)驗,否則難以正確處理上述問題。Swift 5.5 設計了異步函數(shù)的書寫方法,在此基礎上,利用結構化并發(fā)確保運算步驟的交互和通信正確,利用 actor 模型確保共享的計算資源能在隔離的情況下被正確訪問和操作。它們組合在一起,提供了一系列工具讓開發(fā)者能簡單地編寫出穩(wěn)定高效的并發(fā)代碼。我們接下來,會淺顯地對這幾部分內容進行瞥視,并在后面對各個話題展開探究。
戴克斯特拉還發(fā)表了著名的《GOTO 語句有害論》(Go To Statement Considered Harmful),并和霍爾爵士一同推動了結構化編程的發(fā)展。霍爾爵士在稍后也提出了對 null 的反對,最終促成了現(xiàn)代語言中普遍采用的
Optional(或者叫別的名稱,比如Maybe或 null safety 等) 設計。如果沒有他們,也許我們今天在編寫代碼時還在處理無盡的 goto 和 null 檢查,會要辛苦很多。
異步函數(shù)
為了更容易和優(yōu)雅地解決上面兩個問題,Swift 需要在語言層面引入新的工具:第一步就是添加異步函數(shù)的概念。在函數(shù)聲明的返回箭頭前面,加上 async 關鍵字,就可以把一個函數(shù)聲明為異步函數(shù):
func loadSignature() async throws -> String {
fatalError("暫未實現(xiàn)")
}
異步函數(shù)的 async 關鍵字會幫助編譯器確保兩件事情:
它允許我們在函數(shù)體內部使用 await 關鍵字;
它要求其他人在調用這個函數(shù)時,使用 await 關鍵字。
這和與它處于類似位置的 throws 關鍵字有點相似。在使用 throws 時,它允許我們在函數(shù)內部使用 throw 拋出錯誤,并要求調用者使用 try 來處理可能的拋出。async 也扮演了這樣一個角色,它要求在特定情況下對當前函數(shù)進行標記,這是對于開發(fā)者的一種明確的提示,表明這個函數(shù)有一些特別的性質:try/throw 代表了函數(shù)可以被拋出,而 await 則代表了函數(shù)在此處可能會放棄當前線程,它是程序的潛在暫停點。
放棄線程的能力,意味著異步方法可以被“暫?!?,這個線程可以被用來執(zhí)行其他代碼。如果這個線程是主線程的話,那么界面將不會卡頓。被 await 的語句將被底層機制分配到其他合適的線程,在執(zhí)行完成后,之前的“暫?!睂⒔Y束,異步方法從剛才的 await 語句后開始,繼續(xù)向下執(zhí)行。
關于異步函數(shù)的設計和更多深入內容,我們會在隨后的相關章節(jié)展開。在這里,我們先來看看一個簡單的異步函數(shù)的使用。Foundation 框架中已經(jīng)為我們提供了很多異步函數(shù),比如使用 URLSession 從某個 URL 加載數(shù)據(jù),現(xiàn)在也有異步版本了。在由 async 標記的異步函數(shù)中,我們可以調用其他異步函數(shù):
func loadSignature() async throws -> String? {
let (data, _) = try await URLSession.shared.data(from: someURL)
return String(data: data, encoding: .utf8)
}
這些 Foundation,或者 AppKit 或 UIKit 中的異步函數(shù),有一部分是重寫和新添加的,但更多的情況是由相應的 Objective-C 接口轉換而來。滿足一定條件的 Objective-C 函數(shù),可以直接轉換為 Swift 的異步函數(shù),非常方便。在后一章我們也會具體談到。
如果我們把 loadFromDatabase 也寫成異步函數(shù)的形式。那么,在上面串行部分,原本的嵌套式的異步操作代碼:
loadFromDatabase { (strings, error) in
if let strings = strings {
loadSignature { signature, error in
if let signature = signature {
strings.forEach {
strings.forEach {
strings.forEach {
addAppending(signature, to: $0)
}
} else {
print("Error")
}
}
} else {
print("Error.")
}
}
就可以非常簡單地寫成這樣的形式:
let strings = try await loadFromDatabase()
if let signature = try await loadSignature() {
strings.forEach {
addAppending(signature, to: $0)
}
} else {
throw NoSignatureError()
}
不用多說,單從代碼行數(shù)就可以一眼看清優(yōu)劣了。異步函數(shù)極大簡化了異步操作的寫法,它避免了內嵌的回調,將異步操作按照順序寫成了類似“同步執(zhí)行”的方法。另外,這種寫法允許我們使用 try/throw 的組合對錯誤進行處理,編譯器會對所有的返回路徑給出保證,而不必像回調那樣時刻檢查是不是所有的路徑都進行了處理。
結構化并發(fā)
對于同步函數(shù)來說,線程決定了它的執(zhí)行環(huán)境。而對于異步函數(shù),則由任務 (Task) 決定執(zhí)行環(huán)境。Swift 提供了一系列 Task 相關 API 來讓開發(fā)者創(chuàng)建、組織、檢查和取消任務。這些 API 圍繞著 Task 這一核心類型,為每一組并發(fā)任務構建出一棵結構化的任務樹:
- 一個任務具有它自己的優(yōu)先級和取消標識,它可以擁有若干個子任務并在其中執(zhí)行異步函數(shù)。
- 當一個父任務被取消時,這個父任務的取消標識將被設置,并向下傳遞到所有的子任務中去。
- 無論是正常完成還是拋出錯誤,子任務會將結果向上報告給父任務,在所有子任務完成之前 (不論是正常結束還是拋出),父任務是不會完成的。
這些特性看上去和 Operation 類 有一些相似,不過 Task 直接利用異步函數(shù)的語法,可以用更簡潔的方式進行表達。而 Operation 則需要依靠子類或者閉包。
在調用異步函數(shù)時,需要在它前面添加 await 關鍵字;而另一方面,只有在異步函數(shù)中,我們才能使用 await 關鍵字。那么問題在于,第一個異步函數(shù)執(zhí)行的上下文,或者說任務樹的根節(jié)點,是怎么來的?
簡單地使用 Task.init 就可以讓我們獲取一個任務執(zhí)行的上下文環(huán)境,它接受一個 async 標記的閉包:
struct Task<Success, Failure> where Failure : Error {
init(
priority: TaskPriority? = nil,
priority: TaskPriority? = nil,
priority: TaskPriority? = nil,
operation: @escaping @Sendable () async throws -> Success
)
}
它繼承當前任務上下文的優(yōu)先級等特性,創(chuàng)建一個新的任務樹根節(jié)點,我們可以在其中使用異步函數(shù):
var results: [String] = []
func someSyncMethod() {
Task {
try await processFromScratch()
print("Done: \(results)")
}
}
func processFromScratch() async throws {
let strings = try await loadFromDatabase()
if let signature = try await loadSignature() {
strings.forEach {
results.append($0.appending(signature))
}
} else {
throw NoSignatureError()
}
}
注意,在 processFromScratch 中的處理依然是串行的:對 loadFromDatabase 的 await 將使這個異步函數(shù)在此暫停,直到實際操作結束,接下來才會執(zhí)行 loadSignature:

我們當然會希望這兩個操作可以同時進行。在兩者都準備好后,再調用 appending 來實際將簽名附加到數(shù)據(jù)上。這需要任務以結構化的方式進行組織。使用 async let 綁定可以做到這一點:
func processFromScratch() async throws {
async let loadStrings = loadFromDatabase()
async let loadSignature = loadSignature()
results = []
let strings = try await loadStrings
if let signature = try await loadSignature {
strings.forEach {
addAppending(signature, to: $0)
}
} else {
throw NoSignatureError()
}
}
async let 被稱為異步綁定,它在當前 Task 上下文中創(chuàng)建新的子任務,并將它用作被綁定的異步函數(shù) (也就是 async let 右側的表達式) 的運行環(huán)境。和 Task.init 新建一個任務根節(jié)點不同,async let 所創(chuàng)建的子任務是任務樹上的葉子節(jié)點。被異步綁定的操作會立即開始執(zhí)行,即使在 await 之前執(zhí)行就已經(jīng)完成,其結果依然可以等到 await 語句時再進行求值。在上面的例子中,loadFromDatabase 和 loadSignature 將被并發(fā)執(zhí)行。

相對于 GCD 調度的并發(fā),基于任務的結構化并發(fā)在控制并發(fā)行為上具有得天獨厚的優(yōu)勢。為了展示這一優(yōu)勢,我們可以嘗試把事情再弄復雜一點。上面的 processFromScratch 完成了從本地加載數(shù)據(jù),從網(wǎng)絡獲取簽名,最后再將簽名附加到每一條數(shù)據(jù)上這一系列操作。假設我們以前可能就做過類似的事情,并且在服務器上已經(jīng)存儲了所有結果,于是我們有機會在進行本地運算的同時,去嘗試直接加載這些結果作為“優(yōu)化路徑”,避免重復的本地計算。類似地,可以用一個異步函數(shù)來表示“從網(wǎng)絡直接加載結果”的操作:
func loadResultRemotely() async throws {
// 模擬網(wǎng)絡加載的耗時
await Task.sleep(2 * NSEC_PER_SEC)
results = ["data1^sig", "data2^sig", "data3^sig"]
}
除了 async let 外,另一種創(chuàng)建結構化并發(fā)的方式,是使用任務組 (Task group)。比如,我們希望在執(zhí)行 loadResultRemotely 的同時,讓 processFromScratch 一起運行,可以用 withThrowingTaskGroup 將兩個操作寫在同一個 task group 中:
func someSyncMethod() {
Task {
await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await self.loadResultRemotely()
}
group.addTask(priority: .low) {
try await self.processFromScratch()
}
}
}
}
print("Done: \(results)")
}
}
對于 processFromScratch,我們?yōu)樗貏e指定了 .low 的優(yōu)先級,這會導致該任務在另一個低優(yōu)先級線程中被調度。我們一會兒會看到這一點帶來的影響。
withThrowingTaskGroup 和它的非拋出版本 withTaskGroup 提供了另一種創(chuàng)建結構化并發(fā)的組織方式。當在運行時才知道任務數(shù)量時,或是我們需要為不同的子任務設置不同優(yōu)先級時,我們將只能選擇使用 Task Group。在其他大部分情況下,async let 和 task group 可以混用甚至互相替代:

閉包中的 group 滿足 AsyncSequence 協(xié)議,它讓我們可以使用 for await 的方式用類似同步循環(huán)的寫法來訪問異步操作的結果。另外,通過調用 group 的 cancelAll,我們可以在適當?shù)那闆r下將任務標記為取消。比如在 loadResultRemotely 很快返回時,我們可以取消掉正在進行的 processFromScratch,以節(jié)省計算資源。關于異步序列和任務取消這些話題,我們會在稍后專門的章節(jié)中繼續(xù)探討。
actor 模型和數(shù)據(jù)隔離
在 processFromScratch 里,我們先將 results 設置為 [],然后再處理每條數(shù)據(jù),并將結果添加到 results 里:
func processFromScratch() async throws {
// ...
results = []
strings.forEach {
addAppending(signature, to: $0)
}
// ...
}
在作為示例的 loadResultRemotely 里,我們現(xiàn)在則是直接把結果賦值給了 results:
func loadResultRemotely() async throws {
await Task.sleep(2 * NSEC_PER_SEC)
results = ["data1^sig", "data2^sig", "data3^sig"]
}
因此,一般來說我們會認為,不論 processFromScratch 和 loadResultRemotely 執(zhí)行的先后順序如何,我們總是應該得到唯一確定的 results,也就是數(shù)據(jù) ["data1^sig", "data2^sig", "data3^sig"]。但事實上,如果我們對 loadResultRemotely 的 Task.sleep 時長進行一些調整,讓它和 processFromScratch 所耗費的時間相仿,就可能會看到出乎意料的結果。在正確輸出三個元素的情況外,有時候它會輸出六個元素:
// 有機率輸出:
Done: ["data1^sig", "data2^sig", "data3^sig",
"data1^sig", "data2^sig", "data3^sig"]
我們在 addTask 時為兩個任務指定了不同的優(yōu)先級,因此它們中的代碼將運行在不同的調度線程上。兩個異步操作在不同線程同時訪問了 results,造成了數(shù)據(jù)競爭。在上面這個結果中,我們可以將它解釋為 processFromScratch 先將 results 設為了空數(shù)列,緊接著 loadResultRemotely 完成,將它設為正確的結果,然后 processFromScratch 中的 forEach 把計算得出的三個簽名再添加進去。

這大概率并不是我們想要的結果。不過幸運的是兩個操作現(xiàn)在并沒有真正“同時”地去更改 results 的內存,它們依然有先后順序,因此只是最后的數(shù)據(jù)有些奇怪。
processFromScratch 和 loadResultRemotely 在不同的任務環(huán)境中對變量 results 進行了操作。由于這兩個操作是并發(fā)執(zhí)行的,所以也可能出現(xiàn)一種更糟糕的情況:它們對 results 的操作同時發(fā)生。如果 results 的底層存儲被多個操作同時更改的話,我們會得到一個運行時錯誤。作為示例 (雖然沒有太多實際意義),通過增加 someSyncMethod 的運行次數(shù)就可以很容易地讓程序崩潰:
for _ in 0 ..< 10000 {
someSyncMethod()
}
// 運行時崩潰。一個典型的內存錯誤
// Thread 10: EXC_BAD_ACCESS (code=1, address=0x55a8fdbc060c)
為了確保資源 (在這個例子里,是 results 指向的內存) 在不同運算之間被安全地共享和訪問,以前通常的做法是將相關的代碼放入一個串行的 dispatch queue 中,然后以同步的方式把對資源的訪問派發(fā)到隊列中去執(zhí)行,這樣我們可以避免多個線程同時對資源進行訪問。按照這個思路可以進行一些重構,將 results 放到新的 Holder 類型中,并使用私有的 DispatchQueue 將它保護起來:
class Holder {
private let queue = DispatchQueue(label: "resultholder.queue")
private var results: [String] = []
func getResults() -> [String] {
queue.sync { results }
}
func setResults(_ results: [String]) {
queue.sync { self.results = results }
}
func append(_ value: String) {
queue.sync { self.results.append(value) }
}
}
接下來,將原來代碼中使用到 results: [String] 的地方替換為 Holder,并使用暴露出的方法將原來對 results 的直接操作進行替換,可以解決運行時崩潰的問題。
// var results: [String] = []
var holder = Holder()
// ...
// results = []
holder.setResults([])
// results.append(data.appending(signature))
holder.append(data.appending(signature))
// print("Done: \(results)")
print("Done: \(holder.getResults())")
在使用 GCD 進行并發(fā)操作時,這種模式非常常見。但是它存在一些難以忽視的問題:
大量且易錯的模板代碼:凡是涉及 results 的操作,都需要使用 queue.sync 包圍起來,但是編譯器并沒有給我們任何保證。在某些時候忘了使用隊列,編譯器也不會進行任何提示,這種情況下內存依然存在危險。當有更多資源需要保護時,代碼復雜度也將爆炸式上升。
小心死鎖:在一個 queue.sync 中調用另一個 queue.sync 的方法,會造成線程死鎖。在代碼簡單的時候,這很容易避免,但是隨著復雜度增加,想要理解當前代碼運行是由哪一個隊列派發(fā)的,它又運行在哪一個線程上,往往會伴隨著嚴重的困難。必須精心設計,避免重復派發(fā)。
在一定程度上,我們可以用 async 替代 sync 派發(fā)來緩解死鎖的問題;或者放棄隊列,轉而使用鎖 (比如 NSLock 或者 NSRecursiveLock)。不過不論如何做,都需要開發(fā)者對線程調度和這種基于共享內存的數(shù)據(jù)模型有深刻理解,否則非常容易寫出很多坑。
Swift 并發(fā)引入了一種在業(yè)界已經(jīng)被多次證明有效的新的數(shù)據(jù)共享模型,actor 模型 (參與者模型),來解決這些問題。雖然有些偏失,但最簡單的理解,可以認為 actor 就是一個“封裝了私有隊列”的 class。將上面 Holder 中 class 改為 actor,并把 queue 的相關部分去掉,我們就可以得到一個 actor 類型。這個類型的特性和 class 很相似,它擁有引用語義,在它上面定義屬性和方法的方式和普通的 class 沒有什么不同:
actor Holder {
var results: [String] = []
func setResults(_ results: [String]) {
self.results = results
}
func append(_ value: String) {
results.append(value)
}
}
對比由私有隊列保護的“手動擋”的 class,這個“自動檔”的 actor 實現(xiàn)顯然簡潔得多。actor 內部會提供一個隔離域:在 actor 內部對自身存儲屬性或其他方法的訪問,比如在 append(_:) 函數(shù)中使用 results 時,可以不加任何限制,這些代碼都會被自動隔離在被封裝的“私有隊列”里。但是從外部對 actor 的成員進行訪問時,編譯器會要求切換到 actor 的隔離域,以確保數(shù)據(jù)安全。在這個要求發(fā)生時,當前執(zhí)行的程序可能會發(fā)生暫停。編譯器將自動把要跨隔離域的函數(shù)轉換為異步函數(shù),并要求我們使用 await 來進行調用。
雖然實際底層實現(xiàn)中,actor 并非持有一個私有隊列,但是現(xiàn)在,你可以就這樣簡單理解。在本書后面的部分我們會做更深入的探索。
當我們把 Holder 從 class 轉換為 actor 后,原來對 holder 的調用也需要更新。簡單來說,在訪問相關成員時,添加 await 即可:
// holder.setResults([])
await holder.setResults([])
// holder.append(data.appending(signature))
await holder.append(data.appending(signature))
// print("Done: \(holder.getResults())")
print("Done: \(await holder.results)")
現(xiàn)在,在并發(fā)環(huán)境中訪問 holder 不再會造成崩潰了。不過,即時使用 Holder,不論是基于 DispatchQueue 還是 actor,上面代碼所得到的結果中依然可能會存在多于三個元素的情況。這是在預期內的:數(shù)據(jù)隔離只解決同時訪問的造成的內存問題 (在 Swift 中,這種不安全行為大多數(shù)情況下表現(xiàn)為程序崩潰)。而這里的數(shù)據(jù)正確性關系到 actor 的可重入 (reentrancy)。要正確理解可重入,我們必須先對異步函數(shù)的特性有更多了解,因此我們會在之后的章節(jié)里再談到這個話題。
另外,actor 類型現(xiàn)在還并沒有提供指定具體運行方式的手段。雖然我們可以使用 @MainActor 來確保 UI 線程的隔離,但是對于一般的 actor,我們還無法指定隔離代碼應該以怎樣的方式運行在哪一個線程。我們之后也還會看到包括全局 actor、非隔離標記 (nonisolated) 和 actor 的數(shù)據(jù)模型等內容。
小結
我想本章應該已經(jīng)有足夠多的內容了。我們從最基本的概念開始,展示了使用 GCD 或者其他一些“原始”手段來處理并發(fā)程序時可能面臨的困難,并在此基礎上介紹了 Swift 并發(fā)中處理和解決這些問題的方式。
Swift 并發(fā)雖然涉及的概念很多,但是各種的模塊邊界是清晰的:
異步函數(shù):提供語法工具,使用更簡潔和高效的方式,表達異步行為。
結構化并發(fā):提供并發(fā)的運行環(huán)境,負責正確的函數(shù)調度、取消和執(zhí)行順序以及任務的生命周期。
actor 模型:提供封裝良好的數(shù)據(jù)隔離,確保并發(fā)代碼的安全。
熟悉這些邊界,有助于我們清晰地理解 Swift 并發(fā)各個部分的設計意圖,從而讓我們手中的工具可以被運用在正確的地方。作為概覽,在本章中讀者應該已經(jīng)看到如何使用 Swift 并發(fā)的工具書寫并發(fā)代碼了。本書接下來的部分,將會對每個模塊做更加深入的探討,以求將更多隱藏在宏觀概念下的細節(jié)暴露出來。
點點關注,點點贊。iOS更多資料,請關注主頁,加入圈子獲取。