Java面試_生產者消費者模式

Java 生產者消費者模式

簡介
生產者消費者模式並不是GOF提出的23種設計模式之一,23中設計模式是建立在面向對象的基礎上的,但其實面向過程的編程中也有很多高校的編程模式,生產者消費者模式便是其中之一,它是我們編程過程中最常用的一種設計模式。
在實際的軟件開發中,經常會碰到如下場景:某個模塊負責產生數據,這些數據由另一個模塊來負責處理。產生數據的模塊,就形象地稱爲生產者,而處理數據的模塊,就稱爲消費者。
單單抽象出生產者和消費者,還不夠稱之爲生產者消費者模式。該模式還需要有一個緩衝區處於生產者金額消費者之間,作爲一箇中介,生產者把數據放入緩衝區,而消費者從緩衝區取出數據,大概的結構圖如下圖:
在這裏插入圖片描述
在網上有一個很好的例子:就是寄信,假設你要寄一封信,那麼大致過程如下:
1.首先,你要把信寫好-------------------相當於生產者製造數據
2.你要把寫好的信放在郵筒--------------------相當於生產者把數據放入緩衝區
3.郵遞員把信從信箱取出---------------------相當於消費者把數據去除緩衝區
4.郵遞員把信拿去郵局做相應的處理----------------相當於消費者處理數據

優點
1.解耦
假設生產者消費者分別是兩個類,如果讓生產者直接調用消費者的某個方法,那麼生產者對於消費者就會產生依賴。將來如果消費者的代碼改變,可能會影響到生產者。而如果兩個類都依賴於某個緩衝區,兩者之間不直接依賴,那麼耦合也就降低了。

2.支持併發(concurrency)
生產者直接調用消費者的某個方法,還有另一個弊端,就是函數調用是同步的,或者叫阻塞的,如果不清楚概念可以去找找多線程基礎,如果多線程情況下,生產者調用了消費者的方法,那麼如果消費者的方法在沒有返回之前,那麼生產者就只好一直等在那邊隊列。網易消費者處理數據很慢,生產者就一直在那等,很浪費時間。
如果使用了生產者消費者模式,那麼生產者和消費者是兩個獨立的併發主體,生產者就沒有直接依賴於消費者,而是生產者直接把製造出來的數據往緩存區一丟,他就可以接着去生成下一個數據,基本上不用依賴消費者的處理速度。

3.支持忙閒不均
緩衝區還有另一個好處。如果製造數據的速度時快時慢,緩衝區的好處就體現出來了,當數據製造快的時候,消費者來不及處理,未處理的數據可以暫時存在緩衝區中,等生產者的製造速度慢下來後,消費者在慢慢處理掉。

數據單元
什麼是數據單元,簡單來說,每次生產者放放到緩衝區,就是一個數據單元,每次消費者從緩衝區取出的,也是一個數據單元。

數據單元的特性:
1.關聯到業務對象:首先,數據單元必須關聯某種業務對象
2.完整性:所謂完整性,就是在數據單元傳輸的過程中,要保證數據單元的完整性,要麼整個數據單元傳遞給消費者,要麼完全沒有傳遞給消費者。不允許出現部分傳遞這種。
3.獨立性:所謂獨立性,就是各個數據單元之間沒有互相依賴,某個數據單元傳輸失敗不影響已經傳輸成功的單元,也不影響還未傳輸的單元。
4.顆粒度:有時候處於性能等因素的考慮,也可能會把N個業務對象打包成一個數據單元,那麼,這個N該如何取值就是顆粒度的考慮了。顆粒度的大小是有講究的。太大的顆粒度可能會造成某種浪費;太小的顆粒度可能會造成性能問題。顆粒度的權衡要基於多方面的因素,以及一些經驗值的考量。

隊列緩衝區
不同的緩衝區技術,不同的併發場景對於具體的技術野是有較大的影響,現在我們先來看最傳統也最常用的方式。也就是單個生產者單個消費者說起,當中用隊列(FIFO)作爲緩衝。

