<摘錄>開源軟體架構-ZeroMQ

木子你妹發表於2015-06-24

原文連結:http://www.aosabook.org/en/zeromq.html

ØMQ是一個訊息通訊系統,如果你願意的話也可以稱其為“面向訊息的中介軟體”。ØMQ的應用環境很廣泛,包括金融服務、遊戲開發、嵌入式系統、學術研究以及航空航天等領域。

訊息通訊系統完成的工作基本上可看作為負責應用程式之間的即時訊息通訊。一個應用程式決定傳送一個事件給另一個應用程式(或者多個應用程式),它將需要傳送的資料組合起來,點選“傳送”按鈕就行了——訊息通訊系統會搞定剩下的工作。

不同於即時訊息通訊的是,訊息通訊系統沒有圖形使用者介面,並假設當出現錯誤時,對端並不會有人為干預的智慧化處理。因此,訊息通訊系統必須既要有高度的容錯性,也要比一般的即時訊息通訊更快速。

ØMQ最初的設想是作為股票交易中的一個極快速的訊息通訊系統,因此重點放在了高度優化上。專案開始的頭一年都花在制定效能基準測試的方法上了,並嘗試設計出一個儘可能高效的架構。

之後,大約是在專案進行的第二年裡,開發的重點轉變成為構建分散式應用程式而提供的一個通用系統,支援任意模式的訊息通訊、多種傳輸機制、對多種程式語言的繫結等等。

在開發的第三年裡,重點主要集中於提高系統的可用性,將學習曲線平坦化。我們已經採用了BSD套接字API,嘗試整理單個訊息通訊模式的語義等等。

本章試圖向讀者介紹,ØMQ為達到上述三個目標是如何設計其內部架構的,也希望給同樣面對這些問題的人提供一些啟示。

啟動ØMQ專案的第三年裡,其程式碼庫已經膨脹的過於龐大。有一項提議要標準化ØMQ中所使用的協議,以及實驗性地實現一個類ØMQ的訊息通訊系統以加入到Linux核心中等等。不過,本書並未涵蓋這些主題,更多細節可以參考:http://www.250bpm.com/conceptshttp://groups.google.com/group/sp-discuss-group,和http://www.250bpm.com/hits

24.1 應用程式 vs 程式庫

ØMQ是一個程式庫,不是訊息通訊伺服器。我們花了好幾年時間在AMQP上,這是一種在金融行業中嘗試標準化用於商業訊息通訊的協議。我們為其編寫了一個參考性的實現,然後部署到幾個主要基於訊息通訊技術的大型專案中使用——由此我們意識到,智慧訊息伺服器(代理/broker)和啞客戶端之間的這種經典的客戶機/伺服器模型是有問題的。

當時我們主要關心的是效能:如果中間有個伺服器的話,每條訊息都不得不穿越網路兩次(從傳送者到伺服器,然後從伺服器再到接收者),還附帶有延遲和吞吐量方面的損耗。此外,如果所有的訊息都要通過伺服器傳遞的話,某一時刻它就必然會成為效能的瓶頸。

第二點需要關心的是關於大規模部署的問題:當訊息通訊需要跨越公司的界限時,這種中央集權式管理所有訊息流的概念就不再有效了。沒有一家公司願意把對伺服器的控制權放在別的公司裡,這包含有商業機密以及法律責任相關的問題。實際結果就是每家公司都有一個訊息通訊伺服器,可通過手動橋接的方式連線到其他公司的訊息通訊系統中。因此整個經濟系統被極大的劃分開來,但是為每個公司維護這樣大量的橋接並沒有使情況變得更好。要解決這個問題,我們需要一個分散式的架構。在這種架構中每一個元件都可以由一個不同的商業實體來管轄。鑑於基於伺服器架構的管理單元就是伺服器,我們可以通過為每個元件設定一個單獨的伺服器來解決這個問題。在這種情況下,我們可以通過使伺服器和元件共享同一個程式來進一步地優化設計。我們最終得到的就是一個訊息通訊的程式庫。

當我們開始設想一種不需要中間伺服器的訊息通訊機制時,也就是ØMQ專案開始之時。這需要自下而上的將整個訊息通訊的概念顛倒過來,將位於網路中央的集中資訊儲存模型替換為基於端到端機制的“智慧型終端,沉默化網路”的架構。正是由於這樣的技術決策,ØMQ從一開始就作為一個庫而存在,它不是應用程式。 同時,我們也已經證明了這種架構更加高效(低延遲,高吞吐量)也更加靈活(很容易在此之上構建任意複雜的拓撲結構,而不必拘泥於經典的中心輻射模型)。

