Swift多執行緒程式設計總結

FFIB發表於2018-01-06

在開始多執行緒之前,我們先來了解幾個比較容易混淆的概念。

概念

執行緒與程式

執行緒與程式之間的關係,拿公司舉例,程式相當於部門,執行緒相當於部門職員。即程式內可以有一個或多個執行緒。

併發和並行

併發指的是多個任務交替佔用CPU,並行指的是多個CPU同時執行多個任務。好比火車站買票,併發指的是一個視窗有多人排隊買票,而並行指的是多個視窗有多人排隊買票。

同步和非同步

同步指在執行一個函式時,如果這個函式沒有執行完畢,那麼下一個函式便不能執行。非同步指在執行一個函式時,不必等到這個函式執行完畢,便可開始執行下一個函式。

GCD

Swift3之後,GCD的Api有很大的調整,從原來的C語言風格的函式呼叫,變為物件導向的封裝,使用起來更加舒服,靈活性更高。

同步

let queue = DispatchQueue(label: "com.ffib.blog")

queue.sync {
    for i in 0..<5 {
        print(i)
    }
}

for i in 10..<15 {
    print(i)
}

output: 
0
1
2
3
4
10
11
12
13
14
複製程式碼

從結果可以看出佇列同步操作時,當程式在進行佇列任務時,主執行緒的操作並不會被執行,這是由於當程式在執行同步操作時,會阻塞執行緒,所以需要等待佇列任務執行完畢,程式才可以繼續執行。

非同步

let queue = DispatchQueue(label: "com.ffib.blog")

queue.async {
    for i in 0..<5 {
        print(i)
    }
}

for i in 10..<15 {
    print(i)
}

output:
10
0
11
1
12
2
13
3
14
4
複製程式碼

從結果可以看出佇列非同步操作時,當程式在執行佇列任務時,不必等待佇列任務開始執行,便可執行主執行緒的操作。與同步執行相比,非同步佇列並不會阻塞主執行緒,當主執行緒空閒時,便可執行別的任務。

QoS 優先順序

在實際開發中,我們需要對任務分類,比如UI的顯示和互動操作等,屬於優先順序比較高的,有些不著急操作的,比如快取操作、使用者習慣收集等,相對來說優先順序比較低。
在GCD中,我們使用佇列和優先順序劃分任務,以達到更好的使用者體驗,選擇合適的優先順序,可以更好的分配CPU的資源。
GCD內採用DispatchQoS結構體,如果沒有指定QoS,會使用default。 以下等級由高到低。

public struct DispatchQoS : Equatable {

     public static let userInteractive: DispatchQoS //使用者互動級別,需要在極快時間內完成的,例如UI的顯示
     
     public static let userInitiated: DispatchQoS  //使用者發起,需要在很快時間內完成的,例如使用者的點選事件、以及使用者的手勢
     。
     public static let `default`: DispatchQoS  //系統預設的優先順序,
     
     public static let utility: DispatchQoS   //實用級別,不需要很快完成的任務
     
     public static let background: DispatchQoS  //使用者無法感知,比較耗時的一些操作

     public static let unspecified: DispatchQoS
}

複製程式碼

以下通過兩個例子來具體看一下優先順序的使用。

相同優先順序

let queue1 = DispatchQueue(label: "com.ffib.blog.queue1", qos: .utility)
let queue2 = DispatchQueue(label: "com.ffib.blog.queue2", qos: .utility)

queue1.async {
    for i in 5..<10 {
        print(i)
    }
}

queue2.async {
    for i in 0..<5 {
        print(i)
    }
}
 output:
 0
 5
 1
 6
 2
 7
 3
 8
 4
 9
複製程式碼

從結果可見,優先順序相同時,兩個佇列是交替執行的。

不同優先順序

let queue1 = DispatchQueue(label: "com.ffib.blog.queue1", qos: .default)
let queue2 = DispatchQueue(label: "com.ffib.blog.queue2", qos: .utility)

queue1.async {
    for i in 0..<5 {
        print(i)
    }
}

queue2.async {
    for i in 5..<10 {
        print(i)
    }
}

output:
0
5
1
2
3
4
6
7
8
9
複製程式碼

從結果可見,交替輸出,CPU會把更多的資源優先分配給優先順序高的佇列,等到CPU空閒之後才會分配資源給優先順序低的佇列。

主佇列預設使用擁有最高優先順序,即userInteractive,所以慎用這一優先順序,否則極有可能會影響使用者體驗。
一些不需要使用者感知的操作,例如快取等,使用utility即可

序列佇列

在建立佇列時,不指定佇列型別時,預設為序列佇列。

let queue = DispatchQueue(label: "com.ffib.blog.initiallyInactive.queue", qos: .utility)

queue.async {
    for i in 0..<5 {
        print(i)
    }
}

