直觀理解生產者消費者問題

JABread發表於2017-12-19

文章內容:

  • 生產者-消費者問題的模擬場景(程式碼實現)
  • 深入理解(訊號量操作順序)

先把概念丟一邊,來一起下個館子吧。

模擬場景

1. 客人進店點餐,把自己想吃的東西寫在了訂單上,並把訂單放在了服務檯;

2. 服務員從服務檯上拿走客人寫好的訂單,準備上菜。
複製程式碼

客人:生產者(把想吃的菜寫在訂單上,這裡可以理解為下(訂)單)。 服務員:消費者(此消費者不是指代花錢買東西的人,這裡可以理解為做(訂)單處理訂單服務檯:公用緩衝池(這裡可以理解為訂單佇列

一切都跟我們往常所見一樣正常運作,但是計算機卻並不那麼"人性化",當客人和服務員都是機器人時,會發生兩個奇怪的現象:

 1. 當機器人服務員發現服務檯上訂單的數量為0時,它會把客人手上並未寫好的單子搶走,執行處理訂單的操作。

 2. 當機器人客人發現服務檯上擺滿了訂單並且已經明確不允許再放訂單時,它任會固執的把訂單放上去,執行下單的操作。
複製程式碼

為了世界和平,我們需要使用一個標誌來指示機器人何時處於等待狀態,這裡的標誌即是訊號量,訊號量為1時執行,為0時互斥。

何為互斥?

在程式設計實現上,我們把生產者和消費者定義為兩個執行緒在工作,當這兩個執行緒在執行過程中都遇到關鍵程式碼塊或者關鍵變數時,如果此時訊號量為1,則該程式碼塊允許訪問,如果此時訊號量為0,則表示當前程式碼塊已經被對方執行緒在訪問了,需等待對方執行緒訪問完畢才能繼續訪問。

在實際過程中,像這樣的生產者和消費者的數量非常多,這裡這是簡單的開闢兩條執行緒進行演示。

編碼場景: 客人在訂單中的食物欄寫上我要蛋炒飯,並把訂單放在服務檯上,服務員取出該訂單後將我要蛋炒飯改為給你蛋炒飯

下面來看看具體的程式碼實現。

(main.swift)
import Foundation

/// 服務檯允許擺放的最大訂單數量
let maxSize = 10
/// 訂單佇列
var orders = [Order]()
/// 目標訂單
var targetOrder = Order()
/// 訊號量
var semaphore = DispatchSemaphore(value: 1)
/// 生產者執行緒
let producer = DispatchQueue(label: "生產者執行緒")
/// 消費者執行緒
let consumer = DispatchQueue(label: "消費者執行緒")
/// 等待時長
let timeInterval = 0.003125

while true {
    // 生產者執行緒
    producer.async {
        if orders.count < maxSize {
            semaphore.wait()
            
            var createdOrder = Order()
            createdOrder.id = Int(arc4random())
            createdOrder.foods = "我要蛋炒飯"
            orders.append(createdOrder)
            targetOrder = createdOrder
            print("當前執行緒名稱及地址: \(producer.label)<\(String.init(format: "%p", Thread.current))>"
                + " 正在提交的訂單: [訂單編號: \(targetOrder.id) 食物: \(String(describing: targetOrder.foods))]"
                + " 服務檯訂單數量: \(orders.count)")
            // print(orders)

            Thread.sleep(forTimeInterval: timeInterval)
            semaphore.signal()
        }
    }

    // 消費者執行緒
    consumer.async {
        if orders.count > 0 {
            semaphore.wait()
            
            let disposedOrder = orders.removeFirst()
            targetOrder = disposedOrder
            targetOrder.foods = "給你蛋炒飯"

            print("當前執行緒名稱及地址: \(consumer.label)<\(String.init(format: "%p", Thread.current))>"
                + " 正在處理的訂單: [訂單編號: \(targetOrder.id) 食物: \(String(describing: targetOrder.foods))]"
                + " 服務檯訂單數量: \(orders.count)")
            // print(orders)
            
            Thread.sleep(forTimeInterval: timeInterval)
            semaphore.signal()
        }
    }

}
複製程式碼

訂單:

(Order.swift)
import Foundation

struct Order {
    var id: Int = 0
    var foods: String = ""
}
複製程式碼

當不利用訊號量進行互斥操作(註釋相關程式碼)時會報這樣的執行時會報這樣的error: Thread 2: Fatal error: UnsafeMutablePointer.deinitialize with negative count: 同時在不同執行緒中對陣列進行修改。

並且我們仔細觀察也可以看到生產者和消費者操作的目標訂單和訂單佇列也是混亂的。

由此可見進行同步操作尤為重要。

以上是對生產者與消費者問題的直觀理解和具體的程式碼實現。

深入理解

從上面的實現中我們僅設定了一個訂單佇列陣列orders來對公用緩衝池進行具象化,其實實際的公用緩衝池具有n個緩衝區,這時可利用互斥訊號量mutex實現各個程式(或執行緒或程式,以下同)對緩衝池的互斥使用;利用訊號量emptyfull分別表示緩衝池中的空緩衝區滿緩衝區數量。 只要緩衝池未滿,生產者便可以傳送一個訊號量到緩衝區;只要緩衝區未空,消費者便可以從緩衝區取走一個訊號量。

對陣列orders的長度判斷間接對應了emptyfull的職責。

這是較完整意義上的生產者-消費者問題。 我們再進行稍微深入的理解。

它相較於上面訂單例子的實現似乎多了幾個概念,為了方便理解,另外也加入幾個概念,來一起看看:

  1. 死鎖: 在無外力狀態下,兩者(或以上)都將無法從僵持等待狀態中解脫出來。
  2. 臨界資源: 一次只允許一個程式使用的非搶佔式資源。
  3. 臨界區: 每個程式中訪問臨界資源的那段程式碼稱為臨界區。
  4. 互斥訊號量: mutex,其初值為1,取值範圍是(-1,0,1)。 對應程式碼實現中var semaphore = DispatchSemaphore(value: 1)的value值。
  • 當mutex=1時,表示兩個程式都沒有進入需要互斥的臨界區;
  • 當mutex=0時,表示有一個程式進入臨界區執行,另一個程式必須等待;
  • 當mutex=-1時,表示有一個程式正在臨界區執行,另一個程式因等待而阻塞在訊號量佇列中,需要被當前已在臨界區執行的程式退出時喚醒。 注意: 我們對mutex的操作主要有兩個,wait(P操作)和signal(V操作),分別表示對semaphore count的+1和-1,並且這兩步操作必須成對出現。缺少wait會導致系統混亂,不能保證對臨界資源的互斥訪問;缺少signal會使臨界資源永遠不會被釋放,從而使因等待該資源而阻塞的程式不能被喚醒。
  1. 資源訊號量: 空緩衝區與滿緩衝區。 注意: 應先執行對資源訊號量的wait操作,然後再執行對互斥訊號量的wait操作,否則可能引起程式死鎖。 為什麼? 換句話說,先執行對資源訊號量的wait操作,然後再執行對互斥訊號量的wait操作,這樣就不會引起程式死鎖

我們先來分析分析第二句話。

生產者-消費者問題的虛擬碼描述如下:

 int in = 0, out = 0; in: 空緩衝區佇列索引; out: 滿緩衝區佇列索引
 item buffer[n]; // 大小為n的緩衝區
 semaphore mutex = 1, full = 0, empty = n;
 
 // 生產者程式
 void producer() {
 do {
     produce an item nextp; // 生產一個產品
     ...
     wait(empty); // 申請資源訊號量(一個單位空緩衝區)
     wait(mutex); // 申請互斥訊號量(臨界資源)
     buffer[in] = nextp; // 將產品放到緩衝區
     in = (in + 1) % n; //類似迴圈佇列
     signal(mutex); // 釋放臨界資源
     signal(full); // 釋放一個單位滿緩衝區
     } while (TRUE);
 }
 
 // 消費者程式
 void consumer() {
     do {
     wait(full); // 申請資源訊號量(一個單位滿緩衝區)
     wait(mutex); // 申請互斥訊號量(臨界資源)
     nextc = buffer[out]; // 將產品從緩衝區取出
     out = (out + 1) % n; //類似迴圈佇列
     signal(mutex); // 釋放臨界資源
     signal(empty); // 釋放一個單位空緩衝區
     consume the item in nextc; // 消費一個產品
     ...
     } while (TRUE);
 }
複製程式碼

根據墨菲定律,某個情況如果可能會出現,那麼它肯定會出現,無論情況好壞。

現在假設這種可能的極端情況有兩種:

<1>. 當在生產者程式中執行到wait(empty)操作申請一個空緩衝區時,發現空緩衝區已滿。那麼這時生產者程式會掛入到阻塞佇列,等待消費者程式釋放一個空緩衝區後,然後被喚醒。 注意: 生產者程式被喚醒後才會去申請臨界資源。

我們再回來看看為什麼如果先執行對互斥訊號量的wait操作(即申請資源訊號量),再執行對資源訊號量的wait操作(即申請互斥訊號量),可能引起程式死鎖。

<2>. 當生產者程式先申請互斥訊號量再申請資源訊號量,則可能發生一個程式申請互斥訊號量成功,得到訪問的臨界資源,而再去申請空緩衝區時發現空緩衝區已滿,那麼這時生產者會掛入到阻塞佇列,等待消費者程式釋放一個空緩衝區,然後被喚醒。 注意: 此時消費者程式有無能力釋放一個空緩衝區?答案是 沒有。

因為當消費者程式執行到wait(mutex)即想要去申請互斥訊號量訪問臨界資源時,發現此時該臨界資源被訪問,因而wait(mutex)操作必定失敗,消費者程式阻塞。很顯然,這種情況下因為生產者程式帶著臨界資源進入到阻塞狀態,而消費者程式因無法拿到臨界資源也處於阻塞狀態,造成死鎖。

結語

  • 關於生產者-消費者問題的解決辦法有很多,以上的討論只是利用了記錄型訊號量,當然了我們也可以使用AND訊號量(加強版的記錄型訊號量),甚至使用管程機制等等,有興趣的小夥伴可以自行查閱。
  • 小生才疏淺陋,文中難免有錯漏之處,請多多指教,感謝您的閱讀。

相關文章