多線程之GCD與NSOperation

開始之前

首先要解決一個大家對多線程的理解上可能存在的誤區:新開一個線程,能提高速度,避免阻塞主線程。
這句話看著好像是對著呢,但是仔細想想這句話是不那么準確的。

舉個例子:一個主任務需要十個子任務按順序執行來完成。現在有兩種方式完成這個任務:
1.建十個線程,把每個子任務放在對應的線程中執行。執行完一個線程中的任務就切換到另一個線程。
2.把十個任務放在一個線程里,按順序執行。

操作系統的基礎知識告訴我們,線程,是執行程序最基本的單元,它有自己棧和寄存器。說得再具體一些,線程就是“一個CPU執行的一條無分叉的命令列”。

對于第一種方法,在十個線程之間來回切換,就意味著有十組棧和寄存器中的值需要不斷地被備份、替換。 而對于對于第二種方法,只有一組寄存器和棧存在,顯然效率完勝前者。

并發與并行的區別

并發指的是一種現象,一種經常出現,無可避免的現象。它描述的是“多個任務同時發生,需要被處理”這一現象。它的側重點在于“發生”。
比如有很多人排隊等待檢票,這一現象就可以理解為并發。

并行指的是一種技術,一個同時處理多個任務的技術。它描述了一種能夠同時處理多個任務的能力,側重點在于“運行”。
比如景點開放了多個檢票窗口,同一時間內能服務多個游客。這種情況可以理解為并行。

并行的反義詞就是串行,表示任務必須按順序來,一個一個執行,前一個執行完了才能執行后一個。

我們經常提到的“多線程”,正是采用了并行技術,從而提高了執行效率。因為有多個線程,所以計算機的多個CPU可以同時工作,同時處理不同線程內的指令。

并發是一種現象,面對這一現象,我們首先創建多個線程,真正加快程序運行速度的,是并行技術。也就是讓多個CPU同時工作。而多線程,是為了讓多個CPU同時工作成為可能。

同步與異步

同步方法就是我們平時調用的哪些方法。比如在第一行調用a方法,那么程序運行到第二行的時候,a方法肯定是執行完了。

所謂的異步,就是允許在執行某一個任務時,函數立刻返回,但是真正要執行的任務稍后完成。

比如我們在點擊保存按鈕之后,要先把數據寫到內存,然后更新UI。同步方法就是等到數據保存完再更新UI,而異步則是立刻從保存數據的方法返回并向后執行代碼,同時真正用來保存數據的指令將在稍后執行。

區別和聯系

串行/并行針對的是隊列,而同步/異步,針對的則是線程。最大的區別在于,同步線程要阻塞當前線程,必須要等待同步線程中的任務執行完,返回以后,才能繼續執行下一任務;而異步線程則是不用等待。

假設現在有三個任務需要處理。假設單個CPU處理它們分別需要3、1、1秒。

并行/串行討論的是處理這三個任務的速度問題。如果三個CPU并行處理,那么一共只需要3秒。相比于串行處理,節約了兩秒。

同步/異步描述的是任務之間先后順序問題。假設需要三秒的那個是保存數據的任務,而另外兩個是UI相關的任務。那么通過異步執行第一個任務,我們省去了三秒鐘的卡頓時間。

對于同步執行的三個任務來說,系統傾向于在同一個線程里執行它們。因為即使開了三個線程,也得等他們分別在各自的線程中完成。并不能減少總的處理時間,反而徒增了線程切換所耗費的時間。

對于異步執行的三個任務來說,系統傾向于在三個新的線程里執行他們。因為這樣可以最大程度的利用CPU性能,提升程序運行效率。

總結

在需要同時處理寫入寫出操作和UI操作的情況下,真正起作用的是異步,而不是多線程。可以不用多線程,但不能不用異步。

GCD

GCD以block(Swift中是閉包,為了方便,下面都以block表示)為基本單位,一個block中的代碼可以為一個任務。下文中提到任務,可以理解為執行某個block。

同時,GCD中有兩大最重要的概念,分別是“隊列”和“執行方式”。

使用block的過程,概括來說就是把block放進合適的隊列,并選擇合適的執行方式去執行block的過程。

三種隊列:
串行隊列(先進入隊列的任務先出隊列,每次只執行一個任務)
并發隊列(依然是“先入先出”,不過可以形成多個任務并發)
主隊列(這是一個特殊的串行隊列,而且隊列中的任務一定會在主線程中執行)

兩種執行方式:
同步執行
異步執行

關于同步/異步、串行/并行和線程的關系,下面通過一個表格來總結:

同步 異步
主隊列 在主線程中執行 在主線程中執行
串行隊列 在當前線程中執行 新建線程執行
并發隊列 在當前線程中執行 新建線程執行

可以看到,同步方法不一定在本線程,因為加入到主隊列的就會在主線程內執行;異步方法方法也不一定新開線程,也是因為主隊列的特殊情況。

在我們的實際開發過程中,我們要更多考慮的是怎么準確的使用好串行/并行、同步/異步,而不是僅僅只考慮是否新開線程這個問題。

當然,了解任務運行在那個線程中也是為了更加深入的理解整個程序的運行情況,尤其是接下來要討論的死鎖問題。

GCD的死鎖問題

在使用GCD的過程中,如果向當前串行隊列中同步派發一個任務,就會導致死鎖。
這句話有點繞,先舉個例子看看:

override func viewDidLoad() {
        super.viewDidLoad()

        let queue = DispatchQueue.main
        
        queue.sync {
            print("啊哈哈")
        }
}

這段代碼就會導致死鎖,因為我們目前在主隊列中,又將要同步地添加一個block到主隊列中。

先分析一波

我們知道.sync表示同步的執行任務,也就是說執行.sync后,當前線程會阻塞。而.sync中的block如果要在當前線程中執行,就得等待當前線程執行完成。

在上面這個例子中,主線程在執行.sync,隨后主隊列中新增一個任務block。因為主隊列是串行隊列,所以block要等.sync執行完才能執行,但是.sync是同步派發,要等block執行完才算是結束。在主隊列中的兩個任務互相等待,導致了死鎖。

解決方案

其實在通常情況下我們不必要用.sync,因為.async能夠更好的利用CPU,提升程序運行速度。只有當我們需要保證隊列中的任務必須順序執行時,才考慮.sync。在使用.sync的時候應該分析當前處于哪個隊列,以及任務會提交到哪個隊列。

DispatchGroup

在平時的開發過程中,可能會有這樣的需求:我需要在完成一些任務之后緊接著去執行另外一個任務,這里,我們就可以使用GCD任務組解決類似需求。
在單個串行隊列中,這個需求不是問題,因為只要把回調block添加到隊列末尾即可。
但是對于并行隊列,以及多個串行、并行隊列混合的情況,就需要使用DispatchGroup了。

  override func viewDidLoad() {
        super.viewDidLoad()

        let concurrentQueue = DispatchQueue(label: "concurrentQueue", qos: .default, attributes: .concurrent, autoreleaseFrequency: .workItem, target: nil)
        let serialQueue = DispatchQueue(label: "serialQueue")
        
        let group = DispatchGroup()
        
        for i in 0...3 {
            concurrentQueue.async(group: group, qos: .default, flags: []) {
                print("concurrentQueue\(i)")
            }
        }
        
        for i in 0...3 {
            serialQueue.async(group: group, qos: .default, flags: []) {
                print("serialQueue\(i)")
            }
        }
        
        //執行完上面的兩個耗時操作, 回到主隊列中執行下一步的任務
        group.notify(queue: DispatchQueue.main) {
            print("回到主隊列執行一些操作")
        }
  }
  輸出:
  concurrentQueue1
  serialQueue0
  concurrentQueue2
  concurrentQueue0
  concurrentQueue3
  serialQueue1
  serialQueue2
  serialQueue3
  回到主隊列執行一些操作

首先創建一個并發隊列和串行隊列,然后通過DispatchGroup()方法生成一個組。

接下來,在兩個不同的隊列里面分別加入不同的任務,并放入到group中去。

最后調用group.notify方法。這個方法表示把第二個參數 block 傳入第一個參數隊列中去。而且可以保證第二個參數 block 執行時,group中的所有任務已經全部完成。

.asyncAfter方法

通過 GCD 還可以進行簡單的定時操作,比如在 1 秒后執行某個 block 。代碼如下:

DispatchQueue.main.asyncAfter(deadline:DispatchTime.now() + 1 ) {
    print("我是在一秒后執行的")
}

.asyncAfter方法的調用者表示要執行的任務提交到哪個隊列,后面有兩個參數。第一個表示時間,也就是從現在起往后一秒鐘。第二個參數分別表示要提交的任務。

需要注意的是.asyncAfter僅表示在指定時間后提交任務,而非執行任務。如果任務提交到主隊列,它將在main runloop中執行,對于每隔1/60秒執行一次的RunLoop,任務最多有可能在1+1/60秒后執行。

Operation

