Linux高性能服務器設計

C10K和C10M

計算機領域的不少技術都是需求推進的,上世紀90年代,因爲互聯網的飛速發展,網絡服務器沒法支撐快速增加的用戶規模。1999年,Dan Kegel提出了著名的C10問題:一臺服務器上同時處理10000個客戶網絡鏈接。10000個網絡鏈接並不會發送請求到服務器,有些鏈接並不活躍,同一時刻,只有極少的部分鏈接發送請求。不一樣的服務類型,每一個鏈接發送請求的頻率也不相同,遊戲服務器的鏈接會頻繁的發送請求,而Web服務器的鏈接發送請求的頻率就低不少。不管如何,根據經驗法則,對於特定的服務類型,鏈接越多,同一時刻發送請求的鏈接也越多。算法

時至今日,C10K問題固然早已解決,不只如此,一臺機器能支撐的鏈接愈來愈多,後來提出了C10M問題,在一臺機器上支撐1000萬的鏈接,2015年,MigratoryData在單機承載12M的鏈接,解決了C10M問題。編程

本文先回顧C10問題的解決方案,再探討如何構建支撐C10M的應用程序,聊聊其中涉及的各類技術。緩存

C10K問題的解決

時間退回到1999年,當時要實現一個網絡服務器,大概有這樣幾種模式安全

簡單進程/線程模型服務器

這是一種很是簡單的模式,服務器啓動後監聽端口,阻塞在accept上,當新網絡鏈接創建後,accept返回新鏈接,服務器啓動一個新的進程/線程專門負責這個鏈接。從性能和伸縮性來講,這種模式是很是糟糕的,緣由在於網絡

進程/線程建立和銷燬的時間,操做系統建立一個進程/線程顯然須要時間,在一個繁忙的服務器上,若是每秒都有大量的鏈接創建和斷開,採用每一個進程/線程處理一個客戶鏈接的模式,每一個新鏈接都要建立建立一個進程/線程,當鏈接斷開時,銷燬對應的線程/進程。建立和銷燬進程/線程的操做消耗了大量的CPU資源。使用進程池和線程池能夠緩解這個問題。數據結構

內存佔用。主要包含兩方面,一個是內核數據結構所佔用的內存空間,另一個是Stack所佔用的內存。有些應用的調用棧很深,好比Java應用,常常能看到幾十上百層的調用棧。多線程

上下文切換的開銷。上下文切換時,操做系統的調度器中斷當前線程,選擇另一個可運行的線程在CPU上繼續運行。調度器須要保存當前線程的現場信息,而後選擇一個可運行的線程,再將新線程的狀態恢復到寄存器中。保存和恢復現場所須要的時間和CPU型號有關,選擇一個可運行的線程則徹底是軟件操做,Linux 2.6纔開始使用常量時間的調度算法。 以上是上下文切換的直接開銷。除此以外還有一些間接開銷,上下文切換致使相關的緩存失效,好比L1/L2 Cache,TLB等,這些也會影響程序的性能,可是間接開銷很難衡量。架構

有意思的是,這種模式雖然性能極差,但卻依然是咱們今天最多見到的模式,不少Web程序都是這樣的方式在運行。併發

select/poll

另一種方式是使用select/poll,在一個線程內處理多個客戶鏈接。select和poll可以監控多個socket文件描述符,當某個文件描述符就緒,select/soll從阻塞狀態返回,通知應用程序能夠處理用戶鏈接了。使用這種方式,咱們只須要一個線程就能夠處理大量的鏈接,避免了多進程/線程的開銷。之因此把select和poll放在一塊兒說,緣由在於二者很是類似,性能上基本沒有區別,惟一的區別在於poll突破了select 1024個文件描述符的限制,然而當文件描述符數量增長時,poll性能急劇降低,所以所謂突破1024個文件描述符實際上毫無心義。select/poll並不完美,依然存在不少問題:

  1. 每次調用select/poll,都要把文件描述符的集合從用戶地址空間複製到內核地址空間
  2. select/poll返回後,調用方必須遍歷全部的文件描述符,逐一判斷文件描述符是否可讀/可寫。

這兩個限制讓select/poll徹底失去了伸縮性。鏈接數越多,文件描述符就越多,文件描述符越多,每次調用select/poll所帶來的用戶空間到內核空間的複製開銷越大。最嚴重的是當報文達到,select/poll返回以後,必須遍歷全部的文件描述符。假設如今有1萬個鏈接,其中只一個鏈接發送了請求,可是select/poll就要把1萬個鏈接所有檢查一遍。

