卷2:第24章 ZeroMQ

2018-02-24 15:55 更新

原文鏈接:http://www.aosabook.org/en/zeromq.html

?MQ是一個消息通信系統(tǒng),如果你愿意的話也可以稱其為“面向消息的中間件”。?MQ的應(yīng)用環(huán)境很廣泛,包括金融服務(wù)、游戲開發(fā)、嵌入式系統(tǒng)、學(xué)術(shù)研究以及航空航天等領(lǐng)域。

消息通信系統(tǒng)完成的工作基本上可看作為負責應(yīng)用程序之間的即時消息通信。一個應(yīng)用程序決定發(fā)送一個事件給另一個應(yīng)用程序(或者多個應(yīng)用程序),它將需要發(fā)送的數(shù)據(jù)組合起來,點擊“發(fā)送”按鈕就行了——消息通信系統(tǒng)會搞定剩下的工作。

不同于即時消息通信的是,消息通信系統(tǒng)沒有圖形用戶界面,并假設(shè)當出現(xiàn)錯誤時,對端并不會有人為干預(yù)的智能化處理。因此,消息通信系統(tǒng)必須既要有高度的容錯性,也要比一般的即時消息通信更快速。

?MQ最初的設(shè)想是作為股票交易中的一個極快速的消息通信系統(tǒng),因此重點放在了高度優(yōu)化上。項目開始的頭一年都花在制定性能基準測試的方法上了,并嘗試設(shè)計出一個盡可能高效的架構(gòu)。

之后,大約是在項目進行的第二年里,開發(fā)的重點轉(zhuǎn)變成為構(gòu)建分布式應(yīng)用程序而提供的一個通用系統(tǒng),支持任意模式的消息通信、多種傳輸機制、對多種編程語言的綁定等等。

在開發(fā)的第三年里,重點主要集中于提高系統(tǒng)的可用性,將學(xué)習(xí)曲線平坦化。我們已經(jīng)采用了BSD套接字API,嘗試整理單個消息通信模式的語義等等。

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

啟動?MQ項目的第三年里,其代碼庫已經(jīng)膨脹的過于龐大。有一項提議要標準化?MQ中所使用的協(xié)議,以及實驗性地實現(xiàn)一個類?MQ的消息通信系統(tǒng)以加入到Linux內(nèi)核中等等。不過,本書并未涵蓋這些主題,更多細節(jié)可以參考:http://www.250bpm.com/concepts,http://groups.google.com/group/sp-discuss-group,和http://www.250bpm.com/hits。

24.1 應(yīng)用程序 vs 程序庫

?MQ是一個程序庫,不是消息通信服務(wù)器。我們花了好幾年時間在AMQP上,這是一種在金融行業(yè)中嘗試標準化用于商業(yè)消息通信的協(xié)議。我們?yōu)槠渚帉懥艘粋€參考性的實現(xiàn),然后部署到幾個主要基于消息通信技術(shù)的大型項目中使用——由此我們意識到,智能消息服務(wù)器(代理/broker)和啞客戶端之間的這種經(jīng)典的客戶機/服務(wù)器模型是有問題的。

當時我們主要關(guān)心的是性能:如果中間有個服務(wù)器的話,每條消息都不得不穿越網(wǎng)絡(luò)兩次(從發(fā)送者到服務(wù)器,然后從服務(wù)器再到接收者),還附帶有延遲和吞吐量方面的損耗。此外,如果所有的消息都要通過服務(wù)器傳遞的話,某一時刻它就必然會成為性能的瓶頸。

第二點需要關(guān)心的是關(guān)于大規(guī)模部署的問題:當消息通信需要跨越公司的界限時,這種中央集權(quán)式管理所有消息流的概念就不再有效了。沒有一家公司愿意把對服務(wù)器的控制權(quán)放在別的公司里,這包含有商業(yè)機密以及法律責任相關(guān)的問題。實際結(jié)果就是每家公司都有一個消息通信服務(wù)器,可通過手動橋接的方式連接到其他公司的消息通信系統(tǒng)中。因此整個經(jīng)濟系統(tǒng)被極大的劃分開來,但是為每個公司維護這樣大量的橋接并沒有使情況變得更好。要解決這個問題,我們需要一個分布式的架構(gòu)。在這種架構(gòu)中每一個組件都可以由一個不同的商業(yè)實體來管轄。鑒于基于服務(wù)器架構(gòu)的管理單元就是服務(wù)器,我們可以通過為每個組件設(shè)置一個單獨的服務(wù)器來解決這個問題。在這種情況下,我們可以通過使服務(wù)器和組件共享同一個進程來進一步地優(yōu)化設(shè)計。我們最終得到的就是一個消息通信的程序庫。

