目前在 iOS 和 OS X 中有兩套先進的同步 API 可供我們使用:NSOperation 和 GCD 。其中 GCD 是基於 C 的底層的 API ,而 NSOperation 則是 GCD 實現的 Objective-C API。 雖然 NSOperation 是基於 GCD 實現的, 但是並不意味着它是一個 GCD 的 “dumbed-down” 版本, 相反,我們可以用NSOperation 輕易的實現一些 GCD 要寫大量代碼的事情。 因此, NSOperationQueue 是被推薦使用的, 除非你遇到了 NSOperationQueue 不能實現的問題。
1. 爲什麼優先使用NSOperationQueue而不是GCD
曾經我有一段時間我非常喜歡使用GCD來進行併發編程,因爲雖然它是C的api,但是使用起來卻非常簡單和方便, 不過這樣也就容易使開發者忘記併發編程中的許多注意事項和陷阱。
比如你可能寫過類似這樣的代碼(這樣來請求網絡數據):
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> <span class="line-number" style="margin:0px; padding:0px">7</span> <span class="line-number" style="margin:0px; padding:0px">8</span> <span class="line-number" style="margin:0px; padding:0px">9</span> <span class="line-number" style="margin:0px; padding:0px">10</span> |
|
沒錯,它是可以正常的工作,但是有個致命的問題:這個任務是無法取消的 dataWithContentURL:
是同步的拉取數據,它會一直阻塞線程直到完成請求,如果是遇到了超時的情況,它在這個時間內會一直佔有這個線程;在這個期間併發隊列就需要爲其他任務新建線程,這樣可能導致性能下降等問題。
因此我們不推薦這種寫法來從網絡拉取數據。
操作隊列(operation queue)是由 GCD 提供的一個隊列模型的 Cocoa 抽象。GCD 提供了更加底層的控制,而操作隊列則在 GCD 之上實現了一些方便的功能,這些功能對於 app 的開發者來說通常是最好最安全的選擇。NSOperationQueue相對於GCD來說有以下優點:
- 提供了在 GCD 中不那麼容易複製的有用特性。
- 可以很方便的取消一個NSOperation的執行
- 可以更容易的添加任務的依賴關係
- 提供了任務的狀態:isExecuteing, isFinished.
名詞: 本文中提到的 “任務”, “操作” 即代表要再NSOperation中執行的事情。
2. Operation Queues的使用
2.1 NSOperationQueue
NSOperationQueue
有兩種不同類型的隊列:主隊列和自定義隊列。主隊列運行在主線程之上,而自定義隊列在後臺執行。在兩種類型中,這些隊列所處理的任務都使用 NSOperation
的子類來表述。
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> |
|
我們可以通過設置 maxConcurrentOperationCount
屬性來控制併發任務的數量,當設置爲 1
時,
那麼它就是一個串行隊列。主對列默認是串行隊列,這一點和 dispatch_queue_t
是相似的。
2.2 NSOperation
你可以使用系統提供的一些現成的 NSOperation
的子類, 如 NSBlockOperation
、NSInvocationOperation
等(如上例子)。你也可以實現自己的子類,
通過重寫 main
或者 start
方法
來定義自己的 operations 。
使用 main
方法非常簡單,開發者不需要管理一些狀態屬性(例如 isExecuting
和 isFinished),當 main 方法返回的時候,這個 operation 就結束了。這種方式使用起來非常簡單,但是靈活性相對重寫 start 來說要少一些, 因爲main方法執行完就認爲operation結束了,所以一般可以用來執行同步任務。
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> |
|
如果你希望擁有更多的控制權,或者想在一個操作中可以執行異步任務,那麼就重寫 start
方法,
但是注意:這種情況下,你必須手動管理操作的狀態, 只有當發送 isFinished
的
KVO 消息時,才認爲是 operation 結束
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> <span class="line-number" style="margin:0px; padding:0px">7</span> <span class="line-number" style="margin:0px; padding:0px">8</span> <span class="line-number" style="margin:0px; padding:0px">9</span> <span class="line-number" style="margin:0px; padding:0px">10</span> <span class="line-number" style="margin:0px; padding:0px">11</span> <span class="line-number" style="margin:0px; padding:0px">12</span> |
|
當實現了start方法時,默認會執行start方法,而不執行main方法
爲了讓操作隊列能夠捕獲到操作的改變,需要將狀態的屬性以配合 KVO
的方式進行實現。如果你不使用它們默認的
setter 來進行設置的話,你就需要在合適的時候發送合適的 KVO
消息。
需要手動管理的狀態有:
isExecuting
代表任務正在執行中isFinished
代表任務已經執行完成isCancelled
代表任務已經取消執行
手動的發送 KVO
消息, 通知狀態更改如下 :
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> |
|
爲了能使用操作隊列所提供的取消功能,你需要在長時間操作中時不時地檢查 isCancelled
屬性,
比如在一個長的循環中:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> <span class="line-number" style="margin:0px; padding:0px">7</span> <span class="line-number" style="margin:0px; padding:0px">8</span> <span class="line-number" style="margin:0px; padding:0px">9</span> |
|
3. RunLoop
在cocoa中講到多線程,那麼就不得不講到RunLoop。 在ios/mac的編碼中,我們似乎不需要過多關心代碼是如何執行的,一切彷彿那麼自然。比如我們知道當滑動手勢時,tableView就會滾動,啓動一個NSTimer之後,timer的方法就會定時執行, 但是爲什麼呢,其實是RunLoop在幫我們做這些事情:分發消息。
3.1 什麼是RunLoop
你應該看過這樣的僞代碼解釋ios的app中main函數做的事情:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> |
|
也應該看過這樣的代碼用來阻塞一個線程:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> |
|
或許你感覺到他們有些神奇,希望我的解釋能讓你明白一些.
我們先思考一個問題: 當我們打開一個IOS應用之後,什麼也不做,這時候看起來是沒有代碼在執行的,爲什麼應用沒有退出呢?
我們在寫c的簡單的只有一個main函數的程序時就知道,當main的代碼執行完,沒有事情可做的時候,程序就執行完畢退出了。而我們IOS的應用是如何做到在沒有事情做的時候維持應用的運行的呢? 那就是RunLoop。
RunLoop的字面意思就是“運行迴路”,聽起來像是一個循環。實際它就是一個循環,它在循環監聽着事件源,把消息分發給線程來執行。RunLoop並不是線程,也不是併發機制,但是它在線程中的作用至關重要,它提供了一種異步執行代碼的機制。
3.2 事件源
由圖中可以看出NSRunLoop只處理兩種源:輸入源、時間源。而輸入源又可以分爲:NSPort
、自定義源、performSelector:OnThread:delay:
,
下面簡單介紹下這幾種源:
3.2.1 NSPort 基於端口的源
Cocoa和 Core Foundation 爲使用端口相關的對象和函數創建的基於端口的源提供了內在支持。Cocoa中你從不需要直接創建輸入源。你只需要簡單的創建端口對象,並使用NSPort的方法將端口對象加入到run loop。端口對象會處理創建以及配置輸入源。
NSPort一般分三種: NSMessagePort
(基本廢棄)、NSMachPort
、 NSSocketPort
。
系統中的NSURLConnection
就是基於NSSocketPort
進行通信的,所以當在後臺線程中使用NSURLConnection
時,需要手動啓動RunLoop,
因爲後臺線程中的RunLoop默認是沒有啓動的,後面會講到。
3.2.2 自定義輸入源
在Core Foundation程序中,必須使用CFRunLoopSourceRef類型相關的函數來創建自定義輸入源,接着使用回調函數來配置輸入源。Core Fundation會在恰當的時候調用回調函數,處理輸入事件以及清理源。常見的觸摸、滾動事件等就是該類源,由系統內部實現。
一般我們不會使用該種源,第三種情況已經滿足我們的需求
3.2.3 performSelector:OnThread
Cocoa提供了可以在任一線程執行函數(perform selector)的輸入源。和基於端口的源一樣,perform selector請求會在目標線程上序列化,減緩許多在單個線程上容易引起的同步問題。而和基於端口的源不同的是,perform selector執行完後會自動清除出run loop。
此方法簡單實用,使用也更廣泛。
3.2.4 定時源
定時源就是NSTimer了,定時源在預設的時間點同步地傳遞消息。因爲Timer是基於RunLoop的,也就決定了它不是實時的。
3.3 RunLoop觀察者
我們可以通過創建CFRunLoopObserverRef
對象來檢測RunLoop的工作狀態,它可以檢測RunLoop的以下幾種事件:
- Run loop入口
- Run loop將要開始定時
- Run loop將要處理輸入源
- Run loop將要休眠
- Run loop被喚醒但又在執行喚醒事件前
- Run loop終止
3.4 Run Loop Modes
RunLoop對於上述四種事件源的監視,可以通過設置模式來決定監視哪些源。 RunLoop只會處理與當前模式相關聯的源,未與當前模式關聯的源則處於暫停狀態。
cocoa和Core Foundation預先定義了一些模式(Apple文檔翻譯):
Mode | Name | Description |
---|---|---|
Default | NSDefaultRunLoopMode (Cocoa) kCFRunLoopDefaultMode (Core Foundation) | 缺省情況下,將包含所有操作,並且大多數情況下都會使用此模式 |
Connection | NSConnectionReplyMode (Cocoa) | 此模式用於處理NSConnection的回調事件 |
Modal | NSModalPanelRunLoopMode (Cocoa) | 模態模式,此模式下,RunLoop只對處理模態相關事件 |
Event Tracking | NSEventTrackingRunLoopMode (Cocoa) | 此模式下用於處理窗口事件,鼠標事件等 |
Common Modes | NSRunLoopCommonModes (Cocoa) kCFRunLoopCommonModes (Core Foundation) | 此模式用於配置”組模式”,一個輸入源與此模式關聯,則輸入源與組中的所有模式相關聯。 |
我們也可以自定義模式,可以參考ASIHttpRequest
在同步執行時,自定義了 runLoop
的模式叫ASIHTTPRequestRunLoopMode
。ASI的Timer源就關聯了此模式。
3.5 常見問題一:爲什麼TableView滑動時,Timer暫停了?
我們做個測試: 在一個 viewController 的 scrollViewWillBeginDecelerating:
方法裏面打個斷點,
然後滑動 tableView
。 待斷點處, 使用 lldb
打印一下 [NSRunLoop
currentRunLoop]
。 在描述中可以看到當前的RunLoop的運行模式:
current mode = UITrackingRunLoopMode
common modes = <CFBasicHash 0x14656e60 [0x3944dae0]>{type = mutable set, count = 2,
entries =>
0 : <CFString 0x398d54c0 [0x3944dae0]>{contents = "UITrackingRunLoopMode"}
1 : <CFString 0x39449d10 [0x3944dae0]>{contents = "kCFRunLoopDefaultMode"}
}
也就是說,當前主線程的 RunLoop 正在以 UITrackingRunLoopMode
的模式運行。
這個時候 RunLoop 只會處理與 UITrackingRunLoopMode
“綁定”的源,
比如觸摸、滾動等事件;而 NSTimer
是默認“綁定”到NSRunLoopDefaultMode
上的,
所以 Timer
是事情是不會被 RunLoop 處理的,我們的看到的時定時器被暫停了!
常見的解決方案是把Timer“綁定”到 NSRunLoopCommonModes
模式上,
那麼Timer就可以與:
<span class="line-number" style="margin:0px; padding:0px">1</span> |
|
這樣這個Timer就可以和當前組中的兩種模式 UITrackingRunLoopMode
和 kCFRunLoopDefaultMode
相關聯了。
RunLoop在這兩種模式下,Timer都可以正常運行了。
注意: 由上面可以發現 NSTimer
是不準確的。 因爲RunLoop只負責分發源的消息。如果線程當前正在處理繁重的任務,比如循環,就有可能導致Timer本次延時,或者少執行一次。網上有人做過實驗:
上面的Log是一個間隔爲 1 s
的計時器,我們可以發現在 12.836s
~ 15.835s
之間的時間段內, 明顯的13s
的方法沒有執行。 14s
的方法有所延遲。
因此當我們用NSTimer來完成一些計時任務時,如果需要比較精確的話,最好還是要比較“時間戳”。
3.6 常見問題二:後臺的NSURLConnection不回調,Timer不運行
我們知道每個線程都有它的RunLoop, 我們可以通過 [NSRunLoop currentRunLoop]
或CFRunLoopGetCurrent()
來獲取。
但是主線程和後臺線程是不一樣的。主線程的RunLoop是一直在啓動的。而後臺線程的RunLoop是默認沒有啓動的。
後臺線程的RunLoop沒有啓動的情況下的現象就是:“代碼執行完,線程就結束被回收了”。就像我們簡單的程序執行完就退出了。 所以如果我們希望在代碼執行完成後還要保留線程等待一些異步的事件時,比如NSURLConnection和NSTimer, 就需要手動啓動後臺線程的RunLoop。
啓動RunLoop,我們需要設定RunLoop的模式,我們可以設置 NSDefaultRunLoopMode
。
那默認就是監聽所有時間源:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> |
|
我們也可以設置其他模式運行,但是我們就需要把“事件源” “綁定”到該模式上:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> <span class="line-number" style="margin:0px; padding:0px">6</span> <span class="line-number" style="margin:0px; padding:0px">7</span> <span class="line-number" style="margin:0px; padding:0px">8</span> |
|
3.7 問題三:本節開頭的例子爲何可以阻塞線程
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> |
|
你應該知道這樣一段代碼可以阻塞當前線程,你可能會奇怪:RunLoop就是不停循環來檢測源的事件,爲什麼還要加個 while
呢?
這是因爲RunLoop的特性,RunLoop會在沒有“事件源”可監聽時休眠。也就是說如果當前沒有合適的“源”被RunLoop監聽,那麼這步就跳過了,不能起到阻塞線程的作用,所以還是要加個while循環來維持。
同時注意:因爲這段代碼可以阻塞線程,所以請不要在主線程寫下這段代碼,因爲它很可能會導致界面卡住。
4. 線程安全
講了這麼多,你是否已經對併發編程已經躍躍欲試了呢? 但是併發編程一直都不是一個輕鬆的事情,使用併發編程會帶來許多陷阱。哪怕你是一個很成熟的程序員和架構師,也很難避免線程安全的問題;使用的越多,出錯的可能就越大,因此可以不用多線程就不要使用。
關於併發編程的不可預見性有一個非常有名的例子:在1995年, NASA (美國宇航局)發送了開拓者號火星探測器,但是當探測器成功着陸在我們紅色的鄰居星球后不久,任務嘎然而止,火星探測器莫名其妙的不停重啓,在計算機領域內,遇到的這種現象被定爲爲優先級反轉,也就是說低優先級的線程一直阻塞着高優先級的線程。在這裏我們想說明的是,即使擁有豐富的資源和大量優秀工程師的智慧,併發也還是會在不少情況下反咬你你一口。
4.1 資源共享和資源飢餓
併發編程中許多問題的根源就是在多線程中訪問共享資源。資源可以是一個屬性、一個對象,通用的內存、網絡設備或者一個文件等等。在多線程中任何一個共享的資源都可能是一個潛在的衝突點,你必須精心設計以防止這種衝突的發生。
一般我們通過鎖來解決資源共享的問題,也就是可以通過對資源加鎖保證同時只有一個線程訪問資源
4.1.1 互斥鎖
互斥訪問的意思就是同一時刻,只允許一個線程訪問某個特定資源。爲了保證這一點,每個希望訪問共享資源的線程,首先需要獲得一個共享資源的互斥鎖。 對資源加鎖會引發一定的性能代價。
4.1.2 原子性
從語言層面來說,在 Objective-C 中將屬性以 atomic 的形式來聲明,就能支持互斥鎖了。事實上在默認情況下,屬性就是 atomic 的。將一個屬性聲明爲 atomic 表示每次訪問該屬性都會進行隱式的加鎖和解鎖操作。雖然最把穩的做法就是將所有的屬性都聲明爲 atomic,但是加解鎖這也會付出一定的代價。
4.1.3 死鎖
互斥鎖解決了競態條件的問題,但很不幸同時這也引入了一些其他問題,其中一個就是死鎖。當多個線程在相互等待着對方的結束時,就會發生死鎖,這時程序可能會被卡住。
比如下面的代碼:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> |
|
再比如:
<span class="line-number" style="margin:0px; padding:0px">1</span> <span class="line-number" style="margin:0px; padding:0px">2</span> <span class="line-number" style="margin:0px; padding:0px">3</span> <span class="line-number" style="margin:0px; padding:0px">4</span> <span class="line-number" style="margin:0px; padding:0px">5</span> |
|
上面兩個例子也可以說明 dispatch_sync
這個API是危險的,所以儘量不要用。
當你的代碼有死鎖的可能時,它就會發生
4.1.4 資源飢餓
當你認爲已經足夠了解併發編程面臨的問題時,又出現了一個新的問題。鎖定的共享資源會引起讀寫問題。大多數情況下,限制資源一次只能有一個線程進行讀取訪問其實是非常浪費的。因此,在資源上沒有寫入鎖的時候,持有一個讀取鎖是被允許的。這種情況下,如果一個持有讀取鎖的線程在等待獲取寫入鎖的時候,其他希望讀取資源的線程則因爲無法獲得這個讀取鎖而導致資源飢餓的發生。
4.2 優先級反轉
優先級反轉是指程序在運行時低優先級的任務阻塞了高優先級的任務,有效的反轉了任務的優先級。GCD提供了3種級別的優先級隊列,分別是Default, High, Low。 高優先級和低優先級的任務之間共享資源時,就可能發生優先級反轉。當低優先級的任務獲得了共享資源的鎖時,該任務應該迅速完成,並釋放掉鎖,這樣高優先級的任務就可以在沒有明顯延時的情況下繼續執行。然而高優先級任務會在低優先級的任務持有鎖的期間被阻塞。如果這時候有一箇中優先級的任務(該任務不需要那個共享資源),那麼它就有可能會搶佔低優先級任務而被執行,因爲此時高優先級任務是被阻塞的,所以中優先級任務是目前所有可運行任務中優先級最高的。此時,中優先級任務就會阻塞着低優先級任務,導致低優先級任務不能釋放掉鎖,這也就會引起高優先級任務一直在等待鎖的釋放。如下圖:
使用不同優先級的多個隊列聽起來雖然不錯,但畢竟是紙上談兵。它將讓本來就複雜的並行編程變得更加複雜和不可預見。因此我們寫代碼的時候最好只用Default優先級的隊列,不要使用其他隊列來讓問題複雜化。
關於dispatch_queue的底層線程安全設計可參考:底層併發 API
5. 總結
本文主要講了 NSOperationQueue、 NSRunLoop、 和線程安全等三大塊內容。 希望可以幫助你理解 NSOperation的使用, NSRunLoop的作用, 還有併發編程帶來的複雜性和相關問題。
併發實際上是一個非常棒的工具。它充分利用了現代多核 CPU 的強大計算能力。但是因爲它的複雜性,所以我們儘量使用高級的API,儘量寫簡單的代碼,讓併發模型保持簡單; 這樣可以寫出高效、結構清晰、且安全的代碼。