天天看點

高性能伺服器架構資料拷貝(Data Copies)上下文切換(Context Switches)記憶體配置設定(Memory Allocator)鎖競争(Lock Contention)其他方面

  本文将與你分享我多年來在伺服器開發方面的一些經驗。對于這裡所說的伺服器,更精确的定義應該是每秒處理大量離散消息或者請求的服務程式,網絡伺服器更符合這種情況,但并非所有的網絡程式都是嚴格意義上的伺服器。使用“高性能請求處理程式”是一個很糟糕的标題,為了叙述起來簡單,下面将簡稱為“伺服器”。

  本文不會涉及到多任務應用程式,在單個程式裡同時處理多個任務現在已經很常見。比如你的浏覽器可能就在做一些并行處理,但是這類并行程式設計沒有多大挑戰性。真正的挑戰出現在伺服器的架構設計對性能産生制約時,如何通過改善架構來提升系統性能。對于在擁有上G記憶體和G赫茲CPU上運作的浏覽器來說,通過DSL線路進行多個并發下載下傳任務不會有如此的挑戰性。這裡,應用的焦點不在于通過吸管小口吮吸,而是如何通過水龍頭大口暢飲,這裡麻煩是如何解決在硬體性能的制約.(作者的意思應該是怎麼通過網絡硬體的改善來增大流量)

  一些人可能會對我的某些觀點和建議發出置疑,或者自認為有更好的方法, 這是無法避免的。在本文中我不想扮演上帝的角色;這裡所談論的是我自己的一些經驗,這些經驗對我來說, 不僅在提高伺服器性能上有效,而且在降低調試困難度和增加系統的可擴充性上也有作用。但是對某些人的系統可能會有所不同。如果有其它更适合于你的方法,那實在是很不錯. 但是值得注意的是,對本文中所提出的每一條建議的其它一些可替代方案,我經過實驗得出的結論都是悲觀的。你自己的小聰明在這些實驗中或許有更好的表現,但是如果是以慫恿我在這裡建議讀者這麼做,可能會引起無辜讀者的反感。你并不想惹怒讀者,對吧?

  本文的其餘部分将主要說明影響伺服器性能的四大殺手:

  1)   資料拷貝(Data Copies)

  2)   環境切換(Context Switches)

  3)   記憶體配置設定(Memory allocation)

  4)   鎖競争(Lock contention)

  在文章結尾部分還會提出其它一些比較重要的因素,但是上面的四點是主要因素。如果伺服器在處理大部分請求時能夠做到沒有資料拷貝,沒有環境切換,沒有記憶體配置設定,沒有鎖競争,那麼我敢保證你的伺服器的性能一定很出色。

資料拷貝(Data Copies)

  本節會有點短,因為大多數人在資料拷貝上吸取過教訓。幾乎每個人都知道産生資料拷貝是不對的,這點是顯而易見的,在你的職業生涯中,你很早就會見識過它;而且遇到過這個問題,因為10年前就有人開始說這個詞。對我來說确實如此。現今,幾乎每個大學課程和幾乎所有how-to文檔中都提到了它。甚至在某些商業宣傳冊中,"零拷貝" 都是個流行用語。

  盡管資料拷貝的壞處顯而易見,但是還是會有人忽視它。因為産生資料拷貝的代碼常常隐藏很深且帶有僞裝,你知道你所調用的庫或驅動的代碼會進行資料拷貝嗎?答案往往超出想象。猜猜“程式I/O”在計算機上到底指什麼?哈希函數是僞裝的資料拷貝的例子,它有帶拷貝的記憶體通路消耗和更多的計算。曾經指出雜湊演算法作為一種有效的“拷貝+”似乎能夠被避免,但據我所知,有一些非常聰明的人說過要做到這一點是相當困難的。如果想真正去除資料拷貝,不管是因為影響了伺服器性能,還是想在黑客大會上展示"零複制”技術,你必須自己跟蹤可能發生資料拷貝的所有地方,而不是輕信宣傳。

  有一種可以避免資料拷貝的方法是使用buffer的描述符(或者buffer chains的描述符)來取代直接使用buffer指針,每個buffer描述符應該由以下元素組成:

  l  一個指向buffer的指針和整個buffer的長度

  l  一個指向buffer中真實資料的指針和真實資料的長度,或者長度的偏移

  l  以雙向連結清單的形式提供指向其它buffer的指針

  l  一個引用計數

  現在,代碼可以簡單的在相應的描述符上增加引用計數來代替記憶體中資料的拷貝。這種做法在某些條件下表現的相當好,包括在典型的網絡協定棧的操作上,但有些情況下這做法也令人很頭大。一般來說,在buffer chains的開頭和結尾增加buffer很容易,對整個buffer增加引用計數,以及對buffer chains的即刻釋放也很容易。在chains的中間增加buffer,一塊一塊的釋放buffer,或者對部分buffer增加引用技術則比較困難。而分割,組合chains會讓人立馬崩潰。

  我不建議在任何情況下都使用這種技術,因為當你想在鍊上搜尋你想要的一個塊時,就不得不周遊一遍描述符鍊,這甚至比資料拷貝更糟糕。最适用這種技術地方是在程式中大的資料塊上,這些大資料塊應該按照上面所說的那樣獨立的配置設定描述符,以避免發生拷貝,也能避免影響伺服器其它部分的工作.(大資料塊拷貝很消耗CPU,會影響其它并發線程的運作)。

  關于資料拷貝最後要指出的是:在避免資料拷貝時不要走極端。我看到過太多的代碼為了避免資料拷貝,最後結果反而比拷貝資料更糟糕,比如産生環境切換或者一個大的I/O請求被分解了。資料拷貝是昂貴的,但是在避免它時,是收益遞減的(意思是做過頭了,效果反而不好)。為了除去最後少量的資料拷貝而改變代碼,繼而讓代碼複雜度翻番,不如把時間花在其它方面。

