java NIO理论总结
目錄
BIO NIO AIO
BIO (Blocking I/O)
NIO (Non-blocking/New I/O)
AIO (Asynchronous I/O)
BIO 與 NIO 區(qū)別
NIO BIO形象解釋
NIO BIO各自應用場景
NIO在多線程中為什么比BIO好
BIO在多線程的缺點
NIO在服務端多線程的優(yōu)點
NIO在客戶端的優(yōu)點
NIO的缺點
NIO理論
NIO Demo
直接內存與內核態(tài)與用戶態(tài)
NIO適用的場景
NIO三種模型
Reactor單線程模型
Reactor多線程模型
主從Reactor多線程模型
阻塞非阻塞,同步異步
同步和異步
阻塞和非阻塞
阻塞和等待
兩兩組合
I/O
阻塞IO和非阻塞IO
同步IO和同步阻塞IO
異步IO和異步阻塞/非阻塞IO
零拷貝DMA
為什么要有 DMA 技術?
傳統(tǒng)的文件傳輸有多糟糕?
如何優(yōu)化文件傳輸?shù)男阅?#xff1f;
如何減少數(shù)據(jù)拷貝的次數(shù)?
零拷貝簡介
如何實現(xiàn)零拷貝?
mmap + write
sendfile
使用零拷貝技術的項目
PageCache 有什么作用?
大文件傳輸用什么方式實現(xiàn)?
零拷貝DMA總結
IO 多路復用
阻塞 IO
非阻塞 IO
IO 多路復用介紹
select
poll
epoll
select和epoll函數(shù)的區(qū)別
IO多路復用總結
Tomcat Connector(BIO, NIO, APR)三種運行模式
注意:本文參考??docs/java/basis/io.md · SnailClimb/JavaGuide - Gitee.com
NIO | 對線面試官
IO 多路復用
【面試】迄今為止把同步/異步/阻塞/非阻塞/BIO/NIO/AIO講的這么清楚的好文章(快快珍藏)
Tomcat Connector(BIO, NIO, APR)三種運行模式 - 簡書
BIO NIO AIO
BIO (Blocking I/O)
BIO 屬于同步阻塞 IO 模型?。
同步阻塞 IO 模型中,應用程序發(fā)起 read 調用后,會一直阻塞,直到內核把數(shù)據(jù)拷貝到用戶空間。
?
在客戶端連接數(shù)量不高的情況下,是沒問題的。但是,當面對十萬甚至百萬級連接的時候,傳統(tǒng)的 BIO 模型是無能為力的。因此,我們需要一種更高效的 I/O 處理模型來應對更高的并發(fā)量。
NIO (Non-blocking/New I/O)
Java 中的 NIO 于 Java 1.4 中引入,對應?java.nio?包,提供了?Channel?,?Selector,Buffer?等抽象。NIO 中的 N 可以理解為 Non-blocking,不單純是 New。它支持面向緩沖的,基于通道的 I/O 操作方法。 對于高負載、高并發(fā)的(網(wǎng)絡)應用,應使用 NIO 。
Java 中的 NIO 可以看作是?I/O 多路復用模型。也有很多人認為,Java 中的 NIO 屬于同步非阻塞 IO 模型。
跟著我的思路往下看看,相信你會得到答案!
我們先來看看?同步非阻塞 IO 模型。
?
同步非阻塞 IO 模型中,應用程序會一直發(fā)起 read 調用,等待數(shù)據(jù)從內核空間拷貝到用戶空間的這段時間里,線程依然是阻塞的,直到在內核把數(shù)據(jù)拷貝到用戶空間。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型確實有了很大改進。通過輪詢操作,避免了一直阻塞。
但是,這種 IO 模型同樣存在問題:應用程序不斷進行 I/O 系統(tǒng)調用輪詢數(shù)據(jù)是否已經(jīng)準備好的過程是十分消耗 CPU 資源的。
這個時候,I/O 多路復用模型?就上場了。
??
IO 多路復用模型中,線程首先發(fā)起 select 調用,詢問內核數(shù)據(jù)是否準備就緒,等內核把數(shù)據(jù)準備好了,用戶線程再發(fā)起 read 調用。read 調用的過程(數(shù)據(jù)從內核空間->用戶空間)還是阻塞的。
目前支持 IO 多路復用的系統(tǒng)調用,有 select,epoll 等等。select 系統(tǒng)調用,是目前幾乎在所有的操作系統(tǒng)上都有支持
select 調用?:內核提供的系統(tǒng)調用,它支持一次查詢多個系統(tǒng)調用的可用狀態(tài)。幾乎所有的操作系統(tǒng)都支持。
epoll 調用?:linux 2.6 內核,屬于 select 調用的增強版本,優(yōu)化了 IO 的執(zhí)行效率。
IO 多路復用模型,通過減少無效的系統(tǒng)調用,減少了對 CPU 資源的消耗。
Java 中的 NIO ,有一個非常重要的選擇器 ( Selector )?的概念,也可以被稱為?多路復用器。通過它,只需要一個線程便可以管理多個客戶端連接。當客戶端數(shù)據(jù)到了之后,才會為其服務。
在Java NIO有三個核心部分組成。分別是Buffer(緩沖區(qū))、Channel(管道)以及Selector(選擇器)
可以簡單的理解為:Buffer是存儲數(shù)據(jù)的地方,Channel是運輸數(shù)據(jù)的載體,而Selector用于檢查多個Channel的狀態(tài)變更情況,
??
AIO (Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改進版 NIO 2,它是異步 IO 模型。
異步 IO 是基于事件和回調機制實現(xiàn)的,也就是應用操作之后會直接返回,不會堵塞在那里,當后臺處理完成,操作系統(tǒng)會通知相應的線程進行后續(xù)的操作。
??
目前來說 AIO 的應用還不是很廣泛。Netty 之前也嘗試使用過 AIO,不過又放棄了。這是因為,Netty 使用了 AIO 之后,在 Linux 系統(tǒng)上的性能并沒有多少提升。
最后,來一張圖,簡單總結一下 Java 中的 BIO、NIO、AIO。
??
BIO 與 NIO 區(qū)別
1? NIO和傳統(tǒng)IO(一下簡稱IO)之間第一個最大的區(qū)別是,IO是面向流的,NIO是面向緩沖區(qū)的。
Java IO面向流意味著每次從流中讀一個或多個字節(jié),直至讀取所有字節(jié),它們沒有被緩存在任何地方。此外,它不能前后移動流中的數(shù)據(jù)。如果需要前后移動從流中讀取的數(shù)據(jù),需要先將它緩存到一個緩沖區(qū)。NIO的緩沖導向方法略有不同。數(shù)據(jù)讀取到一個它稍后處理的緩沖區(qū),需要時可在緩沖區(qū)中前后移動。這就增加了處理過程中的靈活性。但是,還需要檢查是否該緩沖區(qū)中包含所有您需要處理的數(shù)據(jù)。而且,需確保當更多的數(shù)據(jù)讀入緩沖區(qū)時,不要覆蓋緩沖區(qū)里尚未處理的數(shù)據(jù)。
2? IO的各種流是阻塞的。這意味著,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些數(shù)據(jù)被讀取,或數(shù)據(jù)完全寫入。該線程在此期間不能再干任何事情了。
NIO的非阻塞模式,使一個線程從某通道發(fā)送請求讀取數(shù)據(jù),但是它僅能得到目前可用的數(shù)據(jù),如果目前沒有數(shù)據(jù)可用時,就什么都不會獲取。而不是保持線程阻塞,所以直至數(shù)據(jù)變的可以讀取之前,該線程可以繼續(xù)做其他的事情。 非阻塞寫也是如此。一個線程請求寫入一些數(shù)據(jù)到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。 線程通常將非阻塞IO的空閑時間用于在其它通道上執(zhí)行IO操作,所以一個單獨的線程現(xiàn)在可以管理多個輸入和輸出通道(channel)。
3? IO沒有Selector,而NIO有,就是為了實現(xiàn)非阻塞模式,為了針對網(wǎng)絡編程。
Selector類是NIO的核心類,Selector能夠檢測多個注冊的通道上是否有事件發(fā)生,如果有事件發(fā)生,便獲取事件然后針對每個事件進行相應的響應處理。這樣一來,只是用一個單線程就可以管理多個通道,也就是管理多個連接。這樣使得只有在連接真正有讀寫事件發(fā)生時,才會調用函數(shù)來進行讀寫,就大大地減少了系統(tǒng)開銷,并且不必為每個連接都創(chuàng)建一個線程,不用去維護多個線程,并且避免了多線程之間的上下文切換導致的開銷。
與Selector有關的一個關鍵類是SelectionKey,一個SelectionKey表示一個到達的事件,這2個類構成了服務端處理業(yè)務的關鍵邏輯。
4? IO的流是單向的,是輸出流或者輸入流。NIO的通道負責數(shù)據(jù)的傳輸,是雙向的,就像是鐵路。Buffer負責數(shù)據(jù)的存儲,就像是火車可以在鐵路上來回傳輸數(shù)據(jù)。
通過使用FileChannel可以從文件讀或者向文件寫入數(shù)據(jù);通過SocketChannel,以TCP來向網(wǎng)絡連接的兩端讀寫數(shù)據(jù);通過ServerSocketChanel能夠監(jiān)聽客戶端發(fā)起的TCP連接,并為每個TCP連接創(chuàng)建一個新的SocketChannel來進行數(shù)據(jù)讀寫;通過DatagramChannel,以UDP協(xié)議來向網(wǎng)絡連接的兩端讀寫數(shù)據(jù)。
NIO BIO形象解釋
對于NIO和傳統(tǒng)IO,有一個網(wǎng)友講的生動的例子:
以前的流總是堵塞的,一個線程只要對它進行操作,其它操作就會被堵塞,也就相當于水管沒有閥門,你伸手接水的時候,不管水到了沒有,你就都只能耗在接水(流)上。
nio的Channel的加入,相當于增加了水龍頭(有閥門),雖然一個時刻也只能接一個水管的水,但依賴輪換策略,在水量不大的時候,各個水管里流出來的水,都可以得到妥善接納,這個關鍵之處就是增加了一個接水工,也就是Selector,他負責協(xié)調,也就是看哪根水管有水了的話,在當前水管的水接到一定程度的時候,就切換一下:臨時關上當前水龍頭,試著打開另一個水龍頭(看看有沒有水)。
當其他人需要用水的時候,不是直接去接水,而是事前提了一個水桶給接水工,這個水桶就是Buffer。也就是,其他人雖然也可能要等,但不會在現(xiàn)場等,而是回家等,可以做其它事去,水接滿了,接水工會通知他們。
NIO BIO各自應用場景
(1)NIO適合處理連接數(shù)目特別多,但是連接比較短(輕操作)的場景,Jetty,Mina,ZooKeeper等都是基于java nio實現(xiàn)。
服務器需要支持超大量的長時間連接。比如10000個連接以上,并且每個客戶端并不會頻繁地發(fā)送太多數(shù)據(jù)。例如總公司的一個中心服務器需要收集全國便利店各個收銀機的交易信息,只需要少量線程按需處理維護的大量長期連接。
(2)BIO方式適用于連接數(shù)目比較小且固定的場景,這種方式對服務器資源要求比較高,并發(fā)局限于應用中。
NIO在多線程中為什么比BIO好
BIO在多線程的缺點
之所以使用多線程,主要原因在于socket.accept()、socket.read()、socket.write()三個主要函數(shù)都是同步阻塞的,當一個連接在處理I/O的時候,系統(tǒng)是阻塞的,如果是單線程的話必然就掛死在那里;但CPU是被釋放出來的,開啟多線程,就可以讓CPU去處理更多的事情。
其實這也是所有使用多線程的本質:
1.利用多核。
2.當I/O阻塞系統(tǒng),但CPU空閑的時候,可以利用多線程使用CPU資源。
現(xiàn)在的多線程一般都使用線程池?,可以讓線程的創(chuàng)建和回收成本相對較低。在活動連接數(shù)不是特別高(小于單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接專注于自己的I/O并且編程模型簡單,也不用過多考慮系統(tǒng)的過載、限流等問題。線程池本身就是一個天然的漏斗,可以緩沖一些系統(tǒng)處理不了的連接或請求。
不過,這個模型最本質的問題在于,嚴重依賴于線程。但線程是很"貴"的資源,主要表現(xiàn)在:
1.線程的創(chuàng)建和銷毀成本很高,在Linux這樣的操作系統(tǒng)中,線程本質上就是一個進程。創(chuàng)建和銷毀都是重量級的系統(tǒng)函數(shù)。
2.線程本身占用較大內存,像Java的線程棧,一般至少分配512K~1M的空間,如果系統(tǒng)中的線程數(shù)過千,恐怕整個JVM的內存都會被吃掉一半。
3.線程的切換成本是很高的。操作系統(tǒng)發(fā)生線程切換的時候,需要保留線程的上下文,然后執(zhí)行系統(tǒng)調用。如果線程數(shù)過高,可能執(zhí)行線程切換的時間甚至會大于線程執(zhí)行的時間,這時候帶來的表現(xiàn)往往是系統(tǒng)load偏高、CPU sy使用率特別高(超過20%以上),導致系統(tǒng)幾乎陷入不可用的狀態(tài)。
4.容易造成鋸齒狀的系統(tǒng)負載。因為系統(tǒng)負載是用活動線程數(shù)或CPU核心數(shù),一旦線程數(shù)量高但外部網(wǎng)絡環(huán)境不是很穩(wěn)定,就很容易造成大量請求的結果同時返回,激活大量阻塞線程從而使系統(tǒng)負載壓力過大。
所以,當?面對十萬甚至百萬級連接的時候,傳統(tǒng)的BIO模型是無能為力的?。隨著移動端應用的興起和各種網(wǎng)絡游戲的盛行,百萬級長連接日趨普遍,此時,必然需要一種更高效的I/O處理模型。
NIO在服務端多線程的優(yōu)點
很多剛接觸NIO的人,第一眼看到的就是Java相對晦澀的API,比如:Channel,Selector,Socket什么的;然后就是一坨上百行的代碼來演示NIO的服務端Demo……瞬間頭大有沒有?
我們不管這些,拋開現(xiàn)象看本質,先分析下NIO是怎么工作的。
1.常見I/O模型對比
所有的系統(tǒng)I/O都分為兩個階段:等待就緒和操作。舉例來說,讀函數(shù),分為等待系統(tǒng)可讀和真正的讀;同理,寫函數(shù)分為等待網(wǎng)卡可以寫和真正的寫。
需要說明的是等待就緒的阻塞是不使用CPU的,是在“空等”;而真正的讀寫操作的阻塞是使用CPU的,真正在"干活",而且這個過程非常快,屬于memory copy,帶寬通常在1GB/s級別以上,可以理解為基本不耗時。
以socket.read()為例子:
傳統(tǒng)的BIO里面socket.read(),如果TCP RecvBuffer里沒有數(shù)據(jù),函數(shù)會一直阻塞,直到收到數(shù)據(jù),返回讀到的數(shù)據(jù)。
對于NIO,如果TCP RecvBuffer有數(shù)據(jù),就把數(shù)據(jù)從網(wǎng)卡讀到內存,并且返回給用戶;反之則直接返回0,永遠不會阻塞。
最新的AIO(Async I/O)里面會更進一步:不但等待就緒是非阻塞的,就連數(shù)據(jù)從網(wǎng)卡到內存的過程也是異步的。
換句話說,BIO里用戶最關心“我要讀”,NIO里用戶最關心"我可以讀了",在AIO模型里用戶更需要關注的是“讀完了”。
NIO一個重要的特點是:socket主要的讀、寫、注冊和接收函數(shù),在等待就緒階段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
2.如何結合事件模型使用NIO同步非阻塞特性
下面具體看下如何利用事件模型單線程處理所有I/O請求:
NIO的主要事件有幾個:
讀就緒
寫就緒
有新連接到來
我們首先需要注冊當這幾個事件到來的時候所對應的處理器。然后在合適的時機告訴事件選擇器:我對這個事件感興趣。對于寫操作,就是寫不出去的時候對寫事件感興趣;對于讀操作,就是完成連接和系統(tǒng)沒有辦法承載新讀入的數(shù)據(jù)的時;對于accept,一般是服務器剛啟動的時候;而對于connect,一般是connect失敗需要重連或者直接異步調用connect的時候。
其次,用一個死循環(huán)選擇就緒的事件,會執(zhí)行系統(tǒng)調用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),還會阻塞的等待新事件的到來。新事件到來的時候,會在selector上注冊標記位,標示可讀、可寫或者有連接到來。
注意,select是阻塞的,無論是通過操作系統(tǒng)的通知(epoll)還是不停的輪詢(select,poll),這個函數(shù)是阻塞的。所以你可以放心大膽地在一個while(true)里面調用這個函數(shù)而不用擔心CPU空轉。
最簡單的Reactor模式:注冊所有感興趣的事件處理器,單線程輪詢選擇就緒事件,執(zhí)行事件處理器。
3.優(yōu)化線程模型
NIO是怎么解決掉線程的瓶頸并處理海量連接的:
NIO由原來的阻塞讀寫(占用線程)變成了單線程輪詢事件,找到可以進行讀寫的網(wǎng)絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有可干的事情必須要阻塞),剩余的I/O操作都是純CPU操作,沒有必要開啟多線程。
并且由于線程的節(jié)約,連接數(shù)大的時候因為線程切換帶來的問題也隨之解決,進而為處理海量連接提供了可能。
單線程處理I/O的效率確實非常高,沒有線程切換,只是拼命的讀、寫、選擇事件。但現(xiàn)在的服務器,一般都是多核處理器,如果能夠利用多核心進行I/O,無疑對效率會有更大的提高。
仔細分析一下我們需要的線程,其實主要包括以下幾種:
事件分發(fā)器,單線程選擇就緒的事件。
I/O處理器,包括connect、read、write等,這種純CPU操作,一般開啟CPU核心個線程就可以。
業(yè)務線程,在處理完I/O后,業(yè)務一般還會有自己的業(yè)務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的線程。
Java的Selector對于Linux系統(tǒng)來說,有一個致命限制:同一個channel的select不能被并發(fā)的調用。因此,如果有多個I/O線程,必須保證:一個socket只能屬于一個IoThread,而一個IoThread可以管理多個socket。
另外連接的處理和讀寫的處理通常可以選擇分開,這樣對于海量連接的注冊和讀寫就可以分發(fā)。雖然read()和write()是比較高效無阻塞的函數(shù),但畢竟會占用CPU,如果面對更高的并發(fā)則無能為力。
NIO在客戶端的優(yōu)點
通過上面的分析,可以看出NIO在服務端對于解放線程,優(yōu)化I/O和處理海量連接方面,確實有自己的用武之地。
NIO又有什么使用場景呢?
1.常見的客戶端BIO+連接池模型,可以建立n個連接,然后當某一個連接被I/O占用的時候,可以使用其他連接來提高性能。
但多線程的模型面臨和服務端相同的問題:如果指望增加連接數(shù)來提高性能,則連接數(shù)又受制于線程數(shù)、線程很貴、無法建立很多線程,則性能遇到瓶頸。
2.每連接順序請求的Redis
對于Redis來說,由于服務端是全局串行的,能夠保證同一連接的所有請求與返回順序一致。這樣可以使用單線程+隊列,把請求數(shù)據(jù)緩沖。然后pipeline發(fā)送,返回future,然后channel可讀時,直接在隊列中把future取回來,done()就可以了。
這樣做,能夠充分的利用pipeline來提高I/O能力,同時獲取異步處理能力。
3.多連接短連接的HttpClient
類似于競對抓取的項目,往往需要建立無數(shù)的HTTP短連接,然后抓取,然后銷毀,當需要單機抓取上千網(wǎng)站線程數(shù)又受制的時候,怎么保證性能呢?
何不嘗試NIO,單線程進行連接、寫、讀操作?如果連接、讀、寫操作系統(tǒng)沒有能力處理,簡單的注冊一個事件,等待下次循環(huán)就好了。
如何存儲不同的請求/響應呢?由于http是無狀態(tài)沒有版本的協(xié)議,又沒有辦法使用隊列,好像辦法不多。比較笨的辦法是對于不同的socket,直接存儲socket的引用作為map的key。
4.常見的RPC框架,如Thrift,Dubbo
這種框架內部一般維護了請求的協(xié)議和請求號,可以維護一個以請求號為key,結果的result為future的map,結合NIO+長連接,獲取非常不錯的性能。
NIO的缺點
使用NIO != 高性能,當連接數(shù)<1000,并發(fā)程度不高或者局域網(wǎng)環(huán)境下NIO并沒有顯著的性能優(yōu)勢。
NIO并沒有完全屏蔽平臺差異,它仍然是基于各個操作系統(tǒng)的I/O系統(tǒng)實現(xiàn)的,差異仍然存在。使用NIO做網(wǎng)絡編程構建事件驅動模型并不容易,陷阱重重。
推薦大家使用成熟的?NIO框架:如Netty,MINA等?,解決了很多NIO的陷阱,并屏蔽了操作系統(tǒng)的差異,有較好的性能和編程模型。
NIO理論
NIO Demo
public class NoBlockServer {public static void main(String[] args) throws IOException {// 1.獲取通道ServerSocketChannel server = ServerSocketChannel.open();// 2.切換成非阻塞模式server.configureBlocking(false);// 3. 綁定連接server.bind(new InetSocketAddress(6666));// 4. 獲取選擇器Selector selector = Selector.open();// 4.1將通道注冊到選擇器上,指定接收“監(jiān)聽通道”事件server.register(selector, SelectionKey.OP_ACCEPT);// 5. 輪訓地獲取選擇器上已“就緒”的事件--->只要select()>0,說明已就緒while (selector.select() > 0) {// 6. 獲取當前選擇器所有注冊的“選擇鍵”(已就緒的監(jiān)聽事件)Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();// 7. 獲取已“就緒”的事件,(不同的事件做不同的事)while (iterator.hasNext()) {SelectionKey selectionKey = iterator.next();// 接收事件就緒if (selectionKey.isAcceptable()) {// 8. 獲取客戶端的鏈接SocketChannel client = server.accept();// 8.1 切換成非阻塞狀態(tài)client.configureBlocking(false);// 8.2 注冊到選擇器上-->拿到客戶端的連接為了讀取通道的數(shù)據(jù)(監(jiān)聽讀就緒事件)client.register(selector, SelectionKey.OP_READ);} else if (selectionKey.isReadable()) { // 讀事件就緒// 9. 獲取當前選擇器讀就緒狀態(tài)的通道SocketChannel client = (SocketChannel) selectionKey.channel();// 9.1讀取數(shù)據(jù)ByteBuffer buffer = ByteBuffer.allocate(1024);// 9.2得到文件通道,將客戶端傳遞過來的圖片寫到本地項目下(寫模式、沒有則創(chuàng)建)FileChannel outChannel = FileChannel.open(Paths.get("2.png"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);while (client.read(buffer) > 0) {// 在讀之前都要切換成讀模式buffer.flip();outChannel.write(buffer);// 讀完切換成寫模式,能讓管道繼續(xù)讀取文件的數(shù)據(jù)buffer.clear();}}// 10. 取消選擇鍵(已經(jīng)處理過的事件,就應該取消掉了)iterator.remove();}}} } public class NoBlockClient {public static void main(String[] args) throws IOException {// 1. 獲取通道SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 6666));// 1.1切換成非阻塞模式socketChannel.configureBlocking(false);// 1.2獲取選擇器Selector selector = Selector.open();// 1.3將通道注冊到選擇器中,獲取服務端返回的數(shù)據(jù)socketChannel.register(selector, SelectionKey.OP_READ);// 2. 發(fā)送一張圖片給服務端吧FileChannel fileChannel = FileChannel.open(Paths.get("X:\\Users\\ozc\\Desktop\\面試造火箭\\1.png"), StandardOpenOption.READ);// 3.要使用NIO,有了Channel,就必然要有Buffer,Buffer是與數(shù)據(jù)打交道的呢ByteBuffer buffer = ByteBuffer.allocate(1024);// 4.讀取本地文件(圖片),發(fā)送到服務器while (fileChannel.read(buffer) != -1) {// 在讀之前都要切換成讀模式buffer.flip();socketChannel.write(buffer);// 讀完切換成寫模式,能讓管道繼續(xù)讀取文件的數(shù)據(jù)buffer.clear();}// 5. 輪訓地獲取選擇器上已“就緒”的事件--->只要select()>0,說明已就緒while (selector.select() > 0) {// 6. 獲取當前選擇器所有注冊的“選擇鍵”(已就緒的監(jiān)聽事件)Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();// 7. 獲取已“就緒”的事件,(不同的事件做不同的事)while (iterator.hasNext()) {SelectionKey selectionKey = iterator.next();// 8. 讀事件就緒if (selectionKey.isReadable()) {// 8.1得到對應的通道SocketChannel channel = (SocketChannel) selectionKey.channel();ByteBuffer responseBuffer = ByteBuffer.allocate(1024);// 9. 知道服務端要返回響應的數(shù)據(jù)給客戶端,客戶端在這里接收int readBytes = channel.read(responseBuffer);if (readBytes > 0) {// 切換讀模式responseBuffer.flip();System.out.println(new String(responseBuffer.array(), 0, readBytes));}}// 10. 取消選擇鍵(已經(jīng)處理過的事件,就應該取消掉了)iterator.remove();}}}}直接內存與內核態(tài)與用戶態(tài)
從Linux系統(tǒng)角度出發(fā),內存分為倆塊
1 內核態(tài),由操作系統(tǒng)內核操作,讀寫磁盤,讀寫網(wǎng)絡都是由這負責
2 用戶態(tài),我們的c應用程序能訪問到的部分
當我們要讀文件的時候,首先由內核態(tài)負責將數(shù)據(jù)從磁盤讀到內核態(tài)里,再從內核態(tài)拷貝到我們用戶態(tài)弄內存里,c程序里操作的也就是這部分用戶態(tài)的內存。
說完c我們再說說Java,jvm啟動的時候會在用戶態(tài)申請一塊內存,申請的這塊內存中有一部分會被稱為堆,一般我我們申請的對象就會放在這個堆上,堆上的對象是受gc管理的。
那么除了堆內的內存,其他的內存都被稱為對外內存。在堆外內存中如果我們是通過Java的directbuffer申請的,那么這塊內存其實也是間接受gc管理的,而如果我們通過jni直接調用c函數(shù)申請一塊堆外內存,那么這塊內存就只能我們自己手動管理了。
當我們在Java中發(fā)起一個文件讀操作會發(fā)生什么呢?首先內核會將數(shù)據(jù)從磁盤讀到內存,再從內核拷貝到用戶態(tài)的堆外內存(這部分是jvm實現(xiàn)),然后再將數(shù)據(jù)從堆外拷貝到堆內。拷貝到堆內其實就是我們在Java中自己手動申請的byte數(shù)組中。
以上是Java傳統(tǒng)io的方式,我們發(fā)現(xiàn)經(jīng)過了倆次內存拷貝,而nio中只需要使用directbuffer,就不必將數(shù)據(jù)從堆外拷貝到堆內了,減少了一次內存拷貝,降低了內存的占用,減輕了gc的壓力。
Java中的零拷貝其實是直接調用的Linux系統(tǒng)調用,直接在內核態(tài)進行設備間的內存操作,二不必拷貝到用戶態(tài)中。(相當于用戶態(tài)引用的邏輯地址在內核態(tài)中)
直接內存在用戶態(tài)。DirectByteBuffer屬于user space,也就是用戶態(tài)。
其實本質是減少內存之間拷貝的次數(shù),因為DirectMemory直接分配的是用戶空間的內存,所以不再需要用戶空間和jvm的heap之間的拷貝,所以少了一次拷貝,節(jié)省了時間。然而這只是用戶態(tài)上的空間優(yōu)化,那么用戶態(tài)和內核態(tài)之間是否又被優(yōu)化了呢。
本質上其實就是使用直接內存減少了堆內內存和堆外內存之間的數(shù)據(jù)拷貝,直接將數(shù)據(jù)寫到堆外內存中,然后堆內內存中有個引用地址來操作這個堆外內存。用戶態(tài)和內核態(tài)的邏輯地址使用的是同一個物理空間,所以相當于用戶態(tài)和內核態(tài)也不存在拷貝。
NIO適用的場景
如果需要管理同時打開的成千上萬個連接,這些連接每次只是發(fā)送少量的數(shù)據(jù),例如聊天服務器,這時候用NIO處理數(shù)據(jù)可能是個很好的選擇。
適用于連接數(shù)比較多且連接比較短(輕操作)的架構,比如聊天服務器,并發(fā)局限于應用;編程比較復雜,jdk1.4開始支持;
NIO三種模型
基本可以認為 “NIO = I/O多路復用 + 非阻塞式I/O”,大部分情況下是單線程,但也有超過一個線程實現(xiàn)NIO的情況
上面所講到的只需要一個線程就可以同時處理多個套接字,這只是其中的一種單線程模型,是一種較為極端的情況,NIO主要包含三種線程NIO三種模型
Reactor單線程模型
單個線程完成所有事情包括接收客戶端的TCP連接請求,讀取和寫入套接字數(shù)據(jù)等。
對于一些小容量應用場景,可以使用單線程模型。但是對于高負載、大并發(fā)的應用卻不合適 主要原因如下:
[1]一個NIO線程同時處理成百上千的鏈路,性能上無法支撐,即便NIO線程的CPU負荷達到100%,也無法滿足海量消息的編碼、解碼、讀取和發(fā)送;
[2]當NIO線程負載過重之后,處理速度將變慢,這會導致大量客戶端連接超時,超時之后往往會進行重發(fā),這更加重了NIO線程的負載,最終會導致大量消息積壓和處理超時,NIO線程會成為系統(tǒng)的性能瓶頸;
[3]可靠性問題:一旦NIO線程意外跑飛,或者進入死循環(huán),會導致整個系統(tǒng)通信模塊不可用,不能接收和處理外部消息,造成節(jié)點故障。
Reactor多線程模型
Rector多線程模型與單線程模型最大的區(qū)別就是有一組NIO線程處理真實的IO操作。
[1] 有專門一個NIO線程-Acceptor線程用于監(jiān)聽服務端,接收客戶端的TCP連接請求;
[2] 網(wǎng)絡IO操作-讀、寫等由一個NIO線程池負責,線程池可以采用標準的JDK線程池實現(xiàn),它包含一個任務隊列和N個可用的線程,由這些NIO線程負責消息的讀取、解碼、編碼和發(fā)送;
[3] 1個NIO線程可以同時處理N條鏈路,但是1個鏈路只對應1個NIO線程,防止發(fā)生并發(fā)操作問題。
主從Reactor多線程模型
在絕大多數(shù)場景下,Reactor多線程模型都可以滿足性能需求;但是,在極特殊應用場景中,一個NIO線程負責監(jiān)聽和處理所有的客戶端連接可能會存在性能問題。例如百萬客戶端并發(fā)連接,或者服務端需要對客戶端的握手消息進行安全認證,認證本身非常損耗性能。在這類場景下,單獨一個Acceptor線程可能會存在性能不足問題。
為了解決性能問題,產生了第三種Reactor線程模型-主從Reactor多線程模型
主從Reactor線程模型的特點,服務端用于接收客戶端連接的不再是個1個單獨的NIO線程,而是一個獨立的NIO線程池bossGroup。Acceptor接收到客戶端TCP連接請求并處理完成后,將新創(chuàng)建的SocketChannel注冊到workGroup線程池的某個IO線程上,由它負責SocketChannel的讀寫和編解碼工作。
(1)bossGroup:監(jiān)聽ServerSocketChannel,接收到客戶端連接請求后交由Acceptor處理,成功建立連接后將SocketChannel派發(fā)給workGroup。
(2)Acceptor:聯(lián)想Socket編程大概也能猜到這是處理客戶端請求鏈接的,Acceptor僅僅完成登錄、握手和安全認證等操作,一旦鏈路建立成功,就將SocketChannel注冊到后端workGroup線程池的IO線程上,由IO線程負責后續(xù)的IO操作。
(3)workGroup:監(jiān)聽SocketChannel的IO事件,完成編碼、解碼以及相應業(yè)務處理。
?
阻塞非阻塞,同步異步
同步和異步
所謂同步,指的是協(xié)同步調。既然叫協(xié)同,所以至少要有2個以上的事物存在。協(xié)同的結果就是:
多個事物不能同時進行,必須一個一個的來,上一個事物結束后,下一個事物才開始。
那當一個事物正在進行時,其它事物都在干嘛呢?
嚴格來講這個并沒有要求,但一般都是處于一種“等待”的狀態(tài),因為通常后面事物的正常進行都需要依賴前面事物的結果或前面事物正在使用的資源。
因此,可以認為,同步更希望關注的是從宏觀整體來看,多個事物是一種逐個逐個的串行化關系,絕對不會出現(xiàn)交叉的情況。
所以,自然也不太會去關注某個瞬間某個具體事物是處于一個什么狀態(tài)。
把這個理論應用的出神入化的非“排隊”莫屬。凡是在資源少需求多的場景下都會用到排隊。
比如排隊買火車票這件事:
其實售票大廳更在意的是旅客一個一個的到窗口去買票,因為一次只能賣一張票。
即使大家一窩蜂的都圍上去,還是一次只能賣一張票,何必呢?擠在一起又不安全。
只是有些人素質太差,非要往上擠,售票大廳迫不得已,采用排隊這種形式來達到自己的目的,即一個一個的買票。
至于每個旅客排隊時的狀態(tài),是看手機呀還是說話呀,根本不用去在意。
除了這種由于資源導致的同步外,還存在一種由于邏輯上的先后順序導致的同步。
比如,先更新代碼,然后再編譯,接著再打包。這些操作由于后一步要使用上一步的結果,所以只能按照這種順序一個一個的執(zhí)行。
關于同步還需知道兩個小的點:
一是范圍,并不需要在全局范圍內都去同步,只需要在某些關鍵的點執(zhí)行同步即可。
比如食堂只有一個賣飯窗口,肯定是同步的,一個人買完,下一個人再買。但吃飯的時候也是一個人吃完,下一個人才開始吃嗎?當然不是啦。
二是粒度,并不是只有大粒度的事物才有同步,小粒度的事物也有同步。
只不過小粒度的事物同步通常是天然支持的,而大粒度的事物同步往往需要手工處理。
比如兩個線程的同步就需要手工處理,但一個線程里的兩個語句天然就是同步的。
所謂異步,就是步調各異。既然是各異,那就是都不相同。所以結果就是:
多個事物可以你進行你的、我進行我的,誰都不用管誰,所有的事物都在同時進行中。
一言以蔽之,同步就是多個事物不能同時開工,異步就是多個事物可以同時開工。
注:一定要去體會“多個事物”,多個線程是多個事物,多個方法是多個事物,多個語句是多個事物,多個CPU指令是多個事物。等等等等。
阻塞和非阻塞
所謂阻塞,指的是阻礙堵塞。它的本意可以理解為由于遇到了障礙而造成的動彈不得。
所謂非阻塞,自然是和阻塞相對,可以理解為由于沒有遇到障礙而繼續(xù)暢通無阻。
對這兩個詞最好的詮釋就是,當今中國一大交通難題,堵車:
汽車可以正常通行時,就是非阻塞。一旦堵上了,全部趴窩,一動不動,就是阻塞。
因此阻塞關注的是不能動,非阻塞關注的是可以動。
不能動的結果就是只能等待,可以動的結果就是繼續(xù)前行。
因此和阻塞搭配的詞一定是等待,和非阻塞搭配的詞一定是進行。
回到程序里,阻塞同樣意味著停下來等待,非阻塞表明可以繼續(xù)向下執(zhí)行。
阻塞和等待
等待只是阻塞的一個副作用而已,表明隨著時間的流逝,沒有任何有意義的事物發(fā)生或進行。
阻塞的真正含義是你關心的事物由于某些原因無法繼續(xù)進行,因此讓你等待。但沒必要干等,你可以做一些其它無關的事物,因為這并不影響你對相關事物的等待。
在堵車時,你可以干等。也可以玩手機、和別人聊天,或者打牌、甚至先去吃飯都行。因為這些事物并不影響你對堵車的等待。不過你的車必須呆在原地。
在計算機里,是沒有人這么靈活的,一般在阻塞時,選在干等,因為這最容易實現(xiàn),只需要掛起線程,讓出CPU即可。在條件滿足時,會重新調度該線程。
兩兩組合
所謂同步/異步,關注的是能不能同時開工。
所謂阻塞/非阻塞,關注的是能不能動。
通過推理進行組合:
同步阻塞,不能同時開工,也不能動。只有一條小道,一次只能過一輛車,可悲的是還TMD的堵上了。
同步非阻塞,不能同時開工,但可以動。只有一條小道,一次只能過一輛車,幸運的是可以正常通行。
異步阻塞,可以同時開工,但不可以動。有多條路,每條路都可以跑車,可氣的是全都TMD的堵上了。
異步非阻塞,可以工時開工,也可以動。有多條路,每條路都可以跑車,很爽的是全都可以正常通行。
是不是很容易理解啊。其實它們的關注點是不同的,只要搞明白了這點,組合起來也不是事兒。
回到程序里,把它們和線程關聯(lián)起來:
同步阻塞,相當于一個線程在等待。
同步非阻塞,相當于一個線程在正常運行。
異步阻塞,相當于多個線程都在等待。
異步非阻塞,相當于多個線程都在正常運行。
I/O
IO指的就是讀入/寫出數(shù)據(jù)的過程,和等待讀入/寫出數(shù)據(jù)的過程。一旦拿到數(shù)據(jù)后就變成了數(shù)據(jù)操作了,就不是IO了。
拿網(wǎng)絡IO來說,等待的過程就是數(shù)據(jù)從網(wǎng)絡到網(wǎng)卡再到內核空間。讀寫的過程就是內核空間和用戶空間的相互拷貝。
所以IO就包括兩個過程,一個是等待數(shù)據(jù)的過程,一個是讀寫(拷貝)數(shù)據(jù)的過程。而且還要明白,一定不能包括操作數(shù)據(jù)的過程。
阻塞IO和非阻塞IO
應用程序都是運行在用戶空間的,所以它們能操作的數(shù)據(jù)也都在用戶空間。按照這樣子來理解,只要數(shù)據(jù)沒有到達用戶空間,用戶線程就操作不了。
如果此時用戶線程已經(jīng)參與,那它一定會被阻塞在IO上。這就是常說的阻塞IO。用戶線程被阻塞在等待數(shù)據(jù)上或拷貝數(shù)據(jù)上。
非阻塞IO就是用戶線程不參與以上兩個過程,即數(shù)據(jù)已經(jīng)拷貝到用戶空間后,才去通知用戶線程,一上來就可以直接操作數(shù)據(jù)了。
用戶線程沒有因為IO的事情出現(xiàn)阻塞,這就是常說的非阻塞IO。
同步IO和同步阻塞IO
按照上文中對同步的理解,同步IO是指發(fā)起IO請求后,必須拿到IO的數(shù)據(jù)才可以繼續(xù)執(zhí)行。
按照程序的表現(xiàn)形式又分為兩種:
在等待數(shù)據(jù)的過程中,和拷貝數(shù)據(jù)的過程中,線程都在阻塞,這就是同步阻塞IO。
在等待數(shù)據(jù)的過程中,線程采用死循環(huán)式輪詢,在拷貝數(shù)據(jù)的過程中,線程在阻塞,這其實還是同步阻塞IO。
網(wǎng)上很多文章把第二種歸為同步非阻塞IO,這肯定是錯誤的,它一定是阻塞IO,因為拷貝數(shù)據(jù)的過程,線程是阻塞的。
嚴格來講,在IO的概念上,同步和非阻塞是不可能搭配的,因為它們是一對相悖的概念。
同步IO意味著必須拿到IO的數(shù)據(jù),才可以繼續(xù)執(zhí)行。因為后續(xù)操作依賴IO數(shù)據(jù),所以它必須是阻塞的。
非阻塞IO意味著發(fā)起IO請求后,可以繼續(xù)往下執(zhí)行。說明后續(xù)執(zhí)行不依賴于IO數(shù)據(jù),所以它肯定不是同步的。
因此,在IO上,同步和非阻塞是互斥的,所以不存在同步非阻塞IO。但同步非阻塞是存在的,那不叫IO,叫操作數(shù)據(jù)了。
所以,同步IO一定是阻塞IO,同步IO也就是同步阻塞IO。
異步IO和異步阻塞/非阻塞IO
按照上文中對異步的理解,異步IO是指發(fā)起IO請求后,不用拿到IO的數(shù)據(jù)就可以繼續(xù)執(zhí)行。
用戶線程的繼續(xù)執(zhí)行,和操作系統(tǒng)準備IO數(shù)據(jù)的過程是同時進行的,因此才叫做異步IO。
按照IO數(shù)據(jù)的兩個過程,又可以分為兩種:
在等待數(shù)據(jù)的過程中,用戶線程繼續(xù)執(zhí)行,在拷貝數(shù)據(jù)的過程中,線程在阻塞,這就是異步阻塞IO。
在等待數(shù)據(jù)的過程中,和拷貝數(shù)據(jù)的過程中,用戶線程都在繼續(xù)執(zhí)行,這就是異步非阻塞IO。
第一種情況是,用戶線程沒有參與數(shù)據(jù)等待的過程,所以它是異步的。但用戶線程參與了數(shù)據(jù)拷貝的過程,所以它又是阻塞的。合起來就是異步阻塞IO。
第二種情況是,用戶線程既沒有參與等待過程也沒有參與拷貝過程,所以它是異步的。當它接到通知時,數(shù)據(jù)已經(jīng)準備好了,它沒有因為IO數(shù)據(jù)而阻塞過,所以它又是非阻塞的。合起來就是異步非阻塞IO。
?
零拷貝DMA
為什么要有 DMA 技術?
在沒有 DMA 技術前,I/O 的過程是這樣的:
CPU 發(fā)出對應的指令給磁盤控制器,然后返回;
磁盤控制器收到指令后,于是就開始準備數(shù)據(jù),會把數(shù)據(jù)放入到磁盤控制器的內部緩沖區(qū)中,然后產生一個中斷;
CPU 收到中斷信號后,停下手頭的工作,接著把磁盤控制器的緩沖區(qū)的數(shù)據(jù)一次一個字節(jié)地讀進自己的寄存器,然后再把寄存器里的數(shù)據(jù)寫入到內存,而在數(shù)據(jù)傳輸?shù)钠陂g CPU 是無法執(zhí)行其他任務的。
為了方便你理解,我畫了一副圖:
?
可以看到,整個數(shù)據(jù)的傳輸過程,都要需要 CPU 親自參與搬運數(shù)據(jù)的過程,而且這個過程,CPU 是不能做其他事情的。
簡單的搬運幾個字符數(shù)據(jù)那沒問題,但是如果我們用千兆網(wǎng)卡或者硬盤傳輸大量數(shù)據(jù)的時候,都用 CPU 來搬運的話,肯定忙不過來。
計算機科學家們發(fā)現(xiàn)了事情的嚴重性后,于是就發(fā)明了 DMA 技術,也就是直接內存訪問(Direct Memory Access)?技術。
什么是 DMA 技術?簡單理解就是,在進行 I/O 設備和內存的數(shù)據(jù)傳輸?shù)臅r候,數(shù)據(jù)搬運的工作全部交給 DMA 控制器,而 CPU 不再參與任何與數(shù)據(jù)搬運相關的事情,這樣 CPU 就可以去處理別的事務。
那使用 DMA 控制器進行數(shù)據(jù)傳輸?shù)倪^程究竟是什么樣的呢?下面我們來具體看看。
??
具體過程:
用戶進程調用 read 方法,向操作系統(tǒng)發(fā)出 I/O 請求,請求讀取數(shù)據(jù)到自己的內存緩沖區(qū)中,進程進入阻塞狀態(tài);
操作系統(tǒng)收到請求后,進一步將 I/O 請求發(fā)送 DMA,然后讓 CPU 執(zhí)行其他任務;
DMA 進一步將 I/O 請求發(fā)送給磁盤;
磁盤收到 DMA 的 I/O 請求,把數(shù)據(jù)從磁盤讀取到磁盤控制器的緩沖區(qū)中,當磁盤控制器的緩沖區(qū)被讀滿后,向 DMA 發(fā)起中斷信號,告知自己緩沖區(qū)已滿;
DMA 收到磁盤的信號,將磁盤控制器緩沖區(qū)中的數(shù)據(jù)拷貝到內核緩沖區(qū)中,此時不占用 CPU,CPU 可以執(zhí)行其他任務;
當 DMA 讀取了足夠多的數(shù)據(jù),就會發(fā)送中斷信號給 CPU;
CPU 收到 DMA 的信號,知道數(shù)據(jù)已經(jīng)準備好,于是將數(shù)據(jù)從內核拷貝到用戶空間,系統(tǒng)調用返回;
可以看到, 整個數(shù)據(jù)傳輸?shù)倪^程,CPU 不再參與數(shù)據(jù)搬運的工作,而是全程由 DMA 完成,但是 CPU 在這個過程中也是必不可少的,因為傳輸什么數(shù)據(jù),從哪里傳輸?shù)侥睦?#xff0c;都需要 CPU 來告訴 DMA 控制器。
早期 DRM 只存在在主板上,如今由于 I/O 設備越來越多,數(shù)據(jù)傳輸?shù)男枨笠膊槐M相同,所以每個 I/O 設備里面都有自己的 DMA 控制器。
傳統(tǒng)的文件傳輸有多糟糕?
如果服務端要提供文件傳輸?shù)墓δ?#xff0c;我們能想到的最簡單的方式是:將磁盤上的文件讀取出來,然后通過網(wǎng)絡協(xié)議發(fā)送給客戶端。
傳統(tǒng) I/O 的工作方式是,數(shù)據(jù)讀取和寫入是從用戶空間到內核空間來回復制,而內核空間的數(shù)據(jù)是通過操作系統(tǒng)層面的 I/O 接口從磁盤讀取或寫入。
代碼通常如下,一般會需要兩個系統(tǒng)調用:
read(file,?tmp_buf,?len); write(socket,?tmp_buf,?len);代碼很簡單,雖然就兩行代碼,但是這里面發(fā)生了不少的事情。
?
首先,期間共發(fā)生了 4 次用戶態(tài)與內核態(tài)的上下文切換,因為發(fā)生了兩次系統(tǒng)調用,一次是?read()?,一次是?write(),每次系統(tǒng)調用都得先從用戶態(tài)切換到內核態(tài),等內核完成任務后,再從內核態(tài)切換回用戶態(tài)。
上下文切換到成本并不小,一次切換需要耗時幾十納秒到幾微秒,雖然時間看上去很短,但是在高并發(fā)的場景下,這類時間容易被累積和放大,從而影響系統(tǒng)的性能。
其次,還發(fā)生了 4 次數(shù)據(jù)拷貝,其中兩次是 DMA 的拷貝,另外兩次則是通過 CPU 拷貝的,下面說一下這個過程:
第一次拷貝,把磁盤上的數(shù)據(jù)拷貝到操作系統(tǒng)內核的緩沖區(qū)里,這個拷貝的過程是通過 DMA 搬運的。
第二次拷貝,把內核緩沖區(qū)的數(shù)據(jù)拷貝到用戶的緩沖區(qū)里,于是我們應用程序就可以使用這部分數(shù)據(jù)了,這個拷貝到過程是由 CPU 完成的。
第三次拷貝,把剛才拷貝到用戶的緩沖區(qū)里的數(shù)據(jù),再拷貝到內核的 socket 的緩沖區(qū)里,這個過程依然還是由 CPU 搬運的。
第四次拷貝,把內核的 socket 緩沖區(qū)里的數(shù)據(jù),拷貝到網(wǎng)卡的緩沖區(qū)里,這個過程又是由 DMA 搬運的。
我們回過頭看這個文件傳輸?shù)倪^程,我們只是搬運一份數(shù)據(jù),結果卻搬運了 4 次,過多的數(shù)據(jù)拷貝無疑會消耗 CPU 資源,大大降低了系統(tǒng)性能。
這種簡單又傳統(tǒng)的文件傳輸方式,存在冗余的上文切換和數(shù)據(jù)拷貝,在高并發(fā)系統(tǒng)里是非常糟糕的,多了很多不必要的開銷,會嚴重影響系統(tǒng)性能。
所以,要想提高文件傳輸?shù)男阅?#xff0c;就需要減少「用戶態(tài)與內核態(tài)的上下文切換」和「內存拷貝」的次數(shù)。?
如何優(yōu)化文件傳輸?shù)男阅?#xff1f;
先來看看,如何減少「用戶態(tài)與內核態(tài)的上下文切換」的次數(shù)呢?
讀取磁盤數(shù)據(jù)的時候,之所以要發(fā)生上下文切換,這是因為用戶空間沒有權限操作磁盤或網(wǎng)卡,內核的權限最高,這些操作設備的過程都需要交由操作系統(tǒng)內核來完成,所以一般要通過內核去完成某些任務的時候,就需要使用操作系統(tǒng)提供的系統(tǒng)調用函數(shù)。
而一次系統(tǒng)調用必然會發(fā)生 2 次上下文切換:首先從用戶態(tài)切換到內核態(tài),當內核執(zhí)行完任務后,再切換回用戶態(tài)交由進程代碼執(zhí)行。
所以,要想減少上下文切換到次數(shù),就要減少系統(tǒng)調用的次數(shù)。
如何減少數(shù)據(jù)拷貝的次數(shù)?
在前面我們知道了,傳統(tǒng)的文件傳輸方式會歷經(jīng) 4 次數(shù)據(jù)拷貝,而且這里面,「從內核的讀緩沖區(qū)拷貝到用戶的緩沖區(qū)里,再從用戶的緩沖區(qū)里拷貝到 socket 的緩沖區(qū)里」,這個過程是沒有必要的。
因為文件傳輸?shù)膽脠鼍爸?#xff0c;在用戶空間我們并不會對數(shù)據(jù)「再加工」,所以數(shù)據(jù)實際上可以不用搬運到用戶空間,因此用戶的緩沖區(qū)是沒有必要存在的。
零拷貝簡介
我們以讀操作為例,假設用戶程序發(fā)起一次讀請求。
其實會調用read相關的「系統(tǒng)函數(shù)」,然后會從用戶態(tài)切換到內核態(tài),隨后CPU會告訴DMA去磁盤把數(shù)據(jù)拷貝到內核空間。
等到「內核緩沖區(qū)」真正有數(shù)據(jù)之后,CPU會把「內核緩存區(qū)」數(shù)據(jù)拷貝到「用戶緩沖區(qū)」,最終用戶程序才能獲取到。
稍微解釋一下:為了保證內核的安全,操心系統(tǒng)將虛擬空間劃分為「用戶空間」和「內核空間」,所以在讀系統(tǒng)數(shù)據(jù)的時候會有狀態(tài)切換
??
因為應用程序不能直接去讀取硬盤的數(shù)據(jù),從上面描述可知需要依賴「內核緩沖區(qū)」
一次讀操作會讓DMA將磁盤數(shù)據(jù)拷貝到內核緩沖區(qū),CPU將內核緩沖區(qū)數(shù)據(jù)拷貝到用戶緩沖區(qū)。
所謂的零拷貝就是將「CPU將內核緩沖區(qū)數(shù)據(jù)拷貝到用戶緩沖區(qū)」這次CPU拷貝給省去,來提高效率和性能
常見的零拷貝技術有mmap(內核緩沖區(qū)與用戶緩沖區(qū)的共享)、sendfile(系統(tǒng)底層函數(shù)支持)。
零拷貝可以提高數(shù)據(jù)傳輸?shù)男阅?#xff0c;這塊在Kafka等框架也有相關的實踐。
??
如何實現(xiàn)零拷貝?
零拷貝技術實現(xiàn)的方式通常有 2 種:
mmap + write
sendfile
下面就談一談,它們是如何減少「上下文切換」和「數(shù)據(jù)拷貝」的次數(shù)。
mmap + write
在前面我們知道,read()?系統(tǒng)調用的過程中會把內核緩沖區(qū)的數(shù)據(jù)拷貝到用戶的緩沖區(qū)里,于是為了減少這一步開銷,我們可以用?mmap()?替換?read()?系統(tǒng)調用函數(shù)。
buf?=?mmap(file,?len); write(sockfd,?buf,?len);mmap()?系統(tǒng)調用函數(shù)會直接把內核緩沖區(qū)里的數(shù)據(jù)「映射」到用戶空間,這樣,操作系統(tǒng)內核與用戶空間就不需要再進行任何的數(shù)據(jù)拷貝操作。
?
具體過程如下:
應用進程調用了?mmap()?后,DMA 會把磁盤的數(shù)據(jù)拷貝到內核的緩沖區(qū)里。接著,應用進程跟操作系統(tǒng)內核「共享」這個緩沖區(qū);
應用進程再調用?write(),操作系統(tǒng)直接將內核緩沖區(qū)的數(shù)據(jù)拷貝到 socket 緩沖區(qū)中,這一切都發(fā)生在內核態(tài),由 CPU 來搬運數(shù)據(jù);
最后,把內核的 socket 緩沖區(qū)里的數(shù)據(jù),拷貝到網(wǎng)卡的緩沖區(qū)里,這個過程是由 DMA 搬運的。
我們可以得知,通過使用?mmap()?來代替?read(), 可以減少一次數(shù)據(jù)拷貝的過程。
但這還不是最理想的零拷貝,因為仍然需要通過 CPU 把內核緩沖區(qū)的數(shù)據(jù)拷貝到 socket 緩沖區(qū)里,而且仍然需要 4 次上下文切換,因為系統(tǒng)調用還是 2 次。
sendfile
在 Linux 內核版本 2.1 中,提供了一個專門發(fā)送文件的系統(tǒng)調用函數(shù)?sendfile(),函數(shù)形式如下:
#include?<sys/socket.h> ssize_t?sendfile(int?out_fd,?int?in_fd,?off_t?*offset,?size_t?count);它的前兩個參數(shù)分別是目的端和源端的文件描述符,后面兩個參數(shù)是源端的偏移量和復制數(shù)據(jù)的長度,返回值是實際復制數(shù)據(jù)的長度。
首先,它可以替代前面的?read()?和?write()?這兩個系統(tǒng)調用,這樣就可以減少一次系統(tǒng)調用,也就減少了 2 次上下文切換的開銷。
其次,該系統(tǒng)調用,可以直接把內核緩沖區(qū)里的數(shù)據(jù)拷貝到 socket 緩沖區(qū)里,不再拷貝到用戶態(tài),這樣就只有 2 次上下文切換,和 3 次數(shù)據(jù)拷貝。如下圖:
?
但是這還不是真正的零拷貝技術,如果網(wǎng)卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技術(和普通的 DMA 有所不同),我們可以進一步減少通過 CPU 把內核緩沖區(qū)里的數(shù)據(jù)拷貝到 socket 緩沖區(qū)的過程。
你可以在你的 Linux 系統(tǒng)通過下面這個命令,查看網(wǎng)卡是否支持 scatter-gather 特性:
$?ethtool?-k?eth0?|?grep?scatter-gather scatter-gather:?on于是,從 Linux 內核?2.4?版本開始起,對于支持網(wǎng)卡支持 SG-DMA 技術的情況下,?sendfile()?系統(tǒng)調用的過程發(fā)生了點變化,具體過程如下:
第一步,通過 DMA 將磁盤上的數(shù)據(jù)拷貝到內核緩沖區(qū)里;
第二步,緩沖區(qū)描述符和數(shù)據(jù)長度傳到 socket 緩沖區(qū),這樣網(wǎng)卡的 SG-DMA 控制器就可以直接將內核緩存中的數(shù)據(jù)拷貝到網(wǎng)卡的緩沖區(qū)里,此過程不需要將數(shù)據(jù)從操作系統(tǒng)內核緩沖區(qū)拷貝到 socket 緩沖區(qū)中,這樣就減少了一次數(shù)據(jù)拷貝;
所以,這個過程之中,只進行了 2 次數(shù)據(jù)拷貝,如下圖:
?
這就是所謂的零拷貝(Zero-copy)技術,因為我們沒有在內存層面去拷貝數(shù)據(jù),也就是說全程沒有通過 CPU 來搬運數(shù)據(jù),所有的數(shù)據(jù)都是通過 DMA 來進行傳輸?shù)摹?/strong>
零拷貝技術的文件傳輸方式相比傳統(tǒng)文件傳輸?shù)姆绞?#xff0c;減少了 2 次上下文切換和數(shù)據(jù)拷貝次數(shù),只需要 2 次上下文切換和數(shù)據(jù)拷貝次數(shù),就可以完成文件的傳輸,而且 2 次的數(shù)據(jù)拷貝過程,都不需要通過 CPU,2 次都是由 DMA 來搬運。
所以,總體來看,零拷貝技術可以把文件傳輸?shù)男阅芴岣咧辽僖槐兑陨?/strong>。
使用零拷貝技術的項目
事實上,Kafka 這個開源項目,就利用了「零拷貝」技術,從而大幅提升了 I/O 的吞吐率,這也是 Kafka 在處理海量數(shù)據(jù)為什么這么快的原因之一。
如果你追溯 Kafka 文件傳輸?shù)拇a,你會發(fā)現(xiàn),最終它調用了 Java NIO 庫里的?transferTo方法:
@Overridepublic? long?transferFrom(FileChannel?fileChannel,?long?position,?long?count)?throws?IOException?{?return?fileChannel.transferTo(position,?count,?socketChannel); }如果 Linux 系統(tǒng)支持?sendfile()?系統(tǒng)調用,那么?transferTo()?實際上最后就會使用到?sendfile()?系統(tǒng)調用函數(shù)。
曾經(jīng)有大佬專門寫過程序測試過,在同樣的硬件條件下,傳統(tǒng)文件傳輸和零拷拷貝文件傳輸?shù)男阅懿町?#xff0c;你可以看到下面這張測試數(shù)據(jù)圖,使用了零拷貝能夠縮短?65%?的時間,大幅度提升了機器傳輸數(shù)據(jù)的吞吐量。
?
另外,Nginx 也支持零拷貝技術,一般默認是開啟零拷貝技術,這樣有利于提高文件傳輸?shù)男?#xff0c;是否開啟零拷貝技術的配置如下:
http?{ ...sendfile?on ... }sendfile 配置的具體意思:?
設置為 on 表示,使用零拷貝技術來傳輸文件:sendfile ,這樣只需要 2 次上下文切換,和 2 次數(shù)據(jù)拷貝。
設置為 off 表示,使用傳統(tǒng)的文件傳輸技術:read + write,這時就需要 4 次上下文切換,和 4 次數(shù)據(jù)拷貝。
當然,要使用 sendfile,Linux 內核版本必須要 2.1 以上的版本。
PageCache 有什么作用?
回顧前面說道文件傳輸過程,其中第一步都是先需要先把磁盤文件數(shù)據(jù)拷貝「內核緩沖區(qū)」里,這個「內核緩沖區(qū)」實際上是磁盤高速緩存(PageCache)。
由于零拷貝使用了 PageCache 技術,可以使得零拷貝進一步提升了性能,我們接下來看看 PageCache 是如何做到這一點的。
讀寫磁盤相比讀寫內存的速度慢太多了,所以我們應該想辦法把「讀寫磁盤」替換成「讀寫內存」。于是,我們會通過 DMA 把磁盤里的數(shù)據(jù)搬運到內存里,這樣就可以用讀內存替換讀磁盤。
但是,內存空間遠比磁盤要小,內存注定只能拷貝磁盤里的一小部分數(shù)據(jù)。
那問題來了,選擇哪些磁盤數(shù)據(jù)拷貝到內存呢?
我們都知道程序運行的時候,具有「局部性」,所以通常,剛被訪問的數(shù)據(jù)在短時間內再次被訪問的概率很高,于是我們可以用?PageCache 來緩存最近被訪問的數(shù)據(jù),當空間不足時淘汰最久未被訪問的緩存。
所以,讀磁盤數(shù)據(jù)的時候,優(yōu)先在 PageCache 找,如果數(shù)據(jù)存在則可以直接返回;如果沒有,則從磁盤中讀取,然后緩存 PageCache 中。
還有一點,讀取磁盤數(shù)據(jù)的時候,需要找到數(shù)據(jù)所在的位置,但是對于機械磁盤來說,就是通過磁頭旋轉到數(shù)據(jù)所在的扇區(qū),再開始「順序」讀取數(shù)據(jù),但是旋轉磁頭這個物理動作是非常耗時的,為了降低它的影響,PageCache 使用了「預讀功能」。
比如,假設 read 方法每次只會讀?32 KB?的字節(jié),雖然 read 剛開始只會讀 0 ~ 32 KB 的字節(jié),但內核會把其后面的 32~64 KB 也讀取到 PageCache,這樣后面讀取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,進程讀取到它了,收益就非常大。
所以,PageCache 的優(yōu)點主要是兩個:
緩存最近被訪問的數(shù)據(jù);
預讀功能;
這兩個做法,將大大提高讀寫磁盤的性能。
但是,在傳輸大文件(GB 級別的文件)的時候,PageCache 會不起作用,那就白白浪費 DRM 多做的一次數(shù)據(jù)拷貝,造成性能的降低,即使使用了 PageCache 的零拷貝也會損失性能
這是因為如果你有很多 GB 級別文件需要傳輸,每當用戶訪問這些大文件的時候,內核就會把它們載入 PageCache 中,于是 PageCache 空間很快被這些大文件占滿。
另外,由于文件太大,可能某些部分的文件數(shù)據(jù)被再次訪問的概率比較低,這樣就會帶來 2 個問題:
PageCache 由于長時間被大文件占據(jù),其他「熱點」的小文件可能就無法充分使用到 PageCache,于是這樣磁盤讀寫的性能就會下降了;
PageCache 中的大文件數(shù)據(jù),由于沒有享受到緩存帶來的好處,但卻耗費 DMA 多拷貝到 PageCache 一次;
所以,針對大文件的傳輸,不應該使用 PageCache,也就是說不應該使用零拷貝技術,因為可能由于 PageCache 被大文件占據(jù),而導致「熱點」小文件無法利用到 PageCache,這樣在高并發(fā)的環(huán)境下,會帶來嚴重的性能問題。
大文件傳輸用什么方式實現(xiàn)?
那針對大文件的傳輸,我們應該使用什么方式呢?
我們先來看看最初的例子,當調用 read 方法讀取文件時,進程實際上會阻塞在 read 方法調用,因為要等待磁盤數(shù)據(jù)的返回,如下圖:
?
具體過程:
當調用 read 方法時,會阻塞著,此時內核會向磁盤發(fā)起 I/O 請求,磁盤收到請求后,便會尋址,當磁盤數(shù)據(jù)準備好后,就會向內核發(fā)起 I/O 中斷,告知內核磁盤數(shù)據(jù)已經(jīng)準備好;
內核收到 I/O 中斷后,就將數(shù)據(jù)從磁盤控制器緩沖區(qū)拷貝到 PageCache 里;
最后,內核再把 PageCache 中的數(shù)據(jù)拷貝到用戶緩沖區(qū),于是 read 調用就正常返回了。
對于阻塞的問題,可以用異步 I/O 來解決,它工作方式如下圖:
??
它把讀操作分為兩部分:
前半部分,內核向磁盤發(fā)起讀請求,但是可以不等待數(shù)據(jù)就位就可以返回,于是進程此時可以處理其他任務;
后半部分,當內核將磁盤中的數(shù)據(jù)拷貝到進程緩沖區(qū)后,進程將接收到內核的通知,再去處理數(shù)據(jù);
而且,我們可以發(fā)現(xiàn),異步 I/O 并沒有涉及到 PageCache,所以使用異步 I/O 就意味著要繞開 PageCache。
繞開 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 則叫緩存 I/O。通常,對于磁盤,異步 I/O 只支持直接 I/O。
前面也提到,大文件的傳輸不應該使用 PageCache,因為可能由于 PageCache 被大文件占據(jù),而導致「熱點」小文件無法利用到 PageCache。
于是,在高并發(fā)的場景下,針對大文件的傳輸?shù)姆绞?#xff0c;應該使用「異步 I/O + 直接 I/O」來替代零拷貝技術。
直接 I/O 應用場景常見的兩種:
應用程序已經(jīng)實現(xiàn)了磁盤數(shù)據(jù)的緩存,那么可以不需要 PageCache 再次緩存,減少額外的性能損耗。在 MySQL 數(shù)據(jù)庫中,可以通過參數(shù)設置開啟直接 I/O,默認是不開啟;
傳輸大文件的時候,由于大文件難以命中 PageCache 緩存,而且會占滿 PageCache 導致「熱點」文件無法充分利用緩存,從而增大了性能開銷,因此,這時應該使用直接 I/O。
另外,由于直接 I/O 繞過了 PageCache,就無法享受內核的這兩點的優(yōu)化:
內核的 I/O 調度算法會緩存盡可能多的 I/O 請求在 PageCache 中,最后「合并」成一個更大的 I/O 請求再發(fā)給磁盤,這樣做是為了減少磁盤的尋址操作;
內核也會「預讀」后續(xù)的 I/O 請求放在 PageCache 中,一樣是為了減少對磁盤的操作;
于是,傳輸大文件的時候,使用「異步 I/O + 直接 I/O」了,就可以無阻塞地讀取文件了。
所以,傳輸文件的時候,我們要根據(jù)文件的大小來使用不同的方式:
傳輸大文件的時候,使用「異步 I/O + 直接 I/O」;
傳輸小文件的時候,則使用「零拷貝技術」;
在 nginx 中,我們可以用如下配置,來根據(jù)文件的大小來使用不同的方式:
location?/video/?{?sendfile?on;?aio?on;?directio?1024m;? }當文件大小大于?directio?值后,使用「異步 I/O + 直接 I/O」,否則使用「零拷貝技術」。
零拷貝DMA總結
早期 I/O 操作,內存與磁盤的數(shù)據(jù)傳輸?shù)墓ぷ鞫际怯?CPU 完成的,而此時 CPU 不能執(zhí)行其他任務,會特別浪費 CPU 資源。
于是,為了解決這一問題,DMA 技術就出現(xiàn)了,每個 I/O 設備都有自己的 DMA 控制器,通過這個 DMA 控制器,CPU 只需要告訴 DMA 控制器,我們要傳輸什么數(shù)據(jù),從哪里來,到哪里去,就可以放心離開了。后續(xù)的實際數(shù)據(jù)傳輸工作,都會由 DMA 控制器來完成,CPU 不需要參與數(shù)據(jù)傳輸?shù)墓ぷ鳌?/p>
傳統(tǒng) IO 的工作方式,從硬盤讀取數(shù)據(jù),然后再通過網(wǎng)卡向外發(fā)送,我們需要進行 4 上下文切換,和 4 次數(shù)據(jù)拷貝,其中 2 次數(shù)據(jù)拷貝發(fā)生在內存里的緩沖區(qū)和對應的硬件設備之間,這個是由 DMA 完成,另外 2 次則發(fā)生在內核態(tài)和用戶態(tài)之間,這個數(shù)據(jù)搬移工作是由 CPU 完成的。
為了提高文件傳輸?shù)男阅?#xff0c;于是就出現(xiàn)了零拷貝技術,它通過一次系統(tǒng)調用(sendfile?方法)合并了磁盤讀取與網(wǎng)絡發(fā)送兩個操作,降低了上下文切換次數(shù)。另外,拷貝數(shù)據(jù)都是發(fā)生在內核中的,天然就降低了數(shù)據(jù)拷貝的次數(shù)。
Kafka 和 Nginx 都有實現(xiàn)零拷貝技術,這將大大提高文件傳輸?shù)男阅堋?/p>
零拷貝技術是基于 PageCache 的,PageCache 會緩存最近訪問的數(shù)據(jù),提升了訪問緩存數(shù)據(jù)的性能,同時,為了解決機械硬盤尋址慢的問題,它還協(xié)助 I/O 調度算法實現(xiàn)了 IO 合并與預讀,這也是順序讀比隨機讀性能好的原因。這些優(yōu)勢,進一步提升了零拷貝的性能。
需要注意的是,零拷貝技術是不允許進程對文件內容作進一步的加工的,比如壓縮數(shù)據(jù)再發(fā)送。
另外,當傳輸大文件時,不能使用零拷貝,因為可能由于 PageCache 被大文件占據(jù),而導致「熱點」小文件無法利用到 PageCache,并且大文件的緩存命中率不高,這時就需要使用「異步 IO + 直接 IO 」的方式。
在 Nginx 里,可以通過配置,設定一個文件大小閾值,針對大文件使用異步 IO 和直接 IO,而對小文件使用零拷貝。
IO 多路復用
阻塞 IO
服務端為了處理客戶端的連接和請求的數(shù)據(jù),寫了如下代碼。
listenfd?=?socket();???//?打開一個網(wǎng)絡通信端口 bind(listenfd);????????//?綁定 listen(listenfd);??????//?監(jiān)聽 while(1)?{connfd?=?accept(listenfd);??//?阻塞建立連接int?n?=?read(connfd,?buf);??//?阻塞讀數(shù)據(jù)doSomeThing(buf);??//?利用讀到的數(shù)據(jù)做些什么close(connfd);?????//?關閉連接,循環(huán)等待下一個連接 }這段代碼會執(zhí)行得磕磕絆絆,就像這樣。
可以看到,服務端的線程阻塞在了兩個地方,一個是 accept 函數(shù),一個是 read 函數(shù)。
如果再把 read 函數(shù)的細節(jié)展開,我們會發(fā)現(xiàn)其阻塞在了兩個階段。
?
?
這就是傳統(tǒng)的阻塞 IO。
整體流程如下圖。
?
所以,如果這個連接的客戶端一直不發(fā)數(shù)據(jù),那么服務端線程將會一直阻塞在 read 函數(shù)上不返回,也無法接受其他客戶端連接。
這肯定是不行的。
非阻塞 IO
為了解決上面的問題,其關鍵在于改造這個 read 函數(shù)。
有一種聰明的辦法是,每次都創(chuàng)建一個新的進程或線程,去調用 read 函數(shù),并做業(yè)務處理。?
while(1)?{connfd?=?accept(listenfd);??//?阻塞建立連接pthread_create(doWork);??//?創(chuàng)建一個新的線程 } void?doWork()?{int?n?=?read(connfd,?buf);??//?阻塞讀數(shù)據(jù)doSomeThing(buf);??//?利用讀到的數(shù)據(jù)做些什么close(connfd);?????//?關閉連接,循環(huán)等待下一個連接 }這樣,當給一個客戶端建立好連接后,就可以立刻等待新的客戶端連接,而不用阻塞在原客戶端的 read 請求上。
不過,這不叫非阻塞 IO,只不過用了多線程的手段使得主線程沒有卡在 read 函數(shù)上不往下走罷了。操作系統(tǒng)為我們提供的 read 函數(shù)仍然是阻塞的。
所以真正的非阻塞 IO,不能是通過我們用戶層的小把戲,而是要懇請操作系統(tǒng)為我們提供一個非阻塞的 read 函數(shù)。
這個 read 函數(shù)的效果是,如果沒有數(shù)據(jù)到達時(到達網(wǎng)卡并拷貝到了內核緩沖區(qū)),立刻返回一個錯誤值(-1),而不是阻塞地等待。
操作系統(tǒng)提供了這樣的功能,只需要在調用 read 前,將文件描述符設置為非阻塞即可。
fcntl(connfd,?F_SETFL,?O_NONBLOCK); int?n?=?read(connfd,?buffer)?!=?SUCCESS);這樣,就需要用戶線程循環(huán)調用 read,直到返回值不為 -1,再開始處理業(yè)務。
這里我們注意到一個細節(jié)。
非阻塞的 read,指的是在數(shù)據(jù)到達前,即數(shù)據(jù)還未到達網(wǎng)卡,或者到達網(wǎng)卡但還沒有拷貝到內核緩沖區(qū)之前,這個階段是非阻塞的。
當數(shù)據(jù)已到達內核緩沖區(qū),此時調用 read 函數(shù)仍然是阻塞的,需要等待數(shù)據(jù)從內核緩沖區(qū)拷貝到用戶緩沖區(qū),才能返回。
整體流程如下圖
IO 多路復用介紹
為每個客戶端創(chuàng)建一個線程,服務器端的線程資源很容易被耗光。
?
當然還有個聰明的辦法,我們可以每 accept 一個客戶端連接后,將這個文件描述符(connfd)放到一個數(shù)組里。
fdlist.add(connfd);然后弄一個新的線程去不斷遍歷這個數(shù)組,調用每一個元素的非阻塞 read 方法。
while(1)?{for(fd?<--?fdlist)?{if(read(fd)?!=?-1)?{doSomeThing();}} }這樣,我們就成功用一個線程處理了多個客戶端連接。
你是不是覺得這有些多路復用的意思?
但這和我們用多線程去將阻塞 IO 改造成看起來是非阻塞 IO 一樣,這種遍歷方式也只是我們用戶自己想出的小把戲,每次遍歷遇到 read 返回 -1 時仍然是一次浪費資源的系統(tǒng)調用。
在 while 循環(huán)里做系統(tǒng)調用,就好比你做分布式項目時在 while 里做 rpc 請求一樣,是不劃算的。
所以,還是得懇請操作系統(tǒng)老大,提供給我們一個有這樣效果的函數(shù),我們將一批文件描述符通過一次系統(tǒng)調用傳給內核,由內核層去遍歷,才能真正解決這個問題。
select
select 是操作系統(tǒng)提供的系統(tǒng)調用函數(shù),通過它,我們可以把一個文件描述符的數(shù)組發(fā)給操作系統(tǒng), 讓操作系統(tǒng)去遍歷,確定哪個文件描述符可以讀寫, 然后告訴我們去處理:
select系統(tǒng)調用的函數(shù)定義如下。
int?select(int?nfds,fd_set?*readfds,fd_set?*writefds,fd_set?*exceptfds,struct?timeval?*timeout); //?nfds:監(jiān)控的文件描述符集里最大文件描述符加1 // readfds:監(jiān)控有讀數(shù)據(jù)到達文件描述符集合,傳入傳出參數(shù) // writefds:監(jiān)控寫數(shù)據(jù)到達文件描述符集合,傳入傳出參數(shù) // exceptfds:監(jiān)控異常發(fā)生達文件描述符集合, 傳入傳出參數(shù) // timeout:定時阻塞監(jiān)控時間,3種情況 //??1.NULL,永遠等下去 //??2.設置timeval,等待固定時間 //??3.設置timeval里時間均為0,檢查描述字后立即返回,輪詢服務端代碼,這樣來寫。
首先一個線程不斷接受客戶端連接,并把 socket 文件描述符放到一個 list 里。
while(1)?{connfd?=?accept(listenfd);fcntl(connfd,?F_SETFL,?O_NONBLOCK);fdlist.add(connfd); }然后,另一個線程不再自己遍歷,而是調用 select,將這批文件描述符 list 交給操作系統(tǒng)去遍歷。
while(1)?{//?把一堆文件描述符?list?傳給?select?函數(shù)//?有已就緒的文件描述符就返回,nready?表示有多少個就緒的nready?=?select(list);... }不過,當 select 函數(shù)返回后,用戶依然需要遍歷剛剛提交給操作系統(tǒng)的 list。
只不過,操作系統(tǒng)會將準備就緒的文件描述符做上標識,用戶層將不會再有無意義的系統(tǒng)調用開銷。
while(1)?{nready?=?select(list);//?用戶層依然要遍歷,只不過少了很多無效的系統(tǒng)調用for(fd?<--?fdlist)?{if(fd?!=?-1)?{//?只讀已就緒的文件描述符read(fd,?buf);//?總共只有?nready?個已就緒描述符,不用過多遍歷if(--nready?==?0)?break;}} }正如剛剛的動圖中所描述的,其直觀效果如下。(同一個動圖消耗了你兩次流量,氣不氣?)
可以看出幾個細節(jié):
1. select 調用需要傳入 fd 數(shù)組,需要拷貝一份到內核,高并發(fā)場景下這樣的拷貝消耗的資源是驚人的。(可優(yōu)化為不復制)
2. select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態(tài),是個同步過程,只不過無系統(tǒng)調用切換上下文的開銷。(內核層可優(yōu)化為異步事件通知)
3. select 僅僅返回可讀文件描述符的個數(shù),具體哪個可讀還是要用戶自己遍歷。(可優(yōu)化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
整個 select 的流程圖如下。
可以看到,這種方式,既做到了一個線程處理多個客戶端連接(文件描述符),又減少了系統(tǒng)調用的開銷(多個文件描述符只有一次 select 的系統(tǒng)調用 + n 次就緒狀態(tài)的文件描述符的 read 系統(tǒng)調用)。
poll
poll 也是操作系統(tǒng)提供的系統(tǒng)調用函數(shù)。
int?poll(struct?pollfd?*fds,?nfds_tnfds,?int?timeout);struct?pollfd?{intfd;?/*文件描述符*/shortevents;?/*監(jiān)控的事件*/shortrevents;?/*監(jiān)控事件中滿足條件返回的事件*/ };它和 select 的主要區(qū)別就是,去掉了 select 只能監(jiān)聽 1024 個文件描述符的限制。
epoll
epoll 是最終的大 boss,它解決了 select 和 poll 的一些問題。
還記得上面說的 select 的三個細節(jié)么?
1. select 調用需要傳入 fd 數(shù)組,需要拷貝一份到內核,高并發(fā)場景下這樣的拷貝消耗的資源是驚人的。(可優(yōu)化為不復制)
2. select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態(tài),是個同步過程,只不過無系統(tǒng)調用切換上下文的開銷。(內核層可優(yōu)化為異步事件通知)
3. select 僅僅返回可讀文件描述符的個數(shù),具體哪個可讀還是要用戶自己遍歷。(可優(yōu)化為只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
所以 epoll 主要就是針對這三點進行了改進。
1. 內核中保存一份文件描述符集合,無需用戶每次都重新傳入,只需告訴內核修改的部分即可。
2. 內核不再通過輪詢的方式找到就緒的文件描述符,而是通過異步 IO 事件喚醒。
3. 內核僅會將有 IO 事件的文件描述符返回給用戶,用戶也無需遍歷整個文件描述符集合。
具體,操作系統(tǒng)提供了這三個函數(shù)。
第一步,創(chuàng)建一個 epoll 句柄
int?epoll_create(int?size);第二步,向內核添加、修改或刪除要監(jiān)控的文件描述符。
int?epoll_ctl(int?epfd,?int?op,?int?fd,?struct?epoll_event?*event);第三步,類似發(fā)起了 select() 調用
int?epoll_wait(int?epfd,?struct?epoll_event?*events,?int?max?events,?int?timeout);使用起來,其內部原理就像如下一般絲滑。
如果你想繼續(xù)深入了解 epoll 的底層原理,推薦閱讀飛哥的《圖解 | 深入揭秘 epoll 是如何實現(xiàn) IO 多路復用的!》,從 linux 源碼級別,一行一行非常硬核地解讀 epoll 的實現(xiàn)原理,且配有大量方便理解的圖片,非常適合源碼控的小伙伴閱讀。
select和epoll函數(shù)的區(qū)別
select
select函數(shù)它支持最大的連接數(shù)是1024或2048,因為在select函數(shù)下要傳入fd_set參數(shù),這個fd_set的大小要么1024或2048(其實就看操作系統(tǒng)的位數(shù))
fd_set就是bitmap的數(shù)據(jù)結構,可以簡單理解為只要位為0,那說明還沒數(shù)據(jù)到緩沖區(qū),只要位為1,那說明數(shù)據(jù)已經(jīng)到緩沖區(qū)。
而select函數(shù)做的就是每次將fd_set遍歷,判斷標志位有沒有發(fā)現(xiàn)變化,如果有變化則通知程序做中斷處理。
epoll 是在Linux2.6內核正式提出,完善了select 的一些缺點。
它定義了epoll_event結構體來處理,不存在最大連接數(shù)的限制。
并且它不像select函數(shù)每次把所有的文件描述符(fd)都遍歷,簡單理解就是epoll把就緒的文件描述符(fd)專門維護了一塊空間,每次從就緒列表里邊拿就好了,不再進行對所有文件描述符(fd)進行遍歷。
?
IO多路復用總結
一切的開始,都起源于這個 read 函數(shù)是操作系統(tǒng)提供的,而且是阻塞的,我們叫它?阻塞 IO。
為了破這個局,程序員在用戶態(tài)通過多線程來防止主線程卡死。
后來操作系統(tǒng)發(fā)現(xiàn)這個需求比較大,于是在操作系統(tǒng)層面提供了非阻塞的 read 函數(shù),這樣程序員就可以在一個線程內完成多個文件描述符的讀取,這就是?非阻塞 IO。
但多個文件描述符的讀取就需要遍歷,當高并發(fā)場景越來越多時,用戶態(tài)遍歷的文件描述符也越來越多,相當于在 while 循環(huán)里進行了越來越多的系統(tǒng)調用。
后來操作系統(tǒng)又發(fā)現(xiàn)這個場景需求量較大,于是又在操作系統(tǒng)層面提供了這樣的遍歷文件描述符的機制,這就是?IO 多路復用。
多路復用有三個函數(shù),最開始是 select,然后又發(fā)明了 poll 解決了 select 文件描述符的限制,然后又發(fā)明了 epoll 解決 select 的三個不足。
所以,IO 模型的演進,其實就是時代的變化,倒逼著操作系統(tǒng)將更多的功能加到自己的內核而已。
如果你建立了這樣的思維,很容易發(fā)現(xiàn)網(wǎng)上的一些錯誤。
比如好多文章說,多路復用之所以效率高,是因為用一個線程就可以監(jiān)控多個文件描述符。
這顯然是知其然而不知其所以然,多路復用產生的效果,完全可以由用戶態(tài)去遍歷文件描述符并調用其非阻塞的 read 函數(shù)實現(xiàn)。而多路復用快的原因在于,操作系統(tǒng)提供了這樣的系統(tǒng)調用,使得原來的 while 循環(huán)里多次系統(tǒng)調用,變成了一次系統(tǒng)調用 + 內核層遍歷這些文件描述符。
就好比我們平時寫業(yè)務代碼,把原來 while 循環(huán)里調 http 接口進行批量,改成了讓對方提供一個批量添加的 http 接口,然后我們一次 rpc 請求就完成了批量添加。
Tomcat Connector(BIO, NIO, APR)三種運行模式
Tomcat支持三種接收請求的處理方式:BIO、NIO、APR 。有人測試過的結果如下:
BIO
阻塞式I/O操作即使用的是傳統(tǒng) I/O操作,Tomcat7以下版本默認情況下是以BIO模式運行的,由于每個請求都要創(chuàng)建一個線程來處理,線程開銷較大,不能處理高并發(fā)的場景,在三種模式中性能也最低。
配置如下(tomcat安裝目錄下的/conf/server.xml):
<Connector port="8080" protocol="HTTP/1.1"connectionTimeout="20000"redirectPort="8443" />啟動Tomcat,控制臺會輸出如下log:
NIO
NIO是Java 1.4 及后續(xù)版本提供的一種新的I/O操作方式,是一個基于緩沖區(qū)、并能提供非阻塞I/O操作的Java API,它擁有比傳統(tǒng)I/O操作(BIO)更好的并發(fā)運行性能。tomcat 8版本及以上默認就是在NIO模式下允許。
配置如下(tomcat安裝目錄下的/conf/server.xml):
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"connectionTimeout="20000"redirectPort="8443" />啟動Tomcat,控制臺會輸出如下log:
APR
APR(Apache Portable Runtime/Apache可移植運行時),是Apache HTTP服務器的支持庫。你可以簡單地理解為,Tomcat將以JNI的形式調用Apache HTTP服務器的核心動態(tài)鏈接庫來處理文件讀取或網(wǎng)絡傳輸操作,從而大大地提高Tomcat對靜態(tài)文件的處理性能。 Tomcat apr也是在Tomcat上運行高并發(fā)應用的首選模式。
如果我們的Tomcat不是在apr模式下運行,在啟動Tomcat的時候,我們可以在日志信息中看到類似如下信息:
INFO: The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: /usr/java/packages/lib/amd64:/usr/lib64:/lib64:/lib:/usr/lib從Tomcat 7.0.30版本開始,默認就是在Tomcat apr模式下運行。
配置如下(tomcat安裝目錄下的/conf/server.xml):
總結
以上是生活随笔為你收集整理的java NIO理论总结的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java游戏---俄罗斯方块
- 下一篇: 树莓派——槑槑智能音箱