Swift. Grand Central Dispatch (1)

讓我們看看如何管理應用程序中的佇列與執行緒

Jeremy Xue
Jeremy Xue ‘s Blog

--

Photo by John Anvik on Unsplash

▸ 前言:

在多核心世界裡,我們可以藉由多執行緒的方式來同時間處理多個任務,而不是像過去以排隊方式來執行任務。而在 iOS 中,我們可以透過 GCD(Grand Central Dispatch)來幫助我們管理執行緒的相關操作,透過這種方式我們不需要實際去管理執行緒,而是系統會提供合適的執行緒運行(不需要自己指定)。因此透過這種方式我們在使用上也相對容易。

▸ 常見名詞:

而此篇文章主要的概念都會圍繞在以下內容:

  • serial queue:串行佇列、序列佇列。類似於有「排隊機制」的佇列,你的任務會依照其順序來執行,前一個任務結束後,下一個任務才會開始,以此類推,依序執行直到完成。
  • concurrent queue:併發佇列、並行佇列。雖然稱為併發佇列,但他執行任務的順序依然是 FIFO,不過在它執行一個任務後,不會等任務完成,而是馬上執行下一個任務。
  • sync:同步。在同步執行任務後,需要等待任務完成後才能執行其他任務,可能會有執行緒阻塞的問題。
  • async:異步。在異步執行任務後,不需要等待任務完成,即可執行其他任務,而在該任務完成後再處理,可能會有競爭條件(race condition)的問題。

而在搜尋資料時,有幾個常常會搞混的內容,我將他列於下方給大家參考:

  • queue:佇列、隊列。計算機科學中的一種抽象資料型別,是先進先出(FIFO)的線性表。
  • thread:執行緒。為作業系統能夠進行運算排程的最小單位。大部分情況下,它被包含在行程之中,是行程中的實際運作單位。一條執行緒指的是行程中一個單一順序的控制流,一個行程中可以並行多個執行緒,每條執行緒並列執行不同的任務。
  • process:行程。是指電腦中已執行的程式。行程曾經是分時系統的基本運作單位。在面向行程設計的系統中,行程是程式的基本執行實體;在面向執行緒設計的系統中,行程本身不是基本執行單位,而是執行緒的容器。

▸ DispatchQueue

用於應用程序中的主執行緒或後台執行緒上以 serial 或併發 concurrent 方式管理任務的物件。

Dispatch Queue 為 FIFO(先進先出)佇列,你的應用程序可以以塊為形式向其提交任務。Dispatch Queue 以串行或併發的方式執行任務。提交給 Dispatch Queue 的工作在系統管理的執行緒池上執行。除了代表應用程序的主線成的 Dispatch Queue 外,系統不保證它使用哪個執行緒來執行任務。

你可以使用 sync 或是 async 方式安排工作項目。當你安排 sync 工作項目時,你的程式碼會等到該項目完成執行。而當你 async 安排工作項目時,你的程式碼會在工作項目在別處運行時繼續執行。

我們可以透過以下方式創建或訪問佇列:

▸ 操作佇列

首先先寫一個輔助函式,方便我們印出給定的 icon 字串,我們同時也會看見目前的執行緒:

以下的 concurrent queue 或 async 上的列印結果並不總是相同。

Serial Queue + Sync

首先我們先創建一個 serial 的佇列,並且透過 sync 同步執行任務:

🔴 thread: <NSThread: 0x600001b84900>{number = 1, name = main}
🔴 start
🔴 1
🔴 2
🔴 3
🔴 end
🟡 thread: <NSThread: 0x600001b84900>{number = 1, name = main}
🟡 start
🟡 1
🟡 2
🟡 3
🟡 end
🟢 thread: <NSThread: 0x600001b84900>{number = 1, name = main}
🟢 start
🟢 1
🟢 2
🟢 3
🟢 end

