一、上節回顧
前面內容,我們學習了 Linux 網絡的基礎原理以及性能觀測方法。簡單回顧一下,Linux網絡基於 TCP/IP 模型,構建了其網絡協議棧,把繁雜的網絡功能划分為應用層、傳輸層、網絡層、網絡接口層等四個不同的層次,既解決了網絡環境中設備異構的問題,也解耦了網絡協議的復雜性。
基於 TCP/IP 模型,我們還梳理了 Linux 網絡收發流程和相應的性能指標。在應用程序通過套接字接口發送或者接收網絡包時,這些網絡包都要經過協議棧的逐層處理。我們通常
用帶寬、吞吐、延遲、PPS 等來衡量網絡性能。
今天,我們主要來回顧下經典的 C10K 和 C1000K 問題,以更好理解 Linux 網絡的工作原理,並進一步分析,如何做到單機支持 C10M
注意,C10K 和 C1000K 的首字母 C 是 Client 的縮寫。C10K 就是單機同時處理 1 萬個請求(並發連接 1 萬)的問題,而 C1000K 也就是單機支持處理 100 萬個請求(並發連接100 萬)的問題。
二、C10K
C10K 問題最早由 Dan Kegel 在 1999 年提出。那時的服務器還只是 32 位系統,運行着Linux 2.2 版本(后來又升級到了 2.4 和 2.6,而 2.6 才支持 x86_64),只配置了很少的
內存(2GB)和千兆網卡。
1、怎么在這樣的系統中支持並發 1 萬的請求呢?
1、從資源上來說
從資源上來說,對 2GB 內存和千兆網卡的服務器來說,同時處理 10000 個請求,只要每個請求處理占用不到 200KB(2GB/10000)的內存和 100Kbit (1000Mbit/10000)的
網絡帶寬就可以。所以,物理資源是足夠的,接下來自然是軟件的問題,特別是網絡的I/O 模型問題。
2、 I/O 的模型來說
說到 I/O 的模型,我在文件系統的原理中,曾經介紹過文件 I/O,其實網絡 I/O 模型也類似。在 C10K 以前,Linux 中網絡處理都用同步阻塞的方式,也就是每個請求都分配一個
進程或者線程。請求數只有 100 個時,這種方式自然沒問題,但增加到 10000 個請求時,10000 個進程或線程的調度、上下文切換乃至它們占用的內存,都會成為瓶頸。
2、既然每個請求分配一個線程的方式不合適,那么,為了支持 10000 個並發請求,這里就有兩個問題需要我們解決。
第一,怎樣在一個線程內處理多個請求,也就是要在一個線程內響應多個網絡 I/O。以前的同步阻塞方式下,一個線程只能處理一個請求,到這里不再適用,是不是可以用非阻塞
I/O 或者異步 I/O 來處理多個網絡請求呢?
第二,怎么更節省資源地處理客戶請求,也就是要用更少的線程來服務這些請求。是不是可以繼續用原來的 100 個或者更少的線程,來服務現在的 10000 個請求呢?
當然,事實上,現在 C10K 的問題早就解決了,在繼續學習下面的內容前,你可以先自己思考一下這兩個問題。結合前面學過的內容,你是不是已經有了解決思路呢?
三、I/O 模型優化
1、兩種 I/O 事件通知的方式
異步、非阻塞 I/O 的解決思路,你應該聽說過,其實就是我們在網絡編程中經常用到的I/O 多路復用(I/O Multiplexing)。I/O 多路復用是什么意思呢?
別急,詳細了解前,我先來講兩種 I/O 事件通知的方式:水平觸發和邊緣觸發,它們常用在套接字接口的文件描述符中。
1、水平觸發:只要文件描述符可以非阻塞地執行 I/O ,就會觸發通知。也就是說,應用程序可以隨時檢查文件描述符的狀態,然后再根據狀態,進行 I/O 操作。
2、邊緣觸發:只有在文件描述符的狀態發生改變(也就是 I/O 請求達到)時,才發送一次通知。這時候,應用程序需要盡可能多地執行 I/O,直到無法繼續讀寫,才可以停止。
如果 I/O 沒執行完,或者因為某種原因沒來得及處理,那么這次通知也就丟失了
接下來,我們再回過頭來看 I/O 多路復用的方法。這里其實有很多實現方法,我帶你來逐個分析一下。
1、第一種,使用非阻塞 I/O 和水平觸發通知,比如使用 select 或者 poll。
根據剛才水平觸發的原理,select 和 poll 需要從文件描述符列表中,找出哪些可以執行I/O ,然后進行真正的網絡 I/O 讀寫。由於 I/O 是非阻塞的,一個線程中就可以同時監控
一批套接字的文件描述符,這樣就達到了單線程處理多請求的目的。
所以,這種方式的最大優點,是對應用程序比較友好,它的 API 非常簡單。
但是,應用軟件使用 select 和 poll 時,需要對這些文件描述符列表進行輪詢,這樣,請求數多的時候就會比較耗時。並且,select 和 poll 還有一些其他的限制。
select 使用固定長度的位相量,表示文件描述符的集合,因此會有最大描述符數量的限制。比如,在 32 位系統中,默認限制是 1024。並且,在 select 內部,檢查套接字狀態
是用輪詢的方法,再加上應用軟件使用時的輪詢,就變成了一個 O(n^2) 的關系。
而 poll 改進了 select 的表示方法,換成了一個沒有固定長度的數組,這樣就沒有了最大描述符數量的限制(當然還會受到系統文件描述符限制)。但應用程序在使用 poll 時,同
樣需要對文件描述符列表進行輪詢,這樣,處理耗時跟描述符數量就是 O(N) 的關系。
除此之外,應用程序每次調用 select 和 poll 時,還需要把文件描述符的集合,從用戶空間傳入內核空間,由內核修改后,再傳出到用戶空間中。這一來一回的內核空間與用戶空
間切換,也增加了處理成本。
有沒有什么更好的方式來處理呢?答案自然是肯定的。
2、第二種,使用非阻塞 I/O 和邊緣觸發通知,比如 epoll。
既然 select 和 poll 有那么多的問題,就需要繼續對其進行優化,而 epoll 就很好地解決了這些問題。
epoll 使用紅黑樹,在內核中管理文件描述符的集合,這樣,就不需要應用程序在每次操作時都傳入、傳出這個集合。
epoll 使用事件驅動的機制,只關注有 I/O 事件發生的文件描述符,不需要輪詢掃描整個集合。
不過要注意,epoll 是在 Linux 2.6 中才新增的功能(2.4 雖然也有,但功能不完善)。由於邊緣觸發只在文件描述符可讀或可寫事件發生時才通知,那么應用程序就需要盡可能多
地執行 I/O,並要處理更多的異常事件。
3、第三種,使用異步 I/O(Asynchronous I/O,簡稱為 AIO)。
在前面文件系統原理的內容中,我曾介紹過異步 I/O 與同步 I/O 的區別。異步 I/O 允許應用程序同時發起很多 I/O
操作,而不用等待這些操作完成。而在 I/O 完成后,系統會用事件通知(比如信號或者回調函數)的方式,告訴應用程序。這時,應用程序才會去查詢 I/O 操作的結果。
異步 I/O 也是到了 Linux 2.6 才支持的功能,並且在很長時間里都處於不完善的狀態,比如 glibc 提供的異步 I/O 庫,就一直被社區詬病。同時,由於異步 I/O 跟我們的直觀邏輯
不太一樣,想要使用的話,一定要小心設計,其使用難度比較高。
四、工作模型優化
了解了 I/O 模型后,請求處理的優化就比較直觀了。使用 I/O 多路復用后,就可以在一個進程或線程中處理多個請求,其中,又有下面兩種不同的工作模型。
1、第一種,主進程 + 多個 worker 子進程,這也是最常用的一種模型。
這種方法的一個通用工作模式就是:
主進程執行 bind() + listen() 后,創建多個子進程; 然后,在每個子進程中,都通過 accept() 或 epoll_wait() ,來處理相同的套接字。
比如,最常用的反向代理服務器 Nginx 就是這么工作的。它也是由主進程和多個 worker進程組成。主進程主要用來初始化套接字,並管理子進程的生命周期;而 worker 進程,
則負責實際的請求處理。我畫了一張圖來表示這個關系。
這里要注意,accept() 和 epoll_wait() 調用,還存在一個驚群的問題。換句話說,當網絡I/O 事件發生時,多個進程被同時喚醒,但實際上只有一個進程來響應這個事件,其他被
喚醒的進程都會重新休眠。
為了避免驚群問題, Nginx 在每個 worker 進程中,都增加一個了全局鎖(accept_mutex)。這些 worker 進程需要首先競爭到鎖,只有競爭到鎖的進程,才會加
入到 epoll 中,這樣就確保只有一個 worker 子進程被喚醒。
不過,根據前面 CPU 模塊的學習,你應該還記得,進程的管理、調度、上下文切換的成本非常高。那為什么使用多進程模式的 Nginx ,卻具有非常好的性能呢?
這里最主要的一個原因就是,這些 worker 進程,實際上並不需要經常創建和銷毀,而是在沒任務時休眠,有任務時喚醒。只有在 worker 由於某些異常退出時,主進程才需要創
建新的進程來代替它。
當然,你也可以用線程代替進程:主線程負責套接字初始化和子線程狀態的管理,而子線程則負責實際的請求處理。由於線程的調度和切換成本比較低,實際上你可以進一步把
epoll_wait() 都放到主線程中,保證每次事件都只喚醒主線程,而子線程只需要負責后續的請求處理。
2、第二種,監聽到相同端口的多進程模型。
在這種方式下,所有的進程都監聽相同的接口,並且開啟 SO_REUSEPORT 選項,由內核負責將請求負載均衡到這些監聽進程中去。這一過程如下圖所示。
由於內核確保了只有一個進程被喚醒,就不會出現驚群問題了。比如,Nginx 在 1.9.1 中就已經支持了這種模式。
不過要注意,想要使用 SO_REUSEPORT 選項,需要用 Linux 3.9 以上的版本才可以。
五、C1000K
基於 I/O 多路復用和請求處理的優化,C10K 問題很容易就可以解決。不過,隨着摩爾定律帶來的服務器性能提升,以及互聯網的普及,你並不難想到,新興服務會對性能提出更高的要求。
很快,原來的 C10K 已經不能滿足需求,所以又有了 C100K 和 C1000K,也就是並發從原來的 1 萬增加到 10 萬、乃至 100 萬。從 1 萬到 10 萬,其實還是基於 C10K 的這些理
論,epoll 配合線程池,再加上 CPU、內存和網絡接口的性能和容量提升。大部分情況下,C100K 很自然就可以達到。
那么,再進一步,C1000K 是不是也可以很容易就實現呢?這其實沒有那么簡單了。
首先從物理資源使用上來說,100 萬個請求需要大量的系統資源。比如,
假設每個請求需要 16KB 內存的話,那么總共就需要大約 15 GB 內存。而從帶寬上來說,假設只有 20% 活躍連接,即使每個連接只需要 1KB/s 的吞吐量,總
共也需要 1.6 Gb/s 的吞吐量。千兆網卡顯然滿足不了這么大的吞吐量,所以還需要配置萬兆網卡,或者基於多網卡 Bonding 承載更大的吞吐量。
其次,從軟件資源上來說,大量的連接也會占用大量的軟件資源,比如文件描述符的數量、連接狀態的跟蹤(CONNTRACK)、網絡協議棧的緩存大小(比如套接字讀寫緩存、
TCP 讀寫緩存)等等。
最后,大量請求帶來的中斷處理,也會帶來非常高的處理成本。這樣,就需要多隊列網卡、中斷負載均衡、CPU 綁定、RPS/RFS(軟中斷負載均衡到多個 CPU 核上),以及將
網絡包的處理卸載(Offload)到網絡設備(如 TSO/GSO、LRO/GRO、VXLANOFFLOAD)等各種硬件和軟件的優化。
C1000K 的解決方法,本質上還是構建在 epoll 的非阻塞 I/O 模型上。只不過,除了 I/O模型之外,還需要從應用程序到 Linux 內核、再到 CPU、內存和網絡等各個層次的深度優
化,特別是需要借助硬件,來卸載那些原來通過軟件處理的大量功能。
六、C10M
顯然,人們對於性能的要求是無止境的。再進一步,有沒有可能在單機中,同時處理1000 萬的請求呢?這也就是 C10M 問題。
實際上,在 C1000K 問題中,各種軟件、硬件的優化很可能都已經做到頭了。特別是當升級完硬件(比如足夠多的內存、帶寬足夠大的網卡、更多的網絡功能卸載等)后,你可能
會發現,無論你怎么優化應用程序和內核中的各種網絡參數,想實現 1000 萬請求的並發,都是極其困難的。
究其根本,還是 Linux 內核協議棧做了太多太繁重的工作。從網卡中斷帶來的硬中斷處理程序開始,到軟中斷中的各層網絡協議處理,最后再到應用程序,這個路徑實在是太長
了,就會導致網絡包的處理優化,到了一定程度后,就無法更進一步了。
要解決這個問題,最重要就是跳過內核協議棧的冗長路徑,把網絡包直接送到要處理的應用程序那里去。這里有兩種常見的機制,DPDK 和 XDP。
第一種機制,DPDK,是用戶態網絡的標准。它跳過內核協議棧,直接由用戶態進程通過輪詢的方式,來處理網絡接收。
(圖片來自 https://blog.selectel.com/introduction-dpdk-architecture-principles/)
說起輪詢,你肯定會下意識認為它是低效的象征,但是進一步反問下自己,它的低效主要體現在哪里呢?是查詢時間明顯多於實際工作時間的情況下吧!那么,換個角度來想,如
果每時每刻都有新的網絡包需要處理,輪詢的優勢就很明顯了。比如:
在 PPS 非常高的場景中,查詢時間比實際工作時間少了很多,絕大部分時間都在處理網絡包;
而跳過內核協議棧后,就省去了繁雜的硬中斷、軟中斷再到 Linux 網絡協議棧逐層處理的過程,應用程序可以針對應用的實際場景,有針對性地優化網絡包的處理邏輯,而不
需要關注所有的細節。
此外,DPDK 還通過大頁、CPU 綁定、內存對齊、流水線並發等多種機制,優化網絡包的處理效率。
第二種機制,XDP(eXpress Data Path),則是 Linux 內核提供的一種高性能網絡數據路徑。它允許網絡包,在進入內核協議棧之前,就進行處理,也可以帶來更高的性能。
XDP 底層跟我們之前用到的 bcc-tools 一樣,都是基於 Linux 內核的 eBPF 機制實現的。XDP 的原理如下圖所示:
(圖片來自 https://www.iovisor.org/technology/xdp)
你可以看到,XDP 對內核的要求比較高,需要的是 Linux 4.8 以上版本,並且它也不提供緩存隊列。基於 XDP 的應用程序通常是專用的網絡應用,常見的有 IDS(入侵檢測系
統)、DDoS 防御、 cilium 容器網絡插件等。
七、小結
今天我帶你回顧了經典的 C10K 問題,並進一步延伸到了 C1000K 和 C10M 問題。
C10K 問題的根源,一方面在於系統有限的資源;另一方面,也是更重要的因素,是同步阻塞的 I/O 模型以及輪詢的套接字接口,限制了網絡事件的處理效率。Linux 2.6 中引入
的 epoll ,完美解決了 C10K 的問題,現在的高性能網絡方案都基於 epoll。
從 C10K 到 C100K ,可能只需要增加系統的物理資源就可以滿足;但從 C100K 到C1000K ,就不僅僅是增加物理資源就能解決的問題了。這時,就需要多方面的優化工作
了,從硬件的中斷處理和網絡功能卸載、到網絡協議棧的文件描述符數量、連接狀態跟蹤、緩存隊列等內核的優化,再到應用程序的工作模型優化,都是考慮的重點。
再進一步,要實現 C10M ,就不只是增加物理資源,或者優化內核和應用程序可以解決的問題了。這時候,就需要用 XDP 的方式,在內核協議棧之前處理網絡包;或者用 DPDK
直接跳過網絡協議棧,在用戶空間通過輪詢的方式直接處理網絡包。
當然了,實際上,在大多數場景中,我們並不需要單機並發 1000 萬的請求。通過調整系統架構,把這些請求分發到多台服務器中來處理,通常是更簡單和更容易擴展的方案。