Strict Concurrency Check for Older Projects

前言

前陣子 Swift 5.10 釋出了,同時代表 Swift 正準備進入下一個 major release — Swift 6。Swift 6 這條路算來已經走了超過四年,可喜可賀。

每次這樣的大改版,總是帶給開發者們各種期待及焦慮。雖然升上 4 & 5 時都還算平順,但 Swift 3 可是當年 Swift 的 early adopters 難以忘懷的大改,著實讓人傷了些腦筋。

這次從 Swift 5 升到 6 的一個改進重點是在 concurrency 領域。這些改變對一些老 AppKit/UIKit 專案可能有很大的影響,改動的幅度讓人聯想起當年的 Swift 3。所以在 Swift 5 就有一個 “Strict Concurrency Check” 的 compiler 選項,讓大家可以提前開始準備。

我在手邊的幾個 AppKit 和 UIKit 專案裡試開啟了這個 strict concurrency check = complete,看那一次數百個的 errors 和 warnings 實在是衝擊感十足。

如果手邊有老專案,想先提前先瞭解情況,可以從哪裡下手呢?官方 blog post Swift 5.10 Released 提供了一個準確精簡的概要,而且也預告了將會有官方的 migration guide。其實國內外的開發者社群裡,許多人都已經意識到將臨的挑戰,並開始編輯各種教學資源,最近明顯多了起來。另外在 compiler & Xcode 那邊也可望會有更多改進,所以我們也還不必太焦慮。

我研究這個主題,似乎算是開始得較早。於是一時興起,決定把我目前所知彙整起來,成了這篇筆記性質的雜文。雖然許多細節還沒想得很清楚,但望拋磚引玉,歡迎討論指教。

以下會談到:

  • 什麼是 & 為什麼要做 strict concurrency check
  • 如何理解 concurrency errors/warnings 和解法,基本概念和名詞
  • 如何排除各種錯誤和警告,workarounds/solutions/mindsets

The “What”: Strict Concurrency Check Compiler Flag

在 Xcode 15 / Swift 5.10 的 build settings 中,把 “Strict Concurrency Checking” 的選項改成 “Complete”,這樣 compiler 就會和未來的 Swift 6 一樣,針對 sendable constraints 以及 actor isolation 做完整的檢查。

官網說明:Enabling Complete Concurrency Checking

strict concurrency compiler flag

The Why: 歷史背景

Swift 的創造者 Chris Lattner 在 2017 年時寫了 Swift Concurrency Manifesto,立下了 Swift 語言在 concurrency 上的方向及願景。其中一個目標,是 “safe by default”。

Swift 想要成為一個更安全的語言,一個明顯的先例就是 Optional。如果正確使用 Optional,那只要 compile 能過,我們就已經避開了許多從前 null pointer 會造成的 bugs。代價之一則是我們到處都要思考如何處理 Optional。

而這次的 safety 目標,是想預防 concurrent programming 裡常見所謂的 data race 問題(在此恕不解釋 data race,若不熟悉請參見其他說明)。我們希望 compiler 有能力禁止或警告我們寫的 code 裡可能有 data race bugs,不要等到 run time 出了問題才發現。

為了達到這樣的目標,到 Swift 5.10 時,基本機制終於齊備了,並準備在 Swift 6 進入「正確寫就不會發生 data race」的時代。

不過,Swift 要怎麼達成這件事呢?

心智模型:The Actor Model

Swift 的 concurrency 設計,除了 async/await 語法外,一個主要概念取自 the actor model。在這個 model 裡,所謂的 actor 是一個計算單位,而在各 actor 之間如果要傳遞資訊,它們不能直接存取共享的同一份資訊,而是要透過傳送 messages 來達成。

在 WWDC 2022 Eliminate data races using Swift Concurrency session 中,用了一個 “sea of concurrency” 的類比。Task 是海上的船,各種 data 是船上的貨物,而 actors 是海上的島。各個島獨立運作,而船載著貨物在它們之間互相連繫。

這個類比不是很完美,常常拿它來做多一點的聯想的時候就「壞掉」了,但用來想像 actor model 還是很實用。

WWDC 2022-110351 sea of concurrency screenshot