上下文切換(Context Switches)

  相對于資料拷貝影響的明顯,非常多的人會忽視了上下文切換對性能的影響。在我的經驗裡,比起資料拷貝,上下文切換是讓高負載應用徹底完蛋的真正殺手。系統更多的時間都花費線上程切換上,而不是花在真正做有用工作的線程上。令人驚奇的是,(和資料拷貝相比)在同一個水準上,導緻上下文切換原因總是更常見。引起環境切換的第一個原因往往是活躍線程數比CPU個數多。随着活躍線程數相對于CPU個數的增加,上下文切換的次數也在增加,如果你夠幸運,這種增長是線性的,但更常見是指數增長。這個簡單的事實解釋了為什麼每個連接配接對應一個單獨線程的多線程設計模式的可伸縮性更差。對于一個可伸縮性的系統來說,限制活躍線程數少于或等于CPU個數是更有實際意義的方案。曾經這種方案的一個變種是隻使用一個活躍線程,雖然這種方案避免了環境争用,同時也避免了鎖,但它不能有效利用多CPU在增加總吞吐量上的價值,是以除非程式無CPU限制(non-CPU-bound),(通常是網絡I/O限制 network-I/O-bound),應該繼續使用更實際的方案。

  一個有适量線程的程式首先要考慮的事情是規劃出如何建立一個線程去管理多連接配接。這通常意味着前置一個select/poll,異步I/O,信号或者完成端口,而背景使用一個事件驅動的程式架構。關于哪種前置API是最好的有很多争論。 Dan Kegel的C10K問題在這個領域是一篇不錯的論文。個人認為,select/poll和信号通常是一種醜陋的方案,是以我更傾向于使用AIO或者完成端口,但是實際上它并不會好太多。也許除了select(),它們都還不錯。是以不要花太多精力去探索前置系統最外層内部到底發生了什麼。

  對于最簡單的多線程事件驅動伺服器的概念模型, 其内部有一個請求緩存隊列,用戶端請求被一個或者多個監聽線程擷取後放到隊列裡,然後一個或者多個工作線程從隊列裡面取出請求并處理。從概念上來說,這是一個很好的模型,有很多用這種方式來實作他們的代碼。這會産生什麼問題嗎?引起環境切換的第二個原因是把對請求的處理從一個線程轉移到另一個線程。有些人甚至把對請求的回應又切換回最初的線程去做,這真是雪上加霜,因為每一個請求至少引起了2次環境切換。把一個請求從監聽線程轉換到成工作線程,又轉換回監聽線程的過程中,使用一種“平滑”的方法來避免環境切換是非常重要的。此時,是否把連接配接請求配置設定到多個線程,或者讓所有線程依次作為監聽線程來服務每個連接配接請求,反而不重要了。

  即使在将來,也不可能有辦法知道在伺服器中同一時刻會有多少激活線程.畢竟,每時每刻都可能有請求從任意連接配接發送過來,一些進行特殊任務的“背景”線程也會在任意時刻被喚醒。那麼如果你不知道目前有多少線程是激活的,又怎麼能夠限制激活線程的數量呢?根據我的經驗,最簡單同時也是最有效的方法之一是:用一個老式的帶計數的信号量,每一個線程執行的時候就先持有信号量。如果信号量已經到了最大值,那些處于監聽模式的線程被喚醒的時候可能會有一次額外的環境切換,(監聽線程被喚醒是因為有連接配接請求到來, 此時監聽線程持有信号量時發現信号量已滿,是以即刻休眠), 接着它就會被阻塞在這個信号量上,一旦所有監聽模式的線程都這樣阻塞住了,那麼它們就不會再競争資源了,直到其中一個線程釋放信号量,這樣環境切換對系統的影響就可以忽略不計。更主要的是,這種方法使大部分時間處于休眠狀态的線程避免在激活線程數中占用一個位置,這種方式比其它的替代方案更優雅。

  一旦處理請求的過程被分成兩個階段(監聽和工作),那麼更進一步,這些處理過程在将來被分成更多的階段(更多的線程)就是很自然的事了。最簡單的情況是一個完整的請求先完成第一步,然後是第二步(比如回應)。然而實際會更複雜:一個階段可能産生出兩個不同執行路徑,也可能隻是簡單的生成一個應答(例如傳回一個緩存的值)。由此每個階段都需要知道下一步該如何做,根據階段分發函數的傳回值有三種可能的做法:

  l  請求需要被傳遞到另外一個階段(傳回一個描述符或者指針)

  l  請求已經完成(傳回ok)

  l  請求被阻塞(傳回"請求阻塞")。這和前面的情況一樣,阻塞到直到别的線程釋放資源

  應該注意到在這種模式下,對階段的排隊是在一個線程内完成的,而不是經由兩個線程中完成。這樣避免不斷把請求放在下一階段的隊列裡,緊接着又從該隊列取出這個請求來執行。這種經由很多活動隊列和鎖的階段很沒必要。

  這種把一個複雜的任務分解成多個較小的互相協作的部分的方式,看起來很熟悉,這是因為這種做法确實很老了。我的方法,源于CAR在1978年發明的"通信序列化程序"(Communicating Sequential Processes CSP),它的基礎可以上溯到1963時的Per Brinch Hansen and Matthew Conway--在我出生之前!然而,當Hoare創造出CSP這個術語的時候,他說的“程序”是抽象數學意義上的程序,而且,這個CSP術語中的程序和作業系統中同名的那個程序并沒有關系。依我看來,這種在作業系統提供的單個線程之内,實作類似多線程一樣協同并發工作的CSP的方法,在可擴充性方面讓很多人頭疼。

  一個實際的例子是,Matt Welsh的SEDA,這個例子表明分段執行的(stage-execution)思想朝着一個比較合理的方向發展。SEDA是一個很好的“server Aarchitecture done right”的例子,值得把它的特性評論一下:

  1.   SEDA的批處理傾向于強調一個階段處理多個請求,而我的方式傾向于強調一個請求分成多個階段處理。

  2.   在我看來SEDA的一個重大缺陷是給每個階段申請一個獨立的在加載響應階段隻進行線程“背景”重配置設定的線程池。結果,原因1和原因2引起的環境切換仍然很多。

  3.   在純技術的研究項目中,在Java中使用SEDA是有用的,然而在實際應用場合,我覺得這種方法很少被選擇。