然而選擇以庫的形式釋出,這其中還有一個意想不到的結果,那就是這麼做提高了產品的可用性。使用者反覆地表示由於他們不再需要安裝和管理一個獨立的訊息通訊伺服器了,為此他們感到很慶幸。事實證明,去掉中間伺服器是首選方案,因為這麼做降低了運營的成本(不需要為訊息通訊伺服器安排管理員),也加快了市場響應的時間(沒有必要對客戶、管理層或運營團隊談判溝通是否要執行伺服器)。

我們從中學到的是,當開始一個新專案時,你應該儘可能的選擇以庫的形式來設計。我們可以很容易的通過從小型程式中呼叫庫的實現而建立出一個應用,但是卻幾乎不可能從已有的可執行程式中建立一個庫。庫對使用者來說可以提供更高的靈活性,同時也不需要花費他們很多精力來管理。

24.2 全域性狀態

全域性變數不適於在庫中使用。因為一個程式可能會載入同一個庫幾次,而它們會共用一組全域性變數。在圖24.1中,ØMQ庫被兩個不同的、彼此獨立的庫所呼叫,而應用本身呼叫了這兩個庫。

圖24.1 不同的庫在使用ØMQ

當出現這種情況時,兩個ØMQ的例項會訪問到相同的變數,這會產生競爭條件,出現奇怪的錯誤和未定義的行為。

要防止出現這種問題,ØMQ中沒有使用任何全域性變數。相反地,是由庫的使用者來負責顯式地建立全域性狀態。包含全域性狀態的物件稱為context。從使用者的角度來看,context或多或少類似一個工作者執行緒池,而從ØMQ的角度來看,它僅僅是一個儲存我們所需要的任意全域性狀態的物件。在上圖中,libA會有它自己的context,而libB也會有它自己的context。它們之間無法互相干擾。

看到這裡應該已經非常明顯了:絕不要在庫中使用全域性狀態。如果你這麼做了,當庫恰好需要在同一個程式中例項化兩次時,它很可能會崩潰。

24.3 效能

當ØMQ專案開始之後,主要的目標是優化效能。訊息通訊系統的效能可以用兩個指標來界定:吞吐量——在一段給定的時間內可以傳遞多少條訊息;以及時延——一條訊息從一端傳到另一端需要花費多長時間。

我們應該重點關注哪個指標?這兩者之間的關係是什麼?這還不明擺著嗎?跑測試,用測試的總時間除以訊息的數量,你得到的就是時延。用訊息的數量除以總時間,你得到的就是吞吐量。換句話說,時延是吞吐量的倒數。很簡單,不是嗎?

我們並沒有直接開始編碼,而是花了幾周的時間詳細調查效能指標,我們發現吞吐量和時延之間的關係絕非如此簡單,通常這個指標數是相當違反直覺的。

假設A傳送訊息給B(見圖24.2),測試的總時間是6秒,總共有5條訊息傳遞。因此吞吐量是0.83條訊息/每秒(5/6),而時延是1.2秒(6/5),對吧?

圖24.2 從A到B傳送訊息

請再看看這副圖。每條訊息從A到B所花費的時間是不同的:2秒、2.5秒、3秒、3.5秒、4秒。平均計算是3秒鐘,這和我們之前計算出的1.2秒相比差太遠了。這個例子很直觀的表明,人們很容易對效能指標產生誤解。

現在來看看吞吐量。測試的總時間是6秒。但是,在A點總共花費了2秒才把所有的訊息都傳送完畢。從A的角度來看,吞吐量是2.5條訊息/秒(5/2)。在B點共花費了4秒才將所有的訊息都接收完畢。因此,從B的角度來看,吞吐量是1.25條訊息/秒(5/4)。這兩個資料都同之前計算得出的1.2條訊息/秒不吻合。

長話短說吧,時延和吞吐量是兩個不同的指標,這是非常明顯的。重要的是理解這兩者之間的區別以及它們的相互關係。時延只能在系統的兩個不同端點之間才能測量,A點本身並沒有什麼時延。每條訊息都有它們自己的時延,你可以通過多條訊息來計算平均時延,但是,對於一個訊息流來說並沒有什麼時延。

換句話說,吞吐量只能在系統的某個端點處才能測量。傳送端有吞吐量,接收端有吞吐量,這兩者之間的任意中間結點也有吞吐量,但對整個系統來說就沒有什麼總吞吐量的概念了。另外,吞吐量只對一組訊息有意義,單條訊息是沒有什麼吞吐量可言的。

