前言
學(xué)如逆水行舟,不進(jìn)則退国拇。共勉B迨贰!今天主要是分享一篇關(guān)于Swift并發(fā)初步的文章酱吝。
同步和異步
在我們說(shuō)到線程的執(zhí)行方式時(shí)也殖,同步 (synchronous) 和異步 (asynchronous) 是這個(gè)話題中最基本的一組概念。同步操作意味著在操作完成之前,運(yùn)行這個(gè)操作的線程都將被占用忆嗜,直到函數(shù)最終被拋出或者返回己儒。Swift 5.5 之前,所有的函數(shù)都是同步函數(shù)捆毫,我們簡(jiǎn)單地使用 func 關(guān)鍵字來(lái)聲明這樣一個(gè)同步函數(shù):
var results: [String] = []
func addAppending(_ value: String, to string: String) {
results.append(value.appending(string))
}
addAppending 是一個(gè)同步函數(shù)闪湾,在它返回之前,運(yùn)行它的線程將無(wú)法執(zhí)行其他操作绩卤,或者說(shuō)它不能被用來(lái)運(yùn)行其他函數(shù)途样,必須等待當(dāng)前函數(shù)執(zhí)行完成后這個(gè)線程才能做其他事情。
在 iOS 開發(fā)中濒憋,我們使用的 UI 開發(fā)框架何暇,也就是 UIKit 或者 SwiftUI,不是線程安全的:對(duì)用戶輸入的處理和 UI 的繪制凛驮,必須在與主線程綁定的 main runloop 中進(jìn)行裆站。假設(shè)我們希望用戶界面以每秒 60 幀的速率運(yùn)行,那么主線程中每?jī)纱卫L制之間黔夭,所能允許的處理時(shí)間最多只有 16 毫秒 (1 / 60s)宏胯。當(dāng)主線程中要同步處理的其他操作耗時(shí)很少時(shí) (比如我們的 addAppending,可能耗時(shí)只有幾十納秒)纠修,這不會(huì)造成什么問(wèn)題胳嘲。但是,如果這個(gè)同步操作耗時(shí)過(guò)長(zhǎng)的話扣草,主線程將被阻塞了牛。它不能接受用戶輸入,也無(wú)法向 GPU 提交請(qǐng)求去繪制新的 UI辰妙,這將導(dǎo)致用戶界面掉幀甚至卡死鹰祸。這種“長(zhǎng)耗時(shí)”的操作,其實(shí)是很常見的:比如從網(wǎng)絡(luò)請(qǐng)求中獲取數(shù)據(jù)密浑,從磁盤加載一個(gè)大文件蛙婴,或者進(jìn)行某些非常復(fù)雜的加解密運(yùn)算等。
下面的 loadSignature 從某個(gè)網(wǎng)絡(luò) URL 讀取字符串:如果這個(gè)操作發(fā)生在主線程尔破,且耗時(shí)超過(guò) 16ms (這是很可能發(fā)生的街图,因?yàn)橥ㄟ^(guò)握手協(xié)議建立網(wǎng)絡(luò)連接,以及接收數(shù)據(jù)懒构,都是一系列復(fù)雜操作)餐济,那么主線程將無(wú)法處理其他任何操作,UI 將不會(huì)刷新胆剧。
// 從網(wǎng)絡(luò)讀取一個(gè)字符串
func loadSignature() throws -> String? {
// someURL 是遠(yuǎn)程 URL絮姆,比如 https://example.com
let data = try Data(contentsOf: someURL)
return String(data: data, encoding: .utf8)
}
loadSignature 最終的耗時(shí)超過(guò) 16 ms,對(duì) UI 的刷新或操作的處理不得不被延后。在用戶觀感上篙悯,將表現(xiàn)為掉幀或者整個(gè)界面卡住蚁阳。這是客戶端開發(fā)中絕對(duì)需要避免的問(wèn)題之一。
Swift 5.5 之前鸽照,要解決這個(gè)問(wèn)題螺捐,最常見的做法是將耗時(shí)的同步操作轉(zhuǎn)換為異步操作:把實(shí)際長(zhǎng)時(shí)間執(zhí)行的任務(wù)放到另外的線程 (或者叫做后臺(tái)線程) 運(yùn)行,然后在操作結(jié)束時(shí)提供運(yùn)行在主線程的回調(diào)移宅,以供 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ù)責(zé)將任務(wù)添加到全局后臺(tái)派發(fā)隊(duì)列归粉。在底層椿疗,GCD 庫(kù) (Grand Central Dispatch) 會(huì)進(jìn)行線程調(diào)度漏峰,為實(shí)際耗時(shí)繁重的 Data.init(contentsOf:)
分配合適的線程。耗時(shí)任務(wù)在主線程外進(jìn)行處理届榄,完成后再由 DispatchQueue.main
派發(fā)回主線程浅乔,并按照結(jié)果調(diào)用 completion
回調(diào)方法。這樣一來(lái)铝条,主線程不再承擔(dān)耗時(shí)任務(wù)靖苇,UI 刷新和用戶事件處理可以得到保障。
異步操作雖然可以避免卡頓班缰,但是使用起來(lái)存在不少問(wèn)題贤壁,最主要包括:
- 錯(cuò)誤處理隱藏在回調(diào)函數(shù)的參數(shù)中,無(wú)法用
throw
的方式明確地告知并強(qiáng)制調(diào)用側(cè)去進(jìn)行錯(cuò)誤處理埠忘。 - 對(duì)回調(diào)函數(shù)的調(diào)用沒有編譯器保證脾拆,開發(fā)者可能會(huì)忘記調(diào)用
completion
,或者多次調(diào)用completion
莹妒。 - 通過(guò)
DispatchQueue
進(jìn)行線程調(diào)度很快會(huì)使代碼復(fù)雜化名船。特別是如果線程調(diào)度的操作被隱藏在被調(diào)用的方法中的時(shí)候,不查看源碼的話旨怠,在 (調(diào)用側(cè)的) 回調(diào)函數(shù)中渠驼,幾乎無(wú)法確定代碼當(dāng)前運(yùn)行的線程狀態(tài)。 - 對(duì)于正在執(zhí)行的任務(wù)鉴腻,沒有很好的取消機(jī)制迷扇。
除此之外,還有其他一些沒有列舉的問(wèn)題爽哎。它們都可能成為我們程序中潛在 bug 的溫床蜓席,在之后關(guān)于異步函數(shù)的章節(jié)里,我們會(huì)再回顧這個(gè)例子倦青,并仔細(xì)探討這些問(wèn)題的細(xì)節(jié)瓮床。
需要進(jìn)行說(shuō)明的是,雖然我們將運(yùn)行在后臺(tái)線程加載數(shù)據(jù)的行為稱為異步操作,但是接受回調(diào)函數(shù)作為參數(shù)的 loadSignature(_:)
方法隘庄,其本身依然是一個(gè)同步函數(shù)踢步。這個(gè)方法在返回前仍舊會(huì)占據(jù)主線程,只不過(guò)它現(xiàn)在的執(zhí)行時(shí)間非常短丑掺,UI 相關(guān)的操作不再受影響获印。
Swift 5.5 之前,Swift 語(yǔ)言中并沒有真正異步函數(shù)的概念街州,我們稍后會(huì)看到使用 async
修飾的異步函數(shù)是如何簡(jiǎn)化上面的代碼的兼丰。
串行和并行
另外一組重要的概念是串行和并行。對(duì)于通過(guò)同步方法執(zhí)行的同步操作來(lái)說(shuō)唆缴,這些操作一定是以串行方式在同一線程中發(fā)生的鳍征。“做完一件事面徽,然后再進(jìn)行下一件事”艳丛,是最常見的、也是我們?nèi)祟愖钊菀桌斫獾拇a執(zhí)行方式:
if let signature = try loadSignature() {
addAppending(signature, to: "some data")
}
print(results)
loadSignature趟紊,addAppending 和 print 被順次調(diào)用氮双,它們?cè)谕痪€程中按嚴(yán)格的先后順序發(fā)生。這種執(zhí)行方式霎匈,我們將它稱為串行 (serial)戴差。
同步方法執(zhí)行的同步操作,是串行的充分但非必要條件铛嘱。異步操作也可能會(huì)以串行方式執(zhí)行暖释。假設(shè)除了 loadSignature(_:) 以外,我們還有一個(gè)從數(shù)據(jù)庫(kù)里讀取一系列數(shù)據(jù)的函數(shù)弄痹,它使用類似的方法饭入,把具體工作放到其他線程異步執(zhí)行:
func loadFromDatabase(
_ completion: @escaping ([String]?, Error?) -> Void
)
{
//
}
如果我們先從數(shù)據(jù)庫(kù)中讀取數(shù)據(jù),在完成后再使用 loadSignature 從網(wǎng)絡(luò)獲取簽名肛真,最后將簽名附加到每一條數(shù)據(jù)庫(kù)中取出的字符串上谐丢,可以這么寫:
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ù)庫(kù)讀取 [String]蚓让,從網(wǎng)絡(luò)下載簽名乾忱,最后將簽名添加到每條數(shù)據(jù)中) 依然是串行的,加載簽名必定發(fā)生在讀取數(shù)據(jù)庫(kù)完成之后历极,而最后的 addAppending 也必然發(fā)生在 loadSignature 之后:
雖然圖中把 loadFromDatabase 和 loadSignature 畫在了同一個(gè)線程里窄瘟,但事實(shí)上它們有可能是在不同線程執(zhí)行的。不過(guò)在上面代碼的情況下趟卸,它們的先后次序依然是嚴(yán)格不變的蹄葱。
事實(shí)上氏义,雖然最后的 addAppending 任務(wù)同時(shí)需要原始數(shù)據(jù)和簽名才能進(jìn)行,但 loadFromDatabase 和 loadSignature 之間其實(shí)并沒有依賴關(guān)系图云。如果它們能夠一起執(zhí)行的話惯悠,我們的程序有很大機(jī)率能變得更快。這時(shí)候竣况,我們會(huì)需要更多的線程克婶,來(lái)同時(shí)執(zhí)行兩個(gè)操作:
// loadFromDatabase { (strings, error) in
// ...
// loadSignature { signature, error in {
// ...
// 可以將串行調(diào)用替換為:
loadFromDatabase { (strings, error) in
//...
}
loadSignature { signature, error in
//
}
為了確保在 addAppending 執(zhí)行時(shí),從數(shù)據(jù)庫(kù)加載的內(nèi)容和從網(wǎng)絡(luò)下載的簽名都已經(jīng)準(zhǔn)備好丹泉,我們需要某種手段來(lái)確保這些數(shù)據(jù)的可用性情萤。在 GCD 中,通衬『蓿可以使用 DispatchGroup 或者 DispatchSemaphore 來(lái)實(shí)現(xiàn)這一點(diǎn)筋岛。但是我們并不是一本探討 GCD 的書籍,所以這部分內(nèi)容就略過(guò)了睬塌。
兩個(gè) load 方法同時(shí)開始工作泉蝌,理論上資源充足的話 (足夠的 CPU歇万,網(wǎng)絡(luò)帶寬等)揩晴,現(xiàn)在它們所消耗的時(shí)間會(huì)小于串行時(shí)的兩者之和:
這時(shí)候,
loadFromDatabase
和 loadSignature
這兩個(gè)異步操作贪磺,在不同的線程中同時(shí)執(zhí)行硫兰。對(duì)于這種擁有多套資源同時(shí)執(zhí)行的方式,我們就將它稱為并行 (parallel)寒锚。
Swift 并發(fā)是什么
在有了這些基本概念后劫映,最后可以談?wù)勱P(guān)于并發(fā) (concurrency) 這個(gè)名詞了。在計(jì)算機(jī)科學(xué)中刹前,并發(fā)指的是多個(gè)計(jì)算同時(shí)執(zhí)行的特性泳赋。并發(fā)計(jì)算中涉及的同時(shí)執(zhí)行,主要是若干個(gè)操作的開始和結(jié)束時(shí)間之間存在重疊喇喉。它并不關(guān)心具體的執(zhí)行方式:我們可以把同一個(gè)線程中的多個(gè)操作交替運(yùn)行 (這需要這類操作能夠暫時(shí)被置于暫停狀態(tài)) 叫做并發(fā)祖今,這幾個(gè)操作將會(huì)是分時(shí)運(yùn)行的;我們也可以把在不同處理器核心中運(yùn)行的任務(wù)叫做并發(fā)拣技,此時(shí)這些任務(wù)必定是并行的千诬。
而當(dāng) Apple 在定義“Swift 并發(fā)”是什么的時(shí)候,和上面這個(gè)經(jīng)典的計(jì)算機(jī)科學(xué)中的定義實(shí)質(zhì)上沒有太多不同膏斤。Swift 官方文檔給出了這樣的解釋:
Swift 提供內(nèi)建的支持徐绑,讓開發(fā)者能以結(jié)構(gòu)化的方式書寫異步和并行的代碼,… 并發(fā)這個(gè)術(shù)語(yǔ)莫辨,指的是異步和并行這一常見組合傲茄。
所以在提到 Swift 并發(fā)時(shí)毅访,它指的就是異步和并行代碼的組合。這在語(yǔ)義上盘榨,其實(shí)是傳統(tǒng)并發(fā)的一個(gè)子集:它限制了實(shí)現(xiàn)并發(fā)的手段就是異步代碼俺抽,這個(gè)限定降低了我們理解并發(fā)的難度。在本書中较曼,如果沒有特別說(shuō)明磷斧,我們?cè)谔岬?Swift 并發(fā)時(shí),指的都是“異步和并行代碼的組合”這個(gè)簡(jiǎn)化版的意義捷犹,或者專指 Swift 5.5 中引入的這一套處理并發(fā)的語(yǔ)法和框架弛饭。
除了定義方式稍有不同之外,Swift 并發(fā)和其他編程語(yǔ)言在處理同樣問(wèn)題時(shí)所面臨的挑戰(zhàn)幾乎一樣萍歉。從戴克斯特拉 (Edsger W. Dijkstra) 提出信號(hào)量 (semaphore) 的概念起侣颂,到東尼?霍爾爵士 (Tony Hoare) 使用 CSP 描述和嘗試解決哲學(xué)家就餐問(wèn)題,再到 actor 模型或者通道模型 (channel model) 的提出枪孩,并發(fā)編程最大的困難憔晒,以及這些工具所要解決的問(wèn)題大致上只有兩個(gè):
- 如何確保不同運(yùn)算運(yùn)行步驟之間的交互或通信可以按照正確的順序執(zhí)行
- 如何確保運(yùn)算資源在不同運(yùn)算之間被安全地共享、訪問(wèn)和傳遞
第一個(gè)問(wèn)題負(fù)責(zé)并發(fā)的邏輯正確蔑舞,第二個(gè)問(wèn)題負(fù)責(zé)并發(fā)的內(nèi)存安全拒担。在以前,開發(fā)者在使用 GCD 編寫并發(fā)代碼時(shí)往往需要很多經(jīng)驗(yàn)攻询,否則難以正確處理上述問(wèn)題从撼。Swift 5.5 設(shè)計(jì)了異步函數(shù)的書寫方法,在此基礎(chǔ)上钧栖,利用結(jié)構(gòu)化并發(fā)確保運(yùn)算步驟的交互和通信正確低零,利用 actor 模型確保共享的計(jì)算資源能在隔離的情況下被正確訪問(wèn)和操作。它們組合在一起拯杠,提供了一系列工具讓開發(fā)者能簡(jiǎn)單地編寫出穩(wěn)定高效的并發(fā)代碼掏婶。我們接下來(lái),會(huì)淺顯地對(duì)這幾部分內(nèi)容進(jìn)行瞥視潭陪,并在后面對(duì)各個(gè)話題展開探究雄妥。
戴克斯特拉還發(fā)表了著名的《GOTO 語(yǔ)句有害論》(Go To Statement Considered Harmful),并和霍爾爵士一同推動(dòng)了結(jié)構(gòu)化編程的發(fā)展畔咧【グ牛霍爾爵士在稍后也提出了對(duì) null 的反對(duì),最終促成了現(xiàn)代語(yǔ)言中普遍采用的
Optional
(或者叫別的名稱誓沸,比如Maybe
或 null safety 等) 設(shè)計(jì)梅桩。如果沒有他們,也許我們今天在編寫代碼時(shí)還在處理無(wú)盡的 goto 和 null 檢查拜隧,會(huì)要辛苦很多宿百。
異步函數(shù)
為了更容易和優(yōu)雅地解決上面兩個(gè)問(wèn)題趁仙,Swift 需要在語(yǔ)言層面引入新的工具:第一步就是添加異步函數(shù)的概念。在函數(shù)聲明的返回箭頭前面垦页,加上 async
關(guān)鍵字雀费,就可以把一個(gè)函數(shù)聲明為異步函數(shù):
func loadSignature() async throws -> String {
fatalError("暫未實(shí)現(xiàn)")
}
異步函數(shù)的 async 關(guān)鍵字會(huì)幫助編譯器確保兩件事情:
它允許我們?cè)诤瘮?shù)體內(nèi)部使用 await 關(guān)鍵字;
它要求其他人在調(diào)用這個(gè)函數(shù)時(shí)痊焊,使用 await 關(guān)鍵字盏袄。
這和與它處于類似位置的 throws 關(guān)鍵字有點(diǎn)相似。在使用 throws 時(shí)薄啥,它允許我們?cè)诤瘮?shù)內(nèi)部使用 throw 拋出錯(cuò)誤辕羽,并要求調(diào)用者使用 try 來(lái)處理可能的拋出。async 也扮演了這樣一個(gè)角色垄惧,它要求在特定情況下對(duì)當(dāng)前函數(shù)進(jìn)行標(biāo)記刁愿,這是對(duì)于開發(fā)者的一種明確的提示,表明這個(gè)函數(shù)有一些特別的性質(zhì):try/throw 代表了函數(shù)可以被拋出到逊,而 await 則代表了函數(shù)在此處可能會(huì)放棄當(dāng)前線程铣口,它是程序的潛在暫停點(diǎn)。
放棄線程的能力觉壶,意味著異步方法可以被“暫湍蕴猓”,這個(gè)線程可以被用來(lái)執(zhí)行其他代碼掰曾。如果這個(gè)線程是主線程的話旭蠕,那么界面將不會(huì)卡頓。被 await 的語(yǔ)句將被底層機(jī)制分配到其他合適的線程旷坦,在執(zhí)行完成后,之前的“暫陀映恚”將結(jié)束秒梅,異步方法從剛才的 await 語(yǔ)句后開始,繼續(xù)向下執(zhí)行舌胶。
關(guān)于異步函數(shù)的設(shè)計(jì)和更多深入內(nèi)容捆蜀,我們會(huì)在隨后的相關(guān)章節(jié)展開。在這里幔嫂,我們先來(lái)看看一個(gè)簡(jiǎn)單的異步函數(shù)的使用辆它。Foundation 框架中已經(jīng)為我們提供了很多異步函數(shù),比如使用 URLSession 從某個(gè) URL 加載數(shù)據(jù)履恩,現(xiàn)在也有異步版本了锰茉。在由 async 標(biāo)記的異步函數(shù)中,我們可以調(diào)用其他異步函數(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ù)飒筑,有一部分是重寫和新添加的片吊,但更多的情況是由相應(yīng)的 Objective-C 接口轉(zhuǎn)換而來(lái)。滿足一定條件的 Objective-C 函數(shù)协屡,可以直接轉(zhuǎn)換為 Swift 的異步函數(shù)俏脊,非常方便。在后一章我們也會(huì)具體談到肤晓。
如果我們把 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.")
}
}
就可以非常簡(jiǎn)單地寫成這樣的形式:
let strings = try await loadFromDatabase()
if let signature = try await loadSignature() {
strings.forEach {
addAppending(signature, to: $0)
}
} else {
throw NoSignatureError()
}
不用多說(shuō)沸久,單從代碼行數(shù)就可以一眼看清優(yōu)劣了。異步函數(shù)極大簡(jiǎn)化了異步操作的寫法余蟹,它避免了內(nèi)嵌的回調(diào)卷胯,將異步操作按照順序?qū)懗闪祟愃啤巴綀?zhí)行”的方法。另外威酒,這種寫法允許我們使用 try/throw 的組合對(duì)錯(cuò)誤進(jìn)行處理窑睁,編譯器會(huì)對(duì)所有的返回路徑給出保證,而不必像回調(diào)那樣時(shí)刻檢查是不是所有的路徑都進(jìn)行了處理葵孤。
結(jié)構(gòu)化并發(fā)
對(duì)于同步函數(shù)來(lái)說(shuō)担钮,線程決定了它的執(zhí)行環(huán)境。而對(duì)于異步函數(shù)尤仍,則由任務(wù) (Task) 決定執(zhí)行環(huán)境箫津。Swift 提供了一系列 Task
相關(guān) API 來(lái)讓開發(fā)者創(chuàng)建、組織宰啦、檢查和取消任務(wù)苏遥。這些 API 圍繞著 Task
這一核心類型,為每一組并發(fā)任務(wù)構(gòu)建出一棵結(jié)構(gòu)化的任務(wù)樹:
- 一個(gè)任務(wù)具有它自己的優(yōu)先級(jí)和取消標(biāo)識(shí)赡模,它可以擁有若干個(gè)子任務(wù)并在其中執(zhí)行異步函數(shù)田炭。
- 當(dāng)一個(gè)父任務(wù)被取消時(shí),這個(gè)父任務(wù)的取消標(biāo)識(shí)將被設(shè)置漓柑,并向下傳遞到所有的子任務(wù)中去教硫。
- 無(wú)論是正常完成還是拋出錯(cuò)誤,子任務(wù)會(huì)將結(jié)果向上報(bào)告給父任務(wù)辆布,在所有子任務(wù)完成之前 (不論是正常結(jié)束還是拋出)瞬矩,父任務(wù)是不會(huì)完成的。
這些特性看上去和 Operation
類 有一些相似锋玲,不過(guò) Task
直接利用異步函數(shù)的語(yǔ)法景用,可以用更簡(jiǎn)潔的方式進(jìn)行表達(dá)。而 Operation
則需要依靠子類或者閉包嫩絮。
在調(diào)用異步函數(shù)時(shí)丛肢,需要在它前面添加 await
關(guān)鍵字围肥;而另一方面,只有在異步函數(shù)中蜂怎,我們才能使用 await
關(guān)鍵字穆刻。那么問(wèn)題在于,第一個(gè)異步函數(shù)執(zhí)行的上下文杠步,或者說(shuō)任務(wù)樹的根節(jié)點(diǎn)氢伟,是怎么來(lái)的?
簡(jiǎn)單地使用 Task.init
就可以讓我們獲取一個(gè)任務(wù)執(zhí)行的上下文環(huán)境幽歼,它接受一個(gè) async
標(biāo)記的閉包:
struct Task<Success, Failure> where Failure : Error {
init(
priority: TaskPriority? = nil,
priority: TaskPriority? = nil,
priority: TaskPriority? = nil,
operation: @escaping @Sendable () async throws -> Success
)
}
它繼承當(dāng)前任務(wù)上下文的優(yōu)先級(jí)等特性朵锣,創(chuàng)建一個(gè)新的任務(wù)樹根節(jié)點(diǎn),我們可以在其中使用異步函數(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 中的處理依然是串行的:對(duì) loadFromDatabase 的 await 將使這個(gè)異步函數(shù)在此暫停诚些,直到實(shí)際操作結(jié)束,接下來(lái)才會(huì)執(zhí)行 loadSignature:
我們當(dāng)然會(huì)希望這兩個(gè)操作可以同時(shí)進(jìn)行皇型。在兩者都準(zhǔn)備好后诬烹,再調(diào)用 appending 來(lái)實(shí)際將簽名附加到數(shù)據(jù)上。這需要任務(wù)以結(jié)構(gòu)化的方式進(jìn)行組織弃鸦。使用 async let 綁定可以做到這一點(diǎn):
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 被稱為異步綁定绞吁,它在當(dāng)前 Task 上下文中創(chuàng)建新的子任務(wù),并將它用作被綁定的異步函數(shù) (也就是 async let 右側(cè)的表達(dá)式) 的運(yùn)行環(huán)境唬格。和 Task.init 新建一個(gè)任務(wù)根節(jié)點(diǎn)不同家破,async let 所創(chuàng)建的子任務(wù)是任務(wù)樹上的葉子節(jié)點(diǎn)。被異步綁定的操作會(huì)立即開始執(zhí)行购岗,即使在 await 之前執(zhí)行就已經(jīng)完成汰聋,其結(jié)果依然可以等到 await 語(yǔ)句時(shí)再進(jìn)行求值。在上面的例子中藕畔,loadFromDatabase 和 loadSignature 將被并發(fā)執(zhí)行马僻。
相對(duì)于 GCD 調(diào)度的并發(fā),基于任務(wù)的結(jié)構(gòu)化并發(fā)在控制并發(fā)行為上具有得天獨(dú)厚的優(yōu)勢(shì)注服。為了展示這一優(yōu)勢(shì),我們可以嘗試把事情再弄復(fù)雜一點(diǎn)措近。上面的 processFromScratch 完成了從本地加載數(shù)據(jù)溶弟,從網(wǎng)絡(luò)獲取簽名,最后再將簽名附加到每一條數(shù)據(jù)上這一系列操作北滥。假設(shè)我們以前可能就做過(guò)類似的事情荧缘,并且在服務(wù)器上已經(jīng)存儲(chǔ)了所有結(jié)果灭美,于是我們有機(jī)會(huì)在進(jìn)行本地運(yùn)算的同時(shí)剩胁,去嘗試直接加載這些結(jié)果作為“優(yōu)化路徑”擒权,避免重復(fù)的本地計(jì)算袱巨。類似地,可以用一個(gè)異步函數(shù)來(lái)表示“從網(wǎng)絡(luò)直接加載結(jié)果”的操作:
func loadResultRemotely() async throws {
// 模擬網(wǎng)絡(luò)加載的耗時(shí)
await Task.sleep(2 * NSEC_PER_SEC)
results = ["data1^sig", "data2^sig", "data3^sig"]
}
除了 async let 外碳抄,另一種創(chuàng)建結(jié)構(gòu)化并發(fā)的方式愉老,是使用任務(wù)組 (Task group)。比如剖效,我們希望在執(zhí)行 loadResultRemotely 的同時(shí)嫉入,讓 processFromScratch 一起運(yùn)行,可以用 withThrowingTaskGroup 將兩個(gè)操作寫在同一個(gè) 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)")
}
}
對(duì)于 processFromScratch璧尸,我們?yōu)樗貏e指定了 .low 的優(yōu)先級(jí)咒林,這會(huì)導(dǎo)致該任務(wù)在另一個(gè)低優(yōu)先級(jí)線程中被調(diào)度。我們一會(huì)兒會(huì)看到這一點(diǎn)帶來(lái)的影響爷光。
withThrowingTaskGroup 和它的非拋出版本 withTaskGroup 提供了另一種創(chuàng)建結(jié)構(gòu)化并發(fā)的組織方式垫竞。當(dāng)在運(yùn)行時(shí)才知道任務(wù)數(shù)量時(shí),或是我們需要為不同的子任務(wù)設(shè)置不同優(yōu)先級(jí)時(shí)蛀序,我們將只能選擇使用 Task Group欢瞪。在其他大部分情況下,async let 和 task group 可以混用甚至互相替代:
閉包中的 group 滿足 AsyncSequence 協(xié)議哼拔,它讓我們可以使用 for await 的方式用類似同步循環(huán)的寫法來(lái)訪問(wèn)異步操作的結(jié)果引有。另外,通過(guò)調(diào)用 group 的 cancelAll倦逐,我們可以在適當(dāng)?shù)那闆r下將任務(wù)標(biāo)記為取消譬正。比如在 loadResultRemotely 很快返回時(shí),我們可以取消掉正在進(jìn)行的 processFromScratch檬姥,以節(jié)省計(jì)算資源曾我。關(guān)于異步序列和任務(wù)取消這些話題,我們會(huì)在稍后專門的章節(jié)中繼續(xù)探討健民。
actor 模型和數(shù)據(jù)隔離
在 processFromScratch 里抒巢,我們先將 results 設(shè)置為 [],然后再處理每條數(shù)據(jù)秉犹,并將結(jié)果添加到 results 里:
func processFromScratch() async throws {
// ...
results = []
strings.forEach {
addAppending(signature, to: $0)
}
// ...
}
在作為示例的 loadResultRemotely 里蛉谜,我們現(xiàn)在則是直接把結(jié)果賦值給了 results:
func loadResultRemotely() async throws {
await Task.sleep(2 * NSEC_PER_SEC)
results = ["data1^sig", "data2^sig", "data3^sig"]
}
因此,一般來(lái)說(shuō)我們會(huì)認(rèn)為崇堵,不論 processFromScratch 和 loadResultRemotely 執(zhí)行的先后順序如何型诚,我們總是應(yīng)該得到唯一確定的 results,也就是數(shù)據(jù) ["data1^sig", "data2^sig", "data3^sig"]鸳劳。但事實(shí)上狰贯,如果我們對(duì) loadResultRemotely 的 Task.sleep 時(shí)長(zhǎng)進(jìn)行一些調(diào)整,讓它和 processFromScratch 所耗費(fèi)的時(shí)間相仿,就可能會(huì)看到出乎意料的結(jié)果涵紊。在正確輸出三個(gè)元素的情況外傍妒,有時(shí)候它會(huì)輸出六個(gè)元素:
// 有機(jī)率輸出:
Done: ["data1^sig", "data2^sig", "data3^sig",
"data1^sig", "data2^sig", "data3^sig"]
我們?cè)?addTask 時(shí)為兩個(gè)任務(wù)指定了不同的優(yōu)先級(jí),因此它們中的代碼將運(yùn)行在不同的調(diào)度線程上摸柄。兩個(gè)異步操作在不同線程同時(shí)訪問(wèn)了 results颤练,造成了數(shù)據(jù)競(jìng)爭(zhēng)。在上面這個(gè)結(jié)果中塘幅,我們可以將它解釋為 processFromScratch 先將 results 設(shè)為了空數(shù)列昔案,緊接著 loadResultRemotely 完成,將它設(shè)為正確的結(jié)果电媳,然后 processFromScratch 中的 forEach 把計(jì)算得出的三個(gè)簽名再添加進(jìn)去踏揣。
這大概率并不是我們想要的結(jié)果。不過(guò)幸運(yùn)的是兩個(gè)操作現(xiàn)在并沒有真正“同時(shí)”地去更改 results 的內(nèi)存匾乓,它們依然有先后順序捞稿,因此只是最后的數(shù)據(jù)有些奇怪。
processFromScratch 和 loadResultRemotely 在不同的任務(wù)環(huán)境中對(duì)變量 results 進(jìn)行了操作拼缝。由于這兩個(gè)操作是并發(fā)執(zhí)行的娱局,所以也可能出現(xiàn)一種更糟糕的情況:它們對(duì) results 的操作同時(shí)發(fā)生。如果 results 的底層存儲(chǔ)被多個(gè)操作同時(shí)更改的話咧七,我們會(huì)得到一個(gè)運(yùn)行時(shí)錯(cuò)誤衰齐。作為示例 (雖然沒有太多實(shí)際意義),通過(guò)增加 someSyncMethod 的運(yùn)行次數(shù)就可以很容易地讓程序崩潰:
for _ in 0 ..< 10000 {
someSyncMethod()
}
// 運(yùn)行時(shí)崩潰继阻。一個(gè)典型的內(nèi)存錯(cuò)誤
// Thread 10: EXC_BAD_ACCESS (code=1, address=0x55a8fdbc060c)
為了確保資源 (在這個(gè)例子里耻涛,是 results 指向的內(nèi)存) 在不同運(yùn)算之間被安全地共享和訪問(wèn),以前通常的做法是將相關(guān)的代碼放入一個(gè)串行的 dispatch queue 中瘟檩,然后以同步的方式把對(duì)資源的訪問(wèn)派發(fā)到隊(duì)列中去執(zhí)行抹缕,這樣我們可以避免多個(gè)線程同時(shí)對(duì)資源進(jìn)行訪問(wèn)。按照這個(gè)思路可以進(jìn)行一些重構(gòu)墨辛,將 results 放到新的 Holder 類型中卓研,并使用私有的 DispatchQueue 將它保護(hù)起來(lái):
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) }
}
}
接下來(lái),將原來(lái)代碼中使用到 results: [String] 的地方替換為 Holder睹簇,并使用暴露出的方法將原來(lái)對(duì) results 的直接操作進(jìn)行替換奏赘,可以解決運(yùn)行時(shí)崩潰的問(wèn)題。
// 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 進(jìn)行并發(fā)操作時(shí)太惠,這種模式非常常見志珍。但是它存在一些難以忽視的問(wèn)題:
大量且易錯(cuò)的模板代碼:凡是涉及 results 的操作,都需要使用 queue.sync 包圍起來(lái)垛叨,但是編譯器并沒有給我們?nèi)魏伪WC。在某些時(shí)候忘了使用隊(duì)列,編譯器也不會(huì)進(jìn)行任何提示嗽元,這種情況下內(nèi)存依然存在危險(xiǎn)敛纲。當(dāng)有更多資源需要保護(hù)時(shí),代碼復(fù)雜度也將爆炸式上升剂癌。
小心死鎖:在一個(gè) queue.sync 中調(diào)用另一個(gè) queue.sync 的方法淤翔,會(huì)造成線程死鎖。在代碼簡(jiǎn)單的時(shí)候佩谷,這很容易避免旁壮,但是隨著復(fù)雜度增加,想要理解當(dāng)前代碼運(yùn)行是由哪一個(gè)隊(duì)列派發(fā)的谐檀,它又運(yùn)行在哪一個(gè)線程上抡谐,往往會(huì)伴隨著嚴(yán)重的困難。必須精心設(shè)計(jì)桐猬,避免重復(fù)派發(fā)麦撵。
在一定程度上,我們可以用 async 替代 sync 派發(fā)來(lái)緩解死鎖的問(wèn)題溃肪;或者放棄隊(duì)列免胃,轉(zhuǎn)而使用鎖 (比如 NSLock 或者 NSRecursiveLock)。不過(guò)不論如何做惫撰,都需要開發(fā)者對(duì)線程調(diào)度和這種基于共享內(nèi)存的數(shù)據(jù)模型有深刻理解羔沙,否則非常容易寫出很多坑。
Swift 并發(fā)引入了一種在業(yè)界已經(jīng)被多次證明有效的新的數(shù)據(jù)共享模型厨钻,actor 模型 (參與者模型)扼雏,來(lái)解決這些問(wèn)題。雖然有些偏失莉撇,但最簡(jiǎn)單的理解呢蛤,可以認(rèn)為 actor 就是一個(gè)“封裝了私有隊(duì)列”的 class。將上面 Holder 中 class 改為 actor棍郎,并把 queue 的相關(guān)部分去掉其障,我們就可以得到一個(gè) actor 類型。這個(gè)類型的特性和 class 很相似涂佃,它擁有引用語(yǔ)義励翼,在它上面定義屬性和方法的方式和普通的 class 沒有什么不同:
actor Holder {
var results: [String] = []
func setResults(_ results: [String]) {
self.results = results
}
func append(_ value: String) {
results.append(value)
}
}
對(duì)比由私有隊(duì)列保護(hù)的“手動(dòng)擋”的 class,這個(gè)“自動(dòng)檔”的 actor 實(shí)現(xiàn)顯然簡(jiǎn)潔得多辜荠。actor 內(nèi)部會(huì)提供一個(gè)隔離域:在 actor 內(nèi)部對(duì)自身存儲(chǔ)屬性或其他方法的訪問(wèn)汽抚,比如在 append(_:) 函數(shù)中使用 results 時(shí),可以不加任何限制伯病,這些代碼都會(huì)被自動(dòng)隔離在被封裝的“私有隊(duì)列”里造烁。但是從外部對(duì) actor 的成員進(jìn)行訪問(wèn)時(shí),編譯器會(huì)要求切換到 actor 的隔離域,以確保數(shù)據(jù)安全惭蟋。在這個(gè)要求發(fā)生時(shí)苗桂,當(dāng)前執(zhí)行的程序可能會(huì)發(fā)生暫停。編譯器將自動(dòng)把要跨隔離域的函數(shù)轉(zhuǎn)換為異步函數(shù)告组,并要求我們使用 await 來(lái)進(jìn)行調(diào)用煤伟。
雖然實(shí)際底層實(shí)現(xiàn)中,actor 并非持有一個(gè)私有隊(duì)列木缝,但是現(xiàn)在便锨,你可以就這樣簡(jiǎn)單理解。在本書后面的部分我們會(huì)做更深入的探索我碟。
當(dāng)我們把 Holder 從 class 轉(zhuǎn)換為 actor 后放案,原來(lái)對(duì) holder 的調(diào)用也需要更新。簡(jiǎn)單來(lái)說(shuō)怎囚,在訪問(wèn)相關(guān)成員時(shí)卿叽,添加 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)境中訪問(wèn) holder 不再會(huì)造成崩潰了恳守。不過(guò)考婴,即時(shí)使用 Holder,不論是基于 DispatchQueue 還是 actor催烘,上面代碼所得到的結(jié)果中依然可能會(huì)存在多于三個(gè)元素的情況沥阱。這是在預(yù)期內(nèi)的:數(shù)據(jù)隔離只解決同時(shí)訪問(wèn)的造成的內(nèi)存問(wèn)題 (在 Swift 中,這種不安全行為大多數(shù)情況下表現(xiàn)為程序崩潰)伊群。而這里的數(shù)據(jù)正確性關(guān)系到 actor 的可重入 (reentrancy)考杉。要正確理解可重入,我們必須先對(duì)異步函數(shù)的特性有更多了解舰始,因此我們會(huì)在之后的章節(jié)里再談到這個(gè)話題崇棠。
另外,actor 類型現(xiàn)在還并沒有提供指定具體運(yùn)行方式的手段丸卷。雖然我們可以使用 @MainActor 來(lái)確保 UI 線程的隔離枕稀,但是對(duì)于一般的 actor,我們還無(wú)法指定隔離代碼應(yīng)該以怎樣的方式運(yùn)行在哪一個(gè)線程谜嫉。我們之后也還會(huì)看到包括全局 actor萎坷、非隔離標(biāo)記 (nonisolated) 和 actor 的數(shù)據(jù)模型等內(nèi)容。
小結(jié)
我想本章應(yīng)該已經(jīng)有足夠多的內(nèi)容了沐兰。我們從最基本的概念開始哆档,展示了使用 GCD 或者其他一些“原始”手段來(lái)處理并發(fā)程序時(shí)可能面臨的困難,并在此基礎(chǔ)上介紹了 Swift 并發(fā)中處理和解決這些問(wèn)題的方式住闯。
Swift 并發(fā)雖然涉及的概念很多瓜浸,但是各種的模塊邊界是清晰的:
異步函數(shù):提供語(yǔ)法工具澳淑,使用更簡(jiǎn)潔和高效的方式,表達(dá)異步行為斟叼。
結(jié)構(gòu)化并發(fā):提供并發(fā)的運(yùn)行環(huán)境偶惠,負(fù)責(zé)正確的函數(shù)調(diào)度、取消和執(zhí)行順序以及任務(wù)的生命周期朗涩。
actor 模型:提供封裝良好的數(shù)據(jù)隔離,確保并發(fā)代碼的安全绑改。
熟悉這些邊界谢床,有助于我們清晰地理解 Swift 并發(fā)各個(gè)部分的設(shè)計(jì)意圖,從而讓我們手中的工具可以被運(yùn)用在正確的地方厘线。作為概覽识腿,在本章中讀者應(yīng)該已經(jīng)看到如何使用 Swift 并發(fā)的工具書寫并發(fā)代碼了。本書接下來(lái)的部分造壮,將會(huì)對(duì)每個(gè)模塊做更加深入的探討渡讼,以求將更多隱藏在宏觀概念下的細(xì)節(jié)暴露出來(lái)。
點(diǎn)點(diǎn)關(guān)注耳璧,點(diǎn)點(diǎn)贊成箫。iOS更多資料,請(qǐng)關(guān)注主頁(yè)旨枯,加入圈子獲取蹬昌。