當我們開始設(shè)想一種不需要中間服務(wù)器的消息通信機制時,也就是?MQ項目開始之時。這需要自下而上的將整個消息通信的概念顛倒過來,將位于網(wǎng)絡(luò)中央的集中信息存儲模型替換為基于端到端機制的“智能型終端,沉默化網(wǎng)絡(luò)”的架構(gòu)。正是由于這樣的技術(shù)決策,?MQ從一開始就作為一個庫而存在,它不是應(yīng)用程序。 同時,我們也已經(jīng)證明了這種架構(gòu)更加高效(低延遲,高吞吐量)也更加靈活(很容易在此之上構(gòu)建任意復(fù)雜的拓撲結(jié)構(gòu),而不必拘泥于經(jīng)典的中心輻射模型)。

然而選擇以庫的形式發(fā)布,這其中還有一個意想不到的結(jié)果,那就是這么做提高了產(chǎn)品的可用性。用戶反復(fù)地表示由于他們不再需要安裝和管理一個獨立的消息通信服務(wù)器了,為此他們感到很慶幸。事實證明,去掉中間服務(wù)器是首選方案,因為這么做降低了運營的成本(不需要為消息通信服務(wù)器安排管理員),也加快了市場響應(yīng)的時間(沒有必要對客戶、管理層或運營團隊談判溝通是否要運行服務(wù)器)。

我們從中學(xué)到的是,當開始一個新項目時,你應(yīng)該盡可能的選擇以庫的形式來設(shè)計。我們可以很容易的通過從小型程序中調(diào)用庫的實現(xiàn)而創(chuàng)建出一個應(yīng)用,但是卻幾乎不可能從已有的可執(zhí)行程序中創(chuàng)建一個庫。庫對用戶來說可以提供更高的靈活性,同時也不需要花費他們很多精力來管理。

24.2 全局狀態(tài)

全局變量不適于在庫中使用。因為一個進程可能會加載同一個庫幾次,而它們會共用一組全局變量。在圖24.1中,?MQ庫被兩個不同的、彼此獨立的庫所調(diào)用,而應(yīng)用本身調(diào)用了這兩個庫。

圖24.2 從A到B發(fā)送消息

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

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

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

換句話說,吞吐量只能在系統(tǒng)的某個端點處才能測量。發(fā)送端有吞吐量,接收端有吞吐量,這兩者之間的任意中間結(jié)點也有吞吐量,但對整個系統(tǒng)來說就沒有什么總吞吐量的概念了。另外,吞吐量只對一組消息有意義,單條消息是沒有什么吞吐量可言的。

至于吞吐量和時延之間的關(guān)系,我們已經(jīng)證明了原來它們之間確實有關(guān)系。但是,公式表達中涉及到積分,我們就不在這里討論了。要得到更多的信息,可以去讀一讀有關(guān)隊列的論文。

關(guān)于對消息通信系統(tǒng)進行的基準測試還有許多缺陷存在,但我們不會進一步探討了。這里應(yīng)該再次強調(diào)我們?yōu)榇说玫降慕逃?xùn):確保理解你正在解決的問題。即使是一個“讓它更快”這樣簡單的問題也會耗費你大量的工作才能正確理解之。更何況如果你不理解問題,你很可能會隱式地將假設(shè)和某種流行的觀點置入代碼中,這使得解決方案要么是有缺陷的或者至少會變得非常復(fù)雜,又或者會使得該方案沒有達到它應(yīng)有的適用范圍。

24.4 關(guān)鍵路徑

我們在性能優(yōu)化的過程中發(fā)現(xiàn)有3個因素會對性能產(chǎn)生嚴重的影響:

  • 內(nèi)存分配的次數(shù)
  • 系統(tǒng)調(diào)用的次數(shù)
  • 并發(fā)模型

但是,并不是每個內(nèi)存分配或者每個系統(tǒng)調(diào)用都會對性能產(chǎn)生同樣的影響。對于消息通信系統(tǒng)的性能,我們所感興趣的是在給定的時間內(nèi)能在兩點間傳送的消息數(shù)量。另外,我們可能會感興趣的是消息從一點傳送到另一點需要多久。

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