因為是 serial queue,因此我們任務會按照順序執行,並且由於為 sync 的方式,每個任務會等待前一個任務結束後,才會再接著下一個任務,依序進行,也不會額外有新的執行緒產生。

Serial Queue + Async

接著我們將上方 sync 的部分改為 async 即可異步執行任務:

🟢 thread: <NSThread: 0x600001288900>{number = 1, name = main}
🔴 thread: <NSThread: 0x600001281900>{number = 6, name = (null)}
🟢 start
🔴 start
🟢 1
🟢 2
🟢 3
🔴 1
🔴 2
🟢 end
🔴 3
🔴 end
🟡 thread: <NSThread: 0x600001281900>{number = 6, name = (null)}
🟡 start
🟡 1
🟡 2
🟡 3
🟡 end

你可以發現 🟢 並不會等待前兩個任務完成才運行,這是因為前兩個任務透過 async 的方式來異步執行任務,並且額外開啟一條執行緒處理,因此 🟢 才會馬上被執行。而 🟡 依舊會接著 🔴 的任務完成後接著執行。

Concurrent Queue + Sync

我們可以在 DispatchQueue 的初始化器添加 attributes 參數,並且加上 .concurrent 來表示為併發佇列:

🔴 thread: <NSThread: 0x600002ce4900>{number = 1, name = main}
🔴 start
🔴 1
🔴 2
🔴 3
🔴 end
🟡 thread: <NSThread: 0x600002ce4900>{number = 1, name = main}
🟡 start
🟡 1
🟡 2
🟡 3
🟡 end
🟢 thread: <NSThread: 0x600002ce4900>{number = 1, name = main}
🟢 start
🟢 1
🟢 2
🟢 3
🟢 end

但由於我們是以 sync 的同步方式來執行我們的任務,一樣會等待前面的任務結束後才會接續執行,所以與 Serial Queue + Sync 中的結果相同。

Concurrent Queue + Async

接著我們一樣把上方的 sync 改為 async 即可進行異步執行:

🔴 thread: <NSThread: 0x6000025f8700>{number = 5, name = (null)}
🟡 thread: <NSThread: 0x6000025bd480>{number = 6, name = (null)}
🔴 start
🟡 start
🟢 thread: <NSThread: 0x6000025fc1c0>{number = 1, name = main}
🟢 start
🔴 1
🔴 2
🔴 3
🔴 end
🟢 1
🟡 1
🟡 2
🟢 2
🟡 3
🟡 end
🟢 3
🟢 end

由於我們的佇列為 concurrent 的併發佇列,因此你可以看見這些任務幾乎是同時開始的,並且在多個執行緒上運行,彼此間不會相互等待。

Quality of Service

我們可以透過 qos 來指定該佇列的服務品質,同時也在表示應用程序執行工作背後的意圖,系統使用這些意圖來確定在給定可用資源的情況下執行任務的最佳方式。例如,系統對使用者交互任務的執行緒給予更高的優先度來確保這些任務被快速執行。相反的,它給予後台任務較低的優先度,並可能嘗試透過在更節能的 CPU 核心上執行來節省電量。系統會根據系統條件和你安排的任務來確定如何動態執行你的任務。

而我們有以下幾種不同的服務品質(優先度由高到低排序):

  • userInteractive
    使用者交互的服務品質,例如動畫、事件處理或更新應用程序的使用者介面。
  • userInitiated
    阻止使用者主動使用你的應用程序任務的服務品質。
  • default
    默認的服務品質。
  • utility
    使用者沒有主動追蹤的服務品質。
  • background
    你創建來用於維護或清理任務的服務品質。
  • unspecified
    無指定服務品質。

因此,我們來創建兩個不同 qos 的佇列來測試他們的執行狀況:

background vs userInteractive:

🔴 thread: <NSThread: 0x6000018fa680>{number = 4, name = (null)}
🟡 thread: <NSThread: 0x6000018d7fc0>{number = 7, name = (null)}
🟡 start
🔴 start
🟡 1
🟡 2
🟡 3
🟡 end
🔴 1
🔴 2
🔴 3
🔴 end

default vs utility

🔴 thread: <NSThread: 0x6000001c9f40>{number = 6, name = (null)}
🔴 start
🔴 1
🔴 2
🔴 3
🔴 end
🟡 thread: <NSThread: 0x60000018b0c0>{number = 7, name = (null)}
🟡 start
🟡 1
🟡 2
🟡 3
🟡 end

你可以看到 qos 優先度高的佇列會優先被執行完成,優先度低的則是相反。

DispatchQueue.global

我們可以透過 DispatchQueue.global 來訪問全局的系統佇列,同時我們也能指定其 qos(默認為 default)。特別要注意的是 DispatchQueue.global 是屬於 concurrent queue:

🟡 thread: <NSThread: 0x6000016b0200>{number = 6, name = (null)}
🔴 thread: <NSThread: 0x6000016a3000>{number = 7, name = (null)}
🟢 thread: <NSThread: 0x6000016b00c0>{number = 1, name = main}
🟢 start
🔴 start
🟡 start
🟡 1
🟢 1
🟢 2
🟢 3
🟢 end
🔴 1
🔴 2
🔴 3
🔴 end
🟡 2
🟡 3
🟡 end

DispatchQueue.main

我們也可以透過 DispatchQueue.main 來訪問主佇列(為 serial queue),主要負責畫面相關的 UI 更新,因此我們的主佇列必須總是保持閒置可用的狀態,以便應付任何畫面上的更新。

Important如果主執行緒未響應時間過長,則可能導致主執行緒在 mach_msg_trap 處出現 0x8badf00d 異常。在 iOS 上,如果看門狗(watchdog)機制檢測到你的應用程序未能即時響應某些使用者介面事件,則可能會引發此異常。iOS 中的看門狗的存在是為了保持使用者介面的響應。

如果你的應用程序有一個長時間的運行任務,像是網路呼叫,請在 global queue 或是另一個 background dispatch queue 上運行它。或者,使用呼叫的異步版本(如果可用)。

我們可以嘗試從網路上獲取一個圖片的方法,其中我們不切換至主佇列,你的程式碼應該會出現一個紫色 Main Thread Checker 找到的錯誤:

錯誤訊息如下:

=================================================================
Main Thread Checker: UI API called on a background thread: -[UIImageView setImage:]
PID: 5653, TID: 290773, Thread name: (none), Queue name: com.apple.NSURLSession-delegate, QoS: 0

Backtrace:
4 GCD-Testing 0x000000010c815885 $s11GCD_Testing14ViewControllerC9loadImageyySSFy10Foundation4DataVSg_So13NSURLResponseCSgs5Error_pSgtcfU_ + 341
5 GCD-Testing 0x000000010c815a5c $s10Foundation4DataVSgSo13NSURLResponseCSgs5Error_pSgIegggg_So6NSDataCSgAGSo7NSErrorCSgIeyByyy_TR + 252
6 CFNetwork 0x00007fff23cff83f CFNetwork + 30783
7 CFNetwork 0x00007fff23d1b6ab _CFHTTPMessageSetResponseProxyURL + 16312
8 libdispatch.dylib 0x000000010c9b75e0 _dispatch_call_block_and_release + 12
9 libdispatch.dylib 0x000000010c9b87eb _dispatch_client_callout + 8
10 libdispatch.dylib 0x000000010c9bf1c7 _dispatch_lane_serial_drain + 834
11 libdispatch.dylib 0x000000010c9bfef7 _dispatch_lane_invoke + 490
12 libdispatch.dylib 0x000000010c9cc1a6 _dispatch_workloop_worker_thread + 900
13 libsystem_pthread.dylib 0x00007fff6a5cd45d _pthread_wqthread + 314
14 libsystem_pthread.dylib 0x00007fff6a5cc42f start_wqthread + 15
2021-08-19 15:32:18.934482+0800 GCD-Testing[5653:290773] [reports] Main Thread Checker: UI API called on a background thread: -[UIImageView setImage:]
PID: 5653, TID: 290773, Thread name: (none), Queue name: com.apple.NSURLSession-delegate, QoS: 0