線程方式

  1. 在線程方式下,生產者和消費者各自是一個線程。生產者把數據寫入隊列頭(以下簡稱push),消費者從隊列尾部讀出數據(以下簡稱pop)。當隊列爲空,消費者就稍息(稍事休息);當隊列滿(達到最大長度),生產者就稍息。整個流程並不複雜。
    那麼,上述過程會有什麼問題捏?一個主要的問題是關於內存分配的性能開銷。對於常見的隊列實現:在每次push時,可能涉及到堆內存的分配;在每次pop時,可能涉及堆內存的釋放。假如生產者和消費者都很勤快,頻繁地push、pop,那內存分配的開銷就很可觀了。

2.同步和互斥的性能:另外由於是多線程,然後就會設計到線程間的一些問題,例如同步,互斥,死鎖等等。同步和互斥的性能開銷。在很多場合中,諸如信號量、互斥量等玩意兒的使用也是有不小的開銷的(某些情況下,也可能導致用戶態/核心態切換)。如果像剛纔所說,生產者和消費者都很勤快,那這些開銷也不容小覷啊。這個問題我們可以使用生產者消費者模式的雙緩衝區來解決。

3.上面兩點說了隊列的缺點,但是隊列也有很多優點:由於隊列是很常見的數據結構,大部分編程語言都內置了隊列的支持,有些語言甚至提供了線程安全的隊列(比如JDK 1.5引入的ArrayBlockingQueue)。因此,開發人員可以撿現成,避免了重新發明輪子。

所以,假如你的數據流量不是很大,採用隊列緩衝區的好處還是很明顯的:邏輯清晰、代碼簡單、維護方便。比較符合KISS原則。

進程方式
跨進程的生產者/消費者模式,非常依賴於具體的進程間通訊(IPC)方式。而IPC的種類名目繁多,因此咱們挑選幾種跨平臺、且編程語言支持較多的IPC方式來說。
1.匿名管道:管道其實是最想隊列的IPC類型,生產者進程仔管道的寫端放入數據,消費者仔管道的讀端讀取數據,震哥哥的效果和線程中使用隊列十分相似,區別在於使用管道就無需擔心線程安全。
管道又分命名管道和匿名管道兩種,今天主要聊匿名管道。因爲命名管道在不同的操作系統下差異較大(比如Win32和POSIX,在命名管道的API接口和功能實現上都有較大差異;有些平臺不支持命名管道,比如Windows CE)。除了操作系統的問題,對於有些編程語言(比如Java)來說,命名管道是無法使用的。所以我一般不推薦。
其實匿名管道在不同平臺上的API接口,也是有差異的(比如Win32的CreatePipe和POSIX的pipe,用法就很不一樣)。但是我們可以僅使用標準輸入和標準輸出(以下簡稱stdio)來進行數據的流入流出。然後利用shell的管道符把生產者進程和消費者進程關聯起來

這麼幹有幾個好處:

1、基本上所有操作系統都支持在shell方式下使用管道符。因此很容易實現跨平臺。

2、大部分編程語言都能夠操作stdio,因此跨編程語言也就容易實現。

3、剛纔已經提到,管道方式省卻了線程安全方面的瑣事。有利於降低開發、調試成本。

當然,這種方式也有自身的缺點:

1、生產者進程和消費者進程必須得在同一臺主機上,無法跨機器通訊。這個缺點比較明顯。

2、在一對一的情況下,這種方式挺合用。但如果要擴展到一對多或者多對一,那就有點棘手了。所以這種方式的擴展性要打個折扣。假如今後要考慮類似的擴展,這個缺點就比較明顯。

3、由於管道是shell創建的,對於兩邊的進程不可見(程序看到的只是stdio)。在某些情況下,導致程序不便於對管道進行操縱(比如調整管道緩衝區尺寸)。這個缺點不太明顯。

4、最後,這種方式只能單向傳數據。好在大多數情況下,消費者進程不需要傳數據給生產者進程。萬一你確實需要信息反饋(從消費者到生產者),那就費勁了。可能得考慮換種IPC方式。

順便補充幾個注意事項,大夥兒留意一下:

1、對stdio進行讀寫操作是以阻塞方式進行。比如管道中沒有數據,消費者進程的讀操作就會一直停在哪兒,直到管道中重新有數據。

2、由於stdio內部帶有自己的緩衝區(這緩衝區和管道緩衝區是兩碼事),有時會導致一些不太爽的現象(比如生產者進程輸出了數據,但消費者進程沒有立即讀到)。具體的細節,大夥兒可以看"這裏"。

