Swift 與 Objective-C

[Swift 深入解析 #2] Swift actor 完整整理:防止資料競爭

actor 是能保護自身狀態的第四種型別。本篇整理如何將並行存取排隊,讓編譯器捕捉資料競爭,以及最知名的陷阱:再進入(reentrancy)。

閱讀 5 分鐘
[Swift 深入解析 #2] Swift actor 完整整理:防止資料競爭 封面圖

Swift Concurrency 系列第二篇的主角是 actor。語言新增了第四種型別,既不是 class,也不是 struct。

需要新增型別,代表當時存在嚴重問題。我們先從資料競爭開始精確理解。

資料競爭——最棘手的錯誤條件

資料競爭的條件很明確:兩個以上執行緒同時存取同一段記憶體,且至少一個存取會寫入。

條件成立時,結果是未定義行為。值可能損毀、程式可能當掉,也可能看似什麼都沒發生。

結果取決於當天的排程運氣。

final class Counter {
    var value = 0
    func increment() { value += 1 }  // 讀取-加法-寫入, 3步驟
}
// 兩個執行緒同時呼叫 increment()時
// 1000即使呼叫 value也可能不是 1000

value += 1不是原子操作。讀取、加法、寫入三步之間若插入其他執行緒,增量就會消失。

這個錯誤棘手,是因為難以重現。只有時機剛好才會爆發,因此測試會通過,上市後才以偶發當機回報出現。

值型別篇提過,值型別不會共享,因此資料競爭的前提消失。剩下的是以共享為目的的參考型別。

傳統解法是鎖定:鎖、信號量、序列 DispatchQueue。

它們都能運作,但共同弱點是完全依賴開發者自律。

只要有一處忘了取得鎖就功虧一簣,編譯器也看不出這個錯誤。

這種模式很熟悉吧。就像 Optional 篇的 nil 檢查、ARC 篇的循環參考,Swift 會把依賴自律的規則提升到型別系統。

接下來輪到資料競爭。

actor——保護自身狀態的型別

一句話說,actor 是內建狀態保護的參考型別。

actor Counter {
    var value = 0
    func increment() { value += 1 }
}

只要把 class 改成 actor,就能保證這件事:同一時間只有一個執行可以存取儲存屬性。

每個 actor 都有序列執行器,方法呼叫會排隊逐一處理。increment 的三個步驟之間無法插入其他呼叫,因此不會形成競爭。

重點是由編譯器強制執行。actor 外部存取其狀態或方法時,必須加上 await 才能編譯。

let counter = Counter()
await counter.increment()          // 在外部 await 必須
print(await counter.value)

加上 await 的原因,就是第一篇的 suspension 概念。actor 忙碌時請求必須排隊,等待期間不會佔住執行緒,而是讓出執行權。

這不像鎖一樣讓執行緒睡著等待。函式會暫停,輪到它時再繼續。

相反地,actor 內部程式碼可不加 await 自由存取,因為已在隔離範圍內。

這個「內部或外部」的界線,就是第一篇介紹的隔離(isolation)。

@MainActor 是這個概念的全域版本:把「主執行緒」做成單一序列脈絡的全域 actor,用來隔離 UI 狀態。

原理與自訂 actor 相同。

對比並行存取造成水晶破碎,以及排隊後安全存取的示意圖
讓並行存取排隊,競爭就不會成立

reentrancy——actor 最知名的陷阱

聽到這裡,actor 似乎萬能,但設計上有一個陷阱:actor 允許再進入。

actor 方法遇到 await 時會暫停,actor 便處於可用狀態。這段期間其他呼叫可以進入 actor 執行。

原方法恢復時,actor 狀態可能已不同於暫停前。

actor ImageCache {
    var cache: [URL: Image] = [:]

    func image(for url: URL) async -> Image {
        if let cached = cache[url] { return cached }
        let image = await download(url)   // 暫停點——此期間可能有其他呼叫進入
        cache[url] = image                // 相同 URL可能已經儲存
        return image
    }
}

同一 URL 幾乎同時收到兩個請求時,兩者都看到快取未命中,也都會下載。資料不會損壞(actor 會阻止這點),但邏輯會重複執行。

重要規則是:actor 能阻止低階資料競爭,但不會維持跨越 await 的邏輯一致性。

仍須在 await 前後重新確認狀態假設。以上例而言,可將進行中的下載 Task 存入快取,避免重複執行。

為什麼如此設計?若禁止再進入,actor 必須在 await 期間鎖門,兩個互相等待的 actor 就可能永久陷入死結。

Swift 選擇無死結系統,並將邏輯一致性留給開發者。了解這項取捨,就能預測陷阱。

實務配置——actor 適合放在哪裡

actor 適合的位置很明確:由多個非同步脈絡共享的可變狀態擁有者。

快取、連線池、下載管理器、工作階段儲存區都是例子。凡是需要回答「誰保護這個狀態?」的地方,actor 都值得考慮。

不適合的場景也很明確。第一,共享的狀態不存在。

只在單一畫面使用的 view model,使用 @MainActor class 才合適。改成自訂 actor 只會讓每次 UI 存取增加不必要的 await。

第二,不可變資料。既然沒有競爭,使用 struct 或 let 就足夠,遵循值型別優先。

第三,呼叫頻率極高的熱點路徑。跨越 actor 邊界有序列化成本,每秒呼叫數十萬次就表示應重新檢視設計。

再補充一點:只保護一個原子計數器或旗標時,actor 可能太重。

這類場景使用 Swift 6 Synchronization 模組的 Mutex 等低階工具更輕量,也不需要 await。actor 在受保護狀態與處理邏輯是一體時最能發揮價值。

await 之間其他呼叫進入並改變狀態的再進入情境插圖
再進入:await 之間其他呼叫可能進入並改變狀態

總結

  • 資料競爭由「並行存取+至少一次寫入」構成未定義行為,而且難以重現。鎖能運作,但依賴自律。
  • actor 是內建狀態保護的參考型別。序列執行器讓存取排隊,編譯器則強制外部存取使用 await。
  • @MainActor 將主執行緒做成全域 actor,是 UI 隔離的標準。
  • actor 允許再進入。狀態可能在 await 前後改變,因此除了低階競爭防護,邏輯一致性仍須自行維護。
  • actor 適合當共享可變狀態的擁有者;非共享狀態、不可變資料、極端熱點路徑使用它會過重。

下一篇介紹隔離體系的最後一塊:Sendable。內容涵蓋可安全跨越隔離邊界的型別,以及如何解讀 Swift 6 strict concurrency 對既有程式碼提出的警告。

延伸閱讀