Swift 中var生命的變數預設是非原子性的,如果要保證執行緒安全,我們就需要引入鎖的感念。
注意:謹慎直接在Demo中用for+print()等來證明是否執行緒安全。因為print()方法本身是執行緒安全的,它可能會拯救你的不安全程式碼。第3節objc_sync部分的例子有print()和NSLog()的比較,結果僅作參考。
- 本文將著重介紹
NSCondition
以及DispatchSemaphore
- 本文介紹的內容Demo程式碼都是基於Swift4.0
一 互斥鎖
iOS裡的執行緒互斥鎖主要有以下幾種
- 遵循NSLocking協議. 包括NSLock,NSCondition,NSConditionLock,NSRecursiveLock
- GCD. DispatchSemaphore, DispatchWorkItemFlags.barrier
- objc_sync. 包括 @synchronized
- pthread. 包括POSIX。POSIX比較底層,但是一般很少用了。在此對POSIX也不詳述。文末有相關討論的資源。
1. NSLocking協議
NSlocking協議本身僅僅定義了lock()和unlock()
public protocol NSLocking {
public func lock()
public func unlock()
}
複製程式碼
除了NSCondition,其它三種鎖都是有以下兩個方法
-
open func `try`() -> Bool
嘗試鎖,如果成功,返回true。這裡需要注意的是,如果對一個已經呼叫lock()加鎖的程式再次加鎖會產生死鎖,此時不會有返回值。(參考下面注意點4.1) -
open func lock(before limit: Date) -> Bool
加鎖,並且給這個鎖一個過期時間,時間到了自動解鎖。
1.1 NSLock
open class NSLock : NSObject, NSLocking {
open func `try`() -> Bool
open func lock(before limit: Date) -> Bool
@available(iOS 2.0, *)
open var name: String?
}
複製程式碼
最常用的鎖。在需要加鎖的地方lock(),然後在解鎖的地方unlock()即可。
1.2 NSCondition
@available(iOS 2.0, *)
open class NSCondition : NSObject, NSLocking {
/// 阻塞(休眠)執行緒。收到訊號喚醒
open func wait()
/// 休眠當前執行緒,並設定一個自動喚醒的時間
open func wait(until limit: Date) -> Bool
/// 發出訊號 喚醒一個執行緒
open func signal()
/// 發出訊號,所用運用當前NSConditionh例項的wait()執行緒都會喚醒
open func broadcast()
@available(iOS 2.0, *)
open var name: String?
}
複製程式碼
下面詳細介紹一下NSCondition
。
1.2.1. 執行步驟虛擬碼
lock the condition // 鎖住condition
while (!(boolean_predicate)) { // 一個作為判斷用的Bool值
wait on condition // Wait()
}
do protected work // 執行任務程式碼
(optionally, signal or broadcast the condition again or change a predicate value) // 通過signal或者baroadcast來改變狀態
unlock the condition // 解鎖condition
複製程式碼
1.2.2. 簡介
wait()
其實並不能直接用於鎖住執行緒,作用原理如下。
呼叫condition wait()
之後,condition 例項會解鎖它已有的鎖(保證同時只有一個鎖)然後使當前呼叫的執行緒休眠。當 condition 被signal()
通知後,系統會喚起執行緒。然後 condition 例項會在wait()
或者wait(until:)
方法結束的位置再次給執行緒加鎖。因此,從執行緒的角度來看,就好像wait()
一直在保有這個鎖。
雖然wait()
會給執行緒加鎖,在測試的時候也確實可以按照期望執行,但是根據蘋果官方文件, 單隻使用wait()
加鎖並不能確保安全。所以,無論什麼情況使用Condition的時候,第一步總是加鎖。鎖住當前condition可以確保判斷和任務程式碼不會受其它使用相同condition的執行緒影響。
基於condition發訊號的原理,使用Bool
值來判斷是非常重要的。給condition發射訊號並不能保證condition本身為true
。由於發訊號的時間問題可能會導致假訊號的出現。使用Bool
值來判斷可以確保之前的訊號不會造成程式碼在還不能安全執行的時候執行。這個判斷值就是一個很簡單Bool
標籤,僅僅是用來判斷訊號是否發射完成。
這部分的內容其實和用 POSIX Thread Locks中的情形一樣。
wait()
函式的內部虛擬碼如下
unlock the thread
wait for the signal
lock the thread
複製程式碼
在使用的時候應當如下(不包含Bool
判斷)
self.lock.lock()
self.lock.wait()
self.lock.unlock()
複製程式碼
1.3 NSConditionLock
使用NSConditionLock
,可以確保執行緒僅在condition符合情況時上鎖,並且執行相應的程式碼,然後分配新的狀態。狀態值需要自己定義。
1.4 NSRecurisiveLock
NSRecursiveLock
定義了一種可以多次給相同執行緒上鎖並不會造成死鎖的鎖。
2. GCD
GCD裡的DispatchSemaphore
,和DispatchWorkItemFlag
的Barrier
也是可以達到執行緒鎖的目的
2.1 DispatchSemaphore
open class DispatchSemaphore : DispatchObject {
}
extension DispatchSemaphore {
public func signal() -> Int // 訊號量增加1
public func wait() // 訊號量減少1
public func wait(timeout: DispatchTime) -> DispatchTimeoutResult // 訊號量減少1 並設定在timeout時間後加回這個減少的訊號量1
public func wait(wallTimeout: DispatchWallTime) -> DispatchTimeoutResult
}
extension DispatchSemaphore {
@available(iOS 4.0, *)
public /*not inherited*/ init(value: Int)
}
複製程式碼
2.1.1 初始化
DispatchSemaphore(value: value)
value
對應著最大訊號值,所以訊號值可以對應到如下應用場景
- 當初始化的
value
值等於0,適用於兩個執行緒之間協調任務。 - 當初始化的
value
值小於0,會造成返回Null
,初始化失敗。 - 當初始化的
value
值大於0,適合於管理一個有限的資源池,資源池的大小等於value
值。
而對於某個使用DispatchSemaphore
來加鎖的執行緒來說,僅噹噹前訊號量小於或等於初始值時才會執行。
2.1.2 Demo
class GCDLockTest: TestBase {
let semaphore = DispatchSemaphore(value: 1) // 此value值是最大訊號值
func test() {
queueA.async {
//直到通過signal()增加訊號量
self.semaphore.wait() // 訊號值 +1
print("QueueA Gonna Sleep")
sleep(3)
print("QueueA Woke up")
self.semaphore.signal() // 訊號值 -1
}
queueB.async {
self.semaphore.wait() // 訊號值 +1
print("QueueB Gonna Sleep")
sleep(3)
print("QueueB Work up")
self.semaphore.signal() // 訊號值 -1
}
queueC.async {
self.semaphore.wait() // 訊號值 +1
print("QueueC Gonna Sleep")
sleep(3)
print("QueueC Wake up")
self.semaphore.signal() // 訊號值 -1
}
}
}
複製程式碼
如果初始化value
是1
那麼輸出將會是
QueueA Gonna Sleepd // 訊號量+1 (當前任務正在進行,可以理解為佔用資源池1個資源)
// 3秒 期間queueB, queueC 並沒有執行,因為訊號量初始化的值1,也就是最大允許1,可以理解為資源池只有一個資源
QueueA Woke up // QeueuA 完成 訊號量-1 當前訊號量0,小於1.於是下一個執行緒開始執行
QueueB Gonna Sleep // QueueB執行 訊號量+1
// 3秒
QueueB Work up // QueueB結束 訊號量-1
QueueC Gonna Sleep // QueueC執行 訊號量+1
// 3秒
QueueC Wake up // QueueC 結束 訊號量+1
複製程式碼
同理,如果初始化值為2,最大可以同時兩個執行緒執行。
如果初始值是3的話我們的Demo中三個執行緒就都可以同時執行。
那如果初始化0呢?
顯然,本例中的三個執行緒都將不能執行,因為訊號量一直高於初始值。現在回看我們在2.1中提到的應用場景,是不是就很好理解。
我們將QueueA
,QueueB
稍作改變
queueA.async {
//直到通過signal()增加訊號量
self.semaphore.wait() // 訊號值 +1
print("QueueA Gonna Sleep")
sleep(3)
print("QueueA Woke up")
self.semaphore.signal() // 訊號值 -1
}
queueB.async {
self.semaphore.signal() // 訊號值 +1
print("QueueB Gonna Sleep")
sleep(3)
print("QueueB Work up")
}
複製程式碼
這時的輸出會是什麼?
QueueB Gonna Sleep // 因為QueueA在執行的時候訊號值+1,超過了0,所以只能等待
QueueA Gonna Sleep // 當QueueB執行的時候,訊號值-1,沒有超過0,所以QueueA就能執行了
/// 3秒
QueueB Work up
QueueA Woke up
複製程式碼
所以,當初始化值為0時,就可以達到兩個執行緒其中一個再另一個之後結束等功能。
注意: 如果在主執行緒中
wait()
會阻塞UI重新整理
2.3 DispatchGroup
enter()
是明確告訴GCD你要開始
leave()
是明確標明任務結束
一般情況下不需要明確使用enter()/leave()
。
只有比如說,你的任務中包含其它非同步任務,而你想要在這個子非同步任務開始前就結束等待,那就可以使用leave()了。
3. objc_sync
3.1 objc_sync_enter/objc_sync_exit
class SyncTest {
var count = 0
func test() {
count = 0
let queueA = DispatchQueue(label: "Q1")
let queueB = DispatchQueue(label: "Q2")
let queueC = DispatchQueue(label: "Q3")
queueA.async {
for _ in 1...100 {
NSLog("%i", self.increased())
// print(self.increased())
}
}
queueB.async {
for _ in 1...100 {
NSLog("%i", self.increased())
// print(self.increased())
}
}
queueC.async {
for _ in 1...100 {
NSLog("%i", self.increased())
// print(self.increased())
}
}
}
func increased() -> Int {
objc_sync_enter(count)
count += 1
objc_sync_exit(count)
return count
}
}
複製程式碼
3.1.1
objc_sync_enter(object)
方法會在object上開啟同步(synchronize),如果成功返回OBJC_SYNC_SUCCESS
, 否則返回OBJC_SYNC_NOT_OWNING_THREAD_ERROR
,直到objc_sync_exit(object)
object
是Any
型別。在本Demo中甚至可以直接傳入self
。但是它會鎖住整個
二 自旋鎖
主要介紹兩種
- OSSpinLock。由於存在因為低優先順序爭奪資源導致的死鎖,在iOS10.0之後已廢棄,並引入下面的新方法。
- os_unfair_lock。替代OSSpinLock的自旋鎖方案。需要匯入os
三 效能比較
引用一張被廣泛引用在此類文章中的圖片來說明
根據我後來自己做的測試,OSSpinLock和os_unfair_lock以及dispatch_semaphore三者的效能是最優且接近的。
四 注意點
1. 序列佇列即使非同步執行也不會重新開新執行緒。參考第二點後面的例子。
2. 主執行緒佇列是單一執行緒序列佇列的。不要在主執行緒加鎖,會導致UI重新整理被阻塞。
for i in 1...10 {
DispatchQueue.global().async {
print("\(i)---\(Thread.current)")
}
}
複製程式碼
for i in 1...10 {
DispatchQueue.main.async {
print("\(i)---\(Thread.current)")
}
}
複製程式碼
輸出
5---<NSThread: 0x60c000074280>{number = 11, name = (null)}
2---<NSThread: 0x60800006e500>{number = 5, name = (null)}
6---<NSThread: 0x60400007a0c0>{number = 6, name = (null)}
3---<NSThread: 0x60400007a140>{number = 10, name = (null)}
9---<NSThread: 0x60800006e6c0>{number = 12, name = (null)}
4---<NSThread: 0x600000069b40>{number = 4, name = (null)}
8---<NSThread: 0x60400007a080>{number = 3, name = (null)}
1---<NSThread: 0x60800006e600>{number = 9, name = (null)}
7---<NSThread: 0x60400007a100>{number = 8, name = (null)}
10---<NSThread: 0x60000046c6c0>{number = 7, name = (null)}
複製程式碼
1---<NSThread: 0x60c000077d40>{number = 1, name = main}
2---<NSThread: 0x60c000077d40>{number = 1, name = main}
3---<NSThread: 0x60c000077d40>{number = 1, name = main}
4---<NSThread: 0x60c000077d40>{number = 1, name = main}
5---<NSThread: 0x60c000077d40>{number = 1, name = main}
6---<NSThread: 0x60c000077d40>{number = 1, name = main}
7---<NSThread: 0x60c000077d40>{number = 1, name = main}
8---<NSThread: 0x60c000077d40>{number = 1, name = main}
9---<NSThread: 0x60c000077d40>{number = 1, name = main}
10---<NSThread: 0x60c000077d40>{number = 1, name = main}
複製程式碼
3. 併發和並行: 並行是執行緒被多個CPU核心執行,併發是執行緒輪流交替被單個CPU核心執行。
4. 上鎖的英文 acquire a lock
5. 對一個已經lock()的鎖再次呼叫lock()將會產生死鎖,這也是遞迴鎖引入的原因。遞迴鎖實現的就是可以多次加鎖也不會產生死鎖。
BTW: 同樣的死鎖會產生在在同一個同步執行緒中呼叫這個執行緒的同步佇列
五 資源
本例程式碼後續會上傳到Github
蘋果官方多執行緒程式設計指南
POSIX部落格