Backtrace:
4 GCD-Testing 0x000000010c815885 $s11GCD_Testing14ViewControllerC9loadImageyySSFy10Foundation4DataVSg_So13NSURLResponseCSgs5Error_pSgtcfU_ + 341
5 GCD-Testing 0x000000010c815a5c $s10Foundation4DataVSgSo13NSURLResponseCSgs5Error_pSgIegggg_So6NSDataCSgAGSo7NSErrorCSgIeyByyy_TR + 252
6 CFNetwork 0x00007fff23cff83f CFNetwork + 30783
7 CFNetwork 0x00007fff23d1b6ab _CFHTTPMessageSetResponseProxyURL + 16312
8 libdispatch.dylib 0x000000010c9b75e0 _dispatch_call_block_and_release + 12
9 libdispatch.dylib 0x000000010c9b87eb _dispatch_client_callout + 8
10 libdispatch.dylib 0x000000010c9bf1c7 _dispatch_lane_serial_drain + 834
11 libdispatch.dylib 0x000000010c9bfef7 _dispatch_lane_invoke + 490
12 libdispatch.dylib 0x000000010c9cc1a6 _dispatch_workloop_worker_thread + 900
13 libsystem_pthread.dylib 0x00007fff6a5cd45d _pthread_wqthread + 314
14 libsystem_pthread.dylib 0x00007fff6a5cc42f start_wqthread + 15
2021-08-19 15:32:19.153463+0800 GCD-Testing[5653:290773] [Animation] +[UIView setAnimationsEnabled:] being called from a background thread. Performing any operation from a background thread on UIView or a subclass is not supported and may result in unexpected and insidious behavior. trace=(
0 UIKitCore 0x00007fff254d2c96 kFixedAnimationDuration_block_invoke_3 + 119
1 libdispatch.dylib 0x000000010c9b87eb _dispatch_client_callout + 8
2 libdispatch.dylib 0x000000010c9b9d4c _dispatch_once_callout + 66
3 UIKitCore 0x00007fff254d2c1d +[UIView(Animation) setAnimationsEnabled:] + 62
4 UIKitCore 0x00007fff254d2d71 +[UIView(Animation) performWithoutAnimation:] + 78
5 UIKitCore 0x00007fff25491740 -[UIImageView _updateImageViewForOldImage:newImage:] + 533
6 UIKitCore 0x00007fff25491234 -[UIImageView _resolveImageForTrait:previouslyDisplayedImage:] + 939
7 UIKitCore 0x00007fff25489f34 -[UIImageView setImage:] + 446
8 GCD-Testing 0x000000010c815885 $s11GCD_Testing14ViewControllerC9loadImageyySSFy10Foundation4DataVSg_So13NSURLResponseCSgs5Error_pSgtcfU_ + 341
9 GCD-Testing 0x000000010c815a5c $s10Foundation4DataVSgSo13NSURLResponseCSgs5Error_pSgIegggg_So6NSDataCSgAGSo7NSErrorCSgIeyByyy_TR + 252
10 CFNetwork 0x00007fff23cff83f CFNetwork + 30783
11 CFNetwork 0x00007fff23d1b6ab _CFHTTPMessageSetResponseProxyURL + 16312
12 libdispatch.dylib 0x000000010c9b75e0 _dispatch_call_block_and_release + 12
13 libdispatch.dylib 0x000000010c9b87eb _dispatch_client_callout + 8
14 libdispatch.dylib 0x000000010c9bf1c7 _dispatch_lane_serial_drain + 834
15 libdispatch.dylib 0x000000010c9bfef7 _dispatch_lane_invoke + 490
16 libdispatch.dylib 0x000000010c9cc1a6 _dispatch_workloop_worker_thread + 900
17 libsystem_pthread.dylib 0x00007fff6a5cd45d _pthread_wqthread + 314
18 libsystem_pthread.dylib 0x00007fff6a5cc42f start_wqthread + 15
)
2021-08-19 15:32:19.153929+0800 GCD-Testing[5653:290773] [Assert] -[UIImageView _invalidateImageLayouts] must be called on the main queue
2021-08-19 15:32:19.154159+0800 GCD-Testing[5653:290773] [Assert] -[UIImageView _layoutForImage:inSize:cachePerSize:forBaselineOffset:] must be called on the main queue