至於吞吐量和時延之間的關係,我們已經證明了原來它們之間確實有關係。但是,公式表達中涉及到積分,我們就不在這裡討論了。要得到更多的資訊,可以去讀一讀有關佇列的論文。

關於對訊息通訊系統進行的基準測試還有許多缺陷存在,但我們不會進一步探討了。這裡應該再次強調我們為此得到的教訓:確保理解你正在解決的問題。即使是一個“讓它更快”這樣簡單的問題也會耗費你大量的工作才能正確理解之。更何況如果你不理解問題,你很可能會隱式地將假設和某種流行的觀點置入程式碼中,這使得解決方案要麼是有缺陷的或者至少會變得非常複雜,又或者會使得該方案沒有達到它應有的適用範圍。

24.4 關鍵路徑

我們在效能優化的過程中發現有3個因素會對效能產生嚴重的影響:

  • 記憶體分配的次數
  • 系統呼叫的次數
  • 併發模型

但是,並不是每個記憶體分配或者每個系統呼叫都會對效能產生同樣的影響。對於訊息通訊系統的效能,我們所感興趣的是在給定的時間內能在兩點間傳送的訊息數量。另外,我們可能會感興趣的是訊息從一點傳送到另一點需要多久。

考慮到ØMQ被設計為針對長期連線的場景,因此建立一個連線或者處理一個連線錯誤所花費的時間基本上可忽略。這些事件極少發生,因此它們對總體效能的影響可以忽略不計。

程式碼庫中某個一遍又一遍被頻繁使用的部分,我們稱之為關鍵路徑。優化應該集中到這些關鍵路徑上來。 讓我們看一個例子:ØMQ在記憶體分配方面並沒有做高度優化。比如,當操作字串時,常常是在每個轉化的中間階段分配一個新的字串。但是,如果我們嚴格審查關鍵路徑——實際完成訊息通訊的部分——我們會發現這部分幾乎沒有使用任何記憶體分配。如果是短訊息,那麼每256個訊息才會有一次記憶體分配(這些訊息都被儲存到一個單獨的大記憶體塊中)。此外,如果訊息流是穩定的,在不出現流峰值的情況下,關鍵路徑部分的記憶體分配次數會降為零(已分配的記憶體塊不會返回給系統,而是不斷的進行重用)。

我們從中學到的是:只在對結果能產生影響的地方做優化。優化非關鍵路徑上的程式碼只是在做無用功。

24.5 記憶體分配

假設所有的基礎元件都已經初始化完成,兩點之間的一條連線也已經建立完成,此時要傳送一條訊息時只有一樣東西需要分配記憶體:訊息體本身。因此,要優化關鍵路徑,我們就必須考慮訊息體是如何分配的以及是如何在棧上來回傳遞的。

在高效能網路程式設計領域中,最佳效能是通過仔細地平衡訊息的分配以及訊息拷貝所帶來的開銷而實現的,這是常識(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf 參見針對“小型”、“中型”、“大型”訊息的不同處理)。對於小型的訊息,拷貝操作比記憶體分配要經濟的多。只要有需要,完全不分配新的記憶體塊而直接把訊息拷貝到預分配好的記憶體塊上,這麼做是有道理的。另一方面,對於大型的訊息,拷貝操作比記憶體分配的開銷又要昂貴的多。為訊息體分配一次記憶體,然後傳遞指向分配塊的指標,而不是拷貝整個資料。這種方式被稱為“零拷貝”。

ØMQ以透明的方式同時處理這兩種情況。一條ØMQ訊息由一個不透明的控制程式碼來表示。對於非常短小的訊息,其內容被直接編碼到控制程式碼中。因此,對控制程式碼的拷貝實際上就是對訊息資料的拷貝。當遇到較大的訊息時,它被分配到一個單獨的緩衝區內,而控制程式碼只包含一個指向緩衝區的指標。對控制程式碼的拷貝並不會造成對訊息資料的拷貝,當訊息有數兆位元組長時,這麼處理是很有道理的(圖24.3)。需要提醒的是,後一種情況裡緩衝區是按引用計數的,因此可以做到被多個控制程式碼引用而不必拷貝資料。

圖24.3 訊息拷貝(或者不拷貝)

我們從中學到的是:當考慮效能問題時,不要假設存在有一個最佳解決方案。很可能這個問題有多個子問題(例如,小型訊息和大型訊息),而每一個子問題都有各自的優化演算法。