SOCKET(TCP方式)
基於TCP方式的SOCKET通訊是又一個類似於隊列的IPC方式。它同樣保證了數據的順序到達;同樣有緩衝的機制。而且這玩意兒也是跨平臺和跨語言的,和剛纔介紹的shell管道符方式類似。

SOCKET相比shell管道符的方式,有啥優點捏?主要有如下幾個優點:

1、SOCKET方式可以跨機器(便於實現分佈式)。這是主要優點。

2、SOCKET方式便於將來擴展成爲多對一或者一對多。這也是主要優點。

3、SOCKET可以設置阻塞和非阻塞方法,用起來比較靈活。這是次要優點。

4、SOCKET支持雙向通訊,有利於消費者反饋信息。

當然有利就有弊。相對於上述shell管道的方式,使用SOCKET在編程上會更復雜一些。好在前人已經做了大量的工作,搞出很多SOCKET通訊庫和框架給大夥兒用(比如C++的ACE庫、Python的Twisted)。藉助於這些第三方的庫和框架,SOCKET方式用起來還是比較爽的。由於具體的網絡通訊庫該怎麼用不是本系列的重點,此處就不細說了。
雖然TCP在很多方面比UDP可靠,但鑑於跨機器通訊先天的不可預料性(比如網線可能被某傻X給拔錯了,網絡的忙閒波動可能很大),在程序設計上我們還是要多留一手。具體該如何做捏?可以在生產者進程和消費者進程內部各自再引入基於線程的"生產者/消費者模式"。如下圖所示:
在這裏插入圖片描述
這麼做的關鍵點在於把代碼分爲兩部分:生產線程和消費線程屬於和業務邏輯相關的代碼(和通訊邏輯無關);發送線程和接收線程屬於通訊相關的代碼(和業務邏輯無關)。

這樣的好處是很明顯的,具體如下:

1、能夠應對暫時性的網絡故障。並且在網絡故障解除後,能夠繼續工作。

2、網絡故障的應對處理方式(比如斷開後的嘗試重連),隻影響發送和接收線程,不會影響生產線程和消費線程(業務邏輯部分)。

3、具體的SOCKET方式(阻塞和非阻塞)隻影響發送和接收線程,不影響生產線程和消費線程(業務邏輯部分)。

4、不依賴TCP自身的發送緩衝區和接收緩衝區。(默認的TCP緩衝區的大小可能無法滿足實際要求)

5、業務邏輯的變化(比如業務需求變更)不影響發送線程和接收線程。

針對上述的最後一條,再多囉嗦幾句。如果整個業務系統中有多個進程是採用上述的模式,那或許可以重構一把:在業務邏輯代碼和通訊邏輯代碼之間切一刀,把業務邏輯無關的部分封裝成一個通訊中間件。

環形緩衝區
只有當存儲空間的分配或者釋放非常頻繁並且確實產生了明顯的影響,你才應該考慮環形緩衝區。

環形緩衝區 vs隊列緩衝區
1.外部接口相似:普通的隊列緩也有一個寫入段一個讀出端,當隊列爲空,讀出端無法讀取數據,當隊列滿時,寫入端無法寫入數據。
環形緩衝區也是一樣的,也有一個寫入端(用於push),和一個讀出端(pop),也有緩衝區滿和空的狀態。所以從隊列緩衝區切換到環形緩衝區對於使用者來說並不難。

2.內部結構迥異:雖然說兩者的對外接口差不多,但是內部結構和運作機制差別就很大了,這裏重點說一下環形緩衝區的內部結構。
我們可以把環形緩衝區想象成一個圓形的操場,裏面有兩個人,一個是生產者一個是消費者,生產者在前,消費者在後,生產者一直繞着操場生產,而消費者就在後面一直使用。如果說消費者追上了生產者,,那麼這個操場上就沒有生產者生產的了,也就是說緩存區是空的,反之,如果生產者生產速度很快追上了在後面的消費者的話,那麼就證明了緩存區已經被生產者生產滿了。如下圖所示:
在這裏插入圖片描述
從上圖可以看出,環形緩衝區所有的push和pop操作都是在一個固定的存儲空間內進行。而隊列緩衝區在push的時候,可能會分配存儲空間用於存儲新元素;在pop時,可能會釋放廢棄元素的存儲空間。所以環形方式相比隊列方式,少掉了對於緩衝區元素所用存儲空間的分配、釋放。這是環形緩衝區的一個主要優勢。