queue.async {
    for i in 5..<10 {
        print(i)
    }
}

queue.async {
    for i in 10..<15 {
        print(i)
    }
}
output: 
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
複製程式碼

從結果可見佇列執行結果,是按任務新增的順序,依次執行。

並行佇列

let queue = DispatchQueue(label: "com.ffib.blog.concurrent.queue", qos: .utility, attributes: .concurrent)

queue.async {
    for i in 0..<5 {
        print(i)
    }
}

queue.async {
    for i in 5..<10 {
        print(i)
    }
}

queue.async {
    for i in 10..<15 {
        print(i)
    }
}
output:
5
0
10
1
2
3
11
4
6
12
7
13
8
14
9

複製程式碼

從結果可見,所有任務是以並行的狀態執行的。另外在設定attributes引數時,引數還有另一個列舉值initiallyInactive,表示的任務不會自動執行,需要程式設計師去手動觸發。如果不設定,預設是新增完任務後,自動執行。


let queue = DispatchQueue(label: "com.ffib.blog.concurrent.queue", qos: .utility,
attributes: .initiallyInactive)
queue.async {
    for i in 0..<5 {
        print(i)
    }
}
queue.async {
    for i in 5..<10 {
        print(i)
    }
}
queue.async {
    for i in 10..<15 {
        print(i)
    }
}

//需要呼叫activate,啟用佇列。
queue.activate()

output:
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
複製程式碼

從結果可見,只是把自動執行變為手動觸發,執行結果沒變,新增這一屬性帶來了,更多的靈活性,可以自由的決定執行的時機。
再來看看並行佇列如何設定這一列舉值。

let queue = DispatchQueue(label: "com.ffib.blog.concurrent.queue", qos: .utility, attributes:
[.concurrent, .initiallyInactive])
queue.async {
    for i in 0..<5 {
        print(i)
    }
}
queue.async {
    for i in 5..<10 {
        print(i)
    }
}
queue.async {
    for i in 10..<15 {
        print(i)
    }
}
queue.activate()

output:
10
0
5
11
1
6
12
2
7
13
3
8
14
4
9
複製程式碼

延時執行

GCD提供了任務延時執行的方法,通過對已建立的佇列,呼叫延時任務的函式即可。其中時間以DispatchTimeInterval設定,GCD內跟時間引數有關係的引數都是通過這一列舉來設定。

public enum DispatchTimeInterval : Equatable {

    case seconds(Int)     //秒

    case milliseconds(Int) //毫秒

    case microseconds(Int) //微妙

    case nanoseconds(Int)  //納秒

    case never
}
複製程式碼

在設定呼叫函式時,asyncAfter有兩個及其相同的方法,不同的地方在於引數名有所不同,參照Stack Overflow的解釋。

wallDeadline 和 deadline,當系統睡眠後,wallDeadline會繼續,但是deadline會被掛起。例如:設定引數為60分鐘,當系統睡眠50分鐘,wallDeadline會在系統醒來之後10分鐘執行,而deadline會在系統醒來之後60分鐘執行。

let queue = DispatchQueue(label: "com.ffib.blog.after.queue")

let time = DispatchTimeInterval.seconds(5)

queue.asyncAfter(wallDeadline: .now() + time) {
    print("wall dead line done")
}

queue.asyncAfter(deadline: .now() + time) {
    print("dead line done")
}
複製程式碼

DispatchGroup

如果想等到所有的佇列的任務執行完畢再進行某些操作時,可以使用DispatchGroup來完成。

let group = DispatchGroup()
let queue1 = DispatchQueue(label: "com.ffib.blog.queue1", qos: .utility)
let queue2 = DispatchQueue(label: "com.ffib.blog.queue2", qos: .utility)
queue1.async(group: group) {
    for i in 0..<10 {
        print(i)
    }
}
queue2.async(group: group) {
    for i in 10..<20 {
        print(i)
    }
}

//group內所有執行緒的任務執行完畢
group.notify(queue: DispatchQueue.main) {
    print("done")
}

output: 
5
0
6
1
7
2
8
3
9
4
done
複製程式碼

如果想等待某一佇列先執行完畢再執行其他佇列可以使用wait

let group = DispatchGroup()
let queue1 = DispatchQueue(label: "com.ffib.blog.queue1", qos: .utility)
let queue2 = DispatchQueue(label: "com.ffib.blog.queue2", qos: .utility)
queue1.async(group: group) {
    for i in 0..<10 {
        print(i)
    }
}
queue2.async(group: group) {
    for i in 10..<20 {
        print(i)
    }
}
group.wait()
//group內所有執行緒的任務執行完畢
group.notify(queue: DispatchQueue.main) {
    print("done")
}
output:
0
1
2
3
4
5
6
7
8
9
done
複製程式碼

