非同步任務的處理一直都是 iOS/macOS 開發中非常重要的部分,同時也是讓所有開發者都非常頭痛的部分。非同步任務這麼難以處理的原因,一方面是因為非同步的程式碼通常會牽涉到資料同時讀寫、thread 之間互相等待等等的問題,另外一方面也是因為非同步的程式碼常常會一層包一層,或者邏輯被分散在不同的地方,導致程式碼的可讀性非常低,要抽絲剝繭後才有辦法看到真正的邏輯。
幸好,Swift 5.5 為我們帶來了更好的解決辦法!Swift 5.5 導入了新的非同步任務機制,包括了 async/await、Actor、Task Group 等等好用的工具,還有針對既有的 SwiftUI、Core Data、跟許多 Foundation 下的 API 所做的 async/await 封裝。有了這些新的語法跟機制,我們就可以用非常直觀的方式來撰寫非同步的程式,提升程式碼的可讀性,同時也大幅降低出錯的機率。
async/await 在許多語言都有實作,相信大家應該都不會覺得陌生。相較之下,Actor model 雖然是一個歷史悠久的 model,不過在 iOS 開發上相對比較少見,所以大家對於它的用途跟使用方式可能會有點不熟悉。今天,我們就要來簡單介紹一下 Swift Concurrency 底下的 Actor model 到底是怎樣運行的、有甚麼情境適合使用 Actor model、以及在使用上需要注意的問題。
這篇文章預設大家都已經了解 async/await、structured concurrency 等等新的 Swift Concurrency 機制,如果你還不是很熟,可以點選上面的 WWDC 連結複習一下。
文章的內容大概會分成以下幾個部分:
- Data race 與 GCD 帶來的問題
- 簡單介紹 Swift Actor
- Actor 的隔離機制
- 如何確保在 Actor 間安全地傳遞訊息
- Actor 特殊的 reentrancy 機制
多任務同步處理的問題
首先,我們先用一個範例來說明一下,在同時執行多個任務的狀況下可能會出現甚麼問題。在這個範例裡,我們準備了一個儲存手邊的錢的物件,叫 BalanceStore
,它裡面存了我們目前的餘額 balance
,也提供了 increment
可以增加餘額:
class BalanceStore { var balance: Int = 0 func increment(_ amount: Int) { balance += amount } }
假如我們每次增加餘額前,都會先發一份 API 到 server 確認是不是能夠增加餘額,如果使用者同時在兩個不同的地方增加餘額的話,就會長得像這樣:
let store = BalanceStore2() // action 1 API.checkPermission(completion: { allowed in if allowed { store.increment(1000) } }) // action 2 API.checkPermission(completion: { allowed in if allowed { store.increment(1000) } })
因為 API 回傳的時間點不一定,如果幾乎同時間回傳的話,increment
這個 method 就有可能會被同時呼叫,balance 這個變數就會被同時存取造成 data race。
我們要怎樣來改善這個狀況呢?一個簡單的做法是讓 completion
這個 closure 在同一個 queue 底下執行,如此一來,雖然我們的 API request 還是一樣會被分配到不一樣的 queue 去跑,但是最後回來的結果會依序被處理。如果我們能確保寫入跟讀取 balance
這個變數是依序發生,而不是同時發生的話,就可以避免 data race 的問題。
實作上我們有許多不同的方式可以確保資料的存取是依序的,像是用 GCD queue、 lock、或者用 Swift Atomics。這邊我們先簡單地用 GCD queue 來做示範。現在,我們來把程式碼稍微修改一下:
// 建立一個統一的排隊機制 let queue: DispatchQueue = DispatchQueue(label: "queue") API.checkPermission(completion: { allowed in if allowed { // 加入排隊 queue.async { store.increment(1000) } } }) API.checkPermission(completion: { allowed in if allowed { // 加入排隊 queue.async { store.increment(1000) } } })
這樣我們就可以確保 increment
是依序被呼叫的,也就可以解決剛剛發現的問題。不過使用 GCD 的同時也可能會帶來一些新的問題:
- Thread explosion
首先,DispatchQueue.async
在當下的 thread 已經有其他任務的狀況下,會把任務分配到別的 thread 去執行,或者產生一個新的 thread 來處理這個任務。雖然 thread 已經是輕量化的執行單位,不過一旦需要執行的任務很多,切換或產生 thread 的成本還是會變得很高,而且因為電腦的核心數是固定的,產生大於核心數的 thread 對同步執行任務來說是沒有幫助的,只會讓電腦在眾多的 thread 中疲於奔命。這也就是所謂的 thread explosion,過多的 thread 讓 CPU 負擔過多的 thread 切換成本,並且會增加 thread 跟 thread 之間 deadlock 的機會。
我們有許多方法可以避免 thread explosion。今天要介紹的 Swift Actor (以及其它 Swift Concurrency 底下的機制)並不是以 thread 來作為任務的執行單位,所以它是不會有 thread explosion 的問題的。
- Priority inversion
上面的任務很單純,每一個推進 queue 的任務的優先權都是一樣的,而且它們的共享資源balance
暫時也只有BalanceStore
這個 class 會使用,在這種狀況下,這個 queue 就會正常地與系統其它的 queue 競爭 CPU 資源 。但是,假如我們在同一段程式碼中引入了另外一個高優先權的 queue B,那麼 queue B 的任務就會優先於我們原本計算餘額的任務,被系統提早拿去執行。這個狀態可以從底下這張圖看得出來:
不過,如果今天我們在計算餘額時,因為需要獨占某些共享資源,而選擇使用 DispatchQueue.sync
來排程任務,讓同一個 thread 裡的其它 queue 沒有辦法取得 thread 的執行權,這樣做雖然可以保護共享資源只讓目前這個 queue 使用,但是也會讓高優先權的 queue B 反而被排在這些低優先權的任務之後(因為要等 queue A 釋放資源),這也就是所謂的 priority inversion。會發生這種狀況的原因,是因為雖然目前這個 queue 是比較低優先權的,但是因為 sync
會讓目前執行中的 thread 被 block 住,所以系統會選擇把這個呼叫 sync 的 queue 拉到最前面來執行,好讓其它排程可以在這之後順利執行。讓我們用圖片來說明:
儘管在 Swift Concurrency 底下不同的任務也有可能會呼叫 await,讓當下的任務暫停,但是因為它不是用 thread 當做執行單位,當前的 thread 是不會被停下來,反而是把目前的任務移到別的 context 去做處理,目前的 thread 再從排程裡面去拿下一個任務來做。這樣的機制可以避免 priority inversion 的問題,同時降低了程式執行結果不如預期的機率。
基本上,Swift Concurrency 的機制都能夠解決上面這些問題,而其中 Swift Actor 不但沒有以上的問題,另外還提供了非常直觀的方式,來處理共享資源的分配,是一個非常全面的解決方案。
話不多說,我們就來研究一下怎麼使用 Swift Actor 吧!👨🎓👩🎓
簡介 Actor
在大部分的時候,一個 actor 其實跟一個 class 一樣,它是一個 reference type,可以擁有 stored property 跟 method;不過跟 class 不太一樣的地方是,一個 actor 不能繼承另外一個 actor。另外一個最重要的差異,就是 actor 裡面的資源 (stored property, methods) 都是被保護的,在多 thread 的環境底下,actor 所持有的資源不會因為 thread 的存取先後順序而產生不一樣的結果,也就是說在 actor 底下基本上是不會發生 data race 的。
這個特點讓開發者可以安心地使用 actor 來實作需要非同步的功能,但是又不需要像上面範例那樣要自己實作排隊的機制。
我們來修改一下最一開始的 BalanceStore
,把它改成 actor:
actor BalanceStore { var balance: Int = 0 func increment(_ amount: Int) { balance += amount } }
好,我們改好了。等等,所以只有把 “class” 改成 “actor” 嗎?沒錯,就是這麼簡單(難的在後面)。
balance
這個變數就是一種屬於 actor 的資源,在 actor 底下你可以隨意地對 balance
做讀取跟寫入,而不用擔心 data race 的問題。讓我們回想一下我們最一開始的範例,在那個範例裡,我們用了一個 queue 來確保對 balance
的存取是同步的,而在 actor 的環境底下,它就像是背後有一個人在做 queue 的工作(這個角色叫 serial executor),負責確保來存取資源的人都能夠依序執行。
要注意的是,實作上 actor 並不是以 GCD 的 queue 來實作的,這也是 actor 能夠保證不會發生 thread explosion 的原因:它不需要強制把排隊等待的人放進一個 thread 裡。
Actor 與外界的互動 – Actor Isolation
接著,我們來看看外面的世界要怎樣跟 actor 互動。首先,我們先產生一個 BalanceStore
物件,這個物件是用來記錄餘額的。因為現在餘額是 0 元,所以我們先試著呼叫 increment(100)
加 100 元進去。
let store = BalanceStore() store.increment(100)
在 Actor model 的概念上,對 actor 來說,呼叫 increment
這個動作被稱為送一個 “message” 進去 actor,所以你不是直接呼叫 increment
這個 method,而是送一個 “message” 告訴 actor “你需要做 increment
這個動作”。actor 收到這樣的訊息就會開始做事,而送訊息進去的人就要在外面等它完成。
現在我們發現,寫完這一行程式碼之後 compiler 就很豪邁地噴出了一個 error:
Actor-isolated instance method ‘increment’ can not be referenced from a non-isolated context
這個是因為基本上,外面世界是沒有辦法對 actor 做直接的存取,actor 裡面的資源 (properties, methods) 只能被自己人使用,也就是這些資源是被隔離 (isolated) 的。被隔離的資源並不是完全不能使用,下面我們會介紹如何接觸到這些被隔離的資源。預設被 actor 保護的資源有以下這些:
- Stored property
- Instance property
- Instance Method
- Instance subscripts
如果我們想要從外面的世界去跟 actor 做溝通 (cross-actor reference),我們就需要在呼叫的時候確保這個溝通是安全的。
回到剛剛的例子,如果我們希望在 actor 以外安全地呼叫 increment
,我們可以利用 Task
的 closure,並且加上 await:
let store = BalanceStore() Task { await store.increment(100) }
這邊 Task
會產生一個獨立的環境,也就是一個新的 context (或 concurrency domain),被包在 closure 裡面的程式碼有它自己的排程,你可以在這個 closure 裡面運用 async/await,而不用擔心佔用目前的 thread。更多關於 Task
的運用可以參考:Structured concurrency。
因為 actor 有自己的排程,所以存取 actor 的資源需要遵守 actor 的排程,也就是說,這個存取有可能需要等待其它 actor 處理完正在處理的工作之後才會被執行。這個機制可以用以下的圖來理解:
在 actor 裡面的任務 (A, B, C, …),只要是需要存取被保護的資源,都會在存取前先排隊,以確保目前只有一個人正在存取該資源。這個部分 compiler 已經幫你完成了,所以你不需要寫額外的程式碼做處理。當外面的任務 (X) 也想要存取資源時,內部資源可能同時間有其它 actor 內部的人在做存取,所以 X 需要等到適合的時機點,才能夠執行存取的任務。也就是因為這樣,從外面呼叫 actor 的 method 時,我們都需要加上 await
,來標示這個執行是有可能需要等待的。
有了這樣的機制之後,我們明確地標注了 increment
是可能需要等待的,compiler 也就能夠把個任務做排程,就能夠確保呼叫 increment
這個 method 是安全的,所以 actor
就會讓你順利呼叫這個 method。
當然,不是所有的資源都需要一個協調者來調度處理,如果這個資源本身是不能被修改的 (immutable),那當然也不會有上面描述的非同步問題,所以你可以直接讀取這個資源。比方說假設我們有個 property 叫 accountName
:
actor BalanceStore { let accountName: String } let store = BalanceStore() print(store.accountName) // Okay!
這個是可以直接在外界讀取的,因為它是一個 let
variable,也是一個 value type,所以從它被創建開始,值都是保證不會變的,也就不會有 data race 的問題。延伸上面的例子,我們知道如果在同一個環境下,某個值是不會變化的,這個值就可以安全地被 actor 裡面或外面的人做讀取。
不過這個範例只適用在同一個 module 底下,如果是跨 module 的讀取的話,一樣是需要建立一個獨立的 concurrency domain,並且加上 await 來讀取。
我們來看看其它更多的例子:
actor BalanceStore { let accountName: String // non-isolated var accountNickName: String // isolated let stylizedName: NSAttributedString }
非隔離 (non-isolated) 就表示你不需要準備 concurrency domain(Task {}
或 Task.detached {}
),跟 await
也能夠隨意地讀取。隔離 (isolated) 指的就是這個東西是受到保護的,需要標注 await 才能夠安全地讀取。
這邊有一個特別的例子,第三個 stylizedName
雖然是一個 let
variable,但是它也是一個 reference type,所以我們可以自由讀取這個 variable,同時也可以針對這個 variable 裡面的值做變化,像是:
// Outside of the actor store.stylizedName.setAttributes( [.foregroundColor: UIColor.white], range: ...)
因為外面的程式有可能會同時執行多個這樣的動作,所以對 actor 來說是危險的,應該要被隔離的。不過在 Swift 5.5 時,compiler 還沒有針對 reference type 做進一步的隔離,目前 Swift 只針對 value type 做隔離的檢查,最好的方法就是盡量在 actor 裡面使用 value type,而少用 reference type。📝
參數的安全傳遞 – Sendable
有了隔離的基本概念之後,我們要來看看從 actor 外面要傳遞資料進 actor,系統會怎樣確保這些訊息的傳遞是安全的。actor 本身有自己的同步小空間,在這個空間中,所有同步或非同步的操作,都可以被視為是依序發生的。但是每個 actor 有著不一樣的空間,所以從 actor A 到 actor B 之間,訊息的傳遞是沒有辦法保證是同步的。
我們可以從下面的圖來理解:
Actor A 有它自己的排程,它先產生了一個 class instance(圖上的 “object”),並且把它丟給一樣有自己的排程的 Actor B,這個時候 B 就擁有了可以直通 “object” 的 reference。也就是說,B 可以不用透過原本送訊息的方式去存取 A 的資源,而可以直接操作 “object” 這個資源。原本 actor 做好的隔離措施現在因為 reference type 的傳遞,出現了新的破口。
看圖可能有點難理解,我們應該來看點程式碼!延伸剛剛的範例,我們現在有個 BalanceStore
可以存入餘額,但是錢應該要從哪裡來呢?身為一個剛買完 Macbook M1 Pro 的工程師,目前手頭上應該只剩信用卡能夠變出更多的錢來,所以我們引入可以用信用卡加值的系統。我們用一個 class CreditCard
來代表一張信用卡:
class CreditCard { var credit: Int = 500 func pay(_ amount: Int) -> Bool { if amount < credit { credit -= amount return true } return false } }
credit
代表我們的信用額度(真的很少😭),func pay(_ amount: Int) -> Bool
這個 method 是用來確認你的信用額度夠不夠支付 amount
,如果夠的話就扣額度並且回傳 true,否則回傳 false 代表無法扣款。
現在,我們要幫 BalanceStore
加上加值的功能:
extension BalanceStore { func deposit(amount: Int, by creditCard: CreditCard) async { if creditCard.pay(amount) { balance += amount } } }
這個 method 對應到的,就是上面圖上的 “pass reference”,這個 method 把信用卡的 reference 傳進 actor BalanceStore
裡。這樣子做會有甚麼問題?因為 CreditCard
是一個 class type,所以會像上面的圖一樣,這個物件的傳遞都是透過 reference 傳遞,而所有收到 CreditCard
reference 的 actor 都可以直接在他們的環境裡呼叫 func pay(:)
,會造成同一個時間可能有許多人修改 balance
的狀況,進而發生 data races。
用程式碼來表示:
let creditCard = CreditCard() // concurrency domain 1 await store.deposit(100, creditCard) // concurrency domain 2
呼叫端本身處在一個 concurrency domain,而 actor 的 method 則是在不同的 concurrency domain (還記得 actor 有一個自己的環境嗎?)。在兩個不同的 domain 間傳遞資料,我們就必須要注意這個資料是不受保護,可能會同時被許多人修改的。
在 Swift 5.5 裡面,這種在多個 domain 中傳遞並且有風險的東西,被稱作 non-Sendable
。相反地,如果資料可以安全地在多個 domain 中傳遞,而不會造成 data races,這種資料就會被叫作 Sendable
。在 Actor 的架構底下,我們可以輕易地確保 actor 內的資源都是安全的,但是從外部傳到 actor 裡的資源就不是那麼容易可以檢查了,只要簡單的一個 reference type 就可以造成非同步破口。也因為這樣,Swift 5.5 引入了 Sendable
的概念,讓撰寫程式的人可以知道哪些資料是可以安全傳遞的。
看到這邊,聰明的你大概已經想到,以 CreditCard
這個物件的特性來說,它應該要是一個 actor 而不是一個 class,因為我們希望保護 credit
這個變數,讓它能夠在非同步的環境下也不會出錯。如果它是一個 actor 的話,就可以被自由地傳到不一樣的地方,而不用特地去處理 credit
這個資源的非同步的問題。因為這樣的特性,Actor 在 Swift 裡就是一種 Sendable
的型態。
除了 actor 之外,在 Swift 之中還有哪些型別也是 Sendable
呢?Sendable
聽起來很抽象,不過其實它是一個 protocol(也沒有比較具體!):
@_marker protocol Sendable {}
任何 conform 這個 protocol 的型別,都是可以安全地在不同 concurrency domain 傳遞的。在這邊,我們還可以看到一個神秘的 @_marker
的標記,這個標記代表甚麼意思呢?一般在 Swift 裡,protocol 通常都是拿來定義特定的介面,並且在 runtime 時連結實作的內容,不過 Sendable
的功能並不是要在 runtime 幫型別加上某些功能,它比較像是一個告訴 compiler “這個型別是安全的” 的標籤。如此一來,compiler 在編譯的時候就可以檢查傳遞的參數是不是有符合要求,比方說如果在該使用 Sendable
的地方傳入了不是 Sendable
的 class,compiler 就會報錯。所以雖然 Sendable
是一個 protocol,但是因為 @_marker
讓它成為一個給 compiler 看的標籤,它不能有特定的 interface,你也沒辦法在 runtime 的時候用 is
或 as?
來檢查。
有些型別在 Swift 中是預設就是 Sendable
的(不用另外宣告 conformance):
- Actor:所有的 actor 都是
Sendable
。 - Value types:Int、String、URLRequest 等等的 value type,因為在傳遞的時候是透過值來傳遞,所以天生就是
Sendable
。 - 特定條件下的 sturct/enum/tuple:如果這些型別持有的變數都是
Sendable
,那麼它本身也會是Sendable
。 - Immutable class:如果某個 class 持有的變數都是
let
變數,並且它有宣告Sendable
conformance,那麼這個 class 就是一個Sendable
。
以我們上面的 CreditCard
當例子,如果把 credit
改成 let
,它也可以變成一個 Sendable
class:
class CreditCard: Sendable { let credit: Int = 500 }
當然很明顯地它很安全,因為它現在甚麼資料都存不了,可以直接下班了。
其實還有許多狀況都可以讓某個型別成為 Sendable
,我就不一一列舉了,你可以參考 Sendable Protocol,裡面有更清楚的描述。
在實際應用上,直接傳遞 CreditCard
物件、並且把信用額度寫在可能會被複製跟修改的物件上是不好的架構,不過透過這樣的範例來說明 actor 之間資源的傳遞,應該是非常合適的。
目前 Swift 5.5 雖然已經加入了 Sendable
protocol 跟 Sendable
type 的檢查,不過在 actor 之間傳遞非 Sendable
的值還是被允許的 (WWDC)。這是因為考慮到許多第三方的型別都還沒有支援 Swift Concurrency,如果直接加上限制的話,會破壞 module 介面的穩定。目前這個部分還是需要寫程式碼的人注意,盡量讓 actor 之間傳遞的數值都是 Sendable。完整的 Sendable 限制目前還沒有明確的計畫,不過已經快要進入 proposal 階段了:[Draft] Incremental migration to concurrency checking。
Closure 的安全傳遞 – @Sendable
除了一般的型別之外,因為我們也可以在 actor 之間傳遞 closure, 所以我們也會希望確保這樣的傳遞是安全的。但是不像一般的型別,我們沒有辦法為 closure 加上 protocol conformance,所以 Swift 5.5 引入了一個特別的標籤:@Sendable
。@Sendable
跟 @escaping
一樣,它會被標註在 closure 之前。一旦某個 closure 標上了這個標籤,這個 closure 就是一個 Sendable
closure,它可以被安全地傳遞而不會有任何問題。
怎樣的 closure 是可以被安全地傳遞的呢?我們用一個範例來說明吧。現在我們要做一個訂飯店的服務,可以一次訂購很多家飯店,在訂購的當下,會先連接外部服務來確認飯店是不是還有空房。
程式碼會長得像下面這樣:
actor TravelPlaner { // 已經訂好的飯店 var myHotels: [Hotel] = [] func book(hotels: [Hotel], checkAvailability: (Hotel) async -> Bool) async { // 針對傳入的飯店一個一個檢查 for hotel in hotels { // 連到外部服務去檢查有沒有空房 if await checkAvailability(hotel) { myHotels.append(hotel) } } } }
因為我們需要連結外部的服務,我們在介面設計上想要保留一點彈性,所以就不直接在這個 method 裡面呼叫 API,而是用一個 closure 來包裝這個任務,實際的確認內容就交給呼叫端去實作。
有了上面的介面之後,我們在邏輯層(例如:某個 controller)上就可以這樣子呼叫:
let planer = TravelPlaner() await planer.book(hotels: hotels, checkAvailability: { hotel in let isHotelAvailable = await API.checkAvailability(of: hotel) return isHotelAvailable })
在這個範例中,checkAvailability
就是一個被傳入 actor 的 closure,目前在這份程式碼裡面,這個 closure 是會被依序呼叫的,不過這也表示未來有可能會被同時呼叫好幾次,比方說我們希望可以同時確認好幾個飯店。同時呼叫同一個 closure 很多次,就有可能會造成非同步的 data race 問題。我們來看看一個可能會有問題的狀況吧!假設今天我們接到了一個(不合理)的要求:要在訂完飯店後,同時能看一下最後可以訂的飯店的數量。
// 可以訂的飯店的數量 var numOfAvailableHotels: Int = 0 await planer.book(hotels: hotels, checkAvailability: { hotel in let isHotelAvalible = await APIClient.checkAvailability(of: hotel) // 如果飯店是有空房的就加一 if isHotelAvalible { numOfAvailableHotels += 1 } return isHotelAvalible })
在上面的程式碼中,numOfAvailableHotels
是一個 var variable,所以在 closure 裡面引用這個變數的話,實際上是引用它的 reference。
回想一下剛剛說的,這個 closure 可能會被呼叫好幾次;再想想在 closure 中修改這個變數實際上都是改同一個 instance:「同時有很多人會修改同一個 reference」,念十遍,聽起來就會像 data races 啊(不會)!這個時候你內心應該要警鈴大作,因為這個操作是危險的!
我們從以下的圖片來看一下這樣一言難盡的關係:
在呼叫 book(hotels: checkAvailability)
的當下,我們傳入了一個 closure,這個 closure 其實會在不一樣的環境下被執行,雖然呼叫端是存在 domain 2 裡,不過實際上 closure 的執行是在 domain 1,所以 numOfAvailableHotels
這個變數就會從 domain 2 被拿到 domain 1 去使用,造成了跨 domain 的 reference,如果 domain 1 裡面同時呼叫了這個 closure,就有可能會造成 data race。
這樣的問題要怎麼解決呢?我們需要確保這個傳來傳去的 closure 是安全的,也就是它不能引入任何 reference type,或者換句話說,它引入的變數都必須要是 Sendable
。這個檢查可以交給 compiler 來做,我們只需要把 closure 標成 @Sendable
:
actor TravelPlaner { // 已經訂好的飯店 var myHotels: [Hotel] = [] func book(hotels: [Hotel], checkAvailability: @Sendable (Hotel) async -> Bool) async { .... } }
這樣 compiler 就會在呼叫端加上警告:
用白話文來說,就是傳入 checkAvailability
這個 closure 的人,必須要確保這個 closure 是 Sendable,具體來說要怎麼做呢?就是要避免在 closure 裡面 capture non-Sendable
的變數,也就是要確保所有的 capture 都是 Sendable
的。
另外,因為 nested function 也是會 capture 值,所以一樣值得標上 @Sendable 讓 compiler 來幫我們檢查:
func task() async { var counter: Int = 0 @Sendable func callAPI() { counter += 1 // Error!!!! } }
實作上因為 @Sendable
目前還是需要人標上去,如果不標注的話還是有可能會產生風險,所以未來如果你發現某個 closure 會被非同步呼叫的話,就把它標上 @Sendable
吧,有標有保庇!
Actor 內意外的狀態變化 – Reentrancy
今天我們要來實作一個我的最愛列表的功能,假設在遠端的 server 存放著我的最愛飯店列表,我們在特定的幾個頁面需要讀取它,這個實作可能會長這樣:
actor TravelPlaner { var myFavorites: [Hotel] = [] func loadMyFavorite() async { // 確認列表是不是已經抓過了 guard myFavorites.isEmpty else { return } // 呼叫 API myFavorites = await API.loadMyFavorite() } }
從上面的章節我們可以了解到,在一個 actor 中,所有對於 actor 資源的存取都是安全的,可以把它們想像成這些存取是依序發生的。所以就算有很多頁面同時呼叫 loadMyFavorite
,也不會造成 myFavorites
這個變數的 data race。但是要注意的是,這並不表示 loadMyFavorite
這個 method 會被依序執行。實際上,actor 在執行某個 method 時,如果遇到 await 需要等待工作的完成,它會讓其它呼叫這個 method 的人先進來執行任務,直到 await 工作結束後,才會再把獨佔權還回給原本呼叫的人。這會造成一個問題,就是當我們同時呼叫 loadMyFavorite
兩次的時候,API 也會被呼叫兩次,而不是原本想像的:第二個呼叫因為 myFavorites
已經有值了就停止呼叫:
Task.detached { planner.loadMyFavorite() // => API call (1) } Task.detached { planner.loadMyFavorite() // => API call again! (2) }
這個在 actor 裡發生的任務交錯狀況,我們稱做 “interleaving”,我們來用圖說明一下發生了甚麼事:
一開始任務 A 先進了 actor 裡開始執行 (1),而任務 B 緊接著也加入了戰局 (2)。但是因為 actor 同時只會讓一個任務執行,所以 B 被排在排隊列表 (3)。不過因為任務 A 需要執行一個 API 呼叫,所以在 await 這邊需要等待這個 API 完成 (4)。這個時候,因為 await 中的任務已經交給 API 去跑了(在不同的 concurrency domain),現在 actor 手頭上沒有任務正在進行,所以 actor 會跑去把任務 B 先拉回來,讓它執行它的任務 (5)。
這樣的設計,主要是為了避免 actor 跟 actor 之間,為了互相等彼此的共享資源而產生 deadlock 的狀況。這種可以把執行中的任務擺在一邊,等到等待中的工作完成後再回到執行階段的特性,被稱為 reentrant。Swift Actor 就是一種 reentrant actor。
要避免上面這種事情的發生,首先我們要謹記著 actor method 的一個大原則:await 前後的資源狀態是有可能會變化的。
用程式碼來理解的話:
actor AnActor { func doTask() { print(self.balance) await API.doSomething() print(self.balance) // 可能會不一樣! } }
也就是說在 actor 裡,只要有牽涉到 await,都要小心狀態的變化。
未來 Swift 有可能會引入 non-reentrancy 的行為模式,也就是資源跟 method 的取用都是線性的,有興趣的可以參考 Non-reentrancy。
結論
我們來快速地複習一下(已經被遺忘的)所有重點:Actor 最大的特點,就是它可以保證在同一個 actor 底下,所有的狀態都可以安全地被存取。而外面的人如果想要跟 actor 溝通,就必須要確保整個資料溝通的過程是同步的。Actor 提供了一個隔離 (Actor Isolation) 的機制,讓它看起來像是有一個獨立的環境,這個環境裡的資源跟工作都是可以放心地非同步執行,但是從外界要跟 actor 溝通就需要確保溝通是安全的。Sendable
定義了怎樣的資料是可以安全地在 actor 之間傳遞,而 @Sendable
則可以視為 closure 專用的 Sendable
。雖然 actor 能夠確保資源的存取都是安全的,但是因為 reentrant 特性,我們要特別小心在 await 任務前後的狀態變化。
Swift Concurrency 是一個非常龐大的架構,並且幾乎可以確定這個架構會在未來的 Swift 開發上帶來非常大的變化,這裡面有非常多有趣的題材,有興趣的可以參照底下的連結來研究一下,也可以看看 Swift evolution 跟 Swift forum 上核心成員關於整個架構的設計概念跟演變,相信一定會對平常的工作有幫助!
參考資料
Threading & Concurrency model
- WWDC 2015 – Building Responsive and Efficient Apps with GCD
- WWDC 2021 – Swift concurrency: Behind the scenes
- Concurrency Visualized — Part 3: Pitfalls and Conclusion