[系列文章目錄和關於我](https://www.cnblogs.com/cuzzz/p/16609728.html) ## 零丶背景 最近有很多想學的,像netty的使用、原理源碼,但是苦於自己對於操作系統和nio瞭解不多,有點無從下手,遂學習之。 ## 一丶網路io的過程 ![image-202 ...
零丶背景
最近有很多想學的,像netty的使用、原理源碼,但是苦於自己對於操作系統和nio瞭解不多,有點無從下手,遂學習之。
一丶網路io的過程
上圖粗略描述了網路io的過程,瞭解其中的拷貝過程有利於我們理解非阻塞io,以及IO多路復用的必要性。
-
數據從網卡到內核緩衝區
網卡通過DMA的方式將網路幀copy到內核空間並不是拷貝到內核空間就完事了,因為還需要根據協議對數據進行處理。
所以網卡使用硬中斷通知cpu,cpu響應後會使用網卡註冊函數進行收包,然後協議層處理網路幀。
-
數據從內核緩衝區到用戶空間
根據協議處理好的數據,還需要拷貝到用戶空間才能被運行在內核態的應用程式使用==>cpu進行數據拷貝。隨後內核喚醒用戶進程,相當於我們的java程式從阻塞io中被喚醒,繼續執行下一行代碼的執行。
二丶Socket通信過程與其中的阻塞點
這其中有幾個阻塞的過程
-
accept 系統調用:等待客戶端建立tcp連接
這個問題不大,沒有連接那麼阻塞服務端線程,可以節約cpu資源。
-
read系統調用:等待請求數據來到用戶空間
數據從網卡到用戶空間的過程,線程時阻塞的
-
Servlet#service 處理請求是一個同步過程
tomcat根據http協議構造request,並和response作為參數,找到對應Servlet調用service方法,Servlet#service方法執行結束,返回內容才能通過write系統調用回應數據。
這導致在業務處理上需要使用線程池來讓服務端可以處理多個併發請求。
-
write系統調用:響應數據寫回
write系統調用將servlet處理後的響應數據,寫回到文件描述符中。
三丶NIO解決了什麼問題
1.單線程監測若幹個文件描述符是否可以執行IO操作
這就是常說的IO多路復用,那為什麼需要IO多路復用?
儘量使用較少的系統資源處理更多的連接,如果當前單台伺服器接收了1w個請求,服務端當如何處理?
1.1 傳統BIO模型
上面是一段java BIO模型併發處理多請求的實例代碼,它有以下不足
- 大量的線程占用很大的記憶體空間
- 線程切換會帶來很大的開銷
- process方法中需要需要調用read系統調用,阻塞直到可讀,並沒有真正進行讀寫操作。
1.2. 非阻塞IO
上面是非阻塞IO的一個實例
socketChannel.configureBlocking(false)
可以讓後續的read在通道數據沒有就緒的時候直接返回-1,而不是讓線程阻塞。這個特性讓調度線程池中的線程減少了阻塞,從而節省了線程資源。
但是這種方式也不是沒有任何缺點,多次系統意味著多次系統調用,每次系統調用都需要,用戶態<=>內核態的來回切換,需要cpu保存進程的上下文,調用結束還需要恢復進程的上下文。
1.3 IO多路復用
如上是Java IO多路復用的簡陋例子。操作系統提供了多路復用的機制,將連接上來的客戶端都進行註冊,然後不斷迴圈掃描各個客戶端連接,監聽客戶端的請求。但是,多路復用輪詢掃描各個客戶端連接的過程在操作系統內核中進行
,極大的加快了多路復用的效率,減少了用戶態和內核態的切換
。
2.減少堆內記憶體<=>堆外記憶體的拷貝開銷
使用NIO Channel讀寫時需要需要先讀到堆外記憶體,然後拷貝到堆內記憶體,如果直接使用堆外記憶體則可以減少堆外到堆內的拷貝過程。
下圖是將Channel數據讀取到Buffer,調用IOUtil#read的源碼
下圖是將Buffer數據寫入到Channel,調用IOUtil#write的源碼
2.1 為什麼需要再堆外記憶體和堆內記憶體來回捯飭?
寫入Buffer數據到文件描述符,or讀取文件描述符數據到Buffer都是需要進行系統調用的,執行系統調用依賴於執行native方法,而執行native方法的線程被認為是處於SafePoint,處於SafePoint就有可能發生 GC 重排列對象記憶體的情況。
並且這個寫入和讀取是針對地址的(如下圖,最終的native調用需要傳入地址)如果寫入或者讀取buffer由於gc移動,那麼地址會改變,但是native方法調用可不管這個,就導致讀寫出現錯誤。因此需要依賴於堆外記憶體。
2.2 為什麼Socket基於Inpustream,OutputStream沒有這個問題
以SokcetInputStream的讀為例,讀最終調用socktRead0這個native方法,入參fd是當前Socket對應的文件描述符,byte數組就是數據最終讀入的目的地。
下圖是native 方法socketRead0的實現
可以看到,其實是先將socket fd內容讀取到c語言聲明的數組,然後拷貝到Java byte[],這個c語言聲明的數組其實作用類似於直接記憶體!
3.減少內核空間和用戶空間的拷貝開銷
上面說了直接記憶體的作用:減少堆外堆內的拷貝開銷。無論堆外堆內,都是用戶空間的拷貝。
3.1 DMA控制器替CPU打工
上圖是讀取磁碟文件的時序圖,可以看到如果沒有DMA技術,藍色部分需要CPU來完成,將浪費寶貴的資源。
再DMA讀取到足夠數據後,會發送中斷信號給CPU,讓CPU將內核緩衝區數據,拷貝到用戶緩衝區,隨後CPU再來調度Java程式,Java程式才能操作到用戶緩衝區的數據。
3.2 零拷貝
3.2.1 傳統文件傳輸
如下圖是我們使用IO流,讀取磁碟文件,通過Socket API 發送的流程,其中需要read,和 write 系統調用,每次系統調用都意味著用戶態與內核態的上下文切換。
並且還有四次數據拷貝,其中兩次由DMA負責打工,兩次由CPU負責拷貝。
如何優化:
- 如果Java程式不需要對磁碟數據內容進行再加工(業務操作)那麼不需要拷貝到用戶空間,從而減少拷貝次數
- 由於用戶空間沒有操作網卡和磁碟的許可權,操作這些設備需要由操作系統內核完成,那麼如果操作系統提供新的系統調用函數,豈不是就可以減少用戶態與內核態的上下文切換
3.2.2 mmap + write
- 應用進程調用了
mmap()
後,DMA 會把磁碟的數據拷貝到內核的緩衝區里。接著,應用進程跟操作系統內核共用這個緩衝區; - 應用進程再調用
write()
,操作系統直接將內核緩衝區的數據拷貝到 socket 緩衝區中,這一切都發生在內核態,由 CPU 來搬運數據; - 最後,把內核的 socket 緩衝區里的數據,拷貝到網卡的緩衝區里,這個過程是由 DMA 搬運的
所以mmap優化了什麼?
mmap並沒有減少系統調用帶來的內核態用戶態切換開銷,只是應用程式和內核共用緩衝區,從而讓cpu可以直接將內核緩衝區的數據,拷貝到socket緩衝區,不需要拷貝到用戶緩衝區,再從用戶緩衝區拷貝到socket緩衝區。
3.2.3 sendfile
linux 提供sendfile系統調用,只需這一個系統調用就可以從一個文件描述符拷貝數據到另外一個文件描述符
sendfile可以減少write,read導致的系統調用,從而優化效率。
如果網卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技術,那麼還可以進一步優化。
- 通過 DMA 將磁碟上的數據拷貝到內核緩衝區里;
- 緩衝區描述符和數據長度傳到 socket 緩衝區,這樣網卡的 SG-DMA 控制器就可以直接將內核緩存中的數據拷貝到網卡的緩衝區里,此過程
不需要將數據從操作系統內核緩衝區拷貝到 socket 緩衝區中
,這樣就減少了一次數據拷貝。
這便是所謂的零拷貝,減少記憶體層面拷貝數據的次數,以及系統調用內核態用戶態的切換,從而優化性能。
3.3 NIO中的零拷貝
3.3.1 FileChannel#map
NIO中的FileChannel.map()方法使用了mmap系統調用實現記憶體映射方式
將內核緩衝區的記憶體和用戶緩衝區的記憶體做了一個地址映射。這種方式適合讀取大文件,同時也能對文件內容進行更改,但是如果其後要通過SocketChannel發送,還是需要CPU進行數據的拷貝。
如上是MappedByteBuffer的獲取方式,其實底層是通過反射調用DirectByteBuffer的構造方法實現的,其中的cleaner是直接記憶體的回收器,傳入的unmapper會被回調,從而調用native方法實現資源釋放。
這種方式適合讀取大文件,同時也能對文件內容進行更改。
3.3.2 FileChannel#transferTo,transerFrom
在操作系統層面是調用的一個sendFile系統調用。通過這個系統調用,可以在內核層直接完成文件內容的拷貝。
4.FileChannel#force強制刷盤
由於CPU的運行速度非常快,所以CPU在執行指令時,通常只能與緩存進行交互,而不適合直接操作像磁碟、網卡這樣的硬體。也因此,在進行文件寫入時,操作系統也是先寫入到page cache中,緩存起來,然後再往硬體寫入。
緩存有利也有弊,使用page cache頁緩存,應用程式將數據都寫入到了page cache中,但是卻沒有真正寫入磁碟。如果這個時候出現斷電,那麼將出現緩存數據丟失。
FileChannel#force會進行fsync系統調用
fsync可以實現將page cache緩存內容進行落盤,從而保證不丟失(redis aof可以設置持久化機制,通常設置每秒落盤一次,這裡落盤也是fsync系統調用)。為了性能考慮,應用程式不可能每寫入一點數據就調用fsync,fsync也是有性能損耗的。
四丶IO多路復用 select/poll/epoll
上面我們聊到了IO多路復用解決了什麼問題,以及NIO Selector的基本使用,但是沒有探究在操作系統層面是如何實現的,下麵來學習一下。
1.select系統調用
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout)
- nfds: 最大的文件描述符+1,代表監聽這一組描述符(為什麼要+1?因為除了當前最大描述符之外,還有可能有新的fd連接上來)
- fd_set: 是一個點陣圖集合, 對於同一個文件描述符,可以監聽不同的事件
- readfds:文件描述符“可讀”事件
- writefds:文件描述符“可寫”事件
- exceptfds:文件描述符“異常”事件,一般內核用的,實際編程很少使用
- timeout:超時時間:0是立即返回,-1是一直阻塞,如果大於0,則達到設置值的微秒數即返回
- 返回值: 所監聽的所有監聽集合中滿足條件的總數(滿足條件的讀、寫、異常事件的總數),出錯時返回-1,並設置errno。如果超時時間觸發,則返回0
select 其實就是把NIO中用戶態要遍歷的fd數組拷貝到了內核態,讓內核態來遍歷,因為用戶態判斷socket是否有數據依舊需要通過系統調用,切換到內核態進行。
可以看到select依賴了很多點陣圖參數,系統調用完後需要用戶程式遍歷一次點陣圖才能直到哪一個fd具備了io事件,並且這個點陣圖大小最大為1024,導致select用起來需要很多位操作並且最多只能支持1024路IO。
2.poll系統調用
int poll(struct pollfd *fds, nfds_t nfds/*最大監聽的文件描述符個數*/, int timeout/*最大監聽的文件描述符個數*/);
其中pollfd為:
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
poll可以看作升級版select,它突破了1024
個文件描述符的限制,並且poll
函數的監聽和返回是分開的,簡化了代碼實現。
雖然poll
不需要遍歷所有的文件描述符了,只需要遍歷加入數組中的描述符,範圍縮小了很多,但缺點仍然是需要遍歷,當加入數組描述符很多,但是存在事件的fd很少,這個遍歷操作還是有點不划算的。
3.epoll系統調用
在linux環境下,java nio中的selector就是基於epoll實現的。
3.1 epoll_create
int epoll_create(int size)
//返回一個fd
//傳入大小作為參考值
epoll_create返回一個特殊的文件描述符,它代表紅黑樹的根節點。size
則是樹的大小,它代表你將監聽多少個文件描述符。epoll_create
將按照傳入的大小,構造出一棵大小為size
的紅黑樹。
3.2 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd 是epoll_create的返回值,也就說紅黑樹的根節點
// op 表示操作,比如增加,修改,刪除
//fd 是需要增加,修改,刪除的文件描述符
// struct epoll_event *event 是一個結構體,如下
struct epoll_event {
uint32_t events; /* Epoll events 讀事件or寫事件,or 異常事件*/
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;//代表一個文件描述符,初始化的時候傳入需要監聽的文件描述符,當監聽返回時,此處會傳出一個有事件發生的文件描述符,因此,無需我們遍歷得到結果了
uint32_t u32;
uint64_t u64;
} epoll_data_t;
用來操作epoll
句柄,可以使用該函數往紅黑樹里增加文件描述符,修改文件描述符,和刪除文件描述符。
3.3 epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
//epfd 是epoll_create的返回值,也就說紅黑樹的根節點
// struct epoll_event *events 是一個數組,返回的所有觸發了事件的文件描述符集合
//maxevents代表這個數組的大小
//timeout 0代表立即返回,-1代表永久阻塞,如果大於0,則代表超時等待毫秒數
3.4 水平觸發,邊緣觸發
epoll
有兩種觸發方式,分別為水平觸發和邊沿觸發。
-
水平觸發
只要有數據處於就緒狀態,那麼可讀事件就會一直觸發。
舉個例子,假設客戶端一次性發來了
4K
數據 ,但是伺服器recv
函數定義的buffer
大小僅為1024
位元組,那麼一次肯定是不能將所有數據都讀取完的,這時候就會繼續觸發可讀事件,直到所有數據都處理完成。epoll
預設的觸發方式就是水平觸發。 -
邊緣觸發
只有數據發送過來的時候會觸發一次,即使數據沒有讀取完,也不會繼續觸發。
-
觸發方式的設置:
水平觸發和邊沿觸發在內核里 使用兩個
bit mask
區分,分別為:- EPOLLLT 水平 觸發
- EPOLLET 邊沿觸發
需要在註冊事件的時候將其與需要註冊的事件做一個位或運算即可:
ev.events = EPOLLIN; //LT ev.events = EPOLLIN | EPOLLET; //ET
4.總結
select函數需要一次性傳入所有需要監控的連接(在內核中是FD),併在內核中對這些FD進行持續的掃描。當發現其中有FD不老實時,就會通知應用程式有客戶端事件發生了, 上層應用接到通知後,就只能自己再去遍歷所有的FD,尋找有事件發生的連接,然後進行業務處理。
但是select受限於操作系統,掃描的FD個數是受限的。
於是出現了Poll函數,解決了slelect文件描述符受限的問題。但是,上層應用程式依然要自己去遍歷所有客戶端,尋找哪個客戶端上有事件發 生。高併發場景下,性能依然嚴重受限。
於是又出現了epoll機制。
epoll機制會直接返回有事件發生的FD。這樣就省掉了上層應用頻繁掃描所有客戶端的消耗,進一步解決多路復用的高併發問題。