epoll

FreeBSD 4.1引入了kqueue,此時是2000年7月,而在Linux上,還要等待2年後的2002年纔開始引入kqueue的相似實現: epoll。epoll最初於 2.5.44進入Linux kernel mainline,此時已是2002年,距離C10K問題提出已通過了3年。

epoll是如何提供一個高性能可伸縮的IO多路複用機制呢?首先,epoll引入了epoll instance這個概念,epoll instance在內核中關聯了一組要監聽的文件描述符配置:interest list,這樣的好處在於,每次要增長一個要監聽的文件描述符,不須要把全部的文件描述符都配置一次,而後從用戶地址空間複製到內核地址空間,只須要把單個文件描述符複製到內核地址空間,複製開銷從O(n)降到了O(1)。

註冊完文件描述符後,調用epoll_wait開始等待文件描述符事件。epoll_wait能夠只返回已經ready的文件描述符,所以,在epoll_wait返回以後,程序只須要處理真正須要處理的文件描述符,而不用把全部的文件描述符所有遍歷一遍。假設在所有N個文件描述符中,只有一個文件描述符Ready,select/poll要執行N次循環,epoll只須要一次。

epoll出現以後,Linux上才真正有了一個可伸縮的IO多路複用機制。基於epoll,可以支撐的網絡鏈接數取決於硬件資源的配置,而再也不受限於內核的實現機制。CPU越強,內存越大,能支撐的鏈接數越多。

編程模型

Reactor和proactor

不一樣的操做系統上提供了不一樣的IO多路複用實現,Linux上有epoll,FreeBSD有kqueue,Windows有IOCP。對於須要跨平臺的程序,必然須要一個抽象層,提供一個統一的IO多路複用接口,屏蔽各個系統接口的差別性。

Reactor是實現這個目標的一次嘗試,最先出如今Douglas C. Schmidt的論文"The Reactor An Object-Oriented Wrapper for Event-Driven Port Monitoring and Service Demultiplexing"中。從論文的名字能夠看出,Reactor是poll這種編程模式的一個面向對象包裝。考慮到論文的時間,當時正是面向對象概念正火熱的時候,什麼東西都要蹭蹭面向對象的熱度。論文中,DC Schmidt描述了爲何要作這樣的一個Wrapper,給出了下面幾個緣由:

  1. 操做系統提供的接口太複雜,容易出錯。select和poll都是通用接口,由於通用,增長了學習和正確使用的複雜度。
  2. 接口抽象層次過低,涉及太多底層的細節。
  3. 不能跨平臺移植。
  4. 難以擴展。

實際上除了第三條跨平臺,其餘幾個理由實在難以站得住腳。select/poll這類接口複雜嗎,使用起來容易出錯嗎,寫出來的程序難以擴展嗎?不過不這麼說怎麼體現Reactor的價值呢。正如論文名稱所說的,Reactor本質是對操做系統IO多路複用機制的一個面向對象包裝,爲了證實Reactor的價值,DC Schmidt還用C++面向對象的特性實現了一個編程框架:ACE,實際上使用ACE比直接使用poll或者epoll複雜多了。

後來DC Schmidt寫了一本書《面向模式的軟件架構》,再次提到了Reactor,並從新命名爲Reactor Pattern,如今網絡上能找到的Reactor資料,基本上都是基於Reactor Pattern,而不是早期的面向Object-Orientend Wrapper。

《面向模式的軟件》架構中還提到了另一種叫作Proactor的模式,和Reactor很是相似,Reactor針對同步IO,Proactor則針對異步IO。

Callback,Future和纖程

Reactor看上去並不複雜,可是想編寫一個完整的應用程序時候就會發現其實沒那麼簡單。爲了不Reactor主邏輯阻塞,全部可能會致使阻塞的操做必須註冊到epoll上,帶來的問題就是處理邏輯的支離破碎,大量使用callback,產生的代碼複雜難懂。若是應用程序中還有非網絡IO的阻塞操做,問題更嚴重,好比在程序中讀寫文件。Linux中文件系統操做都是阻塞的,雖然也有Linux AIO,可是一直不夠成熟,難堪大用。不少軟件採用線程池來解決這個問題,不能經過epoll解決的阻塞操做,扔到一個線程池執行。這又產生了多線程內存開銷和上下文切換的問題。