代碼庫中某個一遍又一遍被頻繁使用的部分,我們稱之為關(guān)鍵路徑。優(yōu)化應(yīng)該集中到這些關(guān)鍵路徑上來。 讓我們看一個例子:?MQ在內(nèi)存分配方面并沒有做高度優(yōu)化。比如,當操作字符串時,常常是在每個轉(zhuǎn)化的中間階段分配一個新的字符串。但是,如果我們嚴格審查關(guān)鍵路徑——實際完成消息通信的部分——我們會發(fā)現(xiàn)這部分幾乎沒有使用任何內(nèi)存分配。如果是短消息,那么每256個消息才會有一次內(nèi)存分配(這些消息都被保存到一個單獨的大內(nèi)存塊中)。此外,如果消息流是穩(wěn)定的,在不出現(xiàn)流峰值的情況下,關(guān)鍵路徑部分的內(nèi)存分配次數(shù)會降為零(已分配的內(nèi)存塊不會返回給系統(tǒng),而是不斷的進行重用)。

我們從中學(xué)到的是:只在對結(jié)果能產(chǎn)生影響的地方做優(yōu)化。優(yōu)化非關(guān)鍵路徑上的代碼只是在做無用功。

24.5 內(nèi)存分配

假設(shè)所有的基礎(chǔ)組件都已經(jīng)初始化完成,兩點之間的一條連接也已經(jīng)建立完成,此時要發(fā)送一條消息時只有一樣?xùn)|西需要分配內(nèi)存:消息體本身。因此,要優(yōu)化關(guān)鍵路徑,我們就必須考慮消息體是如何分配的以及是如何在棧上來回傳遞的。

在高性能網(wǎng)絡(luò)編程領(lǐng)域中,最佳性能是通過仔細地平衡消息的分配以及消息拷貝所帶來的開銷而實現(xiàn)的,這是常識(比如,http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf?參見針對“小型”、“中型”、“大型”消息的不同處理)。對于小型的消息,拷貝操作比內(nèi)存分配要經(jīng)濟的多。只要有需要,完全不分配新的內(nèi)存塊而直接把消息拷貝到預(yù)分配好的內(nèi)存塊上,這么做是有道理的。另一方面,對于大型的消息,拷貝操作比內(nèi)存分配的開銷又要昂貴的多。為消息體分配一次內(nèi)存,然后傳遞指向分配塊的指針,而不是拷貝整個數(shù)據(jù)。這種方式被稱為“零拷貝”。

?MQ以透明的方式同時處理這兩種情況。一條?MQ消息由一個不透明的句柄來表示。對于非常短小的消息,其內(nèi)容被直接編碼到句柄中。因此,對句柄的拷貝實際上就是對消息數(shù)據(jù)的拷貝。當遇到較大的消息時,它被分配到一個單獨的緩沖區(qū)內(nèi),而句柄只包含一個指向緩沖區(qū)的指針。對句柄的拷貝并不會造成對消息數(shù)據(jù)的拷貝,當消息有數(shù)兆字節(jié)長時,這么處理是很有道理的(圖24.3)。需要提醒的是,后一種情況里緩沖區(qū)是按引用計數(shù)的,因此可以做到被多個句柄引用而不必拷貝數(shù)據(jù)。

圖24.4 發(fā)送4條消息

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

圖24.6 ?MQ的架構(gòu)框圖

用戶使用被稱為“套接字”的對象同?MQ進行交互。它們同TCP套接字很相似,主要的區(qū)別在于這里的套接字能夠處理同多個對端的通信,有點像非綁定的UDP套接字。

套接字對象存在于用戶線程中(見下一節(jié)的線程模型討論)。除此之外,?MQ運行多個工作者線程用以處理通信中的異步環(huán)節(jié):從網(wǎng)絡(luò)中讀取數(shù)據(jù)、將消息排隊、接受新的連接等等。

工作者線程中存在著多個對象。每一個對象只能由唯一的父對象所持有(所有權(quán)由圖中一個簡單的實線來標記)。與子對象相比,父對象可以存在于其他線程中。大多數(shù)對象直接由套接字sockets所持有。但是,這里有幾種情況下會出現(xiàn)一個對象由另一個對象所持有,而這個對象又由socket所持有。我們得到的是一個對象樹,每個socket都有一個這樣的對象樹。我們在關(guān)閉連接時會用到對象樹,在一個對象關(guān)閉它所有的子對象前,任何對象都不能自行關(guān)閉。這樣我們可以確保關(guān)閉操作可以按預(yù)期的行為那樣正常工作。比如,在隊列中等待發(fā)送的消息要先發(fā)送到網(wǎng)絡(luò)中,之后才能終止發(fā)送過程。