記憶體配置設定(Memory Allocator)

  申請和釋放記憶體是應用程式中最常見的操作, 是以發明了許多聰明的技巧使得記憶體的申請效率更高。然而再聰明的方法也不能彌補這種事實:在很多場合中,一般的記憶體配置設定方法非常沒有效率。是以為了減少向系統申請記憶體,我有三個建議。

  建議一是使用預配置設定。我們都知道由于使用靜态配置設定而導緻對程式的功能加上人為限制是一種糟糕的設計。但是還是有許多其它很不錯的預配置設定方案。通常認為,通過系統一次性配置設定記憶體要比分開幾次配置設定要好,即使這樣做在程式中浪費了某些記憶體。如果能夠确定在程式中會有幾項記憶體使用,在程式啟動時預配置設定就是一個合理的選擇。即使不能确定,在開始時為請求句柄預配置設定可能需要的所有記憶體也比在每次需要一點的時候才配置設定要好。通過系統一次性連續配置設定多項記憶體還能極大減少錯誤處理代碼。在記憶體比較緊張時,預配置設定可能不是一個好的選擇,但是除非面對最極端的系統環境,否則預配置設定都是一個穩賺不賠的選擇。

  建議二是使用一個記憶體釋放配置設定的lookaside list(監視清單或者後備清單)。基本的概念是把最近釋放的對象放到連結清單裡而不是真的釋放它,當不久再次需要該對象時,直接從連結清單上取下來用,不用通過系統來配置設定。使用lookaside list的一個額外好處是可以避免複雜對象的初始化和清理.

  通常,讓lookaside list不受限制的增長,即使在程式空閑時也不釋放占用的對象是個糟糕的想法。在避免引入複雜的鎖或競争情況下,不定期的“清掃"非活躍對象是很有必要的。一個比較妥當的辦法是,讓lookaside list由兩個可以獨立鎖定的連結清單組成:一個"新鍊"和一個"舊鍊".使用時優先從"新"鍊配置設定,然後最後才依靠"舊"鍊。對象總是在"新"鍊上被釋放。清除線程則按如下規則運作:

  1.   鎖住兩個鍊

  2.   儲存舊鍊的頭結點

  3.   把前一個新鍊挂到舊鍊的前頭

  4.   解鎖

  5.   在空閑時通過第二步儲存的頭結點開始釋放舊鍊的所有對象。

  使用了這種方式的系統中,對象隻有在真的沒用時才會釋放,釋放至少延時一個清除間隔期(指清除線程的運作間隔),但同常不會超過兩個間隔期。清除線程不會和普通線程發生鎖競争。理論上來說,同樣的方法也可以應用到請求的多個階段,但目前我還沒有發現有這麼用的。

  使用lookaside lists有一個問題是,保持配置設定對象需要一個連結清單指針(連結清單結點),這可能會增加記憶體的使用。但是即使有這種情況,使用它帶來的好處也能夠遠遠彌補這些額外記憶體的花銷。

  第三條建議與我們還沒有讨論的鎖有關系。先抛開它不說。即使使用lookaside list,記憶體配置設定時的鎖競争也常常是最大的開銷。解決方法是使用線程私有的lookasid list, 這樣就可以避免多個線程之間的競争。更進一步,每個處理器一個鍊會更好,但這樣隻有在非搶先式線程環境下才有用。基于極端考慮,私有lookaside list甚至可以和一個共用的鍊工作結合起來使用。