Future機制是對Callback的簡單優化,本質上仍是Callback,可是提供了一致的接口,代碼相對來講簡單一些,不過在實際使用中仍是比較複雜的。Seastar是一個很是完全的future風格的框架,從它的代碼能夠看到這種編程風格真的很是複雜,阻塞式編程中一個函數幾行代碼就能搞定的事情,在Seastar裏須要上百行代碼,幾十個labmda (在Seastar裏叫作continuation)。

纖程是一種用戶態調度的線程,好比Go語言中的goroutine,有些人可能會把這種機制成爲coroutine,不過我認爲coroutine和纖程仍是有很大區別的,coroutine是泛化的子進程,具備多個進入和退出點,用來一些一些相互協做的程序,典型的例子就是Python中的generator。纖程則是一種運行和調度機制。

纖程真正作到了高性能和易用,在Go語言中,使用goroutine實現的高性能服務器是一件輕鬆愉快的事情,徹底不用考慮線程數、epoll、回調之類的複雜操做,和編寫阻塞式程序徹底同樣。

網絡優化

Kernel bypass

網絡子系統是Linux內核中一個很是龐大的組件,提供了各類通用的網絡能力。通用一般意味在在某些場景下並非最佳選擇。實際上業界的共識是Linux內核網絡不支持超大併發的網絡能力。根據我過去的經驗,Linux最大隻能處理1MPPS,而如今的10Gbps網卡一般能夠處理10MPPS。隨着更高性能的25Gbps,40Gbps網卡出現,Linux內核網絡能力愈加捉襟見肘。

爲何Linux不能充分發揮網卡的處理能力?緣由在於:

  • 大多數網卡收發使用中斷方式,每次中斷處理時間大約100us,另外要考慮cache miss帶來的開銷。部分網卡使用NAPI,輪詢+中斷結合的方式處理報文,當報文放進隊列以後,依然要觸發軟中斷。
  • 數據從內核地址空間複製到用戶地址空間。
  • 收發包都有系統調用。
  • 網卡到應用進程的鏈路太長,包含了不少沒必要要的操做。

Linux高性能網絡一個方向就是繞過內核的網絡棧(kernel bypass),業界有很多嘗試。

  • PF_RING 高效的數據包捕獲技術,比libpcap性能更好。須要本身安裝內核模塊,啓用ZC Driver,設置transparent_mode=2的狀況下,報文直接投遞到客戶端程序,繞過內核網絡棧。
  • Snabbswitch 一個Lua寫的網絡框架。徹底接管網卡,使用UIO(Userspace IO)技術在用戶態實現了網卡驅動。
  • Intel DPDK,直接在用戶態處理報文。很是成熟,性能強大,限制是只能用在Intel的網卡上。根據DPDK的數據,3GHz的CPU Core上,平均每一個報文的處理時間只要60ns(一次內存的訪問時間)。
  • Netmap 一個高性能收發原始數據包的框架,包含了內核模塊以及用戶態庫函數,須要網卡驅動程序配合,所以目前只支持特定的幾種網卡類型,用戶也能夠本身修改網卡驅動。
  • XDP,使用Linux eBPF機制,將報文處理邏輯下放到網卡驅動程序中。通常用於報文過濾、轉發的場景。

kernel bypass技術最大的問題在於不支持POSIX接口,用戶沒辦法不修改代碼直接移植到一種kernel bypass技術上。對於大多數程序來講,還要要運行在標準的內核網絡棧上,經過調整內核參數提高網絡性能。

網卡多隊列

報文到達網卡以後,在一個CPU上觸發中斷,CPU執行網卡驅動程序從網卡硬件緩衝區讀取報文內容,解析後放到CPU接收隊列上。這裏全部的操做都在一個特定的CPU上完成,高性能場景下,單個CPU處理不了全部的報文。對於支持多隊列的網卡,報文能夠分散到多個隊列上,每一個隊列對應一個CPU處理,解決了單個CPU處理瓶頸。

爲了充分發揮多隊列網卡的價值,咱們還得作一些額外的設置:把每一個隊列的中斷號綁定到特定CPU上。這樣作的目的,一方面確保網卡中斷的負載能分配到不一樣的CPU上,另一方面能夠將負責網卡中斷的CPU和負責應用程序的CPU區分開,避免相互干擾。

在Linux中,/sys/class/net/${interface}/device/msi_irqs下保存了每一個隊列的中斷號,有了中斷號以後,咱們就能夠設置中斷和CPU的對應關係了。網上有不少文章能夠參考。

網卡Offloading