可以將此 Main Thread Chceker 錯誤訊息大致上理解為「嘗試在 background thread 執行 UI 相關操作,必須將其在 main queue 上執行」。因此我們需要將 UI 更新的部分放置 DispatchQueue.main 中的 async 操作,這時就不會再出現紫色的 Main Thread Checker 的錯誤:

嘗試同步執行 main queue 上的工作項目會導致死鎖(deadlock)。

▸ 避免過多的執行緒創建

在設計併發任務時,不要調用阻塞目前執行執行緒程的方法。當 concurrent queue 安排的任務阻塞執行緒時,系統會創建額外的執行緒來運行其他排隊的併發任務。如果過多的任務阻塞,系統可能會耗盡你應用程序中的執行緒。

而應用程序消耗過多執行緒的另一種狀況是創建過多的私有 concurrent queue。由與每個 Dispatch Queue 都會消耗執行緒資源,創建額外的併發 Dispatch Queue 會加劇執行緒消耗問題。

因此,我們處理的方式不應該是創建私有併發佇列,而是將任務提交到 DispatchQueue.global 之一。對於串行任務,將其 target 設置為 DispatchQueue.global 之一。這樣你可以維護佇列的序列化行為並且最小化創建執行緒的單獨佇列數量。

因此可以盡量使用 DispatchQueue.global 來取代自定義佇列,防止過多的私有 concurrent queue。如果還是需要建立自訂義佇列,可以將 target 設置為 DispatchQueue.global 使其相關聯。

▸ 死鎖範例:

以上這段程式碼會導致死鎖(deadlock)的狀況發生,首先我們第一個的 sync 會正常印出 🔴 ,但是第二次的 sync 操作要印出 🟡 時會導致死鎖。因為我們第一個 sync 區塊會等待內部的 sync 完成,而內部區塊也不會在外部區塊完成之前開始,因此會導致死鎖的狀況。

▸ 延遲執行:

我們也可以在 DispatchQueue 上執行一些延遲操作,讓特定的任務在某個時間點後才開始執行。

通常我們可以藉由 .now() 來表示目前時間,加上 DispatchTimeInterval 的值(例如: .second(5) 表示延遲 5 秒)來表示延遲多久後執行,我們共有以下幾種不同的 DispatchTimeInterval 可以選擇:

  • seconds
  • milliseconds
  • microseconds
  • nanoseconds
  • never
要注意的是 .never。使用這種情況來指定一個永遠不會發生的時間間隔,而不是沒有。因此不會被執行
deadline: .now()
Date 2021-08-20 03:08:18 +0000
deadline: .now() + .nanoseconds(10)
Date 2021-08-20 03:08:18 +0000
deadline: .now() + .microseconds(10)
Date 2021-08-20 03:08:18 +0000
deadline: .now() + .milliseconds(10)
Date 2021-08-20 03:08:18 +0000
deadline: .now() + .seconds(5)
Date 2021-08-20 03:08:28 +0000

--

--

Jeremy Xue
Jeremy Xue ‘s Blog

Hi, I’m Jeremy. [好想工作室 — iOS Developer]