環形緩衝區的實現

數組方式 vs 鏈表方式
環形緩衝區的內部實現,即可基於數組(此處的數組,泛指連續存儲空間)實現,也可基於鏈表實現。
數組在物理存儲上是一維的連續線性結構,可以在初始化時,把存儲空間一次性分配好,這是數組方式的優點。但是要使用數組來模擬環,你必須在邏輯上把數組的頭和尾相連。在順序遍歷數組時,對尾部元素(最後一個元素)要作一下特殊處理。訪問尾部元素的下一個元素時,要重新回到頭部元素(第0個元素)。如下圖所示:
在這裏插入圖片描述
使用鏈表的方式,正好和數組相反:鏈表省去了頭尾相連的特殊處理。但是鏈表在初始化的時候比較繁瑣,而且在有些場合(比如後面提到的跨進程的IPC)不太方便使用。

讀寫操作
環形緩衝區要維護兩個索引,分別對應寫入端(W)和讀取端(R)。寫入(push)的時候,先確保環沒滿,然後把數據複製到W所對應的元素,最後W指向下一個元素;讀取(pop)的時候,先確保環沒空,然後返回R對應的元素,最後R指向下一個元素。

判斷“空”和“滿”
上述的操作並不複雜,不過有一個小小的麻煩:空環和滿環的時候,R和W都指向同一個位置!這樣就無法判斷到底是“空”還是“滿”。大體上有兩種方法可以解決該問題。

辦法1:始終保持一個元素不用
當空環的時候,R和W重疊。當W比R跑得快,追到距離R還有一個元素間隔的時候,就認爲環已經滿。當環內元素佔用的存儲空間較大的時候,這種辦法顯得很土(浪費空間)。

辦法2:維護額外變量
如果不喜歡上述辦法,還可以採用額外的變量來解決。比如可以用一個整數記錄當前環中已經保存的元素個數(該整數>=0)。當R和W重疊的時候,通過該變量就可以知道是“空”還是“滿”。

元素的儲存
由於環形緩衝區本身就是要降低存儲空間分配的開銷,因此緩衝區中元素的類型要選好。儘量存儲值類型的數據,而不要存儲指針(引用)類型的數據。因爲指針類型的數據又會引起存儲空間(比如堆內存)的分配和釋放,使得環形緩衝區的效果打折扣。

用於併發線程
和線程中的隊列緩衝區類似,線程中的環形緩衝區也要考慮線程安全問題。除非你是用的環形緩衝區的庫已經幫你實現了線程安全,否則你得自己動手搞定。

用於併發進程
進程間的環形緩衝區,似乎少有現成的庫可用。那就只能自己寫了。
適用於進程間環形緩衝區的IPC類型,常見的有共享內存文件。這兩種方式上進行緩衝,都是採用數組的方式實現,程序實現分配好一個固定長度的儲存空間,然後具體的讀寫操作,判斷空和滿,元素儲存等細節就可以照前面所說的進行。

共享內存方式的性能很好,適用於數據流量很大的場景。但是有些語言(比如Java)對於共享內存不支持。因此,該方式在多語言協同開發的系統中,會有一定的侷限性。

而文件方式在編程語言方面支持很好,幾乎所有編程語言都支持操作文件。但它可能會受限於磁盤讀寫(Disk I/O)的性能。所以文件方式不太適合於快速數據傳輸;但是對於某些“數據單元”很大的場合,文件方式是值得考慮的。

生產/消費問題是個非常經典的多線程問題,涉及到的對象包括“生產者”、“消費者”、“倉庫”和“產品”。他們之間的關係如下:
① 生產者僅僅在倉儲未滿時候生產,倉滿則停止生產。

② 消費者僅僅在倉儲有產品時候才能消費,倉空則等待。

③ 當消費者發現倉庫沒產品可消費時候會通知生產者生產。

④ 生產者在生產出可消費產品時候,應該通知等待的消費者去消費。

參考:https://blog.csdn.net/u011109589/article/details/80519863

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章