回憶下TCP數據的發送過程:應用程序將數據寫到套接字緩衝區,內核將緩衝區數據切分紅不大於MSS的片斷,附加上TCP Header和IP Header,計算Checksum,而後將數據推到網卡發送隊列。這個過程當中須要CPU全程參與, 隨着網卡的速度愈來愈快,CPU逐漸成爲瓶頸,CPU處理數據的速度已經趕不上網卡發送數據的速度。經驗法則,發送或者接收1bit/s TCP數據,須要1Hz的CPU,1Gbps須要1GHz的CPU,10Gbps須要10GHz的CPU,已經遠超單核CPU的能力,即便能徹底使用多核,假設單個CPU Core是2.5GHz,依然須要4個CPU Core。

爲了優化性能,現代網卡都在硬件層面集成了TCP分段、添加IP Header、計算Checksum等功能,這些操做再也不須要CPU參與。這個功能叫作tcp segment offloading,簡稱tso。使用ethtool -k 能夠檢查網卡是否開啓了tso

除了tso,還有其餘幾種offloading,好比支持udp分片的ufo,不依賴驅動的gso,優化接收鏈路的lro

充分利用多核

隨着摩爾定律失效,CPU已經從追求高主頻轉向追求更多的核數,如今的服務器大都是96核甚至更高。構建一個支撐C10M的應用程序,必須充分利用全部的CPU,最重要的是程序要具有水平伸縮的能力:隨着CPU數量的增多程序可以支撐更多的鏈接。

不少人都有一個誤解,認爲程序裏使用了多線程就能利用多核,考慮下CPython程序,你能夠建立多個線程,可是因爲GIL的存在,程序最多隻能使用單個CPU。實際上多線程和並行自己就是不一樣的概念,多線程表示程序內部多個任務併發執行,每一個線程內的任務能夠徹底不同,線程數和CPU核數沒有直接關係,單核機器上能夠跑幾百個線程。並行則是爲了充分利用計算資源,將一個大的任務拆解成小規模的任務,分配到每一個CPU上運行。並行能夠 經過多線程實現,系統上有幾個CPU就啓動幾個線程,每一個線程完成一部分任務。

並行編程的難點在於如何正確處理共享資源。併發訪問共享資源,最簡單的方式就加鎖,然而使用鎖又帶來性能問題,獲取鎖和釋放鎖自己有性能開銷,鎖保護的臨界區代碼不能只能順序執行,就像CPython的GIL,沒能充分利用CPU。

Thread Local和Per-CPU變量

這兩種方式的思路是同樣的,都是建立變量的多個副本,使用變量時只訪問本地副本,所以不須要任何同步。現代編程語言基本上都支持Thread Local,使用起來也很簡單,C/C++裏也可使用__thread標記聲明ThreadLocal變量。

Per-CPU則依賴操做系統,當咱們提到Per-CPU的時候,一般是指Linux的Per-CPU機制。Linux內核代碼中大量使用Per-CPU變量,但應用代碼中並不常見,若是應用程序中工做線程數等於CPU數量,且每一個線程Pin到一個CPU上,此時纔可使用。

原子變量

若是共享資源是int之類的簡單類型,訪問模式也比較簡單,此時可使用原子變量。相比使用鎖,原子變量性能更好。在競爭不激烈的狀況下,原子變量的操做性能基本上和加鎖的性能一致,可是在併發比較激烈的時候,等待鎖的線程要進入等待隊列等待從新調度,這裏的掛起和從新調度過程須要上下文切換,浪費了更多的時間。

大部分編程語言都提供了基本變量對應的原子類型,通常提供set, get, compareAndSet等操做。

lock-free

lock-free這個概念來自

An algorithm is called non‐blocking if failure or suspension of any thread cannot cause failure or suspension of another thread; an algorithm is called lock‐free if, at each step, some thread can make progress.

non-blocking算法任何線程失敗或者掛起,不會致使其餘線程失敗或者掛起,lock-free則進一步保證線程間無依賴。這個表述比較抽象,具體來講,non-blocking要求不存在互斥,存在互斥的狀況下,線程必須先獲取鎖再進入臨界區,若是當前持有鎖的線程被掛起,等待鎖的線程必然須要一直等待下去。對於活鎖或者飢餓的場景,線程失敗或者掛起的時候,其餘線程徹底不只能正常運行,說不定還解決了活鎖和飢餓的問題,所以活鎖和飢餓符合non-blocking,可是不符合lock-free。

實現一個lock-free數據結構並不容易,好在已經有了幾種常見數據結構的的lock-free實現:buffer, list, stack, queue, map, deque,咱們直接拿來使用就好了。

優化對鎖的使用