要怎麼利用 actor model 來防止 data race 的危險呢?首先,每座島都一次只做一件事,所以任何「貨物」在不離島的情況下都很安全。再來,如果一個貨物要「上船」,那它必需在設計上就不怕被人在兩個以上的地方同時讀寫。把這些原則化為 compiler 的規則,就成了可以預防 race condition 發生的 strict concurrency check 了。

Concurrency Check 要確保什麼?

如果用上述海洋的比喻來說,那所謂的 strict concurrency check 大略上就是 compiler 會檢查:

  • 有些貨物會指定必需在哪一個特定的島上處理。如果 compiler 覺得我們沒有確保會在特定島上處理,就會阻止我們。這叫做 actor isolation。

  • 有些貨物,我們想要讓它送上船,從一座島送到另一座島。這樣的話,這些貨物必需經過核對、貼上「本貨品可被安全送件」標籤,才準許上船。這稱為 Sendable。

如果能滿足這些檢查,那麼在這片 concurrency 的大海上,compiler 就能確保不會有 data race 發生了。

又或換一種比較抽象的說法,可以說 compiler 要確保的是沒有危險的 shared mutable state(這個詞也不在此解釋,若不熟悉請參見其他說明)。確保的方式,是限制什麼樣的東西可以被 share,和限制要在哪裡 access 它們。

實際上 Compiler 會如何刁難我們?

對 AppKit/UIKit 老專案而言,一開始前者最主要的考量,會是所有跟 UI 扯上關係的地方,我們有沒有用 compiler 看得懂的方式,來確保所有該在 MainActor 上跑的東西都跑在 MainActor 上。

至於後者,則是會要檢討在有 callback closure 之類的地方(也就是「上下船」的時候),使用到的 data、controller 等等的 types,如何幫它們加上 Sendable protocol conformance(「安全標籤」)。

那麼,overview 就到這裡。接下來,我想直接列舉一些我看過、想過的方法、思維,來讓一個老專案通過 strict concurrency check。

首先會從幾個所謂的「逃生門」,那些又快又髒的逃避方法說起。

Swift 5 Language Mode

終極的逃生門,就是留在 Swift 5 mode。就算未來 Swift 6 正式版了,我們還是可以選擇在舊專案裡使用 Swift 5 模式,就這樣永遠逃避下去。

@unchecked Sendable

逃生門之一,為一個 type 無條件加上 Sendable conformance。

Sendable protocol 是個標記「安全貨物」用的 protocol,它不用實作任何東西,但要滿足一些條件。

經典情境可能是有個 network call 之類的 callback。Network call 是在另一座島上發生的,callback closure 是往來的船,而我們讓 self 坐上船,好接收 data。但 compiler 會抱怨說它不是一種能安全上船的東西。

class DataFetcher {
    func fetchStuff() {
        URLSession.shared.dataTask(with: request) { data, response, error in
            self.processData(data)
            // Warning: Capture of 'self' with non-sendable type 'DataFetcher' in a `@Sendable` closure
        }
        .resume()
    }
}

實際上這也的確不安全,因為 callback 可能會和其他操作在不同 thread 上同時發生、同時讀寫 self 的某些 properties。

骯髒危險的解法就是叫 compiler 無條件相信你。不要檢查,當它是安全的就好。

class DataFetcher: @unchecked Sendable {
    // ...
}

nonisolated(unsafe)

逃生門之二,為一個 variable 無條件跳過檢查。

經典情境是有 singleton 之類的 global / static variable 這種,很多地方都可能伸手取用的貨物。

class DataFetcher {
    static var urlString = "http://my.server/..."
    // Warning: Static property 'urlString' is not concurrency-safe because it is non-isolated global shared mutable state; this is an error in Swift 6
}

骯髒危險的解法就是跟 compiler 說,這個貨物的確沒有規定要在哪座島上處理(nonisolated),但不要管我。

class DataFetcher {
    nonisolated(unsafe) static var urlString = "http://my.server/..."
}

@preconcurrency import

逃生門之三,從某個 imported module 來的 type (包含,或者說特別是某些 Apple 自己的 frameworks)沒有被標記成 Sendable。這時 compiler 可能會建議你在 importXXX 前面加上 @preconcurrency,也是一樣,就是忽略檢查從這裡 import 的東西。

