使用 NSOperationQueue 時控制任務數(shù)量會并不總是有效,原因何在?利用 NSOperation 封裝異步代碼有什么需要注意的地方?是否有更好的方法來控制任務的并發(fā)數(shù)量?為此,我們需要深入了解 NSOperation 的運作機制,現(xiàn)在我們從實際應用場景出發(fā)探討這些問題。
本文沒有 Demo,我開源了一個下載庫 SDEDownloadManager ,實現(xiàn)了常見的下載管理需求,還有配套的 UI 組件。
下載管理需求
作為開發(fā)人員的你接到為 App 添加下載管理功能的需求,初始版本的要求很簡單:為避免下載帶寬被過多任務分散,只允許同時最多下載3個文件;只提供全部暫停/開始的功能。你不想大動干戈去使用那些大名鼎鼎的網(wǎng)絡庫,想看看能否利用系統(tǒng)框架現(xiàn)有的工具來快速完成這個功能??戳艘环瑳Q定使用NSOperation封裝NSURLSessionDownloadTask,利用NSOperationQueue的maxConcurrentOperationCount來限制任務數(shù)量。
DownloadOperation的部分代碼:
private(set) var isStarted: Bool = false // 啟動的標記
// 非異步代碼只需要重寫 main(),封裝異步代碼必須重寫 start()
// 以及部分屬性,以及決定結(jié)束 NSOperation 生命周期的時機,
// 這部分關鍵細節(jié)暫且假設已實現(xiàn),放在下一節(jié)討論。
override func start() {
isStarted = true
resume()
}
func resume(){
guard isStarted else {return}
downloadTask.resume()
isExecuting = true
}
func suspend(){
guard isStarted else {return}
downloadTask.suspend()
isExecuting = false
}
實現(xiàn)全部暫停/開始功能的部分代碼:
var downloadOperations: [DownloadOperation]{
return operationQueue.operations as! [DownloadOperation]
}
func pauseAllDownloads(){
downloadOperations.filter({$0.isExecuting == true}).forEach({$0.suspend()})
}
func resumeAllDownloads(){
let pasuedOps = downloadOperations.filter({ $0.isStarted == true && $0.isExecuting == false})
pausedOps.forEach({$0.resume()})
if /* 存在其他未完成的任務 */{
for task in unfinishedTasks{
operationQueue.addOperation(operation)
}
}
}
快速實現(xiàn)后,上手測試了下,符合要求。第二個版本很快來了:
- 開放針對單個任務的暫停/開始功能,便于用戶對任務進行調(diào)度,比如某個任務進展緩慢,暫停這個任務后自動啟動等待中的其它任務,很明顯,
downloadOpeation.suspend()能暫停任務,但等待中的任務沒有自動啟動。 - 提供實時調(diào)節(jié)最大下載量的功能:3個太少,提升到10個,將
maxConcurrentOperationCount設為10,等待中的任務陸續(xù)啟動了;10個似乎太多了,有些任務分配到的速度太低了,還是把速度集中利用起來,重新設置為5,不對勁,還是有10個任務在下載,查看文檔發(fā)現(xiàn)這個值減少時不會對已啟動的NSOperation產(chǎn)生影響,因此只能手動處理了,選中5個任務暫停,達到了要求。但任務陸續(xù)完成后,等待中的任務并沒有自動啟動。
下載任務可能在任意時刻結(jié)束,同時用戶也可能隨時調(diào)整下載量,情況會比上面遇到的情況更加復雜,為了徹底解決各種可能的問題,我們有必要深入了解下NSOperation和NSOperationQueue的運作機制。
NSOperation 的生命周期