為防止佇列執行任務時出現阻塞,導致執行緒鎖死,可以設定超時時間。

group.wait(timeout: <#T##DispatchTime#>)
group.wait(wallTimeout: <#T##DispatchWallTime#>)
複製程式碼

DispatchWorkItem

Swift3新增的api,可以通過此api設定佇列執行的任務。先看看簡單應用吧。通過DispatchWorkItem初始化閉包。

let workItem = DispatchWorkItem {
    for i in 0..<10 {
        print(i)
    }
}
複製程式碼

呼叫一共分兩種情況,第一種是通過呼叫perform(),自動響應閉包。

 DispatchQueue.global().async {
     workItem.perform()
 }
複製程式碼

第二種是作為引數傳給async方法。

 DispatchQueue.global().async(execute: workItem)
複製程式碼

接下來我們來看看DispatchWorkItem的內部都有些什麼方法和屬性。

init(qos: DispatchQoS = default, flags: DispatchWorkItemFlags = default,
    block: @escaping () -> Void)
複製程式碼

從初始化方法開始,DispatchWorkItem也可以設定優先順序,另外還有個引數DispatchWorkItemFlags,來看看DispatchWorkItemFlags的內部組成。

public struct DispatchWorkItemFlags : OptionSet, RawRepresentable {

    public static let barrier: DispatchWorkItemFlags 

    public static let detached: DispatchWorkItemFlags

    public static let assignCurrentContext: DispatchWorkItemFlags

    public static let noQoS: DispatchWorkItemFlags

    public static let inheritQoS: DispatchWorkItemFlags

    public static let enforceQoS: DispatchWorkItemFlags
}
複製程式碼

DispatchWorkItemFlags主要分為兩部分:

  • 覆蓋
    • noQoS 沒有優先順序
    • inheritQoS 繼承Queue的優先順序
    • enforceQoS 覆蓋Queue的優先順序
  • 執行情況
    • barrier
    • detached
    • assignCurrentContext

執行情況會在下文會具體描述,先在這留個坑。
先來看看設定優先順序,會對任務執行有什麼影響。

let queue1 = DispatchQueue(label: "com.ffib.blog.workItem1", qos: .utility)
let queue2 = DispatchQueue(label: "com.ffib.blog.workItem2", qos: .userInitiated)
let workItem1 = DispatchWorkItem(qos: .userInitiated) {
    for i in 0..<5 {
        print(i)
    }
}
let workItem2 = DispatchWorkItem(qos: .utility) {
    for i in 5..<10 {
        print(i)
    }
}
queue1.async(execute: workItem1)
queue2.async(execute: workItem2)

output:
5
0
6
7
8
9
1
2
3
4
複製程式碼

由結果可見即使設定了DispatchWorkItem僅僅只設定了優先順序並不會對任務執行順序有任何影響。
接下來,再來設定DispatchWorkItemFlags試試

let queue1 = DispatchQueue(label: "com.ffib.blog.workItem1", qos: .utility)
let queue2 = DispatchQueue(label: "com.ffib.blog.workItem2", qos: .userInitiated)

let workItem1 = DispatchWorkItem(qos: .userInitiated, flags: .enforceQoS) {
    for i in 0..<5 {
        print(i)
    }
}

let workItem2 = DispatchWorkItem {
    for i in 5..<10 {
        print(i)
    }
}

queue1.async(execute: workItem1)
queue2.async(execute: workItem2)
output:
5
0
6
1
7
2
8
3
9
4
複製程式碼

設定enforceQoS,使優先順序強制覆蓋queue的優先順序,所以兩個佇列呈交替執行狀態,變為同一優先順序。

DispatchWorkItem也有waitnotify方法,和DispatchGroup用法相同。

DispatchSemaphore

如果你想同步執行一個非同步佇列任務,可以使用訊號量。
wait()會使訊號量減一,如果訊號量大於1則會返回.success,否則返回timeout(超時),也可以設定超時時間。

func wait(wallTimeout: DispatchWallTime) -> DispatchTimeoutResult
func wait(timeout: DispatchTime) -> DispatchTimeoutResult
複製程式碼

signal()會使訊號量加一,返回當前訊號量。

func signal() -> Int
複製程式碼

下面通過例項來看看具體的使用。
先看看不使用訊號量時,在檔案非同步寫入會發生什麼。

//初始化訊號量為1
let semaphore = DispatchSemaphore(value: 1)

let queue = DispatchQueue(label: "com.ffib.blog.queue", qos: .utility, attributes: .concurrent)
let fileManager = FileManager.default
let path = NSHomeDirectory() + "/test.txt"
print(path)
fileManager.createFile(atPath: path, contents: nil, attributes: nil)

//迴圈寫入,預期結果為test4
for i in 0..<5 {
        queue.async {
            do {
                try "test\(i)".write(toFile: path, atomically: true, encoding: String.Encoding.utf8)
            }catch {
                print(error)
            }
            semaphore.signal()
        }
    }
}
複製程式碼

Swift多執行緒程式設計總結
發現寫入的結果根本不是我們想要的。此時再使用訊號量試試。

let semaphore = DispatchSemaphore(value: 1)
let queue = DispatchQueue(label: "com.ffib.blog.queue", qos: .utility, attributes: .concurrent)
let fileManager = FileManager.default
let path = NSHomeDirectory() + "/test.txt"
print(path)
fileManager.createFile(atPath: path, contents: nil, attributes: nil)
for i in 0..<5 {
    //.distantFuture代表永遠
    if semaphore.wait(wallTimeout: .distantFuture) == .success {
        queue.async {
            do {
                print(i)
                try "test\(i)".write(toFile: path, atomically: true, encoding: String.Encoding.utf8)
            }catch {
                print(error)
            }
            semaphore.signal()
        }
    }
}
複製程式碼

Swift多執行緒程式設計總結
寫入的結果符合預期效果,
我們來看下for迴圈裡都發生了什麼。第一遍迴圈遇到wait時,此時訊號量為1,大於0,所以if判斷為true,進行寫入操作;當第二遍迴圈遇到wait時,發現訊號量為0,此時就會鎖死執行緒,直到上一遍迴圈的寫入操作完成,呼叫signal()方法,訊號量加一,才會執行寫入操作,迴圈以上操作。好奇的同學,可以加上sleep(1),然後開啟資料夾,會發現test.txt檔案從test1不斷加1變為test4。(ps:寫入檔案的方式略顯粗糙,不過這不是本文討論的重點,僅用以測試DispatchSemaphore)

DispatchSemaphore還有另外一個用法,可以限制佇列的最大併發量,通過前面所說的wait()訊號量減一,signal()訊號量加一,來完成此操作,正如上文所述例子,其實達到的效果就是最大併發量為一。
如果使用過NSOperationQueue的同學,應該知道maxConcurrentOperationCount,效果是類似的。

DispatchWorkItemFlags

前面留了個DispatchWorkItemFlags的坑,現在來具體看看。

barrier

可以理解為隔離,還是以檔案讀寫為例,在讀取檔案時,可以非同步訪問,但是如果突然出現了非同步寫入操作,我們想要達到的效果是在進行寫入操作的時候,使讀取操作暫停,直到寫入操作結束,再繼續進行讀取操作,以保證讀取操作獲取的是檔案的最新內容。
以上文中的test.txt檔案為例,預期結果是:在寫入操作之前,讀取到的內容是test4;在寫入操作之後,讀取到的內容是done(即寫入的內容)。
先看看不使用barrier的結果。

let queue = DispatchQueue(label: "com.ffib.blog.queue", qos: .utility, attributes: .concurrent)

let path = NSHomeDirectory() + "/test.txt"
print(path)

let readWorkItem = DispatchWorkItem {
    do {
        let str = try String(contentsOfFile: path, encoding: .utf8)
        print(str)
    }catch {
        print(error)
    }
    sleep(1)
}

let writeWorkItem = DispatchWorkItem(flags: []) {
    do {
        try "done".write(toFile: path, atomically: true, encoding: String.Encoding.utf8)
        print("write")
    }catch {
        print(error)
    }
    sleep(1)
}
for _ in 0..<3 {
    queue.async(execute: readWorkItem)
}
queue.async(execute: writeWorkItem)
for _ in 0..<3 {
    queue.async(execute: readWorkItem)
}

output:
test4
test4
test4
test4
test4
test4
write
複製程式碼

結果不是我們想要的。再來看看加了barrier之後的效果。

let queue = DispatchQueue(label: "com.ffib.blog.queue", qos: .utility, attributes: .concurrent)

let path = NSHomeDirectory() + "/test.txt"
print(path)

let readWorkItem = DispatchWorkItem {
    do {
        let str = try String(contentsOfFile: path, encoding: .utf8)
        print(str)
    }catch {
        print(error)
    }
}

let writeWorkItem = DispatchWorkItem(flags: .barrier) {
    do {
        try "done".write(toFile: path, atomically: true, encoding: String.Encoding.utf8)
        print("write")
    }catch {
        print(error)
    }
}

for _ in 0..<3 {
    queue.async(execute: readWorkItem)
}
queue.async(execute: writeWorkItem)
for _ in 0..<3 {
    queue.async(execute: readWorkItem)
}

output:
test4
test4
test4
write
done
done
done
複製程式碼

結果符合預期的想法,barrier主要用於讀寫隔離,以保證寫入的時候,不被讀取。

相關文章