CPUs(中央處理器)問世以來,最棒的改革之一就是發展多核心技術,藉此可運行多個執行緒,這代表著,我們可以在同一時間執行多個任務。
依序執行任務(非並行)或是fake multitasking是多年前使用的運行模式,只要你的年紀稍長,應該可以記得過去的老舊電腦,若是你曾經使用過老舊的系統,應該可以輕易的了解到我在說什麼,但是,不管多少個核心的CPU可以帶來多厲害的效能表現,如果開發者不曉得如何使用這些技術,那都是沒有用的,本文就是要介紹多工與多執行緒的編程如何實作,開發者必須在任何裝置上善用CPU多工(multitasking)的優勢,將程式拆分為多個區塊分配在多個執行緒中平行運作。
使用多執行緒的好處很多,最重要的一點就是縮減執行任務所需時間,提供較佳的使用體驗,在運行時不會有停留在某個畫面的延遲感,請想想看,若是有一個應用程式,它將一大串的images都堆放在主執行緒處理,那UI必須等到全部下載任務完成才會有反應,使用者不會想嘗試使用這樣的應用程式。
在iOS當中,蘋果提供兩個方法來實作多工任務:包含Grand Central Dispatch (GCD)以及NSOperationQueue的frameworks,它們都可以幫助開發者達到多個任務分配給多個執行緒或不同queues(佇列)進行同步運行的需求,本文我們將會專注在GCD實作應用上。不管如何,這邊有一個規則必須要遵守:主執行緒必須總是維持在閒置狀態,用於應付使用者操作觸發的介面變化,任何耗時的任務應該跑在concurrent或是background的佇列,這對於新進開發者來說可能難以消化及應用,但它仍是應該去使用的方法。
GCD(Grand Central Dispatch)在iOS4才第一次被介紹出來,當我們嘗試去執行並發任務時,它提供很有彈性的應用,其實,直到Swift 3推出之前,我認為它有一個很大的缺點:就是很難記得GCD的相關語法,過去它的coding style比較像底層的C語言,與Swift甚至是Objective-C的其他coding style有顯著的差異,這也是為何在過去多數開發者盡可能使用NSOperationQueue,避免使用到GCD的原因,但是在最新的Swift版本中,GCD語法將可以給你很好的使用體驗,讀者可以在web簡單查詢看看。
在Swift 3推出後,它有了很顯著的改變,GCD使用起來不一樣了,完全是Swift風格的語法,GCD最新的語法可以讓開發者更容易使用,這樣的改變,也讓我迫不及待想寫這篇文章,與讀者分享GCD在Swift 3之中,所提供最基礎但也最重要的實作應用,如果你過去已經接觸過GCD舊的coding style(即使只有一點點),接下來使用新的語法對你而言將是小事一樁,若是過去沒有碰觸過GCD,接下來就跟著本文一起學習程式的新篇章。
在我們進入本文的特定主題前,先來談一些具體觀念,首先,GCD裡面的關鍵詞彙為dispatch queue,不論是在主執行緒或是背景執行緒上,一個佇列實際上是一個可同步或非同步執行的代碼塊,當一個佇列被生成後,作業系統會將把它分配到CPU上的任一核心上,多個佇列同樣會被相對應管理,這部分的工作開發者不需要去處理,佇列會遵循FIFO的模式(先進先出),代表一個佇列先被執行就會先完成工作(可以想像成在櫃檯前排隊等待的人,排在第一位的人就會先被服務,最後一位則會最後被服務),等下我們會透過實例進行更清楚的解析。
再來,另外一個重點觀念是work item,它是與佇列創建時一起編寫的代碼塊,會被分配到佇列裡面且可以被多次使用,完全如你所想:它是dispatch queue將運行的程式碼,在執行時也遵守FIFO規則,可以選擇同步或非同步的執行方式,在同步模式中,應用程式運行時在任務執行結束前將不會離開這個item的代碼塊,另外一方面,若是佇列被安排為非同步運行,應用程式將會呼叫對應的work item代碼塊,並於工作完成後回傳,再次提醒,後面的篇幅將透過實例看到兩者的不同。
簡單介紹dispatch queues和work items之後,該是時候更深入介紹佇列,它可以設為serial或是concurrent,在第一個情境下,一個work item必須等待前一個結束後才會開始執行(除非它第一個被排入到佇列),第二個情境則是可以讓各個work item同時運行。
你在把任務配發到應用程式主佇列(main queue)時都必須很小心,它應該要總是維持在閒置可用的狀態,藉以應付使用者操作帶來介面變動需求,順帶一提,每個UI變更的動作都必須要在主執行緒執行,如果你曾經嘗試在背景執行緒(background thread)更新你的UI,會發現畫面更新的動作無法保證會在何時啟動,這將會讓你得到不愉快的體驗,然而,在UI創立或更新前必須先完成的工作,絕對要在背景進行作業,舉例來說,你可以在背景執行緒進行圖片下載工作,但是要將更新image view的工作放在主執行緒進行。
請記住,我們不總是需要去建立自己的佇列,系統會建立global dispatch queues供你指定的任務使用,至於佇列會在哪一個執行緒運行,iOS設有一個a pool of threads(執行緒池),意指除主執行緒之外的執行緒集合,系統會挑選一個以上的執行緒來使用(依照你建立的佇列數量,以及建立方式),哪一個執行緒會被使用並不是由開發者定義,操作系統會依據並發任務的數量,處理器上的負載等事項來決定,但是說實話,誰想處理上述所有工作呢?
我們的測試環境
本文中,接下來我們將會使用的幾個小範例來介紹GCD觀念,一般來說,我們只要使用Xcode Playground即可達到示範工作,不需要特別實作一個demo app,但是在使用GCD時無法如願,因為在Playgrounds裡面無法從不同的執行緒中呼叫函式,儘管我們的一些範例可以在這上面運行,但不是全部,所以本文仍將透過一個專案實作來克服潛藏的問題,你可以在這裡下載並且打開它。
這個專案幾乎是空的,除了下列兩個檔案以外:
- 在
ViewController.swift
檔案中看將看到一串函式被定義,但是尚未被實作。每一個都會帶領我們認識GCD的新特性,你要做的事情就是在viewDidAppear(_:)
裡面將註解符號拿掉,讓這些函式被喚醒。 - 在
Main.storyboard
裡面的ViewController
畫面中,你將發現一個image view被加入,而且對應的IBOutlet已經連結到ViewController類別中。
晚一點我們將會需要那個image view供一個真實案例使用。
現在讓我們開始吧。
認識 Dispatch Queues
在Swift 3裡面,建立一個新的dispatch queue最簡單的方式如下:
let queue = DispatchQueue(label: "com.appcoda.myqueue")
你唯一要做的就是提供一個唯一的label給這個佇列,反向的DNS符號(com.appcoda.myqueue
)很容易建立出獨一無二的labels,甚至連Apple也建議這樣的寫法,儘管如此,但它並不是強制性的,你可以使用任何你想要的字串,只是必須維持它的唯一性,除此之外,上述程式碼不只是佇列的初始化作業,你也可以提供更多參數在初始化動作中,我們將會在之後篇幅談論到它。
佇列一旦被建立後,我們就可以透過程式碼使用它,同步則使用sync
函式,若非同步則呼叫async
函式,當我們開始時,先提供一段code當做一個block(closure),接著,我們將初始化它,並使用dispatch work items的物件 (DispatchWorkItem)去取代block(請注意,在佇列裡面block也被當作是一個work item),我們將以同步的方式開始執行,要做的只是列出0~9的數字:
使用紅點可以讓我們在console內較容易識別,尤其是當我們添加更多的佇列或任務去執行時。
將上圖內的程式碼複製並貼在ViewController.swift
檔案的simpleQueues()
函式內,確認這個函式在viewDidAppear(_:)
裡面沒有被註解掉,接者把這個專案跑起來,請看一下Xcode console,發現結果並無法讓我們對GCD的運作做出任何結論,所以請更新simpleQueues()
函式內的程式碼,在佇列的closure後面加入另一個代碼塊,它用來呈現100~109數字(僅用於區別數字不同)。
for i in 100..<110 {
print("Ⓜ️", i)
}
上述的for
loop迴圈將會在主佇列被執行,第一個迴圈則將在背景運行,程式執行的動作會在佇列的block中停止,且直到佇列任務完成前,它將不會繼續主執行緒迴圈,無法呈現100到109的數字,這是因為我們使用同步執行,讀者可以在console中看到輸出結果:
但是如果我們使用async
函式運行會發生什麼事情呢?請將佇列裡的程式塊以非同步方式執行,這個案例中,程式不會先等到佇列任務完成才進行下一步,它將立即返回到主執行緒,而第二個for
loop迴圈也將與佇列迴圈同時執行,在我們看看會發生什麼事之前,先將佇列改為使用async
執行。
現在,將專案行起來,並且看看Xcode的console輸出結果:
相較於同步執行,這個範例看起來有趣多了,你可以看到程式碼在主佇列(第二個for
loop迴圈)以及dispatch queue裡面的程式碼是同時運行的,這個自行定義佇列實際上花費較多運行時間,但這就是優先處理事項的排列關係(接下來將會看到),這裡主要想強調的是,當我們另一個任務在背景執行時,主佇列是閒置隨時準備使用的狀態,這個運作模式不會出現在同步執行的佇列。
即使上述的範例相當簡單,但已經很清楚的展示了一個程式的佇列在同步以及非同步情況下的運作方式,我們將在接下來的範例繼續保持富有色彩的console輸出效果,請記住,特定顏色代表特定的佇列內部程式碼運行結果,不同顏色就代表不同的佇列。
Quality Of Service (QoS) and Priorities
在使用GCD與dispatch queues時,我們經常會需要告訴系統,你的應用程式中哪個任務比較重要,需要優先去執行,當然,由於主佇列都是用來處理UI接收到的指令,所以跑在主執行緒的任務是最優先執行的,在任何情況下,都必須根據自身需求提供佇列執行的優先順序以及其他需要的資訊(例如在CPU上的執行時間)給系統,雖然所有的任務最終都將完成。然而,區別在於哪些任務會更快完成,哪些任務會較晚完成。
關於任務的重要性及優先順序的資訊在GCD稱為Quality of Service (QoS),QoS是一個包含特定情境的enum
,根據你想要的優先順序,提供一個適當的QoS值在佇列初始化作業,如果沒有特別定義,佇列則預設為 default priority,可使用的相關選項介紹可參考這裡, 下列則整理QoS可選擇的項目,它們被稱做QoS classes,第一個class代表最高順位,最後一個則代表最低順位。
- userInteractive
- userInitiated
- default
- utility
- background
- unspecified
現在回到專案中,我們將使用queuesWithQoS()
進行作業,先宣告並初始化下列兩個新的dispatch queues:>
let queue1 = DispatchQueue(label: "com.appcoda.queue1", qos: DispatchQoS.userInitiated) let queue2 = DispatchQueue(label: "com.appcoda.queue2", qos: DispatchQoS.userInitiated)
注意,這邊對它們設定相同的QoS class,所以在執行時的優先順序是相同的,就像我之前做的,第一個佇列將包含一個for
loop 迴圈,用來呈現0~9的數字(外加一個紅點),而在第二個佇列我們將執行另一個for
loop迴圈,展示100~109數字(附加一個藍點)。
看到執行結果可以知道它們佇列被設定相同的優先順序(QoS class相同)-不要忘記在viewDidAppear(_:)
裡將queuesWithQos()
的註解取消(uncomment):
很輕易的可以看到上面截圖的任務被"均勻" 的執行,這就是我們預期的結果,現在,將queue2
的QoS class改為utility(較低的順序),如下圖所示:
let queue2 = DispatchQueue(label: "com.appcoda.queue2", qos: DispatchQoS.utility)
看看現在發生了什麼事:
不要懷疑,由於被賦予更高的優先權,第一個dispatch queue(queue1
)較第二個更快被執行,即使queue2
在第一個佇列開始運行後隨即得到執行的機會,但由於第一個佇列被標記為較重要的任務,所以系統將資源主要提供給它,當它完成後,系統才會去關心第二個佇列。
讓我們進行另一個練習,是時候將第一個佇列的QoS class更改為background:
let queue1 = DispatchQueue(label: "com.appcoda.queue1", qos: DispatchQoS.background)
它被賦予的優先權幾乎是最低的,所以讓我們看一下程式碼運行時發生了什麼事情:
由於QoS class設定為utility比background擁有更高的優先權,因此,這次第二個佇列比較快跑完。
上述的範例讓我們清楚了解QoS classes如何運作,但是如果我們將任務跑在主佇列會發生什麼事情呢?請將下列的程式碼加入到我們的函式尾端:
for i in 1000..<1010 {
print("Ⓜ️", i)
}
同時,也將第一個佇列的QoS class做更改,將優先順序設定為更高層級:
let queue1 = DispatchQueue(label: "com.appcoda.queue1", qos: DispatchQoS.userInitiated)
下圖為輸出結果:
我們再一次看到主佇列預設有較高的優先權,queue1
的dispatch queue與主要的佇列並發執行,queue2
則是最後完成,而且當另外兩個佇列內的任務正在執行時,它沒有太多機會可以被執行到,這就是因為被設定成較低的執行順序。
Concurrent Queues
在目前為止,我們分別看到了dispatch queues同步與非同步的作業情況,以及系統設定的Quality of Service class如何影響執行的優先順序,先前的範例都是將我們的佇列設為serial,這表示如果我們賦予一個以上的任務給任何的佇列,這些任務將是依序被執行,而非一起被執行,接下來,我們將看到如何在同一時間運行多個工作任務(work items),換句話說,下面我們將會實作concurrent queue。
在這個專案中,我們將使用concurrentQueues()
函式(請在viewDidAppear(_:)
將對應的程式碼取消註解),在這個新的函式將會建立一個新的佇列:
let anotherQueue = DispatchQueue(label: "com.appcoda.anotherQueue", qos: .utility)
現在,將下列的任務(或是稱為work items)丟給這個佇列:
當程式碼運行時,這些任務將依序被執行,下面截圖可以看得更清楚:
接下來,請修改anotherQueue
的初始化方法:
let anotherQueue = DispatchQueue(label: "com.appcoda.anotherQueue", qos: .utility, attributes: .concurrent)
上面的初始化動作有添加一個新的參數: attributes
。這個參數我們帶入concurrent
,所以全部的任務在這個佇列上會同時被執行,如果你不使用這個參數,佇列會被設定為serial,事實上,QoS參數也並不是必須的,若是在初始化作業中,我們將這些參數拿掉也是沒有任何問題的。
再一次運行這個app,我們可以注意到,任務都被並發執行了:
注意,改變任務執行的QoS class也會被影響,儘管如此,只要在初始化時將佇列設定為concurrent,這些任務將會以並發方式執行,它們會擁有各自的運行時間。
attributes
參數也可以接受另外一個稱為initiallyInactive
的值,如果使用這個值,這些任務不會被自動執行,開發者必須去觸發這個執行動作,我們接下來將會說明,但需要先做一些修改。首先,聲明一個名為inactiveQueue </ code>的類別屬性,如下圖所示:
var inactiveQueue: DispatchQueue!
現在初始化佇列,並將它賦值給inactiveQueue
:
let anotherQueue = DispatchQueue(label: "com.appcoda.anotherQueue", qos: .utility, attributes: .initiallyInactive) inactiveQueue = anotherQueue
在這個情況中使用類別屬性(class property)是必須的,因為anotherQueue
被定義在concurrentQueues()
函式中,只能在這裡面使用,應用程序不知道它何時退出該函式,我們將無法喚醒這個佇列,但最重要的事情是,運行時可能會閃退。
該是時候再次運行我們的應用程式了,但你將會看到這裡沒有任何輸出,這是原本就預期的結果,我們可以在 viewDidAppear(_:)
函式添加下列程式碼,藉以觸發這個dispatch queue:
if let queue = inactiveQueue { queue.activate() }
DispatchQueue類別內的activate()
函式將觸發這個任務,注意,當這個queue並未被設定為concurrent,則它們將會以序列化方式執行:
現在的問題是,當它一開始未被喚醒前,我們如何先將其設定為concurrent queue?最簡單的做法,我們提供一個array將兩個值存放進去,做為attributes
的參數,代替原本僅提供單一數值的方法:
let anotherQueue = DispatchQueue(label: "com.appcoda.anotherQueue", qos: .userInitiated, attributes: [.concurrent, .initiallyInactive])
延遲執行
有時候應用程式在代碼塊內運行一個work item時會有延遲執行需求,GCD允許開發者透過特定的方法,讓指定任務在一段設定的時候後才被執行。
這次我們將程式碼寫在queueWithDelay()
函式內,它已經寫在我們的初始專案內,請開始將下列代碼添加到裡面:
let delayQueue = DispatchQueue(label: "com.appcoda.delayqueue", qos: .userInitiated) print(Date()) let additionalTime: DispatchTimeInterval = .seconds(2)
剛開始我們就像之前一樣建立一個新的DispatchQueue
;將在下一步使用它。然後,我們印出當前日期,以便將來驗證任務的等待時間,最後我們指定等待時間,延遲時間通常是一個DispatchTimeInterval
枚舉值(enum value),它被添加到DispatchTime
以指定延遲效果。在範例中,替該任務設定兩秒的執行等待時間,這裡我們使用seconds
方法,除此之外還提供以下幾種:
- microseconds
- milliseconds
- nanoseconds
現在開始使用這個佇列:
delayQueue.asyncAfter(deadline: .now() + additionalTime) { print(Date()) }
now()
方法會回傳目前時間,我們另外把想要延遲的時間添加進來,如果運行這個應用程式,我們可以在console看到下列結果:
的確,這個dispatch queue內的任務在兩秒後被執行,但請注意,這裡有另一個替代方式指定等待時間,如果您不想使用上述任何預定義方法,可以直接向當前時間添加Double
值:
delayQueue.asyncAfter(deadline: .now() + 0.75) { print(Date()) }
在這個案例中,任務將會在專案運行後的0.75秒被執行,同時,你也可以避免使用now()
方法,但必須提供自行一個DispatchTime的值當作參數,上面顯示的是佇列內work item延遲執行最簡單的方法,但實際上也不需要任何其他東西。
訪問Main和Global Queues
在之前的所有案例中,我們手動創建了要使用的dispatch queues,儘管如此,它並不總是需要這樣做,尤其是如果你不想要改變dispatch queue的屬性值,就像我們在文章一開始所說,系統會建立一個background dispatch queues集合,也稱為global queues,你可以像使用自定義佇列一樣自由地使用它們,只是記住不要濫用系統,請勿極盡所能使用global queues。
訪問global queue非常簡單:
let globalQueue = DispatchQueue.global()
你可以使用它,就像迄今為止看到的任何其他佇列:
當我們在使用global queues時,沒有太多屬性可以供你修改,儘管如此,開發者仍可以指定想要使用的Quality of Service class。
let globalQueue = DispatchQueue.global(qos: .userInitiated)
如果你沒有指定QoS class(就像我們第一個實作範例),那預設情況下會以default
當成預設值。
不論你使不使用global queues,都需要經常訪問主佇列,這點無庸置疑,最可能的情境就是更新UI。從任何其他佇列訪問主佇列很簡單,就如同下面的代碼,並且在調用時指定是同步執行還是異步執行:
DispatchQueue.main.async { // Do something }
事實上,你可以藉由輸入DispatchQueue.main.
,看到主佇列全部可用的選項, Xcode將會自動列出在main queue中可以呼叫的全部方法,最上面顯示的會是大多數時間所需要的(事實上,這是一般情況都適用的,任何佇列的可用方法在輸入佇列名稱並按”.”符號後,Xcode自動建議的 ),你還可以根據上一部分中所看到的內容,對代碼執行區塊添加延遲效果。
現在有一個真實的範例,我們可以使用主佇列來刷新UI介面,在你作業的初始專案中,位於Main.storyboard
檔案裡面的ViewController
畫面包含了一個image view,而且對應的 IBOutlet屬性已連結至ViewController
類別中,在這裡,我們將進入fetchImage()
(目前仍是空的)函式,需要透過程式碼去下載Appcoda的logo,並且將它展示在image view上面,下面的代碼完成了上述動作(我不會在這裡針對URLSession
類別做相關討論,以及介紹它如何使用):
func fetchImage() { let imageURL: URL = URL(string: "http://www.appcoda.com.tw/wp-content/uploads/2015/12/blog-logo-dark-400.png")! (URLSession(configuration: URLSessionConfiguration.default)).dataTask(with: imageURL, completionHandler: { (imageData, response, error) in if let data = imageData { print("Did download image data") self.imageView.image = UIImage(data: data) } }).resume() }
注意,我們其實不是在主佇列上刷新UI介面,我們試圖在背景執行緒使用dataTask(...)
方法的completion handler block來替代處理,現在請編譯並運行這個應用程式,看看會發生什麼事(不要忘記呼叫fetchImage()
函式)。
即使我們得到image已經被下載完成的訊息,但是我們卻無法在image view看到它,因為UI介面沒有被更新,最可能的是,圖像將在初始消息出現的幾分鐘後顯示(但是如果其他任務也在應用程序中執行,上述情況不保證會發生),問題不僅如此,你也將會獲得一長串的error log,抱怨UI更新的動作被放在背景執行緒執行。
現在,讓我們改變這個有問題的行為,使用主佇列來修改UI介面。 在編輯上述方法時,改變下面所示的部分,並且注意我們如何使用主佇列:
if let data = imageData { print("Did download image data") DispatchQueue.main.async { self.imageView.image = UIImage(data: data) } }
再一次運行這個應用程式,會看到image view在下載作業完成後獲得image,主佇列真的被調用並且更新我們的UI。
使用 DispatchWorkItem 物件
DispatchWorkItem是一個代碼塊,可以在任何的佇列上面被調用,因此,裡面的程式碼可以在背景執行,或是在主執行緒運行,想想它真的很簡單,一堆代碼你可以直接調用,而不是像我們在之前所寫的代碼塊。
使用這種work item最簡單的方法如下所示:
let workItem = DispatchWorkItem { // Do something }
讓我們透過一個小範例來看看DispatchWorkItem
物件如何使用,前往 useWorkItem()
函式,並添加以下代碼:
func useWorkItem() { var value = 10 let workItem = DispatchWorkItem { value += 5 } }
我們work item的目的是將value
變量的值增加5,我們使用workItem
物件去呼叫perform()
,如下所示:
workItem.perform()
這一行程式碼將會在主執行緒上面調用work item,但是你也可以總是使用其他的佇列,讓我們看看下面範例:
let queue = DispatchQueue.global() queue.async { workItem.perform() }
這樣也可以完善地達成工作,儘管如此,另外也有比較快的方式可以調用work item,DispatchQueue類別為此目的提供了一種方便的方法:
queue.async(execute: workItem)
當一個work item被調用後,你可以通知你的主佇列(或是其他任何你想要的佇列),如下所示:
workItem.notify(queue: DispatchQueue.main) { print("value = ", value) }
上面程式碼將在console印出value
變數的值,並且當work item被調用時進行呼叫,現在將所有的東西放在一起,useWorkItem()
函式內的程式碼如下:
func useWorkItem() { var value = 10 let workItem = DispatchWorkItem { value += 5 } workItem.perform() let queue = DispatchQueue.global(qos: .utility) queue.async(execute: workItem) workItem.notify(queue: DispatchQueue.main) { print("value = ", value) } }
這裡你將開始運行這個應用程式(並且在viewDidAppear(_:)
)呼叫上述這個函式):
總結
大多數時候,這篇文章中看到的都是你做多工任務和並發編程所需要的。但是,請記住,仍有GCD概念本教程中沒有涉及,或者已經討論過的一些其他概念,但是沒有深入細節的部分。目的是想保持本篇文章簡單易讀的特性,讓內容是比較好理解的,適合所有級別和技能的開發人員。 如果你沒有使用GCD的習慣,請認真考慮一下,嘗試從主佇列中卸載較繁重的任務;如果有任務可以在背景執行,那麼在背景發送它們。在任何情況下,使用GCD並不困難,且結果一定是正面的,將讓你的應用程式能反應更迅速,享受與GCD互動的樂趣吧!>
範例專案,你可以在這個GitHub看到它。
FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS
原文:Grand Central Dispatch (GCD) and Dispatch Queues in Swift 3