OperationOperationQueue主要涉及這幾個方面:

  • OperationOperationQueue用法介紹
  • Operation的暫停、恢復和取消
  • 通過 KVO 對 Operation的狀態進行檢測
  • 多個 Operation 的之間的依賴關系

從簡單意義上來說,Operation是對 GCD 中的 block 進行的封裝,它也表示一個要被執行的任務,Operation對象有一個start()方法表示開始執行這個任務。

不僅如此,Operation表示的任務還可以被取消。它還有三種狀態isExecuted、isFinished、isCancelled以方便我們通過 KVC 對它的狀態進行監聽。

想要開始執行一個任務可以這么寫:

        let operation = BlockOperation.init { 
            print("初始化 0 的任務\(Thread.current)")
        }
        
        operation.addExecutionBlock {
            print("第 1 個添加任務\(Thread.current)")
        }
        operation.addExecutionBlock {
            print("第 2 個添加任務\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 3 個添加任務\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 4 個添加任務\(Thread.current)")
        }
        operation.addExecutionBlock {
            print("第 5 個添加任務\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 6 個添加任務\(Thread.current)")
        }        
        operation.addExecutionBlock {
            print("第 7 個添加任務\(Thread.current)")
        }       
        operation.addExecutionBlock {
            print("第 8 個添加任務\(Thread.current)")
        }     
        operation.start()
        print("結束了")
輸出內容:
初始化 0 的任務<NSThread: 0x60000007dc40>{number = 1, name = main}
第 2 個添加任務<NSThread: 0x60000026af80>{number = 3, name = (null)}
第 1 個添加任務<NSThread: 0x608000262e80>{number = 5, name = (null)}
第 3 個添加任務<NSThread: 0x608000262d80>{number = 4, name = (null)}
第 4 個添加任務<NSThread: 0x60000007dc40>{number = 1, name = main}
第 5 個添加任務<NSThread: 0x60000026af80>{number = 3, name = (null)}
第 6 個添加任務<NSThread: 0x608000262e80>{number = 5, name = (null)}
第 8 個添加任務<NSThread: 0x60000007dc40>{number = 1, name = main}
第 7 個添加任務<NSThread: 0x608000262d80>{number = 4, name = (null)}
結束了

使用BlockOperation來創建是因為Operation是一個基類,不應該直接生成Operation對象,而是應該用它的子類。BlockOperation是蘋果預定義的子類,它可以用來封裝一個或多個 block ,后面會介紹如何自己創建Operation的子類。

在上面的例子里面我們創建了一個BlockOperation,并且設置好它的 block ,也就是將要執行的任務,同時,我們調用addExecutionBlock方法追加幾個任務,這些任務會并行執行。但是它并非是將所有的 block 都放到放到了子線程中。通過上面的打印記錄我們可以發現,它會優先將 block 放到主線程中執行,若主線程已有待執行的代碼,就開辟新的線程,但最大并發數為4(包括主線程在內,在真機上最大并發數為2,不必糾結這個,明白原理即可),如果 block 數量大于了線程的最大并發數,那么剩下的 block 就會等待某個線程空閑下來之后被分配到該線程,且依然是優先分配到主線程。

最后,調用start()方法讓Operation方法運行起來。start()是一個同步方法,也就是在調用start()方法的那個線程中直接執行,會阻塞調用start()方法的線程。

OperationQueue

從上面我們可以知道Operation是同步執行的。簡單的看一下 NSOperation 類的定義會發現它有一個只讀屬性 asynchronous,這意味著如果想要異步執行,就需要自定Operation的子類。或者使用OperationQueue

OperationQueue類似于 GCD 中的隊列。我們知道 GCD 中的隊列有三種:主隊列串行隊列并行隊列OperationQueue更簡單,只有兩種:主隊列非主隊列。我們自己生成的OperationQueue對象都是非主隊列,主隊列可以用OperationQueue.main取得。

OperationQueue的主隊列是串行隊列,而且其中所有Operation都會在主線程中執行。對于非主隊列來說,一旦一個Operation被放入其中,那這個Operation一定是并發執行的。因為OperationQueue會為每一個Operation創建線程并調用它的start()方法。

OperationQueue有一個屬性叫maxConcurrentOperationCount,它表示最多支持多少個Operation并發執行。如果maxConcurrentOperationCount被設為 1,就認為這個隊列是串行隊列。需要注意的是設備最大并發數是有上限的,即使你設置maxConcurrentOperationCount為100,它也不會超過設備最大并發數上限,而這個上限的數目也是由具體運行環境決定的。

OperationQueueGCD中的隊列有這樣的對應關系:

OperationQueue GCD
主隊列 OperationQueue.main DispatchQueue.main
串行隊列 自建隊列設置maxConcurrentOperationCount為 1 DispatchQueue(label: "serialQueue")
并發隊列 自建隊列設置maxConcurrentOperationCount大于 1 DispatchQueue(label: "concurrentQueue", qos: .default, attributes: .concurrent, autoreleaseFrequency: .workItem, target: nil

想要使用OperationQueue實現異步操作可以這么寫:

let operationQueue = OperationQueue()
let operation = BlockOperation()
        
for i in 1...10 {
    operation.addExecutionBlock {
        print("第 \(i) 個添加任務\(Thread.current)")
    }
}
operationQueue.addOperation(operation) 
輸出內容:
第 1 個添加任務<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 2 個添加任務<NSThread: 0x170261880>{number = 4, name = (null)}
第 3 個添加任務<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 4 個添加任務<NSThread: 0x170261880>{number = 4, name = (null)}
第 5 個添加任務<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 6 個添加任務<NSThread: 0x170261880>{number = 4, name = (null)}
第 7 個添加任務<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 8 個添加任務<NSThread: 0x170261880>{number = 4, name = (null)}
第 9 個添加任務<NSThread: 0x1702619c0>{number = 5, name = (null)}
第 10 個添加任務<NSThread: 0x170261880>{number = 4, name = (null)}

使用OperationQueue來執行任務與之前的區別在于,首先創建一個非主隊列。然后用addOperation方法替換之前的start()方法。剛剛已經說過,OperationQueue會為每一個Operation建立線程并調用他們的start()方法。

觀察一下運行結果,所有的Operation都沒有在主線程執行,從而成功的實現了異步、并行處理。

除了上述的將Operation添加到隊列中的使用方法外,OperationQueue提供了一個更加簡單的方法,只需以下兩行代碼就能實現多線程調用

let operationQueue = OperationQueue()
operationQueue.addOperation {
    print(Thread.current)
}
輸出內容:
<NSThread: 0x170460d40>{number = 4, name = (null)}

你可以同時添加一個或這個多個Block來實現你的操作。

取消任務

如果我們有兩次網絡請求,第二次請求會用到第一次的數據。假設此時網絡情況不好,第一次請求超時了,那么第二次請求也沒有必要發送了。而且用戶也有可能人為地取消某個Operation

當產生這種需求的時候,我們就可以取消這些操作:

//取消Operation
let operation = BlockOperation.init { 
    print("哈哈哈哈哈哈 0")
}
operation.cancel()

//取消某個OperationQueue剩余的Operation
let operationQueue = OperationQueue()
for i in 1...10 {
    operationQueue.addOperation {
       print(Thread.current)
    }
}
operationQueue.cancelAllOperations()

暫停和取消并不會立即暫停或取消當前操作,而是不在調用新的Operation

設置依賴

如果現在需要兩次網絡請求,第二次請求會用到第一次的數據,所以我們要保證發出第二次請求的時候第一個請求已經執行完,但是我們同時還希望利用到OperationQueue的并發特性(因為可能不止這兩個任務)。

這時候我們可以設置Operation之間的依賴關系:

//讓operation1在operation2執行完之后執行
let operationQueue = OperationQueue()

let operation1 = BlockOperation.init {
    print("第 1 個添加任務\(Thread.current)")
}        
let operation2 = BlockOperation.init {
    print("第 2 個添加任務\(Thread.current)")
}        
//需要注意的是Operation之間的相互依賴會導致死鎖,如果1依賴2,2又依賴1,就會導致死鎖。
operation1.addDependency(operation2)
        
operationQueue.addOperation(operation1)
operationQueue.addOperation(operation2)
輸入內容:
第 2 個添加任務<NSThread: 0x17027a340>{number = 4, name = (null)}
第 1 個添加任務<NSThread: 0x17027a340>{number = 4, name = (null)}

OperationQueue暫停與恢復

暫停與恢復只需要操作isSuspended屬性:

operationQueue.isSuspended = true
operationQueue.isSuspended = false

Operation優先級

每一個Operation的對象都一個queuePriority屬性,表示隊列優先級。它是一個枚舉值,有這么幾個等級可選:

public enum QueuePriority : Int {
    case veryLow
    case low
    case normal
    case high
    case veryHigh
}

需要注意的是,這個優先級并不總是起作用,不能完全保證優先級高的任務一定先執行,因為線程優先級代表的是線程獲取CPU時間片的能力,高優先級的執行概率高,但是并不能確保優先級高的一定先執行。

參考:iOS多線程編程總結

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容