逃生門、簡單解法不多,總是逃避也不是辦法,慢慢的我們也只好開始認真思考,到底怎麼把東西做對。

@MainActor

AppKit/UIKit app 常會有一些跟 UI 高度相關的 types,可能是跟 view 相關,也可能是做輔助 navigation 之類的工作。

class Coordinator {
    func showLoginController() {
        let viewController = LoginViewController() // Warning
        viewController.completion = { [self] in
            self.completeLogin() // Warning
        }
    }

    func completeLogin() {
        // ...
    }
}

這種物件在和 view 對接的地方很容易就會爆出各種警告。原因是,UIViewControllerUIView 和許多像是 UITableViewDataSource 一類的東西都已經被標記為 MainActor 「主島專屬貨物」,所以你如果不是人在主島就不能亂動它們。

一種可能的解法,就是主張你的那個物件也是 MainActor isolated。這樣就能保證它會跟其它人在 main thread 上和睦相處。而且一但它有了 MainActor isolation,它就會被默許是 Sendable type。

@MainActor
class Coordinator {
    // ...
}

@MainActor 也可以被加在 function 等等的地方。其實一個 app 裡面,我們本來就預期有很多東西會跑在 main thread 上,所以加 MainActor 也不過是跟 compiler 把話說清楚講明白罷了。

在 Swift Concurrency 之前的時代,我就記得讀 DispatchQueue 的教學時,看到作者說常常最好的 concurrency 就是不要 concurrent(paraphrased,已不記得出處,有看過這種說法的歡迎回饋)。所以我感覺很多時候並不用太害怕加 @MainActor

True Sendable Conformance

總是用 @unchecked Sendable 逃避也不是辦法。假設我們想要正視這個問題,那就要來看看能不能實作堂堂正正的 Sendable types。這時我們可以來看 Sendable 文件 說明的條件是哪些。

原則上,一種 type 如果是「安全貨物」,那代表它不怕有人試著在兩個以上的地方同時讀寫。

最好的情況是,有的 types 其實根本不用 @unchecked 就可以直接加註為 Sendable。次好的情況是,有的 properties 可以從 var 改成 let,及有的 properties 自己也需要實作 Sendable,就不用使用逃生門了。這些情況恐怕不很常見,但當遇到需要 Sendable type 的時候,都可以思考一下它跟 Sendable 的距離到底有多遠。

Sendable 包裝

有時會遇到類似這種情況:

// buffer: CMSampleBuffer

DispatchQueue.main.async {
    self.display(buffer: buffer)
    // Warning: Capture of 'buffer' with non-sendable type 'CMSampleBuffer' in a `@Sendable` closure
}

這裡 compiler 抱怨 AVFoundation 裡的 CMSampleBuffer 不能安全上船。一個方法是把之前說過的 @unchecked Sendable 加在 CMSampleBuffer 上面,但是這樣又太粗暴,所有用 CMSampleBuffer 的地方都會受到影響。

我遇到這種問題時都忍不住想,有沒有辦法把 buffer 用一個箱子「包裝」保護起來,就能安全的送上船?寫一個這樣的容器其實蠻容易,但如果想要現成的,我們有 ConcurrencyExtras package 可用。事實上,ConcurrencyExtras 裡面有三種這類型的容器:

但這時我們會發現,想要靠盒子就做好保護這種想法,其實有點問題。譬如試著修理上個例子:

// buffer: CMSampleBuffer
let isolatedBuffer = LockIsolated(buffer)

DispatchQueue.main.async {
    self.show(buffer: isolatedBuffer.value)
}

// 如果在這裡偷用 buffer...?

很明顯,在這個例子裡,如果我想保護的 buffer 在裝進保護箱之前就已經曝露在外,那光是裝箱子送件,完全不會讓它變得更安全。

其實如果用的是 ConcurrencyExtras 裡的 LockIsolated,這裡會有更多警告。要讓 compiler 高興,就只能用 UncheckedSendable,這個不做保護也不檢查,單純是在單點用來取代 @unchecked Sendable 的箱子。

咦,那如果把東西「裝箱」完全沒有保護效果,那 ActorIsolatedLockIsolated 不是就沒用了嗎?