大致來說,這里有兩種類型的異步對象。有的對象不會涉及到消息傳遞,而有些需要。前者主要負責連接管理。比如,一個TCP監(jiān)聽對象在監(jiān)聽接入的TCP連接,并為每一個新的連接創(chuàng)建一個engine/session對象。類似的,一個TCP連接對象嘗試連接到TCP對端,如果成功,它就創(chuàng)建一個engine/session對象來管理這個連接。如果失敗了,連接對象會嘗試重新建立連接。

而后者用來負責數(shù)據(jù)的傳輸。這些對象由兩部分組成:session對象負責同?MQ的socket交互,而engine對象負責同網(wǎng)絡(luò)進行通信。session對象只有一種類型,而對于每一種?MQ所支持的協(xié)議都會有不同類型的engine對象與之對應(yīng)。因此,我們有TCP engine,IPC(進程間通信)engine,PGM engine(一種可靠的多播協(xié)議,參見RFC 3208),等等。engine的集合非常廣泛——未來我們可能會選擇實現(xiàn)比如WebSocket engine或者SCTP engine。

session對象同socket之間交換消息??梢杂蓛蓚€方向來傳遞消息,在每個方向上由一個pipe對象來處理。基本上來說,pipe就是一個優(yōu)化過的用來在線程之間快速傳遞消息的無鎖隊列。

最后我們來看看context對象(在前一節(jié)中提到過,但沒有在圖中表示出來),該對象保存全局狀態(tài),所有的socket和異步對象都可以訪問它。

24.8 并發(fā)模型

?MQ需要充分利用多核的優(yōu)勢,換句話說就是隨著CPU核心數(shù)的增長能夠線性的擴展吞吐量。

以我們之前對消息通信系統(tǒng)的經(jīng)驗表明,采用經(jīng)典的多線程方式(臨界區(qū)、信號量等等)并不會使性能得到較大提升。事實上,就算是在多核環(huán)境下,一個多線程版的消息通信系統(tǒng)可能會比一個單線程的版本還要慢。有太多時間都花在等待其他線程上了,同時,引入了大量的上下文切換拖慢了整個系統(tǒng)。

針對這些問題,我們決定采用一種不同的模型。目標是完全避免鎖機制,并讓每個線程能夠全速運行。線程間的通信是通過在線程間傳遞異步消息(事件)來實現(xiàn)的。內(nèi)行人都應(yīng)該知道,這就是經(jīng)典的actor模式。

我們的想法是在每一個CPU核心上運行一個工作者線程——讓兩個線程共享同一個核心只會意味著大量的上下文切換而沒有得到任何別的優(yōu)勢。每一個?MQ的內(nèi)部對象,比如說TCP engine,將會緊密地關(guān)聯(lián)到一個特定的工作者線程上。反過來,這意味著我們不再需要臨界區(qū)、互斥鎖、信號量等等這些東西了。此外,這些?MQ對象不會在CPU核之間遷移,從而可以避免由于緩存被污染而引起性能上的下降(圖24.7)。

圖24.8 隊列

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

提高速度的方法——再次采用批量處理。假設(shè)你有10條消息要寫入到隊列。比如,可能會出現(xiàn)當你收到一個網(wǎng)絡(luò)數(shù)據(jù)包時里面包含有10條小型的消息的情況。由于接收數(shù)據(jù)包是一個原子事件,你不能只接收一半,因此這個原子事件導(dǎo)致需要寫10條消息到無鎖隊列中。那么對每條消息都采用一次原子操作就顯得沒什么道理了。相反,你可以讓寫線程擁有一塊自己獨占的“預(yù)寫”區(qū)域,讓它先把消息都寫到這里,然后再用一次單獨的原子操作,整體刷入隊列。

同樣的方法也適用于從隊列中讀取消息。假設(shè)上面提到的10條消息已經(jīng)刷新到隊列中了。讀線程可以對每條消息采用一個原子操作來讀取,但是,這種做法過于重量級了。相反,讀線程可以將所有待讀取的消息用一個單獨的原子操作移動到隊列的“預(yù)讀取”部分。之后就可以從“預(yù)讀”緩存中一條一條的讀取消息了。“預(yù)讀取”部分只能由讀線程單獨訪問,因此這里沒有什么所謂的同步需求。

圖24.9中左邊的箭頭展示了如何通過簡單地修改一個指針來將預(yù)寫入緩存刷新到隊列中的。右邊的箭頭展示了隊列的整個內(nèi)容是如何通過修改另一個指針來移動到預(yù)讀緩存中的。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號