Python | 面試的常客,經典的生產消費者模式

TechFlow2019發表於2020-08-05

本文始發於個人公眾號:TechFlow,原創不易,求個關注


今天是Python專題的第23篇文章,我們來聊聊關於多執行緒的一個經典設計模式

在之前的文章當中我們曾經說道,在多執行緒併發的場景當中,如果我們需要感知執行緒之間的狀態,交換執行緒之間的資訊是一件非常複雜和困難的事情。因為我們沒有更高階的系統許可權,也沒有上帝視角,很難知道目前執行的狀態的全貌,所以想要設計出一個穩健執行沒有bug的功能,不僅非常困難,而且除錯起來非常麻煩。

生產消費者模式

在日常開發當中,從一個執行緒向另外的執行緒傳輸資料又是一件家常便飯的事情。舉個最簡單的例子,我們在處理網頁請求的時候,需要列印下來這一次請求的相關日誌。列印日誌是一次IO行為,這是非常消耗時間的,所以我們不能放在請求當中同步進行,否則會影響系統的效能。最好的辦法就是啟動一系列執行緒專門負責列印,後端的執行緒只負責響應請求,相關的日誌以訊息的形式傳送給列印執行緒列印。

這個簡單的不能再簡單的功能當中涉及了諸多細節,我們來盤點幾個。首先IO執行緒的資料都是從後臺執行緒來的,假如一段時間內沒有請求,那麼這些執行緒都應該休眠,應該在有請求的時候才會啟動。其次,如果某一段時間內請求非常多,導致IO執行緒一時間來不及列印所有的資料,那麼當下的請求應該先暫存起來,等IO執行緒”忙過來“之後再進行處理。

把這些細節都考慮到,自己來設計功能還是挺麻煩的。好在這個問題前人已經替我們想過了,並且得出了一個非常經典的設計模式,使用它可以很好的解決這個問題。這個模式就是生產消費者模式

這個設計模式的原理其實非常簡單,我們來看張圖就明白了。

Java併發-- 生產者-消費者模式| 點滴積累
Java併發-- 生產者-消費者模式| 點滴積累

執行緒根據和資料的關係分為生產者執行緒和消費者執行緒,其中生產者執行緒負責生產資料,產生了資料之後會儲存到任務佇列當中。消費者執行緒從這個佇列獲取需要消費的資料,它和生產者執行緒之間不會直接互動,避免了執行緒之間互相依賴的問題。

另外一個細節是這裡的任務佇列並不是普通的佇列,一般情況下是一個阻塞佇列。也就是說當消費者執行緒嘗試從其中獲取資料的時候,如果佇列是空的,那麼這些消費者執行緒會自動掛起等待,直到它獲得了資料為止。有阻塞佇列當然也有非阻塞佇列,如果是非阻塞佇列的話,當我們嘗試從其中獲取資料的時候,如果它當中沒有資料的話,並不會掛起等待,而是會返回一個空值。

當然阻塞佇列的掛起等待時間也是可以設定的,我們可以讓它一直等待下去,也可以設定一個最長等待時間。如果超過這個時間也會返回空,不同的佇列應用在不同的場景當中,我們需要根據場景性質做出調整。

程式碼實現

看完了設計模式的原理,我們下面來試著用程式碼來實現一下。

在一般的高階語言當中都有現成的佇列的庫,由於在生產消費者模式當中用到的是阻塞型queue,有阻塞性的佇列當然也就有非阻塞型的佇列。我們在用之前需要先了解清楚,如果用錯了佇列會導致整個程式出現問題。在Python當中,我們最常用的queue就是一個支援多執行緒場景的阻塞佇列,所以我們直接拿來用就好了。

由於這個設計模式非常簡單,這個程式碼並不長只有幾行:

from queue import Queue
from threading import Thread

def producer(que):
    data = 0
    while True:
        data += 1
        que.put(data)
        
def consumer(que):
    while True:
        data = que.get()
        print(data)
        
        
que = Queue()
t1 = Thread(target=consumer, args=(que, ))
t2 = Thread(target=producer, args=(que, ))
t1.start()
t2.start()

我們執行一下就會發現它是可行的,並且由於佇列先進先出的限制,可以保證了consumer執行緒讀取到的內容的順序和producer生產的順序是一致的

如果我們執行一下這個程式碼會發現它是不會結束的,因為consumer和producer當中都用到了while True構建的死迴圈,假設我們希望可以控制程式的結束,應該怎麼辦?

其實也很簡單,我們也可以利用佇列。我們建立一個特殊的訊號量,約定好當consumer接受到這個特殊值的時候就停止程式。這樣當我們要結束程式的時候,我們只需要把這個訊號量加入佇列即可。

singal = object()