關鍵在於,這種有保護的箱子,它所保護的東西必需是打從出生就在箱子裡的。譬如上個例子,如果buffer 是我們自己的生成的:

let isolatedBuffer = LockIsolated(createBuffer())

DispatchQueue.main.async {
    isolatedBuffer.withValue { buffer in
        self.show(buffer: buffer)
    }
}

LockIsolated.init 使用 autoclosure,所以 buffer 是在保護範圍內生成的,之後也都是在保護下被使用,那就真的有保護效果。

我覺得這種做 Sendable 包裝的邏輯實在有點微妙,要是能想通,應該會少走一些冤枉路。另一方面,之後也許會有新的方法來讓「事後包裝」變得可能,像是 transferred ownership 或是 region-based isolation 之類的,所以還有待觀察。

反思 Sending & Capturing 策略

和上面相似的情況,有時卻有完全不同的解法:

// userInfo: [String: Any]

DispatchQueue.main.async {
    if let count = userInfo["count"] as? Int {
        // Warning: Capture of 'userInfo' with non-sendable type '[String : Any]' in a `@Sendable` closure
    }
}

和之前的差別在於,這次我們其實不需要整個 Dictionary 上船,因為我們本來就只會用到裡面的一個 Int。

// userInfo: [String: Any]

if let count = userInfo["count"] as? Int {
    DispatchQueue.main.async {
        // use `count`, ok
    }
}

雖然原本整個物件不是 Sendable,但我們並不需要用到整個物件。把需要的部分抽出來以後,這部分是 Sendable 就沒問題了。

與其煩惱怎麼讓某個 type 變成 Sendable,可能更值得思考的是,我們設計、使用 API 時到底想要有多少東西穿越 concurrent context?船上要載的貨物能不能更單純?另外,我們能不能在 closure capture list 裡只捕捉一部份真正會用到的東西?等等。

這樣一來,我們也許就會從只是想要讓 compiler 安靜,升格為重新設計更簡潔、安全的架構。畢竟,程式的安全穩定才是真正的重點。

何時定義 Actor?

回到這個例子。

class DataFetcher: @unchecked Sendable {
    // ...
}

如果這個 class 很難不用 @unchecked 來實作 Sendable,也不希望它限定在 main thread 上,還有什麼選項?

把它重定義為 actor 呢?雖說用起來會變得挺麻煩的,但總可以確保 Sendability 了吧?

actor DataFetcher {
    // ...
}

其實,自從 Swift 有了 actor type,我就一直懷疑著,到底什麼時候適合把東西定義為 actor?如果純粹是為了 Sendability,就把一個 type 給定義成 actor,不覺得很容易變成殺雞用牛刀嗎?

下面談談我目前的思維,一部分是從 WWDC 2021: Swift concurrency: Update a sample app 的前幾分鐘來推論。

在 actor model 的海洋比喻中,actor 是島。生成一個島時,我們就生成了一個所謂的 concurrent context。換一種想法,可以想像每生成一個 actor,它都自帶一個 synchronous DispatchQueue。

如果有重覆、多次生成的 data type,我們應該不會希望每一個都連帶生出一個 DispatchQueue,只為了預防 data race。我們要的應該是只定義一個「情境」,而讓一連串高度相關的事情,一起被隔離在該情境下完成。

所以在想要不要定義一個 actor type 時,我會思考它是不是要成為某個情境的進入點。譬如說圖片轉換,networking,sensor signal filtering… 這些就可能就是情境、是「島」,所以可能會用 actor 定義。但進出這些島、或在島上週轉的貨物,則希望不需要用 actor 來定義。

我有把這樣的原則試應用在一個專案裡,感覺可行,但也許不是最佳解。又是一個期待有更多高手來解惑的問題呢。

投入 Async Await 的懷抱

到目前為止的敘述,多少有些假設我們的 AppKit/UIKit app 還不想用太多的 async/await 語法。可能是我們自己還不熟,也可能還想再支援 macOS 10.13 一陣子(!?)。

但我覺得,現在差不多是可以開始熟悉 async/await 的時候了。如果要滿足 Swift 6 的 strict concurrency check,在 async function 底下的選擇還是比較多,也比較乾淨。在 legacy code 不想一次改太多的前提下,可以適度的用 withCheckedContinuation 之類的方法來把一些 callback 接進 async context。有的時候,Sendability / actor isolation 的問題就會自然改善。