有時候沒有條件使用lock-free,仍是得用鎖,對於這種狀況,仍是有一些優化手段的。首先使用盡可能減小臨界區的大小,使用細粒度的鎖,鎖粒度越細,並行執行的效果越好。其次選擇適合的鎖,好比考慮選擇讀寫鎖。

CPU affinity

使用CPU affinity機制合理規劃線程和CPU的綁定關係。前面提到使用CPU affinity機制,將多隊列網卡的中斷處理分散到多個CPU上。不只是中斷處理,線程也能夠綁定,綁定以後,線程只會運行在綁定的CPU上。爲何要將線程綁定到CPU上呢?綁定CPU有這樣幾個好處:

  • 爲線程保留CPU,確保線程有足夠的資源運行
  • 提升CPU cache的命中率,某些對cache敏感的線程必須綁定到CPU上才行。
  • 更精細的資源控制。能夠預先須要靜態劃分各個工做線程的資源,例如爲每一個請求處理線程分配一個CPU,其餘後臺線程共享一個CPU,工做線程和中斷處理程序工做在不一樣的CPU上。
  • NUMA架構中,每一個CPU有本身的內存控制器和內存插槽,CPU訪問本地內存別訪問遠程內存快3倍左右。使用affinity將線程綁定在CPU上,相關的數據也分配到CPU對應的本地內存上。

Linux上設置CPU affinity很簡單,可使用命令行工具taskset,也能夠在程序內直接調用API sched_getaffinitysched_setaffinity.

其餘優化技術

使用Hugepage

Linux中,程序內使用的內存地址是虛擬地址,並非內存的物理地址。爲了簡化虛擬地址到物理地址的映射,虛擬地址到物理地址的映射最小單位是「Page」,默認狀況下,每一個頁大小爲4KB。CPU指令中出現的虛擬地址,爲了讀取內存中的數據,指令執行前要把虛擬地址轉換成內存物理地址。Linux爲每一個進程維護了一張虛擬地址到物理地址的映射表,CPU先查表找到虛擬地址對應的物理地址,再執行指令。因爲映射表維護在內存中,CPU查表就要訪問內存。相對CPU的速度來講,內存實際上是至關慢的,通常來講,CPU L1 Cache的訪問速度在1ns左右,而一次內存訪問須要60-100ns,比CPU執行一條指令要慢得多。若是每一個指令都要訪問內存,好比嚴重拖慢CPU速度,爲了解決這個問題,CPU引入了TLB(translation lookaside buffer),一個高性能緩存,緩存映射表中一部分條目。轉換地址時,先從TLB查找,沒找到再讀內存。

顯然,最理想的狀況是映射表可以徹底緩存到TLB中,地址轉換徹底不須要訪問內存。爲了減小映射表大小,咱們可使用「HugePages」:大於4KB的內存頁。默認HugePages是2MB,最大能夠到1GB。

避免動態分配內存

內存分配是個複雜且耗時的操做,涉及空閒內存管理、分配策略的權衡(分配效率,碎片),尤爲是在併發環境中,還要保證內存分配的線程安全。若是內存分配成爲了應用瓶頸,能夠嘗試一些優化策略。好比內存複用i:不要重複分配內存,而是複用已經分配過的內存,在C++/Java裏則考慮複用已有對象,這個技巧在Java裏尤爲重要,不只能下降對象建立的開銷,還避免了大量建立對象致使的GC開銷。另一個技巧是預先分配內存,實際上至關於在應用內實現了一套簡單的內存管理,好比Memcached的Slab。

Zero Copy

對於一個Web服務器來講,響應一個靜態文件請求須要先將文件從磁盤讀取到內存中,再發送到客戶端。若是自信分析這個過程,會發現數據首先從磁盤讀取到內核的頁緩衝區,再從頁緩衝區複製到Web服務器緩衝區,接着從Web服務器緩衝區發送到TCP發送緩衝區,最後經網卡發送出去。這個過程當中,數據先從內核複製到進程內,再從進程內回到內核,這兩次複製徹底是多餘的。Zero Copy就是相似狀況的優化方案,數據直接在內核中完成處理,不須要額外的複製。

Linux中提供了幾種ZeroCopy相關的技術,包括sendfile,splice,copy_file_range,Web服務器中常用sendfile優化性能。

最後

千萬牢記:不要過早優化。

優化以前,先考慮兩個問題:

  1. 如今的性能是否已經知足需求了
  2. 若是真的要優化,是否是已經定位了瓶頸

在回答清楚這兩個問題以前,不要盲目動手。


本文做者:太公

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索