在服務器端程序開發領域,性能問題一直是備受關注的重點。業界有大量的框架、組件、類庫都是以性能爲賣點而廣爲人知。然而,服務器端程序在性能問題上應該有何種基本思路,這個卻不多被這些項目的文檔說起。本文正式但願介紹服務器端解決性能問題的基本策略和經典實踐,並分爲幾個部分來講明:java
2.緩存策略的難點:不一樣特色的緩存數據的清理機制nginx
3.分佈策略的概念和實例程序員
4.分佈策略的難點:共享數據安全性與代碼複雜度的平衡算法
咱們提到服務器端性能問題的時候,每每會混淆不清。由於當咱們訪問一個服務器時,出現服務卡住不能獲得數據,就會認爲是「性能問題」。可是實際上這個性能問題多是有不一樣的緣由,表現出來都是針對客戶請求的延遲很長甚至中斷。咱們來看看這些緣由有哪些:第一個是所謂併發數不足,也就是同時請求的客戶過多,致使超過容納能力的客戶被拒絕服務,這種狀況每每會由於服務器內存耗盡而致使的;第二個是處理延遲過長,也就是有一些客戶的請求處理時間已經超過用戶能夠忍受的長度,這種狀況經常表現爲CPU佔用滿額100%。數據庫
咱們在服務器開發的時候,最經常使用到的有下面這幾種硬件:CPU、內存、磁盤、網卡。其中CPU是表明計算機處理時間的,硬盤的空間通常很大,主要是讀寫磁盤會帶來比較大的處理延遲,而內存、網卡則是受存儲、帶寬的容量限制的。因此當咱們的服務器出現性能問題的時候,就是這幾個硬件某一個甚至幾個都出現負荷佔滿的狀況。這四個硬件的資源通常能夠抽象成兩類:一類是時間資源,好比CPU和磁盤讀寫;一類是空間資源,好比內存和網卡帶寬。因此當咱們的服務器出現性能問題,有一個最基本的思路,就是——時間空間轉換。咱們能夠舉幾個例子來講明這個問題。apache
水壩就是用水庫空間來換流量時間的例子編程
當咱們訪問一個WEB的網站的時候,輸入的URL地址會被服務器變成對磁盤上某個文件的讀取。若是有大量的用戶訪問這個網站,每次的請求都會形成對磁盤的讀操做,可能會讓磁盤不堪重負,致使沒法即時讀取到文件內容。可是若是咱們寫的程序,會把讀取過一次的文件內容,長時間的保存在內存中,當有另一個對一樣文件的讀取時,就直接從內存中把數據返回給客戶端,就無需去讓磁盤讀取了。因爲用戶訪問的文件每每很集中,因此大量的請求可能都能從內存中找到保存的副本,這樣就能大大提升服務器能承載的訪問量了。這種作法,就是用內存的空間,換取了磁盤的讀寫時間,屬於用空間換時間的策略。promise
方便麪預先緩存了大量的烹飪操做瀏覽器
舉另一個例子:咱們寫一個網絡遊戲的服務器端程序,經過讀寫數據庫來提供玩家資料存檔。若是有大量玩家進入這個服務器,一定有不少玩家的數據資料變化,好比升級、得到武器等等,這些經過讀寫數據庫來實現的操做,可能會讓數據庫進程負荷太重,致使玩家沒法即時完成遊戲操做。咱們會發現遊戲中的讀操做,大部分都是針是對一些靜態數據的,好比遊戲中的關卡數據、武器道具的具體信息;而不少寫操做,其實是會覆蓋的,好比個人經驗值,可能每打一個怪都會增長几十點,可是最後記錄的只是最終的一個經驗值,而不會記錄下打怪的每一個過程。因此咱們也可使用時空轉換的策略來提供性能:咱們能夠用內存,把那些遊戲中的靜態數據,都一次性讀取並保存起來,這樣每次讀這些數據,都和數據庫無關了;而玩家的資料數據,則不是每次變化都去寫數據庫,而是先在內存中保持一個玩家數據的副本,全部的寫操做都先去寫內存中的結構,而後按期再由服務器主動寫回到數據庫中,這樣能夠把屢次的寫數據庫操做變成一次寫操做,也能節省不少寫數據庫的消耗。這種作法也是用空間換時間的策略。緩存
拼裝傢俱很省運輸空間,可是安裝很費時
最後說說用時間換空間的例子:假設咱們要開發一個企業通信錄的數據存儲系統,客戶要求咱們能保存下通信錄的每次新增、修改、刪除操做,也就是這個數據的全部變動歷史,以即可以讓數據回退到任何一個過去的時間點。那麼咱們最簡單的作法,就是這個數據在任何變化的時候,都拷貝一份副本。可是這樣會很是的浪費磁盤空間,由於這個數據自己變化的部分可能只有很小一部分,可是要拷貝的副本可能很大。這種狀況下,咱們就能夠在每次數據變化的時候,都記下一條記錄,內容就是數據變化的狀況:插入了一條內容是某某的聯繫方法、刪除了一條某某的聯繫方法……,這樣咱們記錄的數據,僅僅就是變化的部分,而不須要拷貝不少份副本。當咱們須要恢復到任何一個時間點的時候,只須要按這些記錄依次對數據修改一遍,直到指定的時間點的記錄便可。這個恢復的時間可能會有點長,可是卻能夠大大節省存儲空間。這就是用CPU的時間來換磁盤的存儲空間的策略。咱們如今常見的MySQL InnoDB日誌型數據表,以及SVN源代碼存儲,都是使用這種策略的。
另外,咱們的Web服務器,在發送HTML文件內容的時候,每每也會先用ZIP壓縮,而後發送給瀏覽器,瀏覽器收到後要先解壓,而後才能顯示,這個也是用服務器和客戶端的CPU時間,來換取網絡帶寬的空間。
在咱們的計算機體系中,緩存的思路幾乎無處不在,好比咱們的CPU裏面就有1級緩存、2級緩存,他們就是爲了用這些快速的存儲空間,換取對內存這種相對比較慢的存儲空間的等待時間。咱們的顯示卡里面也帶有大容量的緩存,他們是用來存儲顯示圖形的運算結果的。
通往大空間的郊區路上容易交通堵塞
緩存的本質,除了讓「已經處理過的數據,不須要重複處理」之外,還有「以快速的數據存儲讀寫,代替較慢速的存儲讀寫」的策略。咱們在選擇緩存策略進行時空轉換的時候,必須明確咱們要轉換的時間和空間是否合理,是否能達到效果。好比早期有一些人會把WEB文件緩存在分佈式磁盤上(例如NFS),可是因爲經過網絡訪問磁盤自己就是一個比較慢的操做,並且還會佔用可能就不充裕的網絡帶寬空間,致使性能可能變得更慢。
在設計緩存機制的時候,咱們還容易碰到另一個風險,就是對緩存數據的編程處理問題。若是咱們要緩存的數據,並非徹底無需處理直接讀寫的,而是須要讀入內存後,以某種語言的結構體或者對象來處理的,這就須要涉及到「序列化」和「反序列化」的問題。若是咱們採用直接拷貝內存的方式來緩存數據,當咱們的這些數據須要跨進程、甚至跨語言訪問的時候,會出現那些指針、ID、句柄數據的失效。由於在另一個進程空間裏,這些「標記型」的數據都是不存在的。所以咱們須要更深刻的對數據緩存的方法,咱們可能會使用所謂深拷貝的方案,也就是跟着那些指針去找出目標內存的數據,一併拷貝。一些更現代的作法,則是使用所謂序列化方案來解決這個問題,也就是用一些明肯定義了的「拷貝方法」來定義一個結構體,而後用戶就能明確的知道這個數據會被拷貝,直接取消了指針之類的內存地址數據的存在。好比著名的Protocol Buffer就能很方便的進行內存、磁盤、網絡位置的緩存;如今咱們常見的JSON,也被一些系統用來做爲緩存的數據格式。
可是咱們須要注意的是,緩存的數據和咱們程序真正要操做的數據,每每是須要進行一些拷貝和運算的,這就是序列化和反序列化的過程,這個過程很快,也有可能很慢。因此咱們在選擇數據緩存結構的時候,必需要注意其轉換時間,不然你緩存的效果可能被這些數據拷貝、轉換消耗去不少,嚴重的甚至比不緩存更差。通常來講,緩存的數據越解決使用時的內存結構,其轉換速度就越快,在這點上,Protocol Buffer採用TLV編碼,就比不上直接memcpy的一個C結構體,可是比編碼成純文本的XML或者JSON要來的更快。由於編解碼的過程每每要進行復雜的查表映射,列表結構等操做。
雖然使用緩存思想彷佛是一個很簡單的事情,可是緩存機制卻有一個核心的難點,就是——緩存清理。咱們所說的緩存,都是保存一些數據,可是這些數據每每是會變化的,咱們要針對這些變化,清理掉保存的「髒」數據,卻可能不是那麼容易。
首先咱們來看看最簡單的緩存數據——靜態數據。這種數據每每在程序的運行時是不會變化的,好比Web服務器內存中緩存的HTML文件數據,就是這種。事實上,全部的不是由外部用戶上傳的數據,都屬於這種「運行時靜態數據」。通常來講,咱們對這種數據,能夠採用兩種創建緩存的方法:一是程序一啓動,就一股腦把全部的靜態數據從文件或者數據庫讀入內存;二就是程序啓動的時候並不加載靜態數據,而是等有用戶訪問相關數據的時候,纔去加載,這也就是所謂lazy load的作法。第一種方法編程比較簡單,程序的內存啓動後就穩定了,不太容易出現內存漏洞(若是加載的緩存太多,程序在啓動後馬上會因內存不足而退出,比較容易發現問題);第二種方法程序啓動很快,但要對緩存佔用的空間有所限制或者規劃,不然若是要緩存的數據太多,可能會耗盡內存,致使在線服務中斷。
通常來講,靜態數據是不會「髒」的,由於沒有用戶會去寫緩存中的數據。可是在實際工做中,咱們的在線服務每每會須要「馬上」變動一些緩存數據。好比在門戶網站上發佈了一條新聞,咱們會但願馬上讓全部訪問的用戶都看到。按最簡單的作法,咱們通常只要重啓一下服務器進程,內存中的緩存就會消失了。對於靜態緩存的變化頻率很是低的業務,這樣是能夠的,可是若是是新聞網站,就不能每隔幾分鐘就重啓一下WEB服務器進程,這樣會影響大量在線用戶的訪問。常見的解決這類問題有兩種處理策略:
第一種是使用控制命令。簡單來講,就是在服務器進程上,開通一個實時的命令端口,咱們能夠經過網絡數據包(如UDP包),或者Linux系統信號(如kill SIGUSR2進程號)之類的手段,發送一個命令消息給服務器進程,讓進程開始清理緩存。這種清理可能執行的是最簡單的「所有清理」,也有的能夠細緻一點的,讓命令消息中帶有「想清理的數據ID」這樣的信息,好比咱們發送給WEB服務器的清理消息網絡包中會帶一個字符串URL,表示要清理哪個HTML文件的緩存。這種作法的好處是清理的操做很精準,能夠明確的控制清理的時間和數據。可是缺點就是比較繁瑣,手工去編寫發送這種命令很煩人,因此通常咱們會把清理緩存命令的工做,編寫到上傳靜態數據的工具當中,好比結合到網站的內容發佈系統中,一旦編輯提交了一篇新的新聞,發佈系統的程序就自動的發送一個清理消息給WEB服務器。
第二種是使用字段判斷邏輯。也就是服務器進程,會在每次讀取緩存前,根據一些特徵數據,快速的判斷內存中的緩存和源數據內容,是否有不一致(是否髒)的地方,若是有不一致的地方,就自動清理這條數據的緩存。這種作法會消耗一部分CPU,可是就不須要人工去處理清理緩存的事情,自動化程度很高。如今咱們的瀏覽器和WEB服務器之間,就有用這種機制:檢查文件MD5;或者檢查文件最後更新時間。具體的作法,就是每次瀏覽器發起對WEB服務器的請求時,除了發送URL給服務器外,還會發送一個緩存了此URL對應的文件內容的MD5校驗串、或者是此文件在服務器上的「最後更新時間」(這個校驗串和「最後更新時間」是第一次獲的文件時一併從服務器得到的);服務器收到以後,就會把MD5校驗串或者最後更新時間,和磁盤上的目標文件進行對比,若是是一致的,說明這個文件沒有被修改過(緩存不是「髒」的),能夠直接使用緩存。不然就會讀取目標文件返回新的內容給瀏覽器。這種作法對於服務器性能是有必定消耗的,因此若是每每咱們還會搭配其餘的緩存清理機制來用,好比咱們會在設置一個「超時檢查」的機制:就是對於全部的緩存清理檢查,咱們都簡單的看看緩存存在的時間是否「超時」了,若是超過了,才進行下一步的檢查,這樣就不用每次請求都去算MD5或者看最後更新時間了。可是這樣就存在「超時」時間內緩存變髒的可能性。
WEB服務器靜態緩存例子
上面說了運行時靜態的緩存清理,如今說說運行時變化的緩存數據。在服務器程序運行期間,若是用戶和服務器之間的交互,致使了緩存的數據產生了變化,就是所謂「運行時變化緩存」。好比咱們玩網絡遊戲,登陸以後的角色數據就會從數據庫裏讀出來,進入服務器的緩存(多是堆內存或者memcached、共享內存),在咱們不斷進行遊戲操做的時候,對應的角色數據就會產生修改的操做,這種緩存數據就是「運行時變化的緩存」。這種運行時變化的數據,有讀和寫兩個方面的清理問題:因爲緩存的數據會變化,若是另一個進程從數據庫讀你的角色數據,就會發現和當前遊戲裏的數據不一致;若是服務器進程忽然結束了,你在遊戲裏升級,或者撿道具的數據可能會從內存緩存中消失,致使你白忙活了半天,這就是沒有回寫(緩存寫操做的清理)致使的問題。這種狀況在電子商務領域也很常見,最典型的就是火車票網上購買的系統,火車票數據緩存在內存必須有合適的清理機制,不然讓兩個買了同一張票就麻煩了,但若是不緩存,大量用戶同時搶票,服務器也應對不過來。所以在運行時變化的數據緩存,應該有一些特別的緩存清理策略。
在實際運行業務中,運行變化的數據每每是根據使用用戶的增多而增多的,所以首先要考慮的問題,就是緩存空間不夠的可能性。咱們不太可能把所有數據都放到緩存的空間裏,也不可能清理緩存的時候就所有數據一塊兒清理,因此咱們通常要對數據進行分割,這種分割的策略常見的有兩種:一種是按重要級來分割,一種是按使用部分分割。
先舉例說說「按重要級分割」,在網絡遊戲中,一樣是角色的數據,有些數據的變化可能會每次修改都馬上回寫到數據庫(清理寫緩存),其餘一些數據的變化會延遲一段時間,甚至有些數據直到角色退出遊戲纔回寫,如玩家的等級變化(升級了),武器裝備的得到和消耗,這些玩家很是看重的數據,基本上會馬上回寫,這些就是所謂最重要的緩存數據。而玩家的經驗值變化、當前HP、MP的變化,就會延遲一段時間才寫,由於就算丟失了緩存,玩家也不會太過關注。最後有些好比玩家在房間(地區)裏的X/Y座標,對話聊天的記錄,可能會退出時回寫,甚至不回寫。這個例子說的是「寫緩存」的清理,下面說說「讀緩存」的按重要級分割清理。
假如咱們寫一個網店系統,裏面容納了不少產品,這些產品有一些會被用戶頻繁檢索到,比較熱銷,而另一些商品則沒那麼熱銷。熱銷的商品的餘額、銷量、評價都會比較頻繁的變化,而滯銷的商品則變化不多。因此咱們在設計的時候,就應該按照不一樣商品的訪問頻繁程度,來決定緩存哪些商品的數據。咱們在設計緩存的結構時,就應該構建一個能夠統計緩存讀寫次數的指標,若是有些數據的讀寫頻率太低,或者空閒(沒有人讀、寫緩存)時間超長,緩存應該主動清理掉這些數據,以便其餘新的數據能進入緩存。這種策略也叫作「冷熱交換」策略。實現「冷熱交換」的策略時,關鍵是要定義一個合理的冷熱統計算法。一些固定的指標和算法,每每並不能很好的應對不一樣硬件、不一樣網絡狀況下的變化,因此如今人們廣泛會用一些動態的算法,如Redis就採用了5種,他們是:
1.根據過時時間,清理最長時間沒用過的
2.根據過時時間,清理即將過時的
3.根據過時時間,任意清理一個
4.不管是否過時,隨機清理
5.不管是否過時,根據LRU原則清理:所謂LRU,就是Least Recently Used,最近最久未使用過。這個原則的思想是:若是一個數據在最近一段時間沒有被訪問到,那麼在未來他被訪問的可能性也很小。LRU是在操做系統中很常見的一種原則,好比內存的頁面置換算法(也包括FIFO,LFU等),對於LRU的實現,仍是很是有技巧的,可是本文就不詳細去說明如何實現,留待你們上網搜索「LRU」關鍵字學習。
數據緩存的清理策略其實遠不止上面所說的這些,要用好緩存這個武器,就要仔細研究須要緩存的數據特徵,他們的讀寫分佈,數據之中的差異。而後最大化的利用業務領域的知識,來設計最合理的緩存清理策略。這個世界上不存在萬能的優化緩存清理策略,只存在針對業務領域最優化的策略,這須要咱們程序員深刻理解業務領域,去發現數據背後的規律。
任何的服務器的性能都是有極限的,面對海量的互聯網訪問需求,是不可能單靠一臺服務器或者一個CPU來承擔的。因此咱們通常都會在運行時架構設計之初,就考慮如何能利用多個CPU、多臺服務器來分擔負載,這就是所謂分佈的策略。分佈式的服務器概念很簡單,可是實現起來卻比較複雜。由於咱們寫的程序,每每都是以一個CPU,一塊內存爲基礎來設計的,因此要讓多個程序同時運行,而且協調運做,這須要更多的底層工做。
首先出現能支持分佈式概念的技術是多進程。在DOS時代,計算機在一個時間內只能運行一個程序,若是你想一邊寫程序,同時一邊聽mp3,都是不可能的。可是,在WIN95操做系統下,你就能夠同時開多個窗口,背後就是同時在運行多個程序。在Unix和後來的Linux操做系統裏面,都廣泛支持了多進程的技術。所謂的多進程,就是操做系統能夠同時運行咱們編寫的多個程序,每一個程序運行的時候,都好像本身獨佔着CPU和內存同樣。在計算機只有一個CPU的時候,實際上計算機會分時複用的運行多個進程,CPU在多個進程之間切換。可是若是這個計算機有多個CPU或者多個CPU核,則會真正的有幾個進程同時運行。因此進程就好像一個操做系統提供的運行時「程序盒子」,能夠用來在運行時,容納任何咱們想運行的程序。當咱們掌握了操做系統的多進程技術後,咱們就能夠把服務器上的運行任務,分爲多個部分,而後分別寫到不一樣的程序裏,利用上多CPU或者多核,甚至是多個服務器的CPU一塊兒來承擔負載。
多進程利用多CPU
這種劃分多個進程的架構,通常會有兩種策略:一種是按功能來劃分,好比負責網絡處理的一個進程,負責數據庫處理的一個進程,負責計算某個業務邏輯的一個進程。另一種策略是每一個進程都是一樣的功能,只是分擔不一樣的運算任務而已。使用第一種策略的系統,運行的時候,直接根據操做系統提供的診斷工具,就能直觀的監測到每一個功能模塊的性能消耗,由於操做系統提供進程盒子的同時,也能提供對進程的全方位的監測,好比CPU佔用、內存消耗、磁盤和網絡I/O等等。可是這種策略的運維部署會稍微複雜一點,由於任何一個進程沒有啓動,或者和其餘進程的通訊地址沒配置好,均可能致使整個系統沒法運做;而第二種分佈策略,因爲每一個進程都是同樣的,這樣的安裝部署就很是簡單,性能不夠就多找幾個機器,多啓動幾個進程就完成了,這就是所謂的平行擴展。
如今比較複雜的分佈式系統,會結合這兩種策略,也就是說系統既按一些功能劃分出不一樣的具體功能進程,而這些進程又是能夠平行擴展的。固然這樣的系統在開發和運維上的複雜度,都是比單獨使用「按功能劃分」和「平行劃分」要更高的。因爲要管理大量的進程,傳統的依靠配置文件來配置整個集羣的作法,會顯得愈來愈不實用:這些運行中的進程,可能和其餘不少進程產生通訊關係,當其中一個進程變動通訊地址時,勢必影響全部其餘進程的配置。因此咱們須要集中的管理全部進程的通訊地址,當有變化的時候,只須要修改一個地方。在大量進程構建的集羣中,咱們還會碰到容災和擴容的問題:當集羣中某個服務器出現故障,可能會有一些進程消失;而當咱們須要增長集羣的承載能力時,咱們又須要增長新的服務器以及進程。這些工做在長期運行的服務器系統中,會是比較常見的任務,若是整個分佈系統有一個運行中的中心進程,能自動化的監測全部的進程狀態,一旦有進程加入或者退出集羣,都能即時的修改全部其餘進程的配置,這就造成了一套動態的多進程管理系統。開源的ZooKeeper給咱們提供了一個能夠充當這種動態集羣中心的實現方案。因爲ZooKeeper自己是能夠平行擴展的,因此它本身也是具有必定容災能力的。如今愈來愈多的分佈式系統都開始使用以ZooKeeper爲集羣中心的動態進程管理策略了。
動態進程集羣
在調用多進程服務的策略上,咱們也會有必定的策略選擇,其中最著名的策略有三個:一個是動態負載均衡策略;一個是讀寫分離策略;一個是一致性哈希策略。動態負載均衡策略,通常會蒐集多個進程的服務狀態,而後挑選一個負載最輕的進程來分發服務,這種策略對於比較同質化的進程是比較合適的。讀寫分離策略則是關注對持久化數據的性能,好比對數據庫的操做,咱們會提供一批進程專門用於提供讀數據的服務,而另一個(或多個)進程用於寫數據的服務,這些寫數據的進程都會每次寫多份拷貝到「讀服務進程」的數據區(可能就是單獨的數據庫),這樣在對外提供服務的時候,就能夠提供更多的硬件資源。一致性哈希策略是針對任何一個任務,看看這個任務所涉及讀寫的數據,是屬於哪一片的,是否有某種能夠緩存的特徵,而後按這個數據的ID或者特徵值,進行「一致性哈希」的計算,分擔給對應的處理進程。這種進程調用策略,能很是的利用上進程內的緩存(若是存在),好比咱們的一個在線遊戲,由100個進程承擔服務,那麼咱們就能夠把遊戲玩家的ID,做爲一致性哈希的數據ID,做爲進程調用的KEY,若是目標服務進程有緩存遊戲玩家的數據,那麼全部這個玩家的操做請求,都會被轉到這個目標服務進程上,緩存的命中率大大提升。而使用「一致性哈希」,而不是其餘哈希算法,或者取模算法,主要是考慮到,若是服務進程有一部分因故障消失,剩下的服務進程的緩存依然能夠有效,而不會整個集羣全部進程的緩存都失效。具體有興趣的讀者能夠搜索「一致性哈希」一探究竟。
以多進程利用大量的服務器,以及服務器上的多個CPU核心,是一個很是有效的手段。可是使用多進程帶來的額外的編程複雜度的問題。通常來講咱們認爲最好是每一個CPU核心一個進程,這樣能最好的利用硬件。若是同時運行的進程過多,操做系統會消耗不少CPU時間在不一樣進程的切換過程上。可是,咱們早期所得到的不少API都是阻塞的,好比文件I/O,網絡讀寫,數據庫操做等。若是咱們只用有限的進程來執行帶這些阻塞操做的程序,那麼CPU會大量被浪費,由於阻塞的API會讓有限的這些進程停着等待結果。那麼,若是咱們但願能處理更多的任務,就必需要啓動更多的進程,以便充分利用那些阻塞的時間,可是因爲進程是操做系統提供的「盒子」,這個盒子比較大,切換耗費的時間也比較多,因此大量並行的進程反而會無謂的消耗服務器資源。加上進程之間的內存通常是隔離的,進程間若是要交換一些數據,每每須要使用一些操做系統提供的工具,好比網絡socket,這些都會額外消耗服務器性能。所以,咱們須要一種切換代價更少,通訊方式更便捷,編程方法更簡單的並行技術,這個時候,多線程技術出現了。
在進程盒子裏面的線程盒子
多線程的特色是切換代價少,能夠同時訪問內存。咱們能夠在編程的時候,任意讓某個函數放入新的線程去執行,這個函數的參數能夠是任何的變量或指針。若是咱們但願和這些運行時的線程通訊,只要讀、寫這些指針指向的變量便可。在須要大量阻塞操做的時候,咱們能夠啓動大量的線程,這樣就能較好的利用CPU的空閒時間;線程的切換代價比進程低得多,因此咱們能利用的CPU也會多不少。線程是一個比進程更小的「程序盒子」,他能夠放入某一個函數調用,而不是一個完整的程序。通常來講,若是多個線程只是在一個進程裏面運行,那實際上是沒有利用到多核CPU的並行好處的,僅僅是利用了單個空閒的CPU核心。可是,在JAVA和C#這類帶虛擬機的語言中,多線程的實現底層,會根據具體的操做系統的任務調度單位(好比進程),儘可能讓線程也成爲操做系統能夠調度的單位,從而利用上多個CPU核心。好比Linux2.6以後,提供了NPTL的內核線程模型,JVM就提供了JAVA線程到NPTL內核線程的映射,從而利用上多核CPU。而Windows系統中,聽說自己線程就是系統的最小調度單位,因此多線程也是利用上多核CPU的。因此咱們在使用JAVAC#編程的時候,多線程每每已經同時具有了多進程利用多核CPU、以及切換開銷低的兩個好處。
早期的一些網絡聊天室服務,結合了多線程和多進程使用的例子。一開始程序會啓動多個廣播聊天的進程,每一個進程都表明一個房間;每一個用戶鏈接到聊天室,就爲他啓動一個線程,這個線程會阻塞的讀取用戶的輸入流。這種模型在使用阻塞API的環境下,很是簡單,但也很是有效。
當咱們在普遍使用多線程的時候,咱們發現,儘管多線程有不少優勢,可是依然會有明顯的兩個缺點:一個內存佔用比較大且不太可控;第二個是多個線程對於用一個數據使用時,須要考慮複雜的「鎖」問題。因爲多線程是基於對一個函數調用的並行運行,這個函數裏面可能會調用不少個子函數,每調用一層子函數,就會要在棧上佔用新的內存,大量線程同時在運行的時候,就會同時存在大量的棧,這些棧加在一塊兒,可能會造成很大的內存佔用。而且,咱們編寫服務器端程序,每每但願資源佔用儘可能可控,而不是動態變化太大,由於你不知道何時會由於內存用完而當機,在多線程的程序中,因爲程序運行的內容致使棧的伸縮幅度可能很大,有可能超出咱們預期的內存佔用,致使服務的故障。而對於內存的「鎖」問題,一直是多線程中複雜的課題,不少多線程工具庫,都推出了大量的「無鎖」容器,或者「線程安全」的容器,而且還大量設計了不少協調線程運做的類庫。可是這些複雜的工具,無疑都是證實了多線程對於內存使用上的問題。
同時排多條隊就是並行
因爲多線程仍是有必定的缺點,因此不少程序員想到了一個釜底抽薪的方法:使用多線程每每是由於阻塞式API的存在,好比一個read()操做會一直中止當前線程,那麼咱們能不能讓這些操做變成不阻塞呢?——selector/epoll就是Linux退出的非阻塞式API。若是咱們使用了非阻塞的操做函數,那麼咱們也無需用多線程來併發的等待阻塞結果。咱們只須要用一個線程,循環的檢查操做的狀態,若是有結果就處理,無結果就繼續循環。這種程序的結果每每會有一個大的死循環,稱爲主循環。在主循環體內,程序員能夠安排每一個操做事件、每一個邏輯狀態的處理邏輯。這樣CPU既無需在多線程間切換,也無需處理複雜的並行數據鎖的問題——由於只有一個線程在運行。這種就是被稱爲「併發」的方案。
服務員兼了點菜、上菜就是併發
實際上計算機底層早就有使用併發的策略,咱們知道計算機對於外部設備(好比磁盤、網卡、顯卡、聲卡、鍵盤、鼠標),都使用了一種叫「中斷」的技術,早期的電腦使用者可能還被要求配置IRQ號。這個中斷技術的特色,就是CPU不會阻塞的一直停在等待外部設備數據的狀態,而是外部數據準備好後,給CPU發一個「中斷信號」,讓CPU轉去處理這些數據。非阻塞的編程實際上也是相似這種行爲,CPU不會一直阻塞的等待某些I/O的API調用,而是先處理其餘邏輯,而後每次主循環去主動檢查一下這些I/O操做的狀態。
多線程和異步的例子,最著名就是Web服務器領域的Apache和Nginx的模型。Apache是多進程/多線程模型的,它會在啓動的時候啓動一批進程,做爲進程池,當用戶請求到來的時候,從進程池中分配處理進程給具體的用戶請求,這樣能夠節省多進程/線程的建立和銷燬開銷,可是若是同時有大量的請求過來,仍是須要消耗比較高的進程/線程切換。而Nginx則是採用epoll技術,這種非阻塞的作法,可讓一個進程同時處理大量的併發請求,而無需反覆切換。對於大量的用戶訪問場景下,apache會存在大量的進程,而nginx則能夠僅用有限的進程(好比按CPU核心數來啓動),這樣就會比apache節省了很多「進程切換」的消耗,因此其併發性能會更好。
Nginx的固定多進程,一個進程異步處理多個客戶端
Apache的多態多進程,一個進程處理一個客戶
在現代服務器端軟件中,nginx這種模型的運維管理會更簡單,性能消耗也會稍微更小一點,因此成爲最流行的進程架構。可是這種好處,會付出一些另外的代價:非阻塞代碼在編程的複雜度變大。
之前咱們的代碼,從上往下執行,每一行都會佔用必定的CPU時間,這些代碼的直接順序,也是和編寫的順序基本一致,任何一行代碼,都是惟一時刻的執行任務。當咱們在編寫分佈式程序的時候,咱們的代碼將再也不好像那些單進程、單線程的程序同樣簡單。咱們要把同時運行的不一樣代碼,在同一段代碼中編寫。就好像咱們要把整個交響樂團的每一個樂器的曲譜,所有寫到一張紙上。爲了解決這種編程的複雜度,業界發展出了多種編碼形式。
在多進程的編碼模型上,fork()函數能夠說一個很是典型的表明。在一段代碼中,fork()調用以後的部分,可能會被新的進程中執行。要區分當前代碼的所在進程,要靠fork()的返回值變量。這種作法,等於把多個進程的代碼都合併到一塊,而後經過某些變量做爲標誌來劃分。這樣的寫法,對於不一樣進程代碼大部份相同的「同質進程」來講,仍是比較方便的,最怕就是有大量的不一樣邏輯要用不一樣的進程來處理,這種狀況下,咱們就只能本身經過規範fork()附近的代碼,來控制混亂的局面。比較典型的是把fork()附近的代碼弄成一個相似分發器(dispatcher)的形式,把不一樣功能的代碼放到不一樣的函數中,以fork以前的標記變量來決定如何調用。
動態多進程的代碼模式
在咱們使用多線程的API時,狀況就會好不少,咱們能夠用一個函數指針,或者一個帶回調方法的對象,做爲線程執行的主體,而且以句柄或者對象的形式來控制這些線程。做爲開發人員,咱們只要掌握了對線程的啓動、中止等有限的幾個API,就能很好的對並行的多線程進行控制。這對比多進程的fork()來講,從代碼上看會更直觀,只是咱們必需要分清楚調用一個函數,和新建一個線程去調用一個函數,之間的差異:新建線程去調用函數,這個操做會很快的結束,並不會依序去執行那個函數,而是表明着,那個函數中的代碼,可能和線程調用以後的代碼,交替的執行。
因爲多線程把「並行的任務」做爲一個明確的編程概念定義了出來,以句柄、對象的形式封裝好,那麼咱們天然會但願對多線程能更多複雜而細緻的控制。所以出現了不少多線程相關的工具。比較典型的編程工具備線程池、線程安全容器、鎖這三類。線程池提供給咱們以「池」的形態,自動管理線程的能力:咱們不須要本身去考慮怎麼創建線程、回收線程,而是給線程池一個策略,而後輸入須要執行的任務函數,線程池就會自動操做,好比它會維持一個同時運行線程數量,或者保持必定的空閒線程以節省建立、銷燬線程的消耗。在多線程操做中,不像多進程在內存上徹底是區分開的,因此能夠訪問同一分內存,也就是對堆裏面的同一個變量進行讀寫,這就可能產生程序員所預計不到的狀況(由於咱們寫程序只考慮代碼是順序執行的)。還有一些對象容器,好比哈希表和隊列,若是被多個線程同時操做,可能還會由於內部數據對不上,形成嚴重的錯誤,因此不少人開發了一些能夠被多個線程同時操做的容器,以及所謂「原子」操做的工具,以解決這樣的問題。有些語言如Java,在語法層面,就提供了關鍵字來對某個變量進行「上鎖」,以保障只有一個線程能操做它。多線程的編程中,不少並行任務,是有必定的阻塞順序的,因此有各類各樣的鎖被髮明出來,好比倒數鎖、排隊鎖等等。java.concurrent庫就是多線程工具的一個大集合,很是值得學習。然而,多線程的這些五花八門的武器,其實也是證實了多線程自己,是一種不太容易使用的順手的技術,可是咱們一會兒尚未更好的替代方案罷了。
多線程的對象模型
在多線程的代碼下,除了啓動線程的地方,是和正常的執行順序不一樣之外,其餘的基本都仍是比較近似單線程代碼的。可是若是在異步併發的代碼下,你會發現,代碼必定要裝入一個個「回調函數」裏。這些回調函數,從代碼的組織形態上,幾乎徹底沒法看出來其預期的執行順序,通常只能在運行的時候經過斷點或者日誌來分析。這就對代碼閱讀帶來了極大的障礙。所以如今有愈來愈多的程序員關注「協程」這種技術:能夠用相似同步的方法來寫異步程序,而無需把代碼塞到不一樣的回調函數裏面。協程技術最大的特色,就是加入了一個叫yield的概念,這個關鍵字所在的代碼行,是一個相似return的做用,可是又表明着後續某個時刻,程序會從yield的地方繼續往下執行。這樣就把那些須要回調的代碼,從函數中得以解放出來,放到yield的後面了。在不少客戶端遊戲引擎中,咱們寫的代碼都是由一個框架,以每秒30幀的速度在反覆執行,爲了讓一些任務,能夠分別放在各幀中運行,而不是一直阻塞致使「卡幀」,使用協程就是最天然和方便的了——Unity3D就自帶了協程的支持。
在多線程同步程序中,咱們的函數調用棧就表明了一系列同屬一個線程的處理。可是在單線程的異步回調的編程模式下,咱們的一個回調函數是沒法簡單的知道,是在處理哪個請求的序列中。因此咱們每每須要本身寫代碼去維持這樣的狀態,最多見的作法是,每一個併發任務啓動的時候,就產生一個序列號(seqid),而後在全部的對這個併發任務處理的回調函數中,都傳入這個seqid參數,這樣每一個回調函數,均可以經過這個參數,知道本身在處理哪一個任務。若是有些不一樣的回調函數,但願交換數據,好比A函數的處理結果但願B函數能獲得,還能夠用seqid做爲key把結果存放到一個公共的哈希表容器中,這樣B函數根據傳入的seqid就能去哈希表中得到A函數存入的結果了,這樣的一份數據咱們每每叫作「會話」。若是咱們使用協程,那麼這些會話可能都不須要本身來維持了,由於協程中的棧表明了會話容器,當執行序列切換到某個協程中的時候,棧上的局部變量正是以前的處理過程的內容結果。
協程的代碼特徵
爲了解決異步編程的回調這種複雜的操做,業界還發明瞭不少其餘的手段,好比lamda表達式、閉包、promise模型等等,這些都是但願咱們,能從代碼的表面組織上,把在多個不一樣時間段上運行的代碼,以業務邏輯的形式組織到一塊兒。
最後我想說說函數式編程,在多線程的模型下,並行代碼帶來最大的複雜性,就是對堆內存的同時操做。因此咱們才弄出來鎖的機制,以及一大批對付死鎖的策略。而函數式編程,因爲根本不使用堆內存,因此就無需處理什麼鎖,反而讓整個事情變得很是簡單。惟一須要改變的,就是咱們習慣於把狀態放到堆裏面的編程思路。函數式編程的語言,好比LISP或者Erlang,其核心數據結果是鏈表——一種能夠表示任何數據結構的結構。咱們能夠把全部的狀態,都放到鏈表這個數據列車中,而後讓一個個函數去處理這串數據,這樣一樣也能夠傳遞程序的狀態。這是一種用棧來代替堆的編程思路,在多線程併發的環境下,很是的有價值。
分佈式程序的編寫,一直都伴隨着大量的複雜性,影響咱們對代碼的閱讀和維護,因此咱們纔有各類各樣的技術和概念,試圖簡化這種複雜性。也許咱們沒法找到任何一個通用的解決方案,可是咱們能夠經過理解各類方案的目標,來選擇最適合咱們的場景:
l 動態多進程fork——同質的並行任務
l 多線程——能明確劃的邏輯複雜的並行任務
l 異步併發回調——對性能要求高,但中間會被阻塞的處理較少的並行任務
l 協程——以同步的寫法編寫併發的任務,可是不合適發起複雜的動態並行操做。
l 函數式編程——以數據流爲模型的並行處理任務
分佈式的編程中,對於CPU時間片的切分自己不是難點,最困難的地方在於並行的多個代碼片斷,如何進行通訊。由於任何一個代碼段,都不可能徹底單獨的運做,都須要和其餘代碼產生必定的依賴。在動態多進程中,咱們每每只能經過父進程的內存提供共享的初始數據,運行中則只能經過操做系統間的通信方式了:Socket、信號、共享內存、管道等等。不管那種作法,這些都帶來了一堆複雜的編碼。這些方式大部分都相似於文件操做:一個進程寫入、另一個進程讀出。因此不少人設計了一種叫「消息隊列」的模型,提供「放入」消息和「取出」消息的接口,底層則是能夠用Socket、共享內存、甚至是文件來實現。這種作法幾乎可以處理任何情況下的數據通信,並且有些還能保存消息。可是缺點是每一個通訊消息,都必須通過編碼、解碼、收包、發包這些過程,對處理延遲有必定的消耗。
若是咱們在多線程中進行通訊,那麼咱們能夠直接對某個堆裏面的變量直接進行讀寫,這樣的性能是最高的,使用也很是方便。可是缺點是可能出現幾個線程同時使用變量,產生了不可預期的結果,爲了對付這個問題,咱們設計了對變量的「鎖」機制,而如何使用鎖又成爲另一個問題,由於可能出現所謂的「死鎖」問題。因此咱們通常會用一些「線程安全」的容器,用來做爲多線程間通信的方案。爲了協調多個線程之間的執行順序,還可使用不少種類型的「工具鎖」。
在單線程異步併發的狀況下,多個會話間的通訊,也是能夠經過直接對變量進行讀寫操做,並且不會出現「鎖」的問題,由於本質上每一個時刻都只有一個段代碼會操做這個變量。然而,咱們仍是須要對這些變量進行必定規劃和整理,不然各類指針或全局變量在代碼中散佈,也是很出現BUG的。因此咱們通常會把「會話」的概念變成一個數據容器,每段代碼均可以把這個會話容器做爲一個「收件箱」,其餘的併發任務若是須要在這個任務中通信,就把數據放入這個「收件箱」便可。在WEB開發領域,和cookie對應的服務器端Session機制,就是這種概念的典型實現。
在分佈式程序架構中,若是咱們須要整個體系有更高的穩定性,可以對進程容災或者動態擴容提供支持,那麼最難解決的問題,就是每一個進程中的內存狀態。由於進程一旦毀滅,內存中的狀態會消失,這就很難不影響提供的服務。因此咱們須要一種方法,讓進程的內存狀態,不太影響總體服務,甚至最好能變成「無狀態」的服務。固然「狀態」若是不寫入磁盤,始終仍是須要某些進程來承載的。在如今流行的WEB開發模式中,不少人會使用PHP+Memcached+MySQL這種模型,在這裏,PHP就是無狀態的,由於狀態都是放在Memcached裏面。這種作法對於PHP來講,是能夠隨時動態的毀滅或者新建,可是Memcached進程就要保證穩定才行;並且Memcached做爲一個額外的進程,和它通訊自己也會消耗更多的延遲時間。所以咱們須要一種更靈活和通用的進程狀態保存方案,咱們把這種任務叫作「分佈式緩存」的策略。咱們但願進程在讀取數據的時候,能有最高的性能,最好能和在堆內存中讀寫相似,又但願這些緩存數據,能被放在多個進程內,以分佈式的形態提供高吞吐的服務,其中最關鍵的問題,就是緩存數據的同步。
PHP經常使用Memached作緩存
爲了解決這個問題,咱們須要先一步步來分解這個問題:
首先,咱們的緩存應該是某種特定形式的對象,而不該該是任意類型的變量。由於咱們須要對這些緩存進行標準化的管理,儘管C++語言提供了運算重載,咱們能夠對「=」號的寫變量操做進行從新定義,可是如今基本已經沒有人推薦去作這樣的事。而咱們手頭就有最多見的一種模型,適合緩存這種概念的使用,它就是——哈希表。全部的哈希表(或者是Map接口),都是把數據的存放,分爲key和value兩個部分,咱們能夠把想要緩存的數據,做爲value存放到「表」當中,同時咱們也能夠用key把對應的數據取出來,而「表」對象就表明了緩存。
其次咱們須要讓這個「表」能在多個進程中都存在。若是每一個進程中的數據都毫無關聯,那問題其實就很是簡單,可是若是咱們可能從A進程把數據寫入緩存,而後在B進程把數據讀取出來,那麼就比較複雜了。咱們的「表」要有能把數據在A、B兩個進程間同步的能力。所以咱們通常會用三種策略:租約清理、租約轉發、修改廣播
l 租約清理,通常是指,咱們把存放某個key的緩存的進程,稱爲持有這個key的數據的「租約」,這個租約要登記到一個全部進程都能訪問到的地方,好比是ZooKeeper集羣進程。那麼在讀、寫發生的時候,若是本進程沒有對應的緩存,就先去查詢一下對應的租約,若是被其餘進程持有,則通知對方「清理」,所謂「清理」,每每是指刪除用來讀的數據,回寫用來寫的數據到數據庫等持久化設備,等清理完成後,在進行正常的讀寫操做,這些操做可能會從新在新的進程上創建緩存。這種策略在緩存命中率比較高的狀況下,性能是最好的,由於通常無需查詢租約狀況,就能夠直接操做;但若是緩存命中率低,那麼就會出現緩存反覆在不一樣進程間「移動」,會嚴重下降系統的處理性能。
l 租約轉發。一樣,咱們把存放某個KEY的緩存的進程,稱爲持有這個KEY數據的「租約」,同時也要登記到集羣的共享數據進程中。和上面租約清理不一樣的地方在於,若是發現持有租約的進程不是本次操做的進程,就會把整個數據的讀、寫請求,都經過網絡「轉發」個持有租約的進程,而後等待他的操做結果返回。這種作法因爲每次操做都須要查詢租約,因此性能會稍微低一些;但若是緩存命中率不高,這種作法能把緩存的操做分擔到多個進程上,並且也無需清理緩存,這比租約清理的策略適應性更好。
l 修改廣播。上面兩種策略,都須要維護一份緩存數據的租約,可是自己對於租約的操做,就是一種比較耗費性能的事情。因此有時候能夠採用一些更簡單,但可能承受一些不一致性的策略:對於讀操做,每一個節點的讀都創建緩存,每次讀都判斷是否超過預設的讀冷卻時間x,超過則清理緩存從持久化重建;對於寫操做,麼個節點上都判斷是否超過預設的寫冷卻時間y,超過則展開清理操做。清理操做也分兩種,若是數據量小就廣播修改數據;若是數據量大就廣播清理通知回寫到持久化中。這樣雖然可能會有必定的不一致風險,可是若是數據不是那種要求過高的,並且緩存命中率又能比較有保障的話(好比根據KEY來進行一致性哈希訪問緩存進程),那麼真正由於寫操做廣播不及時,致使數據不一致的狀況仍是會比較少的。這種策略實現起來很是簡單,無需一箇中心節點進程維護數據租約,也無需複雜的判斷邏輯進行同步,只要有廣播的能力,加上對於寫操做的一些配置,就能實現高效的緩存服務。因此「修改廣播」策略是在大多數須要實時同步,但數據一致性要求不高的領域最多見的手段。著名的DNS系統的緩存就是接近這種策略:咱們要修改某個域名對應的IP,並非馬上在全球全部的DNS服務器上生效,而是須要必定時間廣播修改給其餘服務區。而咱們每一個DSN服務器,都具有了大量的其餘域名的緩存數據。
在高性能的服務器架構中,經常使用的緩存和分佈兩種策略,每每是結合到一塊兒使用的。雖然這兩種策略,都有無數種不一樣的表現形式,成爲各類各樣的技術流派,可是隻有清楚的理解這些技術的原理,而且和實際的業務場景結合起來,才能真正的作出知足應用要求的高性能架構。
原文連接 本文爲雲棲社區原創內容,未經容許不得轉載。