- 原文地址:A Quick Look at Semaphores in Swift ?
- 原文作者:Federico Zanetello
- 譯文出自:掘金翻譯計劃
- 譯者:Deepmissea
- 校對者:Gocy015,skyar2009
首先,如果你對 GCD 和 Dispatch Queue 不熟悉,請看看 AppCoda 的這篇文章。
好了!是時候來聊聊訊號量了!
引言
讓我們想象一下,一群作家只能共同使用一支筆。顯然,在任何指定的時間裡,只有一名作家可以使用筆。
現在,把作家想象成我們的執行緒,把筆想象成我們的共享資源(可以是任何東西:一個檔案、一個變數、做某事的權利等等)。
怎麼才能確保我們的資源是真正互斥的呢?
實現我們自己的資源控制訪問
有人可能會想:我只要用一個 Bool 型別的 resourceIsAvailable 變數,然後設定它為 true 或者 false 就可以互斥了。
if (resourceIsAvailable) {
resourceIsAvailable = false
useResource()
resourceIsAvailable = true
} else {
// resource is not available, wait or do something else
}複製程式碼
問題是出現在併發上,不論執行緒之間的優先順序如何,我們都沒辦法確切知道哪個執行緒會執行下一步。
例子
假設我們實現了上面的程式碼,我們有兩個執行緒,threadA 和 threadB,他們會使用一個互斥的資源:
- threadA 讀取到 if 條件語句,發現資源可用,很棒!
- 但是,在執行下一行程式碼(resourceIsAvalilable = false)之前,處理器切換到 threadB,然後它也讀取了 if 條件語句。
- 現在我們的兩個執行緒都確信資源是可用的,然後他們都會執行使用資源部分的程式碼塊。
不用 GCD 編寫執行緒安全的程式碼可不是一個容易的任務。
訊號量是如何工作的
三步:
- 在我們需要使用一個共享資源的時候,我們傳送一個 request 給它的訊號量;
- 一旦訊號量給出我們綠燈(see what I did here?),我們就可以假定資源是我們的並使用它;
- 一旦不需要資源了,我們通過傳送給訊號量一個 signal 讓它知道,然後它可以把資源分配給另一個的執行緒。
當這個資源只有一個,並且在任何給定的時間裡,只有一個執行緒可以使用,你就可以把這些 request/signal 作為資源的 lock/unlock。
在幕後發生了什麼
結構
訊號量由下面的兩部分組成:
- 一個計數器,讓訊號量知道有多少個執行緒能使用它的資源;
- 一個 FIFO 佇列,用來追蹤這些等待資源的執行緒;
請求資源: wait()
當訊號量收到一個請求時,它會檢查它的計數器是否大於零:
- 如果是,那訊號量會減一,然後給執行緒放綠燈;
- 如果不是,它會把執行緒新增到它佇列的末尾;
釋放資源: signal()
一旦訊號量收到一個訊號,它會檢查它的 FIFO 佇列是否有執行緒存在:
- 如果有,那麼訊號量會把第一個執行緒拉出來,然後給他一個綠燈;
- 如果沒有,那麼它會增加它的計數器;
警告: 忙碌等待
當一個執行緒傳送一個 wait() 資源請求給訊號量時,執行緒會凍結直到訊號量給執行緒綠燈。
⚠️️如果你在在主執行緒這麼做,那整個應用都會凍結⚠️️
在 Swift 裡使用訊號量 (通過 GCD)
讓我們寫一些程式碼!
宣告
宣告一個訊號量很簡單:
let semaphore = DispatchSemaphore(value: 1)複製程式碼
value 引數代表建立的訊號量允許同時訪問該資源的執行緒數量。
資源請求
如果要請求訊號量的資源,我們只需:
semaphore.wait()複製程式碼
要知道訊號量並不能實質上地給我們任何東西,資源都是線上程的範圍內,而我們只是在請求和釋放呼叫之間使用資源。
一旦訊號量給我們放行,那執行緒就會恢復正常執行,並可以放心地將資源納為己用了。
資源釋放
要釋放資源,我們這麼寫:
semaphore.signal()複製程式碼
在傳送這個訊號後,我們就不能接觸到任何資源了,直到我們再次的請求它。
Playgrounds 中的訊號量
跟隨 AppCoda 上這篇文章的例子,讓我們看看實際應用中的訊號量!
注意:這些是 Xcode 中的 Playground,Swift Playground 還不支援日誌記錄。希望 WWDC17 能解決這個問題!
在這些 playground 裡,我們有兩個執行緒,一個執行緒的優先順序比其他的略微高一些,列印 10 次表情和增加的數字。
沒有訊號量的 Playground
import Foundation
import PlaygroundSupport
let higherPriority = DispatchQueue.global(qos: .userInitiated)
let lowerPriority = DispatchQueue.global(qos: .utility)
func asyncPrint(queue: DispatchQueue, symbol: String) {
queue.async {
for i in 0...10 {
print(symbol, i)
}
}
}
asyncPrint(queue: higherPriority, symbol: "?")
asyncPrint(queue: lowerPriority, symbol: "?")
PlaygroundPage.current.needsIndefiniteExecution = true複製程式碼
和你想的一樣,多數情況下,高優先順序的執行緒先完成任務:
有訊號量的 Playground
這次我們會使用和前面一樣的程式碼,但是在同一時間,我們只給一個執行緒賦予列印表情+數字的權利。
為了達到這個目的,我們定義了一個訊號量並且更新了我們的 asyncPrint 函式:
import Foundation
import PlaygroundSupport
let higherPriority = DispatchQueue.global(qos: .userInitiated)
let lowerPriority = DispatchQueue.global(qos: .utility)
let semaphore = DispatchSemaphore(value: 1)
func asyncPrint(queue: DispatchQueue, symbol: String) {
queue.async {
print("\(symbol) waiting")
semaphore.wait() // 請求資源
for i in 0...10 {
print(symbol, i)
}
print("\(symbol) signal")
semaphore.signal() // 釋放資源
}
}
asyncPrint(queue: higherPriority, symbol: "?")
asyncPrint(queue: lowerPriority, symbol: "?")
PlaygroundPage.current.needsIndefiniteExecution = true複製程式碼
我還新增了一些 print 指令,以便我們看到每個執行緒執行中的實際狀態。
就像你看到的,當一個執行緒開始列印佇列,另一個執行緒必須等待,直到第一個結束,然後訊號量會從第一個執行緒收到 signal。當且僅當此後,第二個執行緒才能開始列印它的佇列。
第二個執行緒在佇列的哪個點傳送 wait() 無關緊要,它會一直處於等待狀態直到另一個執行緒結束。
優先順序反轉
現在我們已經明白每個步驟是如何工作的,請看一下這個日誌:
在這種情況下,通過上面的程式碼,處理器決定先執行低優先順序的執行緒。
這時,高優先順序的執行緒必須等待低優先順序的執行緒完成!這是真的,它的確會發生。
問題是即使一個高優先順序執行緒正等待它,低優先順序的執行緒也是低優先順序的:這被稱為優先順序反轉。
在不同於訊號量的其他程式設計概念裡,當發生這種情況時,低優先順序的執行緒會暫時繼承等待它的最高優先順序執行緒的優先順序,這被稱為:優先順序繼承。
在使用訊號量的時候不是這樣的,實際上,誰都可以呼叫 signal() 函式(不僅是當前正使用資源的執行緒)。
執行緒飢餓
為了讓事情變得更糟,讓我們假設在我們的高優先順序和低優先順序執行緒之間還有 1000 多箇中優先順序的執行緒。
如果我們有一種像上面那樣優先順序反轉的情況,高優先順序的執行緒必須等待低優先順序的執行緒,但是,大多數情況下,處理器會執行中優先順序的執行緒,因為他們的優先順序高於我們的低優先順序執行緒。
這種情況下,我們的高優先順序執行緒正被 CPU 餓的要死(於是有了飢餓的概念)。
解決方案
我的觀點是,在使用訊號量的時候,執行緒之間最好都使用相同的優先順序。如果這不符合你的情況,我建議你看看其他的解決方案,比如臨界區塊和管程.
Playground 上的死鎖
現在我們有兩個執行緒,使用兩個互斥的資源,“A” 和 “B”。
如果兩個資源可以分離使用,為每個資源定義一個訊號量是有意義的,如果不可以,那一個訊號量足以管理兩者。
我想用一個用前一種情況(2 個資源, 2 個訊號量)做一個例子:高優先順序執行緒會先使用資源 “A”,然後 “B”,而低優先順序的執行緒會先使用 “B”,然後再使用 "A"。
程式碼在這:
import Foundation
import PlaygroundSupport
let higherPriority = DispatchQueue.global(qos: .userInitiated)
let lowerPriority = DispatchQueue.global(qos: .utility)
let semaphoreA = DispatchSemaphore(value: 1)
let semaphoreB = DispatchSemaphore(value: 1)
func asyncPrint(queue: DispatchQueue, symbol: String, firstResource: String, firstSemaphore: DispatchSemaphore, secondResource: String, secondSemaphore: DispatchSemaphore) {
func requestResource(_ resource: String, with semaphore: DispatchSemaphore) {
print("\(symbol) waiting resource \(resource)")
semaphore.wait() // requesting the resource
}
queue.async {
requestResource(firstResource, with: firstSemaphore)
for i in 0...10 {
if i == 5 {
requestResource(secondResource, with: secondSemaphore)
}
print(symbol, i)
}
print("\(symbol) releasing resources")
firstSemaphore.signal() // releasing first resource
secondSemaphore.signal() // releasing second resource
}
}
asyncPrint(queue: higherPriority, symbol: "?", firstResource: "A", firstSemaphore: semaphoreA, secondResource: "B", secondSemaphore: semaphoreB)
asyncPrint(queue: lowerPriority, symbol: "?", firstResource: "B", firstSemaphore: semaphoreB, secondResource: "A", secondSemaphore: semaphoreA)
PlaygroundPage.current.needsIndefiniteExecution = true複製程式碼
如果我們幸運的話,會這樣:
簡單來說就是,第一個資源會先提供給高優先順序執行緒,然後對於第二個資源,處理器只有稍後把它移動到低優先順序執行緒。
然而,如果我們不是很幸運的話,那這種情況也會發生:
兩個執行緒都沒有完成他們的執行!讓我們檢查一下當前的狀態:
- 高優先順序的執行緒正在等待資源 “B”,可是被低優先順序的執行緒持有;
- 低優先順序的執行緒正在等待資源 “A”,可是被高優先順序的執行緒持有;
兩個執行緒都在等待相互的資源,誰也不能向前一步:歡迎來到執行緒死鎖!
解決方案
避免死鎖很難。最好的解決方案是編寫不能達到這種狀態的程式碼來防止他們。
例如,在其他的作業系統裡,為了其他執行緒的繼續執行,其中一個死鎖執行緒可能被殺死(為了釋放它的所有資源)。
...或者你可以使用鴕鳥演算法(Ostrich_Algorithm) ?。
結論
訊號量是一個很棒的概念,它可以在很多應用裡方便的使用,只是要小心:過馬路要看兩邊。
Federico 是一名在曼谷的軟體工程師,對 Swift、Minimalism、Design 和 iOS 開發有濃厚的熱情。