24.6 批量處理

前面已經提到過,在訊息通訊系統中,系統呼叫的數量太多的話會導致出現效能瓶頸。實際上,這個問題絕非一般。當需要遍歷呼叫棧時會有不小的效能損失,因此,明智的做法是,當建立高效能的應用時應該儘可能多的去避免遍歷呼叫棧。

參見圖24.4,為了傳送4條訊息,你不得不遍歷整個網路協議棧4次(也就是,ØMQ、glibc、使用者/核心空間邊界、TCP實現、IP實現、乙太網鏈路層、網路卡本身,然後反過來再來一次)。

圖24.4 傳送4條訊息

但是,如果你決定將這些訊息集合到一起成為一個單獨的批次,那麼就只需要遍歷一次呼叫棧了(圖24.5)。這種處理方式對訊息吞吐量的影響是巨大的:可大至2個數量級,尤其是如果訊息都比較短小,數百個這樣的短訊息才能包裝成一個批次。

圖24.5 批量處理訊息

另一方面,批量處理會對時延帶來負面影響。我們來分析一下,比如,TCP實現中著名的Nagle演算法。它為待發出的訊息延遲一定的時間,然後將所有的資料合併成一個單獨的資料包。顯然,資料包中的第一條訊息,其端到端的時延要比最後一條訊息嚴重的多。因此,如果應用程式需要持續的低時延的話,常見做法是將Nagle演算法關閉。更常見的是取消整個呼叫棧層次上的批量處理(比如,網路卡的中斷匯聚功能)。

但同樣,不做批量處理就意味著需要大量穿越整個呼叫棧,這會導致訊息吞吐量降低。似乎我們被困在吞吐量和時延的兩難境地中了。

ØMQ嘗試採用以下策略來提供一致性的低時延和高吞吐量。當訊息流比較稀疏,不超過網路協議棧的頻寬時,ØMQ關閉所有的批量處理以改善時延。這裡的權衡是CPU的使用率會變得略高——我們仍然需要經常穿越整個呼叫棧。但是在大多數情況下,這並不是個問題。

當訊息的速率超過網路協議棧的頻寬時,訊息就必須進行排隊處理了——儲存在記憶體中直到協議棧準備好接收它們。排隊處理就意味著時延的上升。如果訊息在佇列中要花費1秒時間,端到端的時延就至少會達到1秒。更糟糕的是,隨著佇列長度的增長,時延會顯著提升。如果佇列的長度沒有限制的話,時延就會超過任何限定值。

據觀察,即使調整網路協議棧以追求最低的時延(關閉Nagle演算法,關閉網路卡中斷匯聚功能,等等),由於受前文所述的佇列的影響,時延仍然會比較高。

在這種情況下,積極的採取批量化處理是有意義的。反正時延已經比較高了,也沒什麼好顧慮的了。另一方面,積極的採用批量處理能夠提高吞吐量,而且可以清空佇列中等待的訊息——這反過來又意味著時延將逐步降低,因為正是排隊才造成了時延的上升。一旦佇列中沒有未傳送的訊息了,就可以關閉批量處理,進一步的改善時延。

我們觀察到批量處理只應該在最高層進行,這是需要額外注意的一點。如果訊息在最高層匯聚為批次,在低層次上就沒什麼可做批量處理的了,而且所有低層次的批量處理演算法除了會增加總體時延外什麼都沒做。 我們從中學到了:在一個非同步系統中,要獲得最佳的吞吐量和響應時間,需要在呼叫棧的底層關閉批量處理演算法,而在高層開啟。僅在新資料到達的速率快於它們被處理的速率時才做批量處理。

24.7 架構概覽

到目前為止,我們都專注於那些使ØMQ變得快速的通用性原則。從現在起,我們可以看一看實際的系統架構了(圖24.6)。

圖24.6 ØMQ的架構框圖

使用者使用被稱為“套接字”的物件同ØMQ進行互動。它們同TCP套接字很相似,主要的區別在於這裡的套接字能夠處理同多個對端的通訊,有點像非繫結的UDP套接字。

套接字物件存在於使用者執行緒中(見下一節的執行緒模型討論)。除此之外,ØMQ執行多個工作者執行緒用以處理通訊中的非同步環節:從網路中讀取資料、將訊息排隊、接受新的連線等等。

