架構設計:生產者/消費者模式[2]:佇列緩衝區

無痕幽雨發表於2018-02-09

架構設計:生產者/消費者模式[2]:佇列緩衝區

文章目錄

★執行緒方式
★程式方式
  經過前面兩個帖子的鋪墊,今天終於開始聊一些具體的程式設計技術了。由於不同的緩衝區型別、不同的併發場景對於具體的技術實現有較大的影響。為了深入淺出、便於大夥兒理解,我們們先來介紹最傳統、最常見的方式。也就是單個生產者對應單個消費者,當中用【佇列】(FIFO)作緩衝。
  關於併發的場景,在之前的帖子“程式還執行緒?是一個問題!”中,已經專門論述了程式和執行緒各自的優缺點,兩者皆不可偏廢。所以,後面對各種緩衝區型別的介紹都會同時提及程式方式和執行緒方式。

★執行緒方式


  先來說一下併發執行緒中使用佇列的例子,以及相關的優缺點。

◇記憶體分配的效能


  線上程方式下,生產者和消費者各自是一個執行緒。生產者把資料寫入佇列頭(以下簡稱 push),消費者從佇列尾部讀出資料(以下簡稱 pop)。當佇列為空,消費者就稍息(稍事休息);當佇列滿(達到最大長度),生產者就稍息。整個流程並不複雜。
  那麼,上述過程會有什麼問題捏?一個主要的問題是關於記憶體分配的效能開銷。對於常見的佇列實現:在每次 push 時,可能涉及到【堆記憶體】的分配;在每次 pop 時,可能涉及【堆記憶體】的釋放。假如生產者和消費者都很勤快,頻繁地 push、pop,那記憶體分配的開銷就很可觀啦!對於記憶體分配的開銷,用 Java 的同學可以參見前幾天的帖子“Java 效能優化[1]”;對於用 C/C++ 的同學,想必對 OS 底層機制會更清楚,應該知道分配【堆記憶體】(new 或 malloc)會有加鎖的開銷和使用者態/核心態切換的開銷。
  那該怎麼辦捏?請聽下文分解,關於“生產者/消費者模式[3]:環形緩衝區”。

◇同步和互斥的效能


  另外,由於兩個執行緒共用一個佇列,自然就會涉及到執行緒間諸如同步啊、互斥啊、死鎖啊等等勞心費神的事情。好在"作業系統"這門課程對此有詳細介紹,學過的同學應該還有點印象吧?對於沒學過這門課的同學,也不必難過,網上相關的介紹挺多的(比如“這裡”),大夥自己去瞅一瞅。關於這方面的細節,我們今天就不多囉嗦了。
  這會兒要細談的是,同步和互斥的效能開銷。在很多場合中,諸如訊號量、互斥量等玩意兒的使用也是有不小的開銷的(某些情況下,也可能導致使用者態/核心態切換)。如果像剛才所說,生產者和消費者都很勤快,那這些開銷也不容小覷啊。
  這又該咋辦捏?請聽下文的下文分解,關於“生產者/消費者模式[4]:雙緩衝區”。

◇適用於佇列的場合


  剛才盡批判了佇列的缺點,難道佇列方式就一無是處?非也。由於佇列是很常見的資料結構,大部分程式語言都內建了佇列的支援(具體介紹見“這裡”),有些語言甚至提供了執行緒安全的佇列(比如JDK 1.5引入的 ArrayBlockingQueue)。因此,開發人員可以撿現成,避免了重新發明輪子。
  所以,假如你的資料流量不是很大,採用佇列緩衝區的好處還是很明顯的:邏輯清晰、程式碼簡單、維護方便。比較符合 KISS 原則

★程式方式


  說完了執行緒的方式,再來介紹基於程式的併發。
  跨程式的生產者/消費者模式,非常依賴於具體的程式間通訊(IPC)方式。而IPC的種類名目繁多,不便於挨個列舉(畢竟口水有限)。因此我們們挑選幾種跨平臺、且程式語言支援較多的IPC方式來說事兒。

◇匿名管道


  感覺管道是最像佇列的IPC型別。生產者程式在管道的寫端放入資料;消費者程式在管道的讀端取出資料。整個的效果和執行緒中使用佇列非常類似,區別在於使用管道就無需操心執行緒安全、記憶體分配等瑣事(作業系統暗中都幫你搞定了)。
  管道又分“命名管道”和“匿名管道”兩種,今天主要聊匿名管道。因為命名管道在不同的作業系統下差異較大(比如 Win32 和 POSIX,在命名管道的 API 介面和功能實現上都有較大差異;有些平臺不支援命名管道,比如 Windows CE)。除了作業系統的問題,對於有些程式語言(比如 Java)來說,命名管道是無法使用的。所以俺一般不推薦使用這玩意兒。
  其實匿名管道在不同平臺上的 API 介面,也是有差異的(比如 Win32 的 CreatePipe 和 POSIX 的 pipe,用法就很不一樣)。但是我們可以僅使用標準輸入和標準輸出(以下簡稱 stdio)來進行資料的流入流出。然後利用 shell 的管道符把生產者程式和消費者程式關聯起來(沒聽說過這種手法的同學,可以看“這裡”)。實際上,很多作業系統(尤其是 POSIX 風格的)自帶的命令都充分利用了這個特性來實現資料的傳輸(比如 more、grep 等)。
  這麼幹有如下幾個好處:
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. 業務邏輯的變化(比如業務需求變更)不影響傳送執行緒和接收執行緒。
  針對上述的最後一條,再多囉嗦幾句。如果整個業務系統中有多個程式是採用上述的模式,那或許可以重構一把:在業務邏輯程式碼和通訊邏輯程式碼之間切一刀,把業務邏輯無關的部分封裝成一個通訊中介軟體(說“中介軟體”顯得比較牛逼 :-)。如果大夥兒對這玩意兒有興趣,以後專門開個帖子聊。
  下一個帖子,我們們來介紹一下環形緩衝區的話題。

回到本系列的目錄

 

相關文章