幾個星期前,我看到一篇 Wojciech Kulik 所寫的文章,當中提到 Swift Concurrency 框架中的一些陷阱。在其中一部分,Wojciech 簡單提到了 thread explosion,以及 Swift Concurrency 如何限制我們使用比 CPU core 數量更多的 thread,來防止 thread explosion 發生。
看完之後我不禁思考,這是真的嗎?這是如何操作的呢?我們是否可以欺騙系統,來建立超出 CPU core 數量的 thread 呢?
我們會在這篇文章中找到答案。讓我們開始吧!
了解 Thread Explosion 💥
Thread explosion 是什麼呢?當系統同時運行大量 thread,而影響效能和導致 memory overhead,就是 thread explosion。
沒有一個明確數字指多少 thread 才算是太多。一般來說,我們可以參考這段 WWDC 影片的範例,當中系統運行中的 thread 數量是其 CPU core 的 16 倍,這就算是 thread explosion。
因為 Grand Central Dispatch (GCD) 沒有內置機制來防止 thread explosion,我們可以簡單利用 dispatch queue 來引發 thread explosion。讓我們來看看以下程式碼:
final class HeavyWork {
static func dispatchGlobal(seconds: UInt32) {
DispatchQueue.global(qos: .background).async {
sleep(seconds)
}
}
}
// Execution:
for _ in 1...150 {
HeavyWork.dispatchGlobal(seconds: 3)
}
執行以上程式碼後會產生共 150 個 thread,因而導致 thread explosion。我們可以暫停執行程序,並查看 debug navigator 來確認 thread explosion 的情況。
現在我們知道如何觸發 thread explosion,接下來讓我們試著使用 Swift Concurrency 執行相同的程式碼,看看會發生什麼事。
Swift Concurrency 如何管理 Thread
Swift Concurrency 有 3 個級別的 task priority,分別是 userInitiated
、utility
、和 background
,其中 userInitiated
的優先級別最高,其次是 utility
,優先級別最低的是 background
。接下來,讓我們相應地更新 HeavyWork
類別:
class HeavyWork {
static func runUserInitiatedTask(seconds: UInt32) {
Task(priority: .userInitiated) {
print("🥸 userInitiated: \(Date())")
sleep(seconds)
}
}
static func runUtilityTask(seconds: UInt32) {
Task(priority: .utility) {
print("☕️ utility: \(Date())")
sleep(seconds)
}
}
static func runBackgroundTask(seconds: UInt32) {
Task(priority: .background) {
print("⬇️ background: \(Date())")
sleep(seconds)
}
}
}
每次創建任務時,我們都會印出創建時間,這個資料可以讓我們可視化 (visualize) 幕後發生的事情。
更新好 HeavyWork
類別之後,讓我們開始進行第一個測試吧!
測試 1:建立優先度相同的 Task
這個測試基本上與前文的 dispatch queue 範例一樣,但這次我們不是使用 GCD 來建立一個 thread,而是使用 Swift Concurrency 的 Task
。
// Test 1: Creating Tasks with Same Priority Level
for _ in 1...150 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
以下是 Xcode 控制台的 log:
我們可以從任務的創建時間看到,當 thread 的數目達到 6,系統就會停止建立 thread,這與我的 6-core iPhone 12 的 CPU core 數量一樣。只有在系統完成其中一個執行中的任務後,才會繼續創建任務。也就是說,最多只能有 6 個 thread 在同一時間運行。
備註:
無論選擇了什麼設備,iOS 模擬器都會將 thread 的上限設置為 1。因此,請使用真實設備來運行上述測試,以獲得更準確的結果。
讓我們暫停執行操作,來看看到底在幕後發生了什麼事吧!
我們從上圖可以看到,任務都是由 “com.apple.root.user-initiated-qos.cooperative” concurrent queue 控制的。
因此我們可以確定,Swift Concurrency 就是利用一個專用的 concurrent queue 來限制 thread 的數量,讓它不過多於 CPU core,來防止 thread explosion:
測試 2:同時按優先級別高至低創建任務
接下來,讓我們試著添加不同優先級別的任務:
// Test 2: Creating Tasks from High to Low Priority Level All at Once
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
在上面的程式碼中,我們會先建立優先級別最高的任務 (userInitiated
),然後才建立 utility
和 background
。根據我們在上一個測試得出的結論,應該會看到 3 個 queue,每個 queue 有 6 個 thread 在同時運行,也就是說應該一共會有 18 個 thread。但事實並非如此,讓我們看看下面的截圖:
如你所見,當優先級較高的 queue 隊列 (userInitiated
) 飽和時,utility
和 background
的 queue 的上限會被限制為 1。也就是說,我們在這個測試中最多可以有 8 個 thread。
這個發現太有趣了!原來優先級別較高的 queue 飽和時,系統會抑制其他優先級別較低的 queue,不讓 thread 的數量繼續增加。
那如果我們把優先級別的順序倒轉,又會發生什麼事呢?
測試 3:同時按優先級別低至高創建任務
首先,讓我們更新程式碼:
// Test 3: Creating Tasks from Low to High Priority Level All at Once
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
以下就是測試結果:
結果與「測試 2」一模一樣。
似乎系統十分聰明,即使我們先創建優先級別較低的任務,系統都會先執行優先級別較高的任務。而且,系統還是會限制我們最多只可以同時執行 8 個 thread,因此不會造成 thread explosion。Apple 做得不錯喔!
測試 4:同時按優先級別從低至高創建任務 並在中間添加一個 Break
在現實生活中,我們不太可能會同時啟動一堆優先級別不同的任務。因此,讓我們構建一個更貼近現實的情況,就是在每個 for loop 之間添加一個 break。在這個測試中,我們還是會按優先級別從低至高創建任務。
// Test 4: Creating Tasks from Low to High Priority Level with Break in Between
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
sleep(3)
print("⏰ 1st break...")
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
sleep(3)
print("⏰ 2nd break...")
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
我們得到的結果很有趣。
如你所見,在第 2 個 break 之後,3 個 queue 都在運行多個 thread。也就是說如果我們先啟動優先級別較低的 queue,並讓它運行一段時間,優先級別較高的 queue 就不會限制優先級別較低的 queue 的數量。
我執行了這個測試幾次,thread 的上限可能會有所不同,但都大約等於 CPU core 的 3 倍。
這算不算是 thread explosion 呢?
我覺得不算是,畢竟 3 倍遠遠不及前文提過的 16 倍。我認為 Apple 其實是故意允許這種情況的,從而在執行效率和 multi-thread overhead 之間取得更好的平衡。如果你有其他想法,歡迎在 Twitter 告訴我,我十分希望了解大家的想法。
總結
Swift Concurrency 真的能防止 thread explosion 發生,但我們不得不承認,如果 userInitiated
queue 不斷處於飽和的情況,它其實會引致很嚴重的瓶頸。我將會繼續深入探討這個議題,敬請期待。
從「測試 4」的結果可見,其實我們平常應該多點使用 background
和 utility
queue,只在必要時使用 userInitiated
queue。
如果有需要,你可以從 GitHub 下載文章的範例程式碼。
如果你喜歡這篇文章,請不要錯過我其他有關 Swift Concurrency 的文章。你也可以在 Twitter 上 follow 我,並訂閱我的 newsletter,以免錯過我之後發表的文章。
謝謝你的閱讀。