工作者執行緒中存在著多個物件。每一個物件只能由唯一的父物件所持有(所有權由圖中一個簡單的實線來標記)。與子物件相比,父物件可以存在於其他執行緒中。大多數物件直接由套接字sockets所持有。但是,這裡有幾種情況下會出現一個物件由另一個物件所持有,而這個物件又由socket所持有。我們得到的是一個物件樹,每個socket都有一個這樣的物件樹。我們在關閉連線時會用到物件樹,在一個物件關閉它所有的子物件前,任何物件都不能自行關閉。這樣我們可以確保關閉操作可以按預期的行為那樣正常工作。比如,在佇列中等待傳送的訊息要先傳送到網路中,之後才能終止傳送過程。

大致來說,這裡有兩種型別的非同步物件。有的物件不會涉及到訊息傳遞,而有些需要。前者主要負責連線管理。比如,一個TCP監聽物件在監聽接入的TCP連線,併為每一個新的連線建立一個engine/session物件。類似的,一個TCP連線物件嘗試連線到TCP對端,如果成功,它就建立一個engine/session物件來管理這個連線。如果失敗了,連線物件會嘗試重新建立連線。

而後者用來負責資料的傳輸。這些物件由兩部分組成:session物件負責同ØMQ的socket互動,而engine物件負責同網路進行通訊。session物件只有一種型別,而對於每一種ØMQ所支援的協議都會有不同型別的engine物件與之對應。因此,我們有TCP engine,IPC(程式間通訊)engine,PGM engine(一種可靠的多播協議,參見RFC 3208),等等。engine的集合非常廣泛——未來我們可能會選擇實現比如WebSocket engine或者SCTP engine。

session物件同socket之間交換訊息。可以由兩個方向來傳遞訊息,在每個方向上由一個pipe物件來處理。基本上來說,pipe就是一個優化過的用來線上程之間快速傳遞訊息的無鎖佇列。

最後我們來看看context物件(在前一節中提到過,但沒有在圖中表示出來),該物件儲存全域性狀態,所有的socket和非同步物件都可以訪問它。

24.8 併發模型

ØMQ需要充分利用多核的優勢,換句話說就是隨著CPU核心數的增長能夠線性的擴充套件吞吐量。

以我們之前對訊息通訊系統的經驗表明,採用經典的多執行緒方式(臨界區、訊號量等等)並不會使效能得到較大提升。事實上,就算是在多核環境下,一個多執行緒版的訊息通訊系統可能會比一個單執行緒的版本還要慢。有太多時間都花在等待其他執行緒上了,同時,引入了大量的上下文切換拖慢了整個系統。

針對這些問題,我們決定採用一種不同的模型。目標是完全避免鎖機制,並讓每個執行緒能夠全速執行。執行緒間的通訊是通過線上程間傳遞非同步訊息(事件)來實現的。內行人都應該知道,這就是經典的actor模式。

我們的想法是在每一個CPU核心上執行一個工作者執行緒——讓兩個執行緒共享同一個核心只會意味著大量的上下文切換而沒有得到任何別的優勢。每一個ØMQ的內部物件,比如說TCP engine,將會緊密地關聯到一個特定的工作者執行緒上。反過來,這意味著我們不再需要臨界區、互斥鎖、訊號量等等這些東西了。此外,這些ØMQ物件不會在CPU核之間遷移,從而可以避免由於快取被汙染而引起效能上的下降(圖24.7)。

圖24.7 多個工作者執行緒

這個設計讓很多傳統多執行緒程式設計中出現的頑疾都消失了。然而,我們還需要在許多物件間共享工作者執行緒,這反過來又意味著必須要有某種多工間的合作機制。這表示我們需要一個排程器,物件必須是事件驅動的,而不是在整個事件迴圈中來控制。我們必須考慮任意序列的事件,甚至非常罕見的情況也要考慮到。我們必須確保不會有哪個物件持有CPU的時間過長等等。

簡單來說,整個系統必須是全非同步的。任何物件都無法承受阻塞式的操作,因為這不僅會阻塞其自身,而且所有共享同一個工作者執行緒的其他物件也都會被阻塞。所有的物件都必須或顯式或隱式的成為一種狀態機。隨著有數百或數千的狀態機在並行運轉著,你必須處理這些狀態機之間的所有可能發生的互動,而其中最重要的就是——關閉過程。

