程式設計師圈子的快速發展使得 Web 應用開發人員大多數情況下面對的是一個 Web Framework 如 Python 的 Django、tornado, PHP 的 Laravel 等,但是在這些 framework 之前的 Server 如 Nginx,Apache 的原理卻顯有了解。本文就網路 Server 模型的原理與演進展開描述,這裡的“網路 Server 模型”指的是具有高阻塞、低佔用特點的一類應用,不僅僅 HTTP 服務,其他的如 ftp 服務,SQL 資料庫連結服務等也都在此列。網路 Server 的發展先後經歷了 Process(程式模型),Thread(執行緒模型),Prefork(程式池),ThreadPool,Event Driven(事件模型)等,本文一一介紹
沒有模型
網路程式設計剛剛興起的時候還沒有人考慮併發這個問題,當做傳統的應用來編寫的 Server 是阻塞並且沒有使用任何模型的。只是簡單的監聽某個埠 accept,接收資料 recv,然後返回 send。邏輯非常簡單,易於實現。但是缺點顯而易見,阻塞佔據了大多數 CPU 時間,併發數只有 1,也就是說某個埠上的應用在服務於某個使用者的時候其他使用者都要等待。
程式、執行緒模型
上面“沒有模型”的設計顯然是低效率的,結合作業系統多程式的概念提出了一個主程式監聽埠,對於每個連線都使用一個獨立的 worker 子程式去處理,連線的讀寫資料操作全部阻塞在這個子程式中,這樣對 server 的併發能力有了很大的提升。但是程式在作業系統中是個相對比較重的概念,程式的建立、銷燬、切換都是非常大的開銷,同時隨著執行緒的興起,在 server 端使用多個子執行緒的 worker 處理不同連線的方式進一步提升了單機併發效能。
面對程式的建立、銷燬、切換成本開銷非常大的問題,除了使用執行緒替代程式處理不同連線外,有人想出了一個很秒的方法,那就是預先建立數個程式在記憶體中,不同連線到來時將請求分發到記憶體中不同的程式裡去處理,也就是預先開闢程式池,這樣避免了頻繁重複建立銷燬程式的問題,從而大大提升了 server 效能。與程式池相對應的便是執行緒池的概念,執行緒池的使用也大大提高的執行緒模型的效率。
當然程式模型與執行緒模型有各自的優缺點,並不存在一方佔據絕對優勢的情況。比如在穩定性方面如果一個程式掛了對另外的程式沒有影響,而執行緒模型中一個執行緒掛了那麼所有程式都掛了;但是執行緒間共享記憶體空間所以執行緒間資料共享要比程式之間更容易。在這個基礎上我們再考慮 memcached 使用的單程式多執行緒模型就更好理解了,memcached 程式啟動後,所有連線過來寫的資料全部儲存在一個程式空間中,不同的執行緒可以無障礙訪問,即滿足高併發的效能要求又不至於去編寫不同程式之間 IPC 的複雜邏輯。同時 redis 在平時工作時也是單程式多執行緒模型,但在涉及到諸如持久化的耗時操作時使用多程式的方式來組織。
程式、執行緒的優缺點對比:
多程式 | 多執行緒 | 總結 | |
---|---|---|---|
CPU,記憶體消耗 | 記憶體佔用多,CPU 利用率低 | 記憶體佔用少,CPU 利用率高 | 執行緒佔優 |
建立、銷燬、切換 | 比較複雜 | 比較簡單 | 執行緒佔優 |
資料同步與共享 | 資料間相互隔離,同步簡單,共享複雜需要 IPC | 資料存在於同一個記憶體空間,共享簡單,但是同步涉及到加鎖等問題 | 各有優勢 |
除錯複雜度 | 簡單 | 複雜 | 程式佔優 |
可靠性 | 程式間不相互影響 | 一個執行緒會導致該程式下所有執行緒掛掉 | 程式佔優 |
擴充套件性 | 多核心、多裝置擴充套件 | 是適用於多核心擴充套件 | 程式佔優 |
事件模型
上面提到的程式、執行緒或者程式池、執行緒池模型都是阻塞模式的。那麼在阻塞的時候連線仍然以程式或者執行緒的形式佔據耗費著系統資源。而在事件驅動模型中只有在阻塞事件就緒時才會分配相應的系統資源,自然大大提高了系統併發處理效能。
得益於作業系統的快速發展,從作業系統層面提供了 select,poll,epoll(Linux),kqueue(BSD)等基於事件的 IO 多路複用機制。一旦某個檔案描述符轉變為可讀或者可寫的狀態,就通知相應的程式進行操作。但他們本質上都是同步 IO,因為在收到讀或者寫事件後程式需要自己負責進行讀或者寫操作,也就是說這個讀寫過程還是阻塞的。而非同步 IO 則無需程式自己負責進行讀寫操作而是作業系統核心直接把資料儲存到使用者空間提供使用。我們先來看看同步 IO 的 select, poll, 和 epoll。
上圖是 select() 呼叫過程,select 有 3 大缺點:
- 每次呼叫 select 需要把程式空間中所有 fd 從使用者態拷貝到核心態,這在 fd 數量很大的時候開銷很大
- 同時每次呼叫 select 需要在核心態遍歷所有的 fd 並掛進阻塞佇列,如果 fd 數量很大的情況開銷很大
- select 支援的檔案描述符數量少,預設是1024,這限制了併發連線的上限
poll 方式相對於 select 方式只是檔案描述符的結構由 fd_set 變成了 pollfd,並沒有從本質上解決 select 的問題。
epoll 是對 select、poll 方式的改進,那麼 epoll 是怎樣解決上面 3 個問題的呢。首先從暴露出的 API 上來看,select 和 poll 只有一個同名函式,而 epoll 提供了 epoll_create,epoll_ctl和epoll_wait 三個函式,分別為了建立一個 epoll 控制程式碼,註冊要監聽的事件型別,等待事件的產生。對於缺點1,每次註冊新事件到 epoll 控制程式碼中時(在 epoll_ctl 中指定 EPOLL_CTL_ADD)會把 fd 拷貝到核心中,而不是在 epoll_wait 時重複拷貝,這樣保證了每個 fd 在整個過程中只會被拷貝一次。對於缺點2,epoll 不像 select 和 poll 每次都把 fd 掛進阻塞佇列,而是隻在 epoll_ctl 時掛一次並同時給相應 fd 註冊一個回撥函式,當相應裝置被喚醒執行這個回撥函式的時候實際上就是把這個 fd 放入就緒佇列,然後 epoll_wait 的時候就檢視就緒佇列中有沒有內容並返回即可。對於缺點3,epoll 沒有這個限制,epoll 支援的 fd 數量是系統最大 fd 數量,通常 cat /proc/sys/fs/file-max 檢視,跟裝置記憶體有很大關係。
非同步 IO
上面事件驅動的 select,poll,epoll 機制即使很大的提升了效能,但是在資料的讀寫操作上還是同步的。而非同步 IO 的出現進一步提升了 Server 的處理能力,應用程式發起一個非同步讀寫操作,並提供相關引數(如用於存放資料的緩衝區、讀寫資料的大小、以及請求完成後的回撥函式等),作業系統在自身的核心執行緒中執行實際的讀或者寫操作,並將結果存入程式制定的緩衝區中,然後把事件和緩衝區回撥給應用程式。目前有很多語言已經封裝了各自的非同步 IO 庫,如 Python 的 asyncio。
總結
在沒有新的模型提出來之前,我們能做的就是結合實際的應用場景和上面模型的優缺點組合出最高效的解決方案,比如 epoll + ThreadPool 就是 muduo 這個高效 C++ 網路庫採取的方案。
在當前 C10K 問題的主流背景下,epoll 和非同步 IO 這種事件驅動模型正逐漸變為人們的首選方案,這也是 Nginx 能不斷從 Apache 中搶佔市場的一個重要原因。然而從 Linux 社群來看對填平 aio(非同步 IO)這個大坑並沒有太大興趣,那麼為了非同步 IO 的統一隻能從應用層進行相容,免不了多次核心態與使用者態的互動,這對程式效能自然會有損失。當然隨著時間的發展單機併發效能的解決辦法越來越高效,但是對應的程式開發複雜度也越來越高,我們要做的就是在這兩者之間做出最優權衡。