鎖競争(Lock Contention)

  高效率的鎖是非常難規劃的, 以至于我把它稱作卡律布狄斯和斯庫拉(參見附錄)。一方面, 鎖的簡單化(粗粒度鎖)會導緻并行處理的串行化,因而降低了并發的效率和系統可伸縮性; 另一方面, 鎖的複雜化(細粒度鎖)在空間占用上和操作時的時間消耗上都可能産生對性能的侵蝕。偏向于粗粒度鎖會有死鎖發生,而偏向于細粒度鎖則會産生競争。在這兩者之間,有一個狹小的路徑通向正确性和高效率,但是路在哪裡?

  由于鎖傾向于對程式邏輯産生束縛,是以如果要在不影響程式正常工作的基礎上規劃出鎖方案基本是不可能的。這也就是人們為什麼憎恨鎖,并且為自己設計的不可擴充的單線程方案找借口了。

  幾乎我們每個系統中鎖的設計都始于一個"鎖住一切的超級大鎖",并寄希望于它不會影響性能,當希望落空時(幾乎是必然), 大鎖被分成多個小鎖,然後我們繼續禱告(性能不會受影響),接着,是重複上面的整個過程(許多小鎖被分成更小的鎖), 直到性能達到可接受的程度。通常,上面過程的每次重複都回增加大于20%-50%的複雜性和鎖負荷,并減少5%-10%的鎖競争。最終結果是取得了适中的效率,但是實際效率的降低是不可避免的。設計者開始抓狂:"我已經按照書上的指導設計了細粒度鎖,為什麼系統性能還是很糟糕?"

  在我的經驗裡,上面的方法從基礎上來說就不正确。設想把解決方案當成一座山,優秀的方案表示山頂,糟糕的方案表示山谷。上面始于"超級鎖"的解決方案就好像被形形色色的山谷,凹溝,小山頭和死胡同擋在了山峰之外的登山者一樣,是一個典型的糟糕爬山法;從這樣一個地方開始登頂,還不如下山更容易一些。那麼登頂正确的方法是什麼?

  首要的事情是為你程式中的鎖形成一張圖表,有兩個軸:

  l  圖表的縱軸表示代碼。如果你正在應用剔出了分支的階段架構(指前面說的為請求劃分階段),你可能已經有這樣一張劃分圖了,就像很多人見過的OSI七層網絡協定架構圖一樣。

  l  圖表的水準軸表示資料集。在請求的每個階段都應該有屬于該階段需要的資料集。

  現在,你有了一張網格圖,圖上每個單元格表示一個特定階段需要的特定資料集。下面是應該遵守的最重要的規則:兩個請求不應該産生競争,除非它們在同一個階段需要同樣的資料集。如果你嚴格遵守這個規則,那麼你已經成功了一半。

  一旦你定義出了上面那個網格圖,在你的系統中的每種類型的鎖就都可以被辨別出來了。你的下一個目标是確定這些辨別出來的鎖盡可能在兩個軸之間均勻的分布,這部分工作是和具體應用相關的。你得像個鑽石切割工一樣,根據你對程式的了解,找出請求階段和資料集之間的自然“紋理線”。有時候它們很容易發現,有時候又很難找出來,此時需要不斷回顧來發現它。在程式設計時,把代碼分隔成不同階段是很複雜的事情,我也沒有好的建議,但是對于資料集的定義,有一些建議給你:

  l  如果你能對請求按順序編号,或者能對請求進行哈希,或者能把請求和事物ID關聯起來,那麼根據這些編号或者ID就能對資料更好的進行分隔。

  l  有時,基于資料集的資源最大化利用,把請求動态的配置設定給資料,相對于依據請求的固有屬性來配置設定會更有優勢。就好像現代CPU的多個整數運算單元知道把請求分離一樣。

  l  确定每個階段指定的資料集是不一樣的是非常有用的,以便保證一個階段争奪的資料在另外階段不會争奪。

  如果你在縱向和橫向上把“鎖空間(這裡實際指鎖的分布)" 分隔了,并且確定了鎖均勻分布在網格上,那麼恭喜你獲得了一個好方案。現在你處在了一個好的登山點,打個比喻,你面有了一條通向頂峰的緩坡,但你還沒有到山頂。現在是時候對鎖競争進行統計,看看該如何改進了。以不同的方式分隔階段和資料集,然後統計鎖競争,直到獲得一個滿意的分隔。當你做到這個程度的時候,那麼無限風景将呈現在你腳下。