事實證明,要以一種清晰的方式關閉一個全非同步的系統是一個相當複雜的任務。試圖關閉一個有著上千個運轉著的部分的系統,其中有的正在工作中,有的處於空閒狀態,有的正在初始化過程中,有的已經自行關閉了,此時極易出現各種競態條件、資源洩露等諸如此類的情況。ØMQ中最為複雜的部分肯定就是這個關閉子系統了。快速檢查一下bug跟蹤系統的記錄顯示,約30%到50%的bug都同關閉有某種聯絡。

我們從中學到的是:當要追求極端的效能和可擴充套件性時,考慮採用actor模型。在這種情況下這幾乎是你唯一的選擇。不過,如果不使用像Erlang或者ØMQ這種專門的系統,你將不得不手工編寫並除錯大量的基礎元件。此外,從一開始就要好好思考關於系統關閉的步驟。這將是程式碼中最為複雜的部分,而如果你沒有清晰的思路該如何實現它,你可能應該重新考慮在一開始就使用actor模型。

24.9 無鎖演算法

最近比較流行使用無鎖演算法。它們是用於執行緒間通訊的一種簡單機制,同時並不會依賴於作業系統核心提供的同步原語,如互斥鎖和訊號量。相反,它們通過使用CPU原子操作來實現同步,比如原子化的CAS指令(比較並交換)。我們應該理解清楚的是它們並不是字面意義上的無鎖——相反,鎖機制是在硬體層面實現的。

ØMQ在pipe物件中採用無鎖佇列來在使用者執行緒和ØMQ的工作者執行緒之間傳遞訊息。關於ØMQ是如何使用無鎖佇列的,這裡有兩個有趣的地方。

首先,每個佇列只有一個寫執行緒,也只有一個讀執行緒。如果有1對多的通訊需求,那麼就建立多個佇列(圖24.8)。鑑於採用這種方式時佇列不需要考慮對寫執行緒和讀執行緒的同步(只有一個寫執行緒,也只有一個讀執行緒),因此可以以非常高效的方式來實現。

圖24.8 佇列

其次,儘管我們意識到無鎖演算法要比傳統的基於互斥鎖的演算法更加高效,CPU的原子操作開銷仍然非常高昂(尤其是當CPU核心之間有競爭時),對每條訊息的讀或者寫都採用原子操作的話,效率將低於我們所能接受的水平。

提高速度的方法——再次採用批量處理。假設你有10條訊息要寫入到佇列。比如,可能會出現當你收到一個網路資料包時裡面包含有10條小型的訊息的情況。由於接收資料包是一個原子事件,你不能只接收一半,因此這個原子事件導致需要寫10條訊息到無鎖佇列中。那麼對每條訊息都採用一次原子操作就顯得沒什麼道理了。相反,你可以讓寫執行緒擁有一塊自己獨佔的“預寫”區域,讓它先把訊息都寫到這裡,然後再用一次單獨的原子操作,整體刷入佇列。

同樣的方法也適用於從佇列中讀取訊息。假設上面提到的10條訊息已經重新整理到佇列中了。讀執行緒可以對每條訊息採用一個原子操作來讀取,但是,這種做法過於重量級了。相反,讀執行緒可以將所有待讀取的訊息用一個單獨的原子操作移動到佇列的“預讀取”部分。之後就可以從“預讀”快取中一條一條的讀取訊息了。“預讀取”部分只能由讀執行緒單獨訪問,因此這裡沒有什麼所謂的同步需求。

圖24.9中左邊的箭頭展示瞭如何通過簡單地修改一個指標來將預寫入快取重新整理到佇列中的。右邊的箭頭展示了佇列的整個內容是如何通過修改另一個指標來移動到預讀快取中的。

圖24.9 無鎖佇列

我們從中學到的是:發明新的無鎖演算法是很困難的,而且實現起來很麻煩,幾乎不可能對其除錯。如果可能的話,可以使用現有的成熟演算法而不是自己來發明輪子。當需要追求極度的效能時,不要只依靠無鎖演算法。雖然它們的速度很快,但可以在其之上通過智慧化的批量處理來顯著提高效能。

24.10 API

使用者介面是任何軟體產品中最為重要的部分。這是你的程式唯一暴露給外部世界的部分,如果搞砸了全世界都會恨你的。對於面向終端使用者的產品來說,使用者介面就是圖形使用者介面或者命令列介面,而對於庫來說,那就是API了。

