近期很長的一段時間,都在學習總結,什么是高并發,怎么樣實現高并發,接觸了解的內容包括Nginx工作原理,Tomcat 的三種運營模式,Pistache并發機制,CGI/Fast-CGI,nodejs及其底層的異步IO庫linuv,Linux的事件輪詢機制Epoll,以及Go語言的GMP調度模型。自認為形成一套自圓其說的邏輯,所以記錄總結一下。
首先談一下并發,在OS的課程上對于并發和并行有嚴格的區分:
- 并發指的是多個任務同一個core上一起執行,通過調度算法,給用戶以所有的任務正在同時的進行“錯覺”,但是本質上,任務是串行的
- 并行指的是多個任務在多個core上同時執行
而我們這里討論的并發可以認為是上述兩種情況的融合,即所謂高并發是指,如何在有限核的服務器上盡可能多的處理請求或任務(任務數>>核數),同時還要追求低延時(平均延時、尾部延時)、高資源利用率(主要考慮CPU利用率)等性能指標。
這里先給出結論吧,要想實現最極致的并發,核心在于滿足以下三點:
-
執行任務的線程等于核數
線程數等于核數,旨在減少線程切換的開銷,顯然這個開銷并不是特大,因此超過核數引入的開銷其實并不大,但是引入過多的線程(如啟動上萬線程)開銷就無法忽視了,其次線程切換是需要進行用戶態/內核態的轉化的,而且CPU的調度算法是O(n)的。 -
CPU的利用率為100%
CPU的利用率為100%,這是為了不讓CPU空閑,我們都知道,一個請求的執行一般可以劃分為兩種情況:執行計算、執行IO,其中后者是不會使用CPU的。到這里我們知道,一般來說前兩點是矛盾的,如果線程等于核數,那么CPU利用率一定不會高,因為CPU時間被IO任務占據。 -
任務完全公平的使用CPU
關于這點,我關注的比較少,其實,實現前兩點就是高并發了,能做到公平性的也只有Go語言了,不過由于用戶級線程是無法被內核感知的,其公平性也是無法達到CFS算法的水準。我們給的公平下一個定義的話:假設當前有N個優先級相同的任務在同一個core上運行,那么在一個調度周期T時間內,每個任務的得到的執行時間是(T/N),那么可以認為是公平的。
顯然要想嚴格實現上述三點是不可能的,對于第三點,關于什么是完全的公平,本身也是一個需要討論的話題。我們要做的只是向上面三點靠攏。
下面我將從BIO、NIO、Nodejs、Go,四個方面,以遞進的方式,闡述,我對于實現高并的的理解。
一、BIO
對于一個web服務器而言,是基于TCP + HTTP協議的。TCP與UDP不同之處在于,在使用TCP進行數據傳送之前,必須要在客戶端和服務端之間建立TCP連接,然后再進行數據傳輸,過程及涉及的系統調用如下圖所示:
- accept()
如果任何客戶端沒有進行connect()操作,那么執行accept()將會阻塞 - read()
如果客戶端沒有通過write()發送數據(即HTTP請求,HTTP請求就是遵循了HTTP協議的消息),那么執行read()將會阻塞
BIO的模式是這樣的:
- 首先,由一個主線程來監聽server-fd(listen-fd)
- 執行accept()阻塞等待新連接,當新連接到達時創建client-fd
- 從線程池中選擇一個工作線程,講client-fd交給線程處理,然后執行循環執行2、3
- 工作線程執行read(),阻塞等待HTTP請求的到達,然后解析、處理、封裝HTTP請求,發送響應
- 如果是長連接(一般都是長連接),那么將循環執行4,知道斷開連接,釋放線程
BIO模式要求,每一個線程處理一個連接,這樣同時處理的連接數將取決于線程池的大小,那我們假設啟動一個合理大小的線程池,使之不會過多的消耗系統資源(如,是當前核數的2倍):
要點 | 表現 | 解釋 |
---|---|---|
線程數目 | 尚可 | 因為我們假設啟動一個不大的線程池 |
CPU利用率 | 很低 | 因為每個線程會在阻塞等待http請求的到達,在次期間,CPU無法做任何事情,且長連接會讓情況更糟,因為這意味著CPU長時間的阻塞 |
公平性 | 很差 | 因為處理的連接數目受限與線程池的大小,新的連接只能等待前面的連接處理完畢,長連接模式顯然讓這個問題更加嚴重 |
二、NIO
BIO的主要癥結在于阻塞等待新連接和阻塞等待請求,而基于IO復用機制的epoll是解決網絡IO阻塞的靈丹妙藥,基于epoll,每個線程可以處理多個連接:
- 主線程維護一個epoll,用于監聽listen-fd
- 當新連接到達時,調用accept()創建一個client-fd,然后將client-fd放到主線程的epoll中進行監聽
- 當epoll監聽的client-fd有先請求到達時,會返回產生事件的client-fd
- 主線程將HTTP請求交給工作線程處理,然后繼續執行epoll的監聽
- 工作線程執行解析、處理、封裝HTTP請求,發送響應,處理完畢后,等待主線程的任務派發
注,以上只是一個NIO的一種簡單模式
與NIO顯著不同的一點在于,工作線程對應于一個HTTP請求,而非一個連接,這樣我們每個線程都在執行HTTP請求,而不會像BIO那樣阻塞等待請求的到達。也就是說我們使用epoll實現了網絡IO的異步。
但是顯然,同時處理http請求的數目依然收到線程池大小的限制,我們再次假設啟動一個合理大小的線程池:
要點 | 表現 | 解釋 |
---|---|---|
線程數目 | 尚可 | 因為我們假設啟動一個不大的線程池 |
CPU利用率 | 有提高,但還不夠 | 提高是因為實現了網絡IO的異步,不需要阻塞等待HTTP請求,在長連接的情況下改進很大;不夠是因為,用戶的處理函數中進行的IO操作依然會阻塞線程,如需要訪問數據庫或本地的文件等。 |
公平性 | 有提高,但同樣不夠 | 有提高是因為新連接可以馬上被處理,且任何一個連接的HTTP請求都遵循FIFO的原則;不夠的原因依然是FIFO,只能一個個的處理HTTP請求 |
三、Nodejs
NIO的主要問題在于,僅僅將處理TCP連接和HTTP請求的部分實現了異步,但用戶處理函數中的IO操作,依然會阻塞線程。
于是nodejs,使用libuv封裝了所有的IO操作(當然了主要就是網絡IO和文件IO),實現了完全的異步IO。
其中網絡IO是基于epoll的,而文件IO是基于線程池的。
這里需要注意一點,關于網絡IO和文件IO可以看此圖:
真正的IO操作,是由DMA完成的,由網卡或磁盤到socket_Buff或pageCache的這一部分,這一部分CPU是不會參與的,但是將數據再從socket_Buff或pageCache讀到用戶空間,是CPU操作。
當epoll感知到可讀時,數據已經達了socket_Buff了,因此epoll實現異步網絡IO可謂絕配;但顯然文件IO是無法使用epoll的,而Linux的AIO操作又不成熟,因此只能使用多線程來實現異步了,Go也是這么做的。
而使用線程實現異步文件IO結束的標志是數據已經讀取到用戶空間(即read(2)操作返回),因此整個過程其實是包含了CPU操作的,當然了epoll機制實現的異步網絡IO雖然不包括CPU的copy過程,但是也要內核執行中斷處理程序、調用協議棧處理packet。但是IO的時間肯定是占大頭,因此我們可以認為執行異步IO的線程,幾乎不參與CPU調度的競爭,這是我們滿足1. 執行任務的線程等于核數
的一種變通方式!!!
要點 | 表現 | 解釋 |
---|---|---|
線程數目 | 接近完美 | 雖然我們啟動實現異步文件IO的線程,但是他幾乎不會參與線程的CPU調度 |
CPU利用率 | 完美 | 一切IO都是異步的,CPU將專注與計算任務 |
公平性 | 又有提高,但是依然不夠 | 因為事件循環機制,每個HTTP任務都被以回調函數的形式拆分,誰的異步IO完成,誰得到執行;但是畢竟是單線程的模式,一旦某個請求的計算任務很長,那么也會指導處理完才能處理別的 |
四、Go
現在只剩下公平性的問題了。
其實上面的優化,總結一下就是:異步I/O。
Go語言使用了另一種方法:協程或者叫纖程或者叫用戶級線程。
每個任務都以協程的方式,在線程上執行,也就是PMG模型!這里就不展開PMG的運行原理了。
Go在執行到IO這樣阻塞的任務時,同Nodejs一樣,對于網絡IO,使用epoll機制,用一個線程運行net-poller來等待;對于文件IO,則也是啟動新的線程(M)來處理,當然了,不光是IO操作,但凡要執行長時間的系統調用,Go都會啟動一個線程來執行等待,然后切換執行其他的協程任務。
此外Go語言還是實現了,基于搶占的協程調度,具體的方法是當協程要調用一個函數時,在函數的入口處,由編譯器插入檢查代碼,如果已經運行了足夠的時間,那就切換到其他線程,顯然這種方法和CFS調度相比還是弱。
所以我認為,Go在線程數目和CPU利用率上與nodejs差不多,在公平性上比Nodejs又進了一步,但是依然不是完美的!
后記
以上所有,都是一些不夠成熟的見解,在描述上也很欠缺,以后隨著我的理解深入,一定會進一步的更新!