其他方面

  我已經闡述完了影響性能的四個主要方面。然而還有一些比較重要的方面需要說一說,大多數都可歸結于你的平台或系統環境:

  l  你的存儲子系統在大資料讀寫和小資料讀寫,随即讀寫和順序讀寫方面是如何進行?在預讀和延遲寫入方面做得怎樣?

  l  你使用的網絡協定效率如何?是否可以通過修改參數改善性能?是否有類似于TCP_CORK, MSG_PUSH,Nagle-toggling算法的手段來避免小消息産生?

  l  你的系統是否支援Scatter-Gather I/O(例如readv/writev)? 使用這些能夠改善性能,也能避免使用緩沖鍊(見第一節資料拷貝的相關叙述)帶來的麻煩。(說明:在dma傳輸資料的過程中,要求源實體位址和目标實體位址必須是連續的。但在有的計算機體系中,如IA,連續的存儲器位址在實體上不一定是連續的,則dma傳輸要分成多次完成。如果傳輸完一塊實體連續的資料後發起一次中斷,同時主機進行下一塊實體連續的傳輸,則這種方式即為block dma方式。scatter/gather方式則不同,它是用一個連結清單描述實體不連續的存儲器,然後把連結清單首位址告訴dma master。dma master傳輸完一塊實體連續的資料後,就不用再發中斷了,而是根據連結清單傳輸下一塊實體連續的資料,最後發起一次中斷。很顯然 scatter/gather方式比block dma方式效率高)

  l  你的系統的頁大小是多少?高速緩存大小是多少?向這些大小邊界進行對起是否有用?系統調用和上下文切換花的代價是多少?

  l  你是否知道鎖原語的饑餓現象?你的事件機制有沒有"驚群"問題?你的喚醒/睡眠機制是否有這樣糟糕的行為: 當X喚醒了Y, 環境立刻切換到了Y,但是X還有沒完成的工作?

  我在這裡考慮的了很多方面,相信你也考慮過。在特定情況下,應用這裡提到的某些方面可能沒有價值,但能考慮這些因素的影響還是有用的。如果在系統手冊中,你沒有找到這些方面的說明,那麼就去努力找出答案。寫一個測試程式來找出答案;不管怎樣,寫這樣的測試代碼都是很好的技巧鍛煉。如果你寫的代碼在多個平台上都運作過,那麼把這些相關的代碼抽象為一個平台相關的庫,将來在某個支援這裡提到的某些功能的平台上,你就赢得了先機。

  對你的代碼,“知其是以然”, 弄明白其中進階的操作, 以及在不同條件下的花銷.這不同于傳統的性能分析, 不是關于具體的實作,而是關乎設計. 低級别的優化永遠是蹩腳設計的最後救命稻草.

本文翻譯自: http://pl.atyp.us/content/tech/servers.html

繼續閱讀