在ØMQ的早期版本中,其API是基於AMQP的交易和佇列模型的(參見AMQP規範)。從歷史的角度來看,2007年的白皮書嘗試要將AMQP同一個代理模式的訊息通訊系統相整合,這很有趣。我於2009年底重新使用BSD套接字API從零開始重寫了整個專案。那就是轉折點,從那一刻起ØMQ的使用者數量開始猛增。之前的ØMQ是由訊息通訊領域的專家們所使用的產品,而現在成為任何人都能方便使用的普通工具。在1年左右的時間裡,ØMQ的使用者社群擴大了10倍之多,我們還實現了對20多種不同程式語言的繫結等等。

使用者介面定義了人們對產品的感觀。基本沒有改變功能——僅僅通過修改了API——ØMQ就從一個“企業級訊息通訊”產品轉變為一個“網路化”的產品。換句話說,人們對ØMQ的感觀從一個“大金融機構所使用的複雜基礎元件”轉變為“嘿,這工具可以幫助我從程式A傳送10位元組長的訊息到程式B”。

我們從中學到的是:正確理解你的專案,根據你對專案的願景來合理地設計使用者介面。使用者介面同專案的願景不相符合的話,可以100%保證該專案註定會失敗。

將ØMQ的使用者介面替換為BSD套接字API,這其中有個很重要的因素,那就是BSD套接字API並不是一個新的發明,而是早就為人們所熟悉了。事實上,BSD套接字API是當今仍在使用中的最為古老的API之一了。那得回溯到1983年以及4.2版BSD Unix的時代。它已經被廣泛且穩定的使用了幾十年了。

上面的事實帶來了很多優勢。首先,人人都知道BSD套接字API,因此學習的難度曲線非常平坦。就算你從未聽說過ØMQ,你也可以在幾分鐘內建立出一個應用程式,這都得感謝你可以重用過去在BSD套接字上積累的經驗。

其次,使用這樣一種被廣泛支援的API使得ØMQ可以同已有的技術進行融合。比如,將ØMQ物件暴露為“套接字”或者“檔案描述符”,這可以讓我們在同樣的事件迴圈中處理TCP、UDP、管道、檔案以及ØMQ事件。另一個例子是:要將類似ØMQ的功能加入到Linux核心中,這個實驗性的專案就變得非常容易實現了。通過共享相同的概念框架,ØMQ可以複用很多已有的基礎元件。

第三,也許也是最重要的一點,那就是BSD套接字API已經存活了將近30年的時間了,儘管中間人們曾多次嘗試替換它。這意味著設計中有某種固有的正確性。BSD套接字API的設計者——無論是故意的還是偶然的——都做出了正確的設計決策。通過借用這套API,我們可以自動分享到這些設計決策,而不必知道這些決策究竟是什麼,或者它們到底解決了什麼問題。

我們從中學到的是:雖然程式碼複用的思想從遠古時代就有了,隨後模式複用的概念也加入了進來,重要的是要以一種更一般化的方式來思考複用。當做產品設計時,參考一下其他相似的產品。調查一下哪些方面是失敗的,哪些方面是成功的,從成功的專案中學習。不要覺得沒有創新就接受不了。複用好的點子、API、概念框架,任何你覺得合適的東西都可以複用。這麼做的好處是你可以讓使用者重用他們之前的知識,同時你也可以避免當前你並不瞭解的技術方面的陷阱。

24.11 訊息模式

在任何訊息通訊系統中,所面臨的最重要的設計問題是如何提供一種方式可以讓使用者指定哪條訊息可以路由到哪個目的地。這裡主要有兩種方法,而且我相信這兩種方法是相當通用的,基本可適用於軟體領域中遇到的任何問題。

第一種方式是吸收Unix哲學中的“只做一件事,並把它做好”的原則。這意味著問題域應該人為地限制在一個較小且易理解的範圍內。然後,程式應該以正確和詳盡的方式來解決這個受限制的問題。在訊息通訊領域中,一個採用這種方式的例子是MQTT。這是一種將訊息分發給一組消費者的協議。它很容易使用,而且在訊息分發方面做得很出色,但除此之外它不能用於任何其他用途(比如說RPC)。

另一種方式是致力於一般性,並提供一種功能強大且高度可配置的系統。AMQP就是這樣一個例子。它的佇列和互換的模式提供給使用者可程式設計的能力,幾乎可以定義出他們可想到的任意一種路由演算法。當然了,有得必有失,取捨的結果就是增加了許多選項需要我們去處理。

ØMQ選擇了前一種方式,因為這種方式下的產品幾乎所有的人都可以使用,而通用的方式下的產品需要訊息通訊方面的專家才能用上。為了闡明這個觀點,讓我們看看模式是如何對API的複雜度產生影響的。如下程式碼是在通用系統(AMQP)之上的RPC客戶端實現:

connect ("192.168.0.111")
exchange.declare (exchange="requests", type="direct", passive=false,
    durable=true, no-wait=true, arguments={})
exchange.declare (exchange="replies", type="direct",passive=false,
    durable=true, no-wait=true, arguments={})
reply-queue=queue.declare(queue="", passive=false, durable=false,
    exclusive=true, auto-delete=true, no-wait=false, arguments={})
queue.bind (queue=reply-queue, exchange="replies",  routing-key=reply-queue)
queue.consume (queue=reply-queue, consumer-tag="", no-local=false,
    no-ack=false, exclusive=true, no-wait=true, arguments={})
request = new-message ("Hello World!")
request.reply-to = reply-queue
request.correlation-id = generate-unique-id ()
basic.publish (exchange="requests", routing-key="my-service",
    mandatory=true, immediate=false)
reply = get-message ()

而另一方面,ØMQ將訊息劃分為所謂的“訊息模式”。幾個模式方面的例子有“釋出者/訂閱者”,“請求/回覆”或者“並行管線”。每一種訊息通訊的模式之間都是完全正交的,可被看做是一個單獨的工具。

接下來採用ØMQ的請求/回覆模式對上面的應用進行重構,注意ØMQ將繁雜的選擇縮減為一個單一的步驟,這隻要通過選擇正確的訊息模式“REQ”就可以了。

s = socket (REQ)
s.connect ("tcp://192.168.0.111:5555")
s.send ("Hello World!")
reply = s.recv ()

到這裡為止,我們已經可以認為具體化的解決方案比通用型解決方案要更好。我們希望自己的解決方案能儘可能的具體化。但是,同時我們又希望提供給使用者的功能面儘可能的廣。我們該如何解決這個明顯的矛盾?

答案分兩步:

  1. 定義一個堆疊層,用以處理某個特定的問題領域。(比如,傳輸、路由、演示等)
  2. 為該層提供多種實現方式。對於每種實現的使用,都應該是非互相干擾的。

讓我們看看網路協議棧中有關傳輸層的例子。傳輸層意味著需要在網路層(IP)之上提供例如資料流傳輸、流控、可靠性等服務。它是通過定義多種互不干擾的解決方案來實現的:TCP作為面向連線的可靠資料流傳輸機制、UDP作為面向非連線的非可靠式資料包傳輸機制、SCTP作為多個流的傳輸、DCCP作為非可靠性連線等等。

注意,這裡每種實現都是完全正交的:UDP端不能同TCP端通訊,SCTP端也不能同DCCP端通訊。這意味著新的實現可以在任意時刻加到這個棧上,而不會對棧中已有的部分產生影響。相反如果實現是失敗的,則可以被完全丟棄而不會影響傳輸層的整體能力。

同樣的道理也適用於ØMQ中定義的訊息模式。訊息模式在傳輸層(TCP及其它成員)之上組成了新的一層(所謂的“可擴充套件性層”)。每個訊息模式都是這一層的具體實現。它們都是嚴格正交的——“釋出者/訂閱者”端無法同“請求/回覆”端通訊,等等之類。訊息模式之間的嚴格分離反過來又意味著新的模式可以按照需求增加進來,開發新模式的實驗如果失敗了,也不會對已有的模式產生影響。

我們從中學到的是:當解決一個複雜且多面化的問題時,單個通用型的解決方案可能並不是最好的方式。相反,我們可以把問題的領域想象成一個抽象層,並基於這個層次提供多個實現,每種實現只致力於解決一種定義良好的情況。當我們這麼做時,要仔細劃定用例情況。要確認什麼在範圍內,什麼不在範圍內。如果對使用範圍限制的太過於嚴格,軟體的應用性就會受到限制。如果對問題定義的太廣,那麼產品就會變得非常複雜,給使用者帶來模糊和混亂的感覺。

24.12 結論

由於我們的世界裡已經充斥著大量通過網際網路相連的小型計算機——行動電話、RFID閱讀器、平板電腦以及行動式計算機、GPS裝置等等。分散式計算已經不再侷限於學術領域了,成為了每位開發者需要去解決的日常問題。不幸的是,對此的解決方案大多數都是領域相關的獨門祕技。本文以系統化的方式總結了我們在構建大規模分散式系統中的經驗。本文主要側重於從軟體架構的觀點來闡明我們需要面對的挑戰,希望開源社群中的架構師和程式設計師會覺得本文很有幫助。

相關文章