def producer(que):
    data = 0
    while data < 20:
        data += 1
        que.put(data)
    que.put(singal)
        
def consumer(que):
    while True:
        data = que.get()
        if data is singal:
            # 繼續插入singal
            que.put(singal)
            break
        print(data)

這裡有一個細節是我們在consumer當中,當讀取到singal的時候,在跳出迴圈之前我們又把singal放回了佇列。原因也很簡單,因為有時候consumer執行緒不止一個,這個singal上游只放置了一個,只會被一個執行緒讀取進來,其他執行緒並不會知道已經獲得了singal的訊息,所以還是會繼續執行。

而當consumer關閉之前放入singal就可以保證每一個consumer在關閉的之前都會再傳遞一個結束的訊號給其他未關閉的consumer讀取。這樣一個一個的傳遞,就可以保證所有consumer都關閉。

這裡還有一個小細節,雖然利用佇列可以解決生產者和消費者通訊的問題,但是上游的生產者並不知道下游的消費者是否已經執行完成了。假如我們想要知道,應該怎麼辦?

Python的設計者們也考慮到了這個問題,所以他們在Queue這個類當中加入了task_done和join方法。利用task_done,消費者可以通知queue這一個任務已經執行完成了。而通過呼叫join,可以等待所有的consumer完成。

from queue import Queue
from threading import Thread

def producer(que):
    data = 0
    while data < 20:
        data += 1
        que.put(data)
        
def consumer(que):
    while True:
        data = que.get()
        print(data)
        que.task_done()
        
        
que = Queue()
t1 = Thread(target=consumer, args=(que, ))
t2 = Thread(target=producer, args=(que, ))
t1.start()
t2.start()

que.join()

除了使用task_done之外,我們還可以在que傳遞的訊息當中加入一個Event,這樣我們還可以繼續感知到每一個Event執行的情況。

優先佇列與其他設定

我們之前在介紹一些分散式排程系統的時候曾經說到過,在排程系統當中,排程者會用一個優先佇列來管理所有的任務。當有機器空閒的時候,會有限排程那些優先順序高的任務。

其實這個排程系統也是基於我們剛才介紹的生產消費者模型開發的,只不過將排程佇列從普通佇列換成了優先佇列而已。所以如果我們也希望我們的consumer能夠根據任務的優先順序來改變執行順序的話,也可以使用優先佇列來進行管理任務。

關於優先佇列的實現我們已經很熟悉了,但是有一個問題是我們需要實現掛起等待的阻塞功能。這個我們自己實現是比較麻煩的,但好在我們可以通過呼叫相關的庫來實現。比如threading中的Condition,Condition是一個條件變數可以通知其他執行緒,也可以實現掛起等待

from threading import Thread, Condition

class PriorityQueue:
    def __init__(self):
        self._queue = []
        self._cv = Condition()
        
    def put(self, item, priority):
        with self._cv:
            heapq.heappush(self._queue, (-priority, self._count, item))
            # 通知下游,喚醒wait狀態的執行緒
            self._cv.notify()

    def get(self):
        with self._cv:
            # 如果對列為空則掛起
            while len(self._queue) == 0:
                self._cv.wait()
            # 否則返回優先順序最大的
            return heapq.heappop(self._queue)[-1]

最後介紹一下Queue的其他設定,比如我們可以通過size引數設定佇列的大小,由於這是一個阻塞式佇列,所以如果我們設定了佇列的大小,那麼當佇列被裝滿的時候,往其中插入資料的操作也會被阻塞。此時producer執行緒會被掛起,一直到佇列不再滿為止。

當然我們也可以通過block引數將佇列的操作設定成非阻塞。比如que.get(block=False),那麼當佇列為空的時候,將會丟擲一個佇列為空的異常。同樣,que.put(data, block=False)時也一樣會得到一個佇列已滿的異常。

總結

今天這篇文章當中我們主要介紹了多執行緒場景中經典的生產消費者模式,這個模式在許多場景當中都有使用。比如kafka等訊息系統,以及yarn等排程系統等等,幾乎只要是涉及到多執行緒上下游通訊的,往往都會用到。也正因此它的使用場景太廣了,所以它經常在各種面試當中出現,也可以認為是工程師必須知道的幾種基礎設計模式之一。

另外,佇列也是一個在設計模式以及使用場景當中經常出現的資料結構。從側面也說明了,為什麼演算法和資料結構非常重要,許多大公司喜歡問一些演算法題,也是因為有實際的使用場景,並且的的確確能鍛鍊工程師的思維能力。經常有同學問我演算法和資料結構的使用案例,這就是一個很好的例子。

今天的文章到這裡就結束了,如果喜歡本文的話,請來一波素質三連,給我一點支援吧(關注、轉發、點贊)。

相關文章