英文原文:High-Performance Server Architecture
引言
本文件的目的是為了同大家分享多年來我在開發一種特定型別的應用時形成的一些觀點,而“伺服器”只是對這類應用程式的一個不是那麼恰如其分的稱謂。更準確的說,我將描述的是一大類的程式,這類程式的設計使得它們能夠在每秒鐘內處理數量十分巨大的離散訊息或請求。網路伺服器是最為常見的同此定義吻合的軟體,但是,並非所有同此定義吻合的程式絕對可以稱作是伺服器。然而,“高效能請求處理程式”這種稱謂又很難讓人接受,所以,為了行文簡單起見,我就用“伺服器”這個詞了事了。
儘管在單個程式中進行多工處理現在早已司空見慣了,但我將不會對“適度並行”的應用程式進行討論。就在現在,你閱讀本文件所用的瀏覽器可能正在以並行的方式做著一些事情,但是如此低水平的並行真是不會帶來任何值得關注的挑戰。真正值得關注的挑戰出現在處理請求的架構本身是總體效能的限制性因素的時候,此時對架構進行改善就能夠真正地提高效能。執行在主頻為幾G赫茲的CPU和上G記憶體的環境中,通過DSL線路同時進行著6個下載任務的瀏覽器,往往就不屬於這種情況。這裡的重點並不在於象是用吸管喝著飲料似的應用程式,而是在於象是通過消防拴來喝水的應用程式,這類程式處於馬上就要突破硬體能力的邊緣地帶,你對這類程式的設計起著至關重要的作用。
毫無疑問有些人會反對我的意見和建議,或者認為他們有更好的辦法。這非常好。這裡我可不是想要發出什麼上帝之聲;這些只是我發現正合我意的方法,但合意的標準並不僅是它們在效能方面的表現不錯,而且還包括後期對這些程式碼進行除錯和擴充套件的難度也不高這個標準。你的衡量標準可能有所不同。如果你發現有別的方法更適合你那就太棒了,但是要警告的是,我在本文中建議的作為替代方案的幾乎所有方法我都試過了,而且其結果都很令人氣惱和不可接受。你所鍾愛的觀點要是放到本文中作為其中的故事之一可能也會非常的合適,如果你慫恿我把這些故事寫出來,無辜的讀者可能會被我煩死的。你可不想傷害讀者,對吧?
本文剩下的部分將圍繞著我稱之為“效能低下的四騎士”的四個方面的內容來進行:
- 資料拷貝
- 上下文切換
- 記憶體分配
- 鎖的爭用
本文最後還包含了一個包羅永珍的部分,但是這四個是最大的效能殺手。如果你能在不拷貝資料、無需上下文切換、不用進行記憶體分配而且不會引起對鎖的爭用的情況下處理絕大多數請求,那麼你的伺服器效能一定會非常好,即使有些小地方做得不對也沒有太大的關係。
資料拷貝
因為一個非常簡單的原因,這一小節本來可以寫得非常簡短:絕大多數人都已經有過這方面的教訓了。每個人都知道,資料拷貝很不好;這很顯然,對吧?嗯,真地很對,正是因為你在你的計算領域生涯的早期就有過這方面的教訓了所以很顯然,並且之所以你有這方面教訓是因為早在幾十年前就有人開始提出資料拷貝這個詞了。我知道我的情況就是這樣的,但我有點跑題了。現如今,在每個學校的課程和各種非正規的指南中都會對資料拷貝進行討論。即使那些做銷售的都已經弄明白了,“零拷貝”是個不錯的時髦詞。
儘管後知後覺顯然認為資料拷貝很不好,但是,貌似人們還是沒有弄明白其中的一些細微之處。其中最重要的一點就是,資料拷貝往往發生地很隱蔽,形式上也有所偽裝。你真的瞭解你所呼叫的驅動程式或者程式碼庫裡面到底有沒有進行資料拷貝嗎?可能情況比你所想的要複雜一些。請你猜猜看PC中“程式控制的I/O”指的是什麼。雜湊函式就是一個不是隱蔽的而是經過偽裝的資料拷貝的例子,它具有資料拷貝的所有記憶體訪問開銷,而且還涉及了大量的計算過程。 只要指出來雜湊實際上是個“資料拷貝再加其它操作“的過程,那麼貌似有一點很顯然,就是要避免使用雜湊函式了,但是,我知道至少有一群高人會把這個問題解決掉。如果你真想消除資料拷貝,不管是因為它們真地會損害效能,還是因為你就是想把“零拷貝操作”寫入你在黑客大會上的幻燈片裡,你將需要對很多並不沒有大張旗鼓告訴你但其實真的包含了資料拷貝的很多東西一直追查到底。
經實踐驗證過的避免資料拷貝的方法就是使用間接法,傳遞緩衝區描述符(或者是一個緩衝區描述符組成的鏈)而不是僅僅傳遞緩衝區指標。每個描述符一般都由以的幾個部分組成:
- 一個指標以及整個緩衝區的長度。
- 一個指標和長度,或者是緩衝區中真正填充了資料部分的。
- 指向列表中其它緩衝區描述符的前向和後向指標。
- 一個引用計數
現在,不用通過拷貝一段資料來確保這些資料能夠呆在記憶體中了,程式碼可以很簡單地對適當的緩衝區描述符中的引用計數加一。在某些情況下這種做法會相當地成功,包括在典型的網路協議棧的運作模式中也沒問題,但是,這種做法也有可能會成為一件讓你大為頭疼的事情。一般來說,要在緩衝區描述符鏈的開頭或者結尾部分新增新的緩衝區很容易,同樣為整個緩衝區增加引用以及立即撤銷為整個鏈分配的記憶體也很容易。在中間部分新增新緩衝區、一點一點的撤銷已分配的記憶體或者引用部分緩衝區這三種操作每一個都會讓你的日子越來越難過。要想對緩衝區進行分割或者合併只會把你逼瘋。
然而,實際上我並不建議在所有情況下都採用這種方法。為什麼不建議呢?因為採用這種方法後,每次想檢視報頭部分的時候你都不得不對描述符鏈進行遍歷,這麼做真是太痛苦了。這裡真的還有比資料拷貝更加糟糕的事情。我發現,要做的最好的事情就是找出程式裡的大物件,比如資料塊,確保象前文所述那樣,為這些資料塊獨立分配記憶體,這樣就不需要對它們進行拷貝了,至於剩下其它的東西就不要操那麼多心了。
這就是我對資料拷貝要說一下我的最後一個觀點:在避免資料拷貝時不要做得太過火。我看到過太多程式碼,為了避免資料拷貝而它們把某些事情搞得更糟了,比如,它們會迫使系統進行上下文切換或者會打斷資料規模較大的I/O請求。資料拷貝代價比較高,當你正在尋找需要避免冗餘操作的地方時,其中首要的就是應該看看有沒有出現資料拷貝的地方。但是,有一點會減少這麼做對你的回報。仔細排查程式碼,然後就是為了排除掉最後的幾個資料拷貝而把程式碼搞到複雜了兩倍多,這通常是對時間的一種浪費,這些時間本可以更好地花在別的地方。
上下文切換
鑑於每個人都認為資料拷貝顯然不好,我經常驚歎於竟然有那麼多人會完全忽略上下文切換對效能的影響。按照我的經驗來看,在高負載的情況下,同資料拷貝相比,實際上上下文切換實際才是更多的導致系統“完全失靈”的元凶;系統開始在來回從一個執行緒到另一個執行緒的切換中所花的時間比執行緒真正做有用的工作所花的時間還要多。令人驚奇的是,從某個角度講,引起系統過度進行上下文切換的元凶十分顯而易見。上下文切換的頭號原因就是活躍執行緒數超過了處理器的總數。隨著活躍執行緒數同處理器總數比值的增大,上下文切換的數量也會增大。如果幸運,這種增加會是線性的,但通常都是成指數級增長。這個非常簡單事實可以解釋出每個連線都用一個執行緒來處理的多執行緒設計為什麼伸縮性會非常之差。可伸縮系統唯一比較現實的方案就是限制活躍執行緒的總數,讓該數(在一般情況下)小於或等於處理器的總數。這種方案有一種比較多見的經過修改的版本就是隻使用一個執行緒;儘管這麼做的確能夠完全避免上下文胡亂切換,而且還不用再使用鎖了,但它也無法利用多CPU來提高總吞吐量了,所以除非所設計的程式是非CPU密集型的(通常是網路I/O密集型的),一般大家都不採用這種方案。
一個“適度使用執行緒”的程式要做的第一件事就是找出如何讓一個執行緒同時處理多個連線的辦法。這通常意味著要在前臺使用select/poll API、非同步I/O、訊號或者完成埠,而後端使用一個事件驅動的結構。關於到底哪種前臺API才是最好的,已經發生過許多類似於“宗教之爭”的爭論,而且這種爭論還會持續下去。Dan Kegel所寫的C10K論文是這個領域中最好的參考資料。我個人認為,各種select/poll API和訊號都是些醜陋的伎倆,因此我比較偏愛AIO或者完成埠,但這實際上並沒有那麼重要。所以這些方案,也許要將除select()外,用起來都相當不錯,真地都不會做太多的事情來解決發生在你的程式前端最外層之外的任何問題。既然將請求的處理過程分為了兩個階段(監聽者和工作者)並由多個執行緒來為這兩個階段服務,那麼將處理過程更進一步分為多於兩個的階段就是很自然的事情了。按照最簡單的形式,請求的處理就變成了先在一個方向完成一個階段的處理過程,然後再在另外一個方向上(為了響應請求)進行另外一個階段的處理。然而,死去可能會變得更加複雜;有一個階段可能會代表著在涉及不同階段的兩個處理路徑上進行“分叉”,或者該階段可能會產生一個響應(比如,該響應是個快取中的值)而無需進行下一個階段的處理了。因此,每個階段都需要能夠為請求指定“下個階段應該幹什麼了”。這裡有三種可能,由每個階段的分發函式的返回值來表示:
- 該請求需要接著傳遞到另外一個階段(在返回值裡用一個ID或指標來表示這個階段)。
- 該請求已經處理完畢(用一個專門的“請求處理完畢”返回值來表示)。
- 該請求被阻塞(用一個專門的“請求被阻塞”返回值來表示)。這等價於一種情況,只是該請求仍未釋放,隨後會在另外一個執行緒中接著對其進行處理。
請注意,在本模型中,請求的佇列操作是在階段 中完成的,而不是在階段間完成的。這樣就能夠避免常見的愚蠢做法:不斷將請求放入後繼階段的佇列之中,然後立即進行該後繼階段並將該請求從佇列中取出。我認為,類似這樣的佇列活動以及加解鎖的動作絕對是無事生非。
把一個複雜的認為分割成相互通訊的多個較小部分的這種做法如果感覺很熟悉的話,那是因為這種做法已由來已久。我的方法的根源是1978年由C.A.R. Hoar闡明的概念通訊順序程式(Communicating Sequential Process,簡稱CSP),而CSP又是基於早在1963年,也就是在我出生之前,由Per Brinch Hansen和Matthew Conway提出的一些觀點。然而,在當初Hoare創造CSP這個名詞時,他所說的“程式”是在抽象的數學意義上講的程式,CSP程式跟作業系統中那個具有相同名字的實體並無關聯。使用執行在單個OS執行緒中的、跟執行緒看上去很象的協程(coroutine)是實現CSP的最常見方法,而且依我看,就是這種實現方法給使用者造成了這樣的棘手的局面:使用了併發程式設計卻仍舊不具有併發程式設計的可伸縮性。
Matt Welsh的SEDA是當代實現階段化任務執行理念的一個朝著更為理性方向發展的例項。實際上,SEDA是個非常好的例子,它"具有非常恰當的伺服器體系結構”,所以它的一些具體特性非常值得拿來說一說(特別是同我在上文中總結的不大相同的特性)。
- SEDA的“批處理(batching)”傾向於強調在一個階段中同時處理多個請求,而我的方法更傾向於強調同時在多個階段中處理同一個的請求。
- 在我看來,SEDA的一個重大缺陷是它為每個階段分配了一個單獨的執行緒池,只是在“後臺”根據負載情況對執行緒進行重新分配。這樣一來,引起上下文切換的頭號和第二號原因仍然會不斷出現。
- 從學術研究專案的角度講,用Java來實現SEDA也許可以說得過去。但從實際應用角度來講,我認為這種選擇可以說是很令人遺憾的。
記憶體分配
分配和釋放記憶體是許多應用程式中最常見的操作之一。因此,人們為了讓通用的記憶體分配器更加的高效而開研究出了許多巧妙的花招。然而,正是由於這些記憶體分配器的通用性,使得它們不可避免地會在許多場合下其效率遠低於它們的其它替代方案,而且即使再巧妙也無法避免這種情況的發生。因此,關於如何徹底避免系統記憶體分配器,我有三個建議。
建議一是使用一個簡單的預分配方案。我們都知道,靜態記憶體分配在對程式的功能會施加人為限制的情況下最好不要誰好用,但是,預分配還有其它我們能夠從中獲益匪淺的很多種形式。通常使用預分配的原因來自於只呼叫一次系統記憶體分配器比呼叫多次好,即使在這個過程中會有部分記憶體被“浪費掉”了。所以,如果有可能可以斷定,同時使用的資料不會多於N項,在程式啟動之初就先進行記憶體預分配可能會是個正確的選擇。即使無法做出這樣的斷定,為請求處理器在開始時就會需要進行分配的記憶體進行預分配可能要比隨著需要一點一點來分配記憶體強;而且通過一次呼叫系統記憶體分配器就為許多個資料項而分配的記憶體還可能是連續的,這往往會極大的降低錯誤恢復程式碼的複雜度。在記憶體非常緊張的情況下,預分配就不是一個好的選擇了,但是除了一些最極端的情況,其它情況下預分配一般都會是個絕對上算的選擇。
建議二是採用後備列表(lookaside list)來對分配和釋放的頻率比較高的物件進行管理。其基本的想法是要將最近要釋放的物件放入該列表而不是真正的釋放它們,希望其後不久再需要它們的時候只需從後備列表中將它們重新取回來而不是從系統記憶體中為它們再次分配記憶體。使用後備列表還會帶來一個額外的好處,就是在實現從後備列表中傳進/傳出複雜物件時,我們可以跳過對這些複雜物件的初始化/終止化(initialization/finalization)操作。
即使程式在空閒狀態時也永不讓真正釋放所有物件,就會讓後備列表無限制地增長下去,通常情況下這種做法是不可取的。因此,一般都很有必要設立一個週期性的“清掃者”任務來釋放非活躍物件,但是如果因引入清掃者而增加了鎖的複雜度以及出現鎖爭用的機率,那麼這也同樣是不可取的。這裡有一個比較好的折中的辦法,把後備列表分為兩個獨立鎖定的“舊”列表和“新”列表。使用時首選從新列表中分配物件,然後是從舊列表中分配,萬不得已時才從系統中分配物件;物件總是釋放到新列表中。清掃者執行緒要按照下來步驟進行操作:
- 鎖定這兩個列表。
- 將舊列表的頭指標儲存起來。
- 通過列表的頭指標賦值將(先前的)新列表轉變為舊列表。
- 解鎖。
- 在空閒時將在第二步儲存起來的舊列表中的所有物件都釋放掉。
在這種系統中,物件只有在至少一個完整的清掃週期且最多絕對不會超過兩個清掃週期的時間內沒有被使用到後,才會被真正的釋放掉。更重要的是,清掃者執行緒在做的大部分工作時都不會和普通執行緒發生鎖爭用。從理論上講,同樣的方法也可以推廣到多於兩個處理階段的系統中,但我還沒有看出來這種推廣有多大的實用價值。
使用後備列表有一個讓人擔心的問題是列表指標可能會增加物件的大小。從我的經驗來看,我用後備列表來管理的絕大多數物件反正都已經包含了列表指標,所以這個問題沒有什麼太大的意義。但是,即使指標只是用於後備列表的,因為後備列表避免了多次呼叫系統記憶體分配器(而且還避免了物件初始化操作的多次執行),用由此而節省下來的系統開銷來彌補列表指標所佔用的那點額外的記憶體還是綽綽有餘的。
建議三實際上同我們還尚未討論的鎖有關係,但無論如何我在這裡要先說幾句。通常在分配記憶體時,最大的開銷化在了鎖爭用上,即使使用後備列表情況也是這樣的。有個解決辦法就是維護多個私有的後備列表,如此一來每個列表都絕不可能再發生鎖爭用的情況。例如,你可以為每個執行緒建立一個單獨的後備列表。基於快取熱度(cache-warmth)方面的考慮,為每個處理器建立一個列表可能會更好,但這隻有在非搶佔式執行緒環境下才可行。為了建立記憶體分配開銷極低的系統,如有必要,私有的後備列表甚至還可以同共享的後備列表結合起來使用。
鎖的爭用
眾所周知,要設計出高效的鎖定機制是極其困難的, 我將造成這個困難的原因稱為斯庫拉和卡律布狄斯(譯者注:這兩個名字放到一起在英語中的意思一般是讓人進退兩難、腹背受敵的意思),她倆是古希臘史詩《奧德賽》中的兩個女妖。斯庫拉代表非常簡化和/或粗粒度的鎖,她會把本可以或本應該並行進行的活動轉化為必須按順序執行的活動,因而會對效能和可伸縮性造成損失;卡律布狄斯代表超複雜或細粒度的鎖,但她需要鎖的地方太多再加上鎖操作佔用的時間同樣也會造成效能損失。 靠近斯庫拉的陷阱代表著發生死鎖和活鎖(deadlock and livelock)的情況;靠近卡律布狄斯的陷阱代表著競態條件(race condition)。在這二者之間有一條狹窄的通道,它代表著即高效又正確的鎖。。。但是這樣的通道在哪裡呢?因為鎖一般會和程式邏輯緊密的聯絡在一起,所以在不深刻改變程式執行基礎的情況下,想要設計出很好的鎖定方案往往都是不太可能的。這就是人們為什麼憎恨鎖,並努力為他們採用不具伸縮性的單執行緒方案正名的原因。
幾乎每一個鎖定方案開頭都會設計成“一個可以鎖住所有東西的大鎖”並且心存僥倖,希望這種設計效能不會糟到哪裡去。這種僥倖心理往往不能得逞,當希望破滅後,大鎖就會被分解成許多個相對較小的鎖並繼續心存僥倖,隨後在重複一遍這個過程,大概在效能基本說的過去時整個設計過程才會結束。 通常每個迭代過程都會將程式的複雜度和鎖操作的開銷提高20-50%,但只能減少5-10%的鎖爭用。幸運的話,最終還是會在效能方面有適度的提高的,但效能沒有提升卻反而出現下降也並不罕見。設計者會因此而感到一頭霧水,心裡想:“我是按照所有的教科書教我的辦法將鎖的粒度調整到更小的,可為什麼效能卻更糟了呢?”
依我看,情況變得更糟了的原因在於,上文中所說的那個方法完全是被誤導了。請把這個設計問題的“解空間”想象為一個山脈,山脈中的高地代表著優秀的設計方案,而山脈中的低處代表著糟糕的方案。上文中的問題就在於,開始時的那個“大鎖”同山脈中的高峰間橫亙著各種各樣的山谷、山鞍、小山峰和絕路。這是個經典的爬山問題;從這樣的一個起點開始,試圖通過一小步一小步的方式爬到更高的山峰還不想走下坡路基本上是件不可能實現的事情。設計者所需要的是應該採用一種完全不同方式來爬向頂峰。
你要做的第一件事就是在你的頭腦中要對你的程式的鎖操作有一個示意圖,該圖具有兩個軸:
- 縱軸表示的是程式碼。如果你採用的是無分支階段的階段化架構,那很可能你已經有了一張類似大家在學習OSI模型中的網路協議棧時所使用的那種圖。
- 橫軸表示的是資料。在每個階段中,每個請求都應該分配到一個單獨的資料集之中,該資料集包含著屬於請求本身的資源。
這樣你就會得到一個網格,網格中的每個單元格表示的是某特定處理階段中的某特定資料集。其中最重要的是這個規則:兩個請求間不應該發生爭用,除非這兩個請求處於同一個資料集並且處於同一個處理階段。如果你能做到嚴格遵守這個規則,那麼你就已經成功一半了。
上面的網格各項內容都弄明確之後,你就能夠畫出你的程式中所有型別的鎖操作了,你的下一個目標是確保最終的畫出來點在兩個軸的方向上的分佈要越均勻越好。很不幸,這部分工作同具體應用的相關性很大,你得象鑽石切割師那樣,根據你對程式要達到什麼目的的瞭解,找出階段和資料集間最自然的“切割線”。有時開頭就能非常容易的找出來,有時就比較困難了,但貌似通過反覆琢磨才能更容易的找到這些切割系。將程式碼分割到若個個階段之中是程式設計中比較複雜的一件事,所以這塊我也沒有什麼太多要說的,關於如何定義資料集我倒是有幾個建議:
- 如果請求有與之相關聯的某種形式的批號、雜湊或者事務ID,那除了將這個值除以資料集的總數之外,很少有其它更好的做法。
- 有時,最好是基於哪個資料集具有最大可用資源而不是請求記憶體的屬性將請求動態地分配給資料集。我們可以把資料集看作現代CPU中的多個整數單元;這些整數單元還知道點如何在系統中發出離散請求流。
- 要確保每個階段的資料集分配方案互不相同,這樣一來,在一個階段中會發生爭用的請求就能夠保證不會在另一個階段中也發生爭用了。這個建議通常會對你很有幫助作用。
其他說明
正如我所承諾的那樣,我的講解涵蓋了伺服器設計中與效能相關的四個關鍵問題。不過,我們還是需要根據每個伺服器各自的情況進行分別對待。一般來說,下面的列表可以更好地幫助你瞭解你所使用的平臺(或環境):
- 你是如何完成儲存子系統的部署?根據請求量?有序的還是隨機的?預讀和後寫工作進展的如何?
- 你所使用的網路協議效率如何?你在傳遞中使用的引數後標識可以改進的更好嗎?像 TCP_CORK、MSG_PUSH 或者 Nagle-toggling trick 這些工具可以幫助你消除極小的資訊嗎?
- 你的系統支援分散或聚集 I/O (例如讀寫操作)嗎?使用這些方法可以改進伺服器的效能,並且可以免除你在使用緩衝區鏈時所遭受的巨大痛苦。
- 你的頁面體積有多大?你的快取線體積?是否值得將這些內容排列好放在邊界?系統呼叫和環境切換的程式碼有多大,是否關聯到其它事物?
- 你的讀寫者是否因為頻繁加鎖基本物件而無畏地消耗能源?這些物件又是什麼呢?你的事件是否存在“雷鳴猛獸”的問題?你的睡眠和喚醒是否存在巢狀行為(儘管這很常見),如 X 喚醒 Y 後 Y 便立即發生無論此時 X 是否完成所有任務?
毫不誇張地講,沿著這個思路我還能想出更多的問題。我相信你也能。 在有些情況下,可能這些問題還不值得你真正花時間為它們做點什麼,但通常它們至少還屬於值得你去思考的問題。大部分問題的答案你從系統文件中是找不到的,如果你不知道這些答案,那麼 去找吧!寫個測試程式或者是小型基準測試程式來在實踐中找出答案吧;不管怎樣,編寫這些程式碼本身就是一種很有用的技能。如果你寫的程式碼要執行在多個平臺之上,那這些問題中的大部分問題可能都需要你將功能抽象到各平臺位元組的程式碼庫中,這樣就能夠根據平臺支援的特性不同,實現在某個平臺上獲得單獨的效能提高。
有個“知道所有答案”的理論同樣適用於你自己的程式碼。弄清你的程式碼中比較重要的高層操作在哪裡並在不同的情況下對它們執行所花的時間進行統計。這和傳統的效能分析並不完全相同;這是在衡量設計元素,而不是真正的實現。底層優化一般是那些把設計搞砸了的人最後的救命稻草。