該圖截自 WWDC 2015 Session 226: Advanced NSOperations。NSOperation 的以上狀態(tài)并非通過一個枚舉,而是多個 Bool 值來表示,除了 Pending 狀態(tài)(可簡單理解為isReady == false),其他四個狀態(tài)有對應的屬性來表示:
var isReady: Bool { get }
var isExecuting: Bool { get }
var isFinished: Bool { get }
var isCancelled: Bool { get }
執(zhí)行operationQueue.addOperation(operation)這行代碼后,NSOperationQueue為其分配線程并調(diào)用operation.start()來啟動這個任務,啟動條件如下:
-
isReady == true(NSOperation的isReady的默認值是true,如果添加了依賴則需要等待依賴的NSOperation結(jié)束后才能進入isReady狀態(tài)); - 已啟動的
NSOperation數(shù)量少于maxConcurrentOperationCount(默認值為-1,無限制); -
isReady == true的NSOperation的數(shù)量多于剩余可啟動數(shù)量時,較高queuePriority值的有優(yōu)先啟動權; -
queuePriority相同時再比較加入隊列的時間順序,NSOperationQueue基于 GCD 實現(xiàn),采用 FIFO 機制。
滿足以上條件的NSOperation會分配線程啟動。start()是NSOperation的啟動入口,而且只能由其所屬的NSOperationQueue來調(diào)用,否則拋出異常。從文檔描述和實際測試來看,start()的默認實現(xiàn)的邏輯是這樣的:
// 由于相關屬性都是只讀的,我猜測實際的代碼里是設置對應的私有屬性
// 并手動發(fā)布 KVO 通知,我在重寫`isFinished`時也是這么做的。
func start() {
if !isCancelled{
isExecuting = true
main()
isExecuting = false
}
isFinished = true
}
start()返回前設置isFinished = true并發(fā)出了 KVO 通知,NSOperation所屬的NSOperationQueue在收到這個通知后會執(zhí)行它的completionBlock,并收回分配給該NSOperation的線程,該NSOperation就此結(jié)束了它的任務生涯。整個過程可簡單歸納為: isReady -> start() -> isFinished ->completionBlock。
對NSOperationQueue來說,isFinished的 KVO 通知是NSOperation生命周期結(jié)束的唯一標志,在NSOperation生命周期的任意時刻發(fā)出isFinished的 KVO 通知(并且isFinished確實為true),該NSOperation會被視為結(jié)束,completionBlock被執(zhí)行;相反,只要NSOperation沒有發(fā)出isFinished的 KVO 通知,這個NSOperation會持續(xù)占據(jù)一個maxConcurrentOperationCount指定的名額。
重寫start()的注意事項:封裝非異步的代碼時,由于start()的默認實現(xiàn)已經(jīng)替我們在合適的時機更新了相關狀態(tài),start()返回后NSOperation的生命周期就結(jié)束了,重寫main()就夠了,取消和暫停功能也在main()里實現(xiàn);而封裝異步代碼時,比如這里的NSURLSessionDownloadTask,應該觀察它的狀態(tài),待其結(jié)束時更新isFinished的狀態(tài)并且發(fā)布 KVO 通知,而不要在start()返回前發(fā)出這個通知,所以封裝異步代碼必須重寫start()以及isFinished屬性,另外isExecuting這個屬性對于外部了解NSOperation的執(zhí)行狀態(tài)是必要的因此也必須重寫,這三點也是文檔里對實現(xiàn)異步NSOperation所要求的。
重寫isFinished屬性,isExecuting類似:
private var _isFinished: Bool = false
override private(set) var isFinished: Bool{
get {return _isFinished}
set{// 手動維護 KVO 通知,盡管這里使用 #keyPath 更安全(包括實現(xiàn) KVO 時),
// 但是在 iOS 8/9/10 里使用 #keyPath 無法收到通知,在 iOS 11 里
// 使用 #keyPath 是正常的,而且你會發(fā)現(xiàn)它實際觀察的鍵為 finished,
// 手動指定為 isFinished 在所有版本里正常。
self.willChangeValueForKey("isFinished")
self._isFinished = newValue
self.didChangeValueForKey("isFinished")
}
}
重寫start():
override func start() {
if isCancelled || isFinished{
isExecuting = false
isFinished = true
}else if !isStarted{
isStarted = true
resume()
}
}
定義“暫停“
為何開頭里暫停任務后無法自動啟動其他等待的任務?這里創(chuàng)建兩個變量便于大家理解:
actualConcurrentOperationCount(實際并發(fā)數(shù)) = 已經(jīng)啟動的 Operation 的數(shù)量
availableConcurrentOperationCount(剩余并發(fā)數(shù)) = maxConcurrentOperationCount - actualConcurrentOperationCount
只有當availableConcurrentOperationCount > 0時,等待的NSOperation才有機會啟動。
第一個需求:針對單個任務的暫停/開始功能,在DownloadOpeation上調(diào)用suspend()暫停了任務,但對其所屬的NSOperationQueue來說并沒有NSOperation結(jié)束,availableConcurrentOperationCount沒有增加,所以什么都不會發(fā)生。
第二個需求:在開頭的例子里,actualConcurrentOperationCount為10,當maxConcurrentOperationCount從10減少到5后,availableConcurrentOperationCount == -5,不會有NSOperation啟動,我在這里手動暫停了5個NSOperation并沒有改變這個情況,而其它5個NSOperation陸續(xù)完成時,availableConcurrentOperationCount從-5遞增至0,在此期間依然不會有其它NSOperation啟動。
問題的根源在于用戶和NSOperationQueue對"運行"的定義偏差,一個例子:設置maxConcurrentOperationCount = 5,此時有兩個正在下載的任務,還有3個任務可啟動,暫停1個下載的任務后,用戶認為還可運行4個任務,而在NSOperationQueue那里,只剩下3個NSOperation能夠啟動。通常,我們在NSOperation子類里重寫isExecuting用來作為封裝的任務是否真正執(zhí)行的標記;但是從設計上來講,isExecuting對NSOperationQueue沒有意義,對后者來說,NSOperation只有三種狀態(tài):未啟動(Non-Started),已啟動(Started),結(jié)束(Finished)。啟動后的NSOperation只要還沒有發(fā)出isFinished的 KVO 通知,從NSOperationQueue的角度來看它的狀態(tài)就是廣義上的運行狀態(tài)(Started and Unfinished)。
怎么解決?兩種方案:開源,節(jié)流。
maxConcurrentOperationCount是源(這里不考慮是無限的情況),通過“開源”彌補DownloadOperation進入暫停狀態(tài)時造成的偏差,做法很簡單:
var maxDownloadCount: Int // 記錄最大下載量
func pauseTask(_ task: String){
if downloadOperation.isExecuting{
downloadOperation.suspend()
// 暫停下載后用戶的期待:剩余可下載的任務數(shù)量+1,
// 通過增加整體可啟動的任務數(shù)量來實現(xiàn)。
operationQueue.maxConcurrentOperationCount += 1
}
}
func resumePausedTask(_ task: String){
// 任務啟動后,可以在運行和暫停狀態(tài)間隨意切換,OperationQueue 無法干涉,
// 所以恢復暫停的下載任務要小心,避免下載的總數(shù)量超出用戶的預期。
guard /* 正在下載的數(shù)量 < maxDownloadCount */ else{return}
if downloadOperation.isStarted && !downloadOperation.isExecuting{
downloadOperation.resume()
// 恢復下載后用戶的期待:剩余可下載的任務數(shù)量-1,
// 通過減少整體可啟動的任務數(shù)量來實現(xiàn)。
operationQueue.maxConcurrentOperationCount -= 1
}
}
以上面的例子來說:maxConcurrentOperationCount為5,現(xiàn)在有2個已經(jīng)啟動的任務,以“開源”的方式暫停1個下載的任務后,maxConcurrentOperationCount變?yōu)?,此時下載的任務數(shù)量為1,暫停的任務數(shù)量為1,剩余可啟動的任務數(shù)量為 6-1-1 = 4,符合用戶預期;如果恢復被暫停的下載任務,maxConcurrentOperationCount變回5,此時下載的任務數(shù)量為2,剩余可啟動的任務數(shù)量為 5-2 = 3,符合用戶預期。
暫停的任務是actualConcurrentOperationCount中可以被節(jié)省的流,所謂“暫停”任務,就是保留任務當時的狀態(tài)以便后續(xù)從這個狀態(tài)繼續(xù)任務,如果在“暫?!比蝿盏耐瑫r結(jié)束NSOperation,可以直接消除DownloadOperation進入暫停狀態(tài)時造成的偏差。
首先來看看具體在什么時機結(jié)束DownloadOperation,這是NSURLSessionDownloadTask的狀態(tài)URLSessionTask.State,和NSOperation的狀態(tài)類似:
enum State : Int {
case running
case suspended
case canceling
// 與 Operation 的 isFinished 狀態(tài)類似,代表了結(jié)束。
case completed
}
一種很自然的選擇是當NSURLSessionDownloadTask的狀態(tài)變?yōu)?code>completed時,讓DownloadOperation成為isFinished,可以方便地通過 KVO 觀察來實現(xiàn)。在DownloadOperation中實現(xiàn)如下方法:
func stop(){
guard isStarted else{return}
// 狀態(tài)變化:running/suspended -> canceling -> completed
downloadTask.cancel(byProducingResumeData: { resumeData in
/* 稍后可以利用 resumeData 從中斷的地方繼續(xù)下載 */
})
isExecuting = false
}
從用戶的角度來看,suspend()和stop()并沒有什么區(qū)別;在DownloadOperation上調(diào)用suspend()后downloadTask 的狀態(tài)變化:running -> suspended,從NSOperationQueue的角度來看,suspend()沒有任何影響,但是stop()會結(jié)束所在的DownloadOperation,用stop()實現(xiàn)的暫停將會直接空出一個啟動名額。不過,在外部調(diào)用stop()時,需要謹慎處理:
func stopTask(_ task: String){
// 如果任務處于暫停狀態(tài),那么 maxConcurrentOperationCount 肯定
// 增加過了,現(xiàn)在要結(jié)束該 Operation,必須進行平衡(不考慮無限的情況)
if downloadOperation.isStarted && !downloadOperation.isExecuting{
operationQueue.maxConcurrentOperationCount -= 1
}
downloadOperation.stop()
}
采用suspend()實現(xiàn)暫停時,前面的代碼都需要重新調(diào)整maxConcurrentOperationCount,而采用stop()來實現(xiàn)用戶角度的暫停功能時,代碼要簡單得多,在幾個需求里所有需要暫停任務的地方直接調(diào)用stopTask(_:),調(diào)整下載量的時候直接設置maxConcurrentOperationCount就行了,而且測試起來成本也要小得多。stop()這種方式的缺點在于:1. 可能有些服務器不支持斷點續(xù)傳,2.會斷開和服務器的連接,恢復下載時需要重新連接。
如果你考慮到這樣的場景:用戶暫停了所有的下載然后退出 App,可能一開始就使用了stop()這種方式來實現(xiàn)暫停,那么本文的問題就不存在了。
調(diào)整最大下載量
采用上面這兩種方案來實現(xiàn)對最大下載量的調(diào)整,重寫上面的maxDownloadCount,邏輯大致是這樣的:
var pauseBySuspendingSessionTask: Bool // 決定采用哪種方案
public var maxDownloadCount: Int{
didSet{
// 為避免 maxConcurrentOperationCount 增加時會啟動任務產(chǎn)生干擾,
// 等調(diào)整完畢后再開放,這個期間 operationQueue 不會再啟動 Operation
operationQueue.isSuspended = true
let executingCount = downloadOperations.filter({$0.executing == true}).count
// 處理超額的任務
if executingCount > maxDownloadCount{
if pauseBySuspendingSessionTask{
/* 暫停(suspend())超額的任務 */
}else{
/* 停止(stop())超額的任務 */
}
}else{
let pendingCount = downloadOperations.filter({$0.isStarted == false}).count
if pendingCount < maxDownloadCount - executingCount{
/* 如果等待中的任務全部啟動后,全部啟動的任務數(shù)量 < maxDownloadCount,
可以選擇恢復暫停的任務進行補充,是否實現(xiàn)這個功能酌情處理 */
}
}
// 調(diào)整 maxConcurrentOperationCount
if pauseBySuspendingSessionTask{
// 超額的任務被暫停(suspend())后 DownloadOperation 依然占據(jù)了
// 一個 maxConcurrentOperationCount 的名額,需要”開源“補充回來
let pausedCount = downloadOperations.filter({
$0.isStarted == true && $0.isExecuting == false && $0.isFinished == false}).count
operationQueue.maxConcurrentOperationCount = maxDownloadCount + pausedCount
}else{
// 超額的任務被停止(stop())后 DownloadOperation 的生命結(jié)束,不占據(jù)名額,直接設置
operationQueue.maxConcurrentOperationCount = maxDownloadCount
}
downloadOperation.isSuspended = false
}
}
在實際中,在即將結(jié)束的NSURLSessionDownloadTask上調(diào)用suspend(),當NSOperationQueue里有其他任務結(jié)束時,在沒有調(diào)用resume()的情況下,這個任務可能會主動繼續(xù)并結(jié)束,在“開源”這種方式下,這個任務應該調(diào)用resumePausedTask(_:)去平衡maxConcurrentOperationCount的值,這個問題可以在NSOperation的completionBlock里檢查修正,而另一種方式則不會存在這個問題。
上面的代碼無法處理DownloadOperation的suspend()和stop()混合使用的情況,有兩種解決方式:1. 只使用一種暫停方式,譬如在DownloadOperation的pauseTask(_:)根據(jù)pauseBySuspendingSessionTask的值決定是否改為調(diào)用stopTask(_:);2. 考慮兩者混用的情況,在調(diào)整maxConcurrentOperationCount時下面這行代碼就是通用的:
operationQueue.maxConcurrentOperationCount = maxDownloadCount + pausedCount
至此,實現(xiàn)同時至多下載 N 個文件的核心部分完成了。
start() 補遺
isStarted除了用來標記是否啟動,主要是用來做安全隔離,在被NSOperationQueue啟動前,不能讓外界通過resume()執(zhí)行計劃之外的下載,其它會更改NSURLSessionDownloadTask狀態(tài)的操作,包括stop()和cancel()也采取相同的安全設計。
NSOperation添加到NSOperationQueue執(zhí)行時,只能由NSOperationQueue來調(diào)用start(),否則會拋出異常。但這里重寫的start()方法可能會被其它對象調(diào)用而導致任務被意外啟動,安全設計從根源上就被破解了。如何防范呢?雖然可以在DownloadOperation的start()里調(diào)用super.start()來沿用原來的安全機制,不過文檔里強烈建議不要這么做。最直接的辦法還是防止外界調(diào)用start(),要確保兩點:
- 避免外界獲取到你使用的
DownloadOperation對象,比如上面的operatonQueue,可以通過其operations屬性來間接調(diào)用start()。 - 避免在內(nèi)部任何地方調(diào)用
DownloadOperation對象的start()。
NSOperation 狀態(tài)補遺
isFinished的 KVO 通知是NSOperation生命結(jié)束的標志,如果在NSOperation生命周期正常結(jié)束之前發(fā)出isFinished的 KVO 通知會發(fā)生什么?NSOperationQueue接到通知后,對isFinished進行校驗:如果值確實為true,那么會按照正常流程處理,將該NSOperation視為結(jié)束,執(zhí)行它的completionBlock,如果此時有等待中的NSOperation,選擇并啟動一個;如果isFinished值不為true,什么都不會發(fā)生。這個提前發(fā)出了isFinished通知的NSOperation,如果此時其start()尚未返回,它依然會占據(jù)分配給它的線程(這里我揣測下為何maxConcurrentOperationCount減少時不會對已啟動的NSOperation產(chǎn)生影響,NSOperation封裝的代碼對于NSOperationQueue來說是未知的,不干涉是明智的選擇),繼續(xù)執(zhí)行,如果此后的流程再次發(fā)出了 KVO 通知,是否會按原本的流程走一遍:執(zhí)行completionBlock,啟動一個等待的任務?在 Objective-C 類里,completionBlock會再次執(zhí)行;而在 Swift 類里,可考證的是從 Swift 3.1 起,completionBlock不會被重復執(zhí)行。而是否會啟動一個等待的任務,不管是 Objective-C 類還是 Swift 類里,代碼都有著可靠的校驗機制,使得啟動的NSOperation數(shù)量保持在maxConcurrentOperationCount的范圍內(nèi)。
isCancelled是啟動任務的一個校驗點,如果在任務在啟動之前就被取消了,顯然就沒有必要為這個任務分配線程并啟動了,不過現(xiàn)實并非如此,文檔里的關鍵說明如下:
Canceling an operation that is currently in an operation queue, but not yet executing, makes it possible to remove the operation from the queue sooner than usual.
Because it is already cancelled, this behavior allows the operation queue to call the operation’s start method sooner and clear the object out of the queue.
雖然是 sooner,還是會調(diào)用start()!有多 sooner?在啟動前調(diào)用cancel()會移除NSOperation的依賴并讓其立刻進入isReady狀態(tài),就快了這部分而已,除此之外,上面的啟動流程一樣沒省,只不過啟動后會檢查isCancelled屬性而不會執(zhí)行main()。這個實現(xiàn)就好比你在排隊,由于各種原因不想排隊了,結(jié)果被告知必須排到你的時候才能取消排隊!這顯然不是我們想要的,但,測試表明,并沒有妥善的辦法在其它部分不出紕漏的情況下省去啟動流程直接結(jié)束該NSOperation。顯然NSOperationQueue能做到我們期待的那樣,但還是沒有那樣做,我猜測這是為了堅持isFinished是NSOperation的最終狀態(tài)這個設計所造成的,由于isFinished可能會在NSOperation子類里重寫(設置isFinished的權限就轉(zhuǎn)移到了子類里),這樣一來只有在NSOperation子類啟動后才能設置isFinished。這也造成了另外一個結(jié)果:即使在啟動前cancel()了,NSOperation的狀態(tài)變化:isCancelled->isFinished,completionBlock還是會被執(zhí)行的,這也增加了我們的工作,比如在開頭我添加了isStarted來標記任務是否真的啟動過。我們剩下能做的就是加速啟動,queuePriority,直接設置至最高級別,一旦有前面的任務完成,優(yōu)先啟動,即使有多個待取消的NSOperation,處理起來是很快的。
NSOperationQueue由于限制只能提供半自動的啟動數(shù)量管理,而且cancel()的邏輯設計,可能令人無法接受,如果只是需要控制任務的啟動數(shù)量,我們可以實現(xiàn)一個簡化版的NSOperationQueue,對DownloadOperation實行全程掌控:使用數(shù)組實現(xiàn)一個 FIFO 的隊列來添加DownloadOperation,根據(jù)queuePriority來動態(tài)調(diào)整在隊列中的位置;使用 GCD 來分配線程,手動執(zhí)行start(),響應DownloadOperation狀態(tài)的 KVO 通知來實現(xiàn)NSOperationQueue的相應行為;如果想采取cancel()后提前移除NSOperation的設計,可以通過 KVO 觀察來跟蹤isCancelled的狀態(tài),如果DownloadOperation尚未啟動,直接將其移出隊列。