例如,離開 main thread 再回來的情況,原本看起來像是要確保兩處的上下船。

class ViewController: UIViewController {

    // ...

    func getUsers() {
        clearUsersList() // on main queue
        userFetcher.fetch { users in // 在別的 queue callback
            DispatchQueue.main.async { // 再回去 main queue
                self.show(users: users)
            }
        }
    }
}

可是一但用 async await 改寫,就看起來像是兩次 UI 操作都是在 MainActor 底下,只是中間去做了其他事情。

class ViewController: UIViewController {

    // ...

    func getUsers() async {
        clearUsersList() // on MainActor
        let users = await userFetcher.fetch()
        show(users: users) // on MainActor, 有如從未離開
    }
}

如果有一些其他 data 是在 fetch 的前後都會用到的,那在 async 版本底下它們會看起來從未跨越 actor context。這個例子有點爛,也許沒什麼幫助,但我想表達的是,你可能會發現,在 async function 底下做 concurrency check 的相關處理,阻力會比較低。

Be More Functional

在思考怎麼讓老專案通過 concurrency check 時,很難不注意到有些原則和 functional programming 的原則不謀而合。的確,FP 的一大賣點就是它很適合 concurrent programming。不知道 actor model 或 Sendable 的設計上,是不是也融合了來自於 FP 的觀念呢。

其一,是 immutability 和使用 value types。可以標註為 Sendable 的,正是 value types 和 “reference types with no mutable storage”。我們在試著滿足 strict concurrency check 時,也可以多思考:這裡是否可以用 let 取代 var?或用 struct 取代 class?如此來實作 Sendable type。

其二,是 pure functions。我們能不能減少使用 global variables 和 singletons,以及 classes 裡的 mutable properties?如果試著去思考怎麼讓 function 更 “pure”,那也許能幫助我們減少 shared mutable state。與其問如何把東西變成 Sendable,不如從根源減少需要 Sendability 的情況。

正義,未來

以上從 complete strict concurrency check 的背景、思維,漫談到各種可能的解法。

如開頭所說,這是篇筆記性質的雜文。想必在不久的將來,很多內容就會過時了吧。這是件好事,因為我覺得目前所看到的 concurrency check,侷限實在是有點太多了。

最近聽電腦科學教育學家 Amy J. Ko 的一個講座時,突然很有感觸。Swift 這個語言在設計上,不論是強型別、Optional 還是現在的 concurrency,常偏向給 compiler 更大的能力來決定對錯,給我們各種限制,逼我們寫對。但相對於其他如 JavaScript 或 Python 等的語言,我們寫 Swift 時可以說是比較不自由的。對專業工程師來說這也許很有幫助。但對新手來說,我們本希望寫程式是給予他們自由創造的工具和能力,但他們感受到的卻是處處受 compiler 的限制、compiler 才知道什麼是對的,做什麼都要聽 compiler 的指示,剝奪了使用者的 self-efficacy (「自我效能」,自主性?),可視為一種不正義。(Juiz… j/k)

我在這方面涉獵不多,沒辦法解釋得很好,但言歸正傳回到 Swift 的 Sendability & actor isolation check,我期待後續的發展能給 Swift programmers 更多的自由與權力。

一方面,compiler 會更聰明、在一些地方懂得放鬆規則。像是 SE-0414: Region based Isolation 就會讓之前討論「Sendable 包裝」時的一些情境下,compiler 能夠自行判斷這裡「上船」的貨物不需要有「安全標籤」。這樣我們也會較少需要在明明知道安全的情況下,還要繞圈子來設法迴避 compiler warnings。

另一方面,我不知道這方面的進展如何,但 programmers 也應該要有更多跳過 compiler check 的方法,就好像我們如果想要直接管理 memory,可以用 UnsafePointer 系列的 API,或是用 compiler flag 來限制某些警告。

以目前來說,有興趣的人可以和我一樣,把 strict concurrency check 當成一個有趣的謎題來挑戰、學習。不然的話,讓子彈再飛一會兒,也是個不錯的選項。

雜資源

Leave a Reply

Your email address will not be published.