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 在受保護狀態與處理邏輯是一體時最能發揮價值。
總結
- 資料競爭由「並行存取+至少一次寫入」構成未定義行為,而且難以重現。鎖能運作,但依賴自律。
- actor 是內建狀態保護的參考型別。序列執行器讓存取排隊,編譯器則強制外部存取使用 await。
- @MainActor 將主執行緒做成全域 actor,是 UI 隔離的標準。
- actor 允許再進入。狀態可能在 await 前後改變,因此除了低階競爭防護,邏輯一致性仍須自行維護。
- actor 適合當共享可變狀態的擁有者;非共享狀態、不可變資料、極端熱點路徑使用它會過重。
下一篇介紹隔離體系的最後一塊:Sendable。內容涵蓋可安全跨越隔離邊界的型別,以及如何解讀 Swift 6 strict concurrency 對既有程式碼提出的警告。

![[Swift 深入解析 #2] Swift actor 完整整理:防止資料競爭 封面圖](/assets/images/posts/d7809f68-f42a-4283-a270-2c25867c9087/swift-actor-1.jpg)