系列文章目錄和關于我
零丶背景
最近有很多想學的,像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,這樣就省掉了上層應用頻繁掃描所有客戶端的消耗,進一步解決多路復用的高并發問題,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/554899.html
標籤:其他
上一篇:【VS Code 與 Qt6】運用事件過濾器批量操作子級組件
下一篇:返回列表
