NIO(Non-blocking I/O,在Java領域,也稱為New I/O),是一種同步非阻塞的I/O模型,也是I/O多路復用的基礎,已經被越來越多地應用到大型應用服務器,成為解決高并發與大量連接、I/O處理問題的有效方式。
IO模型的分類
按照《Unix網絡編程》的劃分,I/O模型可以分為:阻塞I/O模型、非阻塞I/O模型、I/O復用模型、信號驅動式I/O模型和異步I/O模型,按照POSIX標準來劃分只分為兩類:同步I/O和異步I/O。
如何區分呢?首先一個I/O操作其實分成了兩個步驟:發起IO請求和實際的IO操作。同步I/O和異步I/O的區別就在于第二個步驟是否阻塞,如果實際的I/O讀寫阻塞請求進程,那么就是同步I/O,因此阻塞I/O、非阻塞I/O、I/O復用、信號驅動I/O都是同步I/O,如果不阻塞,而是操作系統幫你做完I/O操作再將結果返回給你,那么就是異步I/O。
阻塞I/O和非阻塞I/O的區別在于第一步,發起I/O請求是否會被阻塞,如果阻塞直到完成那么就是傳統的阻塞I/O,如果不阻塞,那么就是非阻塞I/O。
- 阻塞I/O模型 :在linux中,默認情況下所有的socket都是blocking,一個典型的讀操作流程大概是這樣:
- 非阻塞I/O模型:linux下,可以通過設置socket使其變為non-blocking。當對一個non-blocking socket執行讀操作時,流程是這個樣子:
- I/O復用模型:我們可以調用
select
或poll
,阻塞在這兩個系統調用中的某一個之上,而不是真正的IO系統調用上:
- 信號驅動式I/O模型:我們可以用信號,讓內核在描述符就緒時發送SIGIO信號通知我們:
- 異步I/O模型:用戶進程發起read操作之后,立刻就可以開始去做其它的事。而另一方面,從內核的角度,當它受到一個asynchronousread之后,首先它會立刻返回,所以不會對用戶進程產生任何block。然后,內核會等待數據準備完成,然后將數據拷貝到用戶內存,當這一切都完成之后,內核會給用戶進程發送一個signal,告訴它read操作完成了:
以上參考自:《UNIX網絡編程》
從前面 I/O 模型的分類中,我們可以看出 AIO 的動機。阻塞模型需要在 I/O 操作開始時阻塞應用程序。這意味著不可能同時重疊進行處理和 I/O 操作。非阻塞模型允許處理和 I/O 操作重疊進行,但是這需要應用程序來檢查 I/O 操作的狀態。對于異步I/O ,它允許處理和 I/O 操作重疊進行,包括 I/O 操作完成的通知。除了需要阻塞之外,select 函數所提供的功能(異步阻塞 I/O)與 AIO 類似。不過,它是對通知事件進行阻塞,而不是對 I/O 調用進行阻塞。
參考下知乎上的回答:
- 同步與異步:同步和異步關注的是消息通信機制 (synchronous communication/ asynchronous communication)。所謂同步,就是在發出一個調用時,在沒有得到結果之前,該調用就不返回。但是一旦調用返回,就得到返回值了。換句話說,就是由調用者主動等待這個調用的結果;
- 阻塞與非阻塞:阻塞和非阻塞關注的是程序在等待調用結果(消息,返回值)時的狀態。阻塞調用是指調用結果返回之前,當前線程會被掛起。調用線程只有在得到結果之后才會返回;而非阻塞調用指在不能立刻得到結果之前,該調用不會阻塞當前線程。
兩種IO多路復用方案:Reactor和Proactor
一般地,I/O多路復用機制都依賴于一個事件多路分離器(Event Demultiplexer)。分離器對象可將來自事件源的I/O事件分離出來,并分發到對應的read/write事件處理器(Event Handler)。開發人員預先注冊需要處理的事件及其事件處理器(或回調函數);事件分離器負責將請求事件傳遞給事件處理器。
兩個與事件分離器有關的模式是Reactor和Proactor。Reactor模式采用同步I/O,而Proactor采用異步I/O。在Reactor中,事件分離器負責等待文件描述符或socket為讀寫操作準備就緒,然后將就緒事件傳遞給對應的處理器,最后由處理器負責完成實際的讀寫工作。
而在Proactor模式中,處理器或者兼任處理器的事件分離器,只負責發起異步讀寫操作。I/O操作本身由操作系統來完成。傳遞給操作系統的參數需要包括用戶定義的數據緩沖區地址和數據大小,操作系統才能從中得到寫出操作所需數據,或寫入從socket讀到的數據。事件分離器捕獲I/O操作完成事件,然后將事件傳遞給對應處理器。比如,在windows上,處理器發起一個異步I/O操作,再由事件分離器等待IOCompletion事件。典型的異步模式實現,都建立在操作系統支持異步API的基礎之上,我們將這種實現稱為“系統級”異步或“真”異步,因為應用程序完全依賴操作系統執行真正的I/O工作。
舉個例子,將有助于理解Reactor與Proactor二者的差異,以讀操作為例(寫操作類似)。
在Reactor中實現讀:
- 注冊讀就緒事件和相應的事件處理器;
- 事件分離器等待事件;
- 事件到來,激活分離器,分離器調用事件對應的處理器;
- 事件處理器完成實際的讀操作,處理讀到的數據,注冊新的事件,然后返還控制權。
在Proactor中實現讀:
- 處理器發起異步讀操作(注意:操作系統必須支持異步I/O)。在這種情況下,處理器無視I/O就緒事件,它關注的是完成事件;
- 事件分離器等待操作完成事件;
- 在分離器等待過程中,操作系統利用并行的內核線程執行實際的讀操作,并將結果數據存入用戶自定義緩沖區,最后通知事件分離器讀操作完成;
- 事件分離器呼喚處理器;
- 事件處理器處理用戶自定義緩沖區中的數據,然后啟動一個新的異步操作,并將控制權返回事件分離器。
可以看出,兩個模式的相同點,都是對某個I/O事件的事件通知(即告訴某個模塊,這個I/O操作可以進行或已經完成)。在結構上,兩者的相同點和不同點如下:
- 相同點:demultiplexor負責提交I/O操作(異步)、查詢設備是否可操作(同步),然后當條件滿足時,就回調handler;
- 不同點:異步情況下(Proactor),當回調handler時,表示I/O操作已經完成;同步情況下(Reactor),回調handler時,表示I/O設備可以進行某個操作(can read or can write)。
參考自:https://www.zhihu.com/question/26943938/answer/68773398
傳統BIO模型
BIO是同步阻塞式IO,通常在while循環中服務端會調用accept方法等待接收客戶端的連接請求,一旦接收到一個連接請求,就可以建立通信套接字在這個通信套接字上進行讀寫操作,此時不能再接收其他客戶端連接請求,只能等待同當前連接的客戶端的操作執行完成。
如果BIO要能夠同時處理多個客戶端請求,就必須使用多線程,即每次accept阻塞等待來自客戶端請求,一旦受到連接請求就建立通信套接字同時開啟一個新的線程來處理這個套接字的數據讀寫請求,然后立刻又繼續accept等待其他客戶端連接請求,即為每一個客戶端連接請求都創建一個線程來單獨處理。
我們看下傳統的BIO方式下的編程模型大致如下:
public class BIODemo {
public static void main(String[] args) throws IOException {
ExecutorService executor = Executors.newFixedThreadPool(128);
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(1234));
// 循環等待新連接
while (true) {
Socket socket = serverSocket.accept();
// 為新的連接創建線程執行任務
executor.submit(new ConnectionTask(socket));
}
}
}
class ConnectionTask extends Thread {
private Socket socket;
public ConnectionTask(Socket socket) {
this.socket = socket;
}
public void run() {
while (true) {
InputStream inputStream = null;
OutputStream outputStream = null;
try {
inputStream = socket.getInputStream();
// read from socket...
inputStream.read();
outputStream = socket.getOutputStream();
// write to socket...
outputStream.write();
} catch (IOException e) {
e.printStackTrace();
} finally {
// 關閉資源...
}
}
}
}
這里之所以使用多線程,是因為socket.accept()、inputStream.read()、outputStream.write()都是同步阻塞的,當一個連接在處理I/O的時候,系統是阻塞的,如果是單線程的話在阻塞的期間不能接受任何請求。所以,使用多線程,就可以讓CPU去處理更多的事情。其實這也是所有使用多線程的本質:
- 利用多核。
- 當I/O阻塞系統,但CPU空閑的時候,可以利用多線程使用CPU資源。
使用線程池能夠讓線程的創建和回收成本相對較低。在活動連接數不是特別高(小于單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接專注于自己的I/O并且編程模型簡單,也不用過多考慮系統的過載、限流等問題。線程池可以緩沖一些過多的連接或請求。
但這個模型最本質的問題在于,嚴重依賴于線程。但線程是很"貴"的資源,主要表現在:
- 線程的創建和銷毀成本很高,在Linux這樣的操作系統中,線程本質上就是一個進程。創建和銷毀都是重量級的系統函數;
- 線程本身占用較大內存,像Java的線程棧,一般至少分配512K~1M的空間,如果系統中的線程數過千,恐怕整個JVM的內存都會被吃掉一半;
- 線程的切換成本是很高的。操作系統發生線程切換的時候,需要保留線程的上下文,然后執行系統調用。如果線程數過高,可能執行線程切換的時間甚至會大于線程執行的時間,這時候帶來的表現往往是系統load偏高、CPU sy使用率特別高(超過20%以上),導致系統幾乎陷入不可用的狀態;
- 容易造成鋸齒狀的系統負載。因為系統負載是用活動線程數或CPU核心數,一旦線程數量高但外部網絡環境不是很穩定,就很容易造成大量請求的結果同時返回,激活大量阻塞線程從而使系統負載壓力過大。
所以,當面對十萬甚至百萬級連接的時候,傳統的BIO模型是無能為力的。隨著移動端應用的興起和各種網絡游戲的盛行,百萬級長連接日趨普遍,此時,必然需要一種更高效的I/O處理模型。
NIO的實現原理
NIO本身是基于事件驅動思想來完成的,其主要想解決的是BIO的大并發問題,即在使用同步I/O的網絡應用中,如果要同時處理多個客戶端請求,或是在客戶端要同時和多個服務器進行通訊,就必須使用多線程來處理。也就是說,將每一個客戶端請求分配給一個線程來單獨處理。這樣做雖然可以達到我們的要求,但同時又會帶來另外一個問題。由于每創建一個線程,就要為這個線程分配一定的內存空間(也叫工作存儲器),而且操作系統本身也對線程的總數有一定的限制。如果客戶端的請求過多,服務端程序可能會因為不堪重負而拒絕客戶端的請求,甚至服務器可能會因此而癱瘓。
NIO基于Reactor,當socket有流可讀或可寫入socket時,操作系統會相應的通知應用程序進行處理,應用再將流讀取到緩沖區或寫入操作系統。
也就是說,這個時候,已經不是一個連接就要對應一個處理線程了,而是有效的請求,對應一個線程,當連接沒有數據時,是沒有工作線程來處理的。
下面看下代碼的實現:
NIO服務端代碼(新建連接):
//獲取一個ServerSocket通道
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(port));
//獲取通道管理器
selector = Selector.open();
//將通道管理器與通道綁定,并為該通道注冊SelectionKey.OP_ACCEPT事件,
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
NIO服務端代碼(監聽):
while(true){
//當有注冊的事件到達時,方法返回,否則阻塞。
selector.select();
for(SelectionKey key : selector.selectedKeys()){
if(key.isAcceptable()){
ServerSocketChannel server =
(ServerSocketChannel)key.channel();
SocketChannel channel = server.accept();
channel.write(ByteBuffer.wrap(
new String("send message to client").getBytes()));
//在與客戶端連接成功后,為客戶端通道注冊SelectionKey.OP_READ事件。
channel.register(selector, SelectionKey.OP_READ);
}else if(key.isReadable()){//有可讀數據事件
SocketChannel channel = (SocketChannel)key.channel();
ByteBuffer buffer = ByteBuffer.allocate(10);
int read = channel.read(buffer);
byte[] data = buffer.array();
String message = new String(data);
System.out.println("receive message from client, size:"
+ buffer.position() + " msg: " + message);
}
}
}
NIO模型示例如下:
- Acceptor注冊Selector,監聽accept事件;
- 當客戶端連接后,觸發accept事件;
- 服務器構建對應的Channel,并在其上注冊Selector,監聽讀寫事件;
- 當發生讀寫事件后,進行相應的讀寫處理。
Reactor模型
有關Reactor模型結構,可以參考Doug Lea在 Scalable IO in Java 中的介紹。這里簡單介紹一下Reactor模式的典型實現:
Reactor單線程模型
這是最簡單的單Reactor單線程模型。Reactor線程負責多路分離套接字、accept新連接,并分派請求到處理器鏈中。該模型適用于處理器鏈中業務處理組件能快速完成的場景。不過,這種單線程模型不能充分利用多核資源,所以實際使用的不多。
這個模型和上面的NIO流程很類似,只是將消息相關處理獨立到了Handler中去了。
代碼實現如下:
public class Reactor implements Runnable {
final Selector selector;
final ServerSocketChannel serverSocketChannel;
public static void main(String[] args) throws IOException {
new Thread(new Reactor(1234)).start();
}
public Reactor(int port) throws IOException {
selector = Selector.open();
serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(port));
serverSocketChannel.configureBlocking(false);
SelectionKey key = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
key.attach(new Acceptor());
}
@Override
public void run() {
while (!Thread.interrupted()) {
try {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey selectionKey : selectionKeys) {
dispatch(selectionKey);
}
selectionKeys.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void dispatch(SelectionKey selectionKey) {
Runnable run = (Runnable) selectionKey.attachment();
if (run != null) {
run.run();
}
}
class Acceptor implements Runnable {
@Override
public void run() {
try {
SocketChannel channel = serverSocketChannel.accept();
if (channel != null) {
new Handler(selector, channel);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
class Handler implements Runnable {
private final static int DEFAULT_SIZE = 1024;
private final SocketChannel socketChannel;
private final SelectionKey seletionKey;
private static final int READING = 0;
private static final int SENDING = 1;
private int state = READING;
ByteBuffer inputBuffer = ByteBuffer.allocate(DEFAULT_SIZE);
ByteBuffer outputBuffer = ByteBuffer.allocate(DEFAULT_SIZE);
public Handler(Selector selector, SocketChannel channel) throws IOException {
this.socketChannel = channel;
socketChannel.configureBlocking(false);
this.seletionKey = socketChannel.register(selector, 0);
seletionKey.attach(this);
seletionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
@Override
public void run() {
if (state == READING) {
read();
} else if (state == SENDING) {
write();
}
}
class Sender implements Runnable {
@Override
public void run() {
try {
socketChannel.write(outputBuffer);
} catch (IOException e) {
e.printStackTrace();
}
if (outIsComplete()) {
seletionKey.cancel();
}
}
}
private void write() {
try {
socketChannel.write(outputBuffer);
} catch (IOException e) {
e.printStackTrace();
}
while (outIsComplete()) {
seletionKey.cancel();
}
}
private void read() {
try {
socketChannel.read(inputBuffer);
if (inputIsComplete()) {
process();
System.out.println("接收到來自客戶端(" + socketChannel.socket().getInetAddress().getHostAddress()
+ ")的消息:" + new String(inputBuffer.array()));
seletionKey.attach(new Sender());
seletionKey.interestOps(SelectionKey.OP_WRITE);
seletionKey.selector().wakeup();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public boolean inputIsComplete() {
return true;
}
public boolean outIsComplete() {
return true;
}
public void process() {
// do something...
}
}
雖然上面說到NIO一個線程就可以支持所有的IO處理。但是瓶頸也是顯而易見的。我們看一個客戶端的情況,如果這個客戶端多次進行請求,如果在Handler中的處理速度較慢,那么后續的客戶端請求都會被積壓,導致響應變慢!所以引入了Reactor多線程模型。
Reactor多線程模型
相比上一種模型,該模型在處理器鏈部分采用了多線程(線程池):
Reactor多線程模型就是將Handler中的IO操作和非IO操作分開,操作IO的線程稱為IO線程,非IO操作的線程稱為工作線程。這樣的話,客戶端的請求會直接被丟到線程池中,客戶端發送請求就不會堵塞。
可以將Handler做如下修改:
class Handler implements Runnable {
private final static int DEFAULT_SIZE = 1024;
private final SocketChannel socketChannel;
private final SelectionKey seletionKey;
private static final int READING = 0;
private static final int SENDING = 1;
private int state = READING;
ByteBuffer inputBuffer = ByteBuffer.allocate(DEFAULT_SIZE);
ByteBuffer outputBuffer = ByteBuffer.allocate(DEFAULT_SIZE);
private Selector selector;
private static ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime()
.availableProcessors());
private static final int PROCESSING = 3;
public Handler(Selector selector, SocketChannel channel) throws IOException {
this.selector = selector;
this.socketChannel = channel;
socketChannel.configureBlocking(false);
this.seletionKey = socketChannel.register(selector, 0);
seletionKey.attach(this);
seletionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
}
@Override
public void run() {
if (state == READING) {
read();
} else if (state == SENDING) {
write();
}
}
class Sender implements Runnable {
@Override
public void run() {
try {
socketChannel.write(outputBuffer);
} catch (IOException e) {
e.printStackTrace();
}
if (outIsComplete()) {
seletionKey.cancel();
}
}
}
private void write() {
try {
socketChannel.write(outputBuffer);
} catch (IOException e) {
e.printStackTrace();
}
if (outIsComplete()) {
seletionKey.cancel();
}
}
private void read() {
try {
socketChannel.read(inputBuffer);
if (inputIsComplete()) {
process();
executorService.execute(new Processer());
}
} catch (IOException e) {
e.printStackTrace();
}
}
public boolean inputIsComplete() {
return true;
}
public boolean outIsComplete() {
return true;
}
public void process() {
}
synchronized void processAndHandOff() {
process();
state = SENDING; // or rebind attachment
seletionKey.interestOps(SelectionKey.OP_WRITE);
selector.wakeup();
}
class Processer implements Runnable {
public void run() {
processAndHandOff();
}
}
}
但是當用戶進一步增加的時候,Reactor會出現瓶頸!因為Reactor既要處理IO操作請求,又要響應連接請求。為了分擔Reactor的負擔,所以引入了主從Reactor模型。
主從Reactor多線程模型
主從Reactor多線程模型是將Reactor分成兩部分,mainReactor負責監聽server socket,accept新連接,并將建立的socket分派給subReactor。subReactor負責多路分離已連接的socket,讀寫網絡數據,對業務處理功能,其扔給worker線程池完成。通常,subReactor個數上可與CPU個數等同:
這時可以把Reactor做如下修改:
public class Reactor {
final ServerSocketChannel serverSocketChannel;
Selector[] selectors; // also create threads
AtomicInteger next = new AtomicInteger(0);
ExecutorService sunReactors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
public static void main(String[] args) throws IOException {
new Reactor(1234);
}
public Reactor(int port) throws IOException {
serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(port));
serverSocketChannel.configureBlocking(false);
selectors = new Selector[4];
for (int i = 0; i < 4; i++) {
Selector selector = Selector.open();
selectors[i] = selector;
SelectionKey key = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
key.attach(new Acceptor());
new Thread(() -> {
while (!Thread.interrupted()) {
try {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey selectionKey : selectionKeys) {
dispatch(selectionKey);
}
selectionKeys.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
private void dispatch(SelectionKey selectionKey) {
Runnable run = (Runnable) selectionKey.attachment();
if (run != null) {
run.run();
}
}
class Acceptor implements Runnable {
@Override
public void run() {
try {
SocketChannel connection = serverSocketChannel.accept();
if (connection != null)
sunReactors.execute(new Handler(selectors[next.getAndIncrement() % selectors.length], connection));
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
可見,主Reactor用于響應連接請求,從Reactor用于處理IO操作請求。
AIO
與NIO不同,當進行讀寫操作時,只須直接調用API的read或write方法即可。這兩種方法均為異步的,對于讀操作而言,當有流可讀取時,操作系統會將可讀的流傳入read方法的緩沖區,并通知應用程序;對于寫操作而言,當操作系統將write方法傳遞的流寫入完畢時,操作系統主動通知應用程序。
即可以理解為,read/write方法都是異步的,完成后會主動調用回調函數。
在JDK1.7中,這部分內容被稱作NIO.2,主要在java.nio.channels包下增加了下面四個異步通道:
- AsynchronousSocketChannel
- AsynchronousServerSocketChannel
- AsynchronousFileChannel
- AsynchronousDatagramChannel
我們看一下AsynchronousSocketChannel中的幾個方法:
public abstract class AsynchronousSocketChannel
implements AsynchronousByteChannel, NetworkChannel
{
public abstract Future<Integer> read(ByteBuffer dst);
public abstract <A> void read(ByteBuffer[] dsts,
int offset,
int length,
long timeout,
TimeUnit unit,
A attachment,
CompletionHandler<Long,? super A> handler);
public abstract <A> void write(ByteBuffer src,
long timeout,
TimeUnit unit,
A attachment,
CompletionHandler<Integer,? super A> handler);
public final <A> void write(ByteBuffer src,
A attachment,
CompletionHandler<Integer,? super A> handler)
{
write(src, 0L, TimeUnit.MILLISECONDS, attachment, handler);
}
public abstract Future<Integer> write(ByteBuffer src);
public abstract <A> void write(ByteBuffer[] srcs,
int offset,
int length,
long timeout,
TimeUnit unit,
A attachment,
CompletionHandler<Long,? super A> handler);
}
其中的read/write方法,有的會返回一個Future
對象,有的需要傳入一個CompletionHandler
對象,該對象的作用是當執行完讀取/寫入操作后,直接該對象當中的方法進行回調。
對于AsynchronousSocketChannel
而言,在windows和linux上的實現類是不一樣的。
在windows上,AIO的實現是通過IOCP來完成的,實現類是:
WindowsAsynchronousSocketChannelImpl
實現的接口是:
Iocp.OverlappedChannel
而在linux上,實現類是:
UnixAsynchronousSocketChannelImpl
實現的接口是:
Port.PollableChannel
可見,最大的區別就是windows與linux中poll的實現不同。
AIO是一種接口標準,各家操作系統可以實現也可以不實現。在不同操作系統上在高并發情況下最好都采用操作系統推薦的方式。Linux上還沒有真正實現網絡方式的AIO。
select和epoll的區別
當需要讀兩個以上的I/O的時候,如果使用阻塞式的I/O,那么可能長時間的阻塞在一個描述符上面,另外的描述符雖然有數據但是不能讀出來,這樣實時性不能滿足要求,大概的解決方案有以下幾種:
- 使用多進程或者多線程,但是這種方法會造成程序的復雜,而且對與進程與線程的創建維護也需要很多的開銷(Apache服務器是用的子進程的方式,優點可以隔離用戶);
- 用一個進程,但是使用非阻塞的I/O讀取數據,當一個I/O不可讀的時候立刻返回,檢查下一個是否可讀,這種形式的循環為輪詢(polling),這種方法比較浪費CPU時間,因為大多數時間是不可讀,但是仍花費時間不斷反復執行read系統調用;
- 異步I/O,當一個描述符準備好的時候用一個信號告訴進程,但是由于信號個數有限,多個描述符時不適用;
- 一種較好的方式為I/O多路復用,先構造一張有關描述符的列表(epoll中為隊列),然后調用一個函數,直到這些描述符中的一個準備好時才返回,返回時告訴進程哪些I/O就緒。select和epoll這兩個機制都是多路I/O機制的解決方案,select為POSIX標準中的,而epoll為Linux所特有的。
它們的區別主要有三點:
- select的句柄數目受限,在linux/posix_types.h頭文件有這樣的聲明:
#define __FD_SETSIZE 1024
表示select最多同時監聽1024個fd。而epoll沒有,它的限制是最大的打開文件句柄數目; - epoll的最大好處是不會隨著FD的數目增長而降低效率,在selec中采用輪詢處理,其中的數據結構類似一個數組的數據結構,而epoll是維護一個隊列,直接看隊列是不是空就可以了。epoll只會對"活躍"的socket進行操作---這是因為在內核實現中epoll是根據每個fd上面的callback函數實現的。那么,只有"活躍"的socket才會主動的去調用 callback函數(把這個句柄加入隊列),其他idle狀態句柄則不會,在這點上,epoll實現了一個"偽"AIO。但是如果絕大部分的I/O都是“活躍的”,每個I/O端口使用率很高的話,epoll效率不一定比select高(可能是要維護隊列復雜);
- 使用mmap加速內核與用戶空間的消息傳遞。無論是select,poll還是epoll都需要內核把FD消息通知給用戶空間,如何避免不必要的內存拷貝就很重要,在這點上,epoll是通過內核于用戶空間mmap同一塊內存實現的。
NIO與epoll
上文說到了select與epoll的區別,再總結一下Java NIO與select和epoll:
- Linux2.6之后支持epoll
- windows支持select而不支持epoll
- 不同系統下nio的實現是不一樣的,包括Sunos linux 和windows
- select的復雜度為O(N)
- select有最大fd限制,默認為1024
- 修改sys/select.h可以改變select的fd數量限制
- epoll的事件模型,無fd數量限制,復雜度O(1),不需要遍歷fd
以下代碼基于Java 8。
下面看下在NIO中Selector的open方法:
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
這里使用了SelectorProvider去創建一個Selector,看下provider方法的實現:
public static SelectorProvider provider() {
synchronized (lock) {
if (provider != null)
return provider;
return AccessController.doPrivileged(
new PrivilegedAction<SelectorProvider>() {
public SelectorProvider run() {
if (loadProviderFromProperty())
return provider;
if (loadProviderAsService())
return provider;
provider = sun.nio.ch.DefaultSelectorProvider.create();
return provider;
}
});
}
}
看下sun.nio.ch.DefaultSelectorProvider.create()
方法,該方法在不同的操作系統中的代碼是不同的,在windows中的實現如下:
public static SelectorProvider create() {
return new WindowsSelectorProvider();
}
在Mac OS中的實現如下:
public static SelectorProvider create() {
return new KQueueSelectorProvider();
}
在linux中的實現如下:
public static SelectorProvider create() {
String str = (String)AccessController.doPrivileged(new GetPropertyAction("os.name"));
if (str.equals("SunOS"))
return createProvider("sun.nio.ch.DevPollSelectorProvider");
if (str.equals("Linux"))
return createProvider("sun.nio.ch.EPollSelectorProvider");
return new PollSelectorProvider();
}
我們看到create方法中是通過區分操作系統來返回不同的Provider的。其中SunOs就是Solaris返回的是DevPollSelectorProvider,對于Linux,返回的Provder是EPollSelectorProvider,其余操作系統,返回的是PollSelectorProvider。
Zero Copy
許多web應用都會向用戶提供大量的靜態內容,這意味著有很多數據從硬盤讀出之后,會原封不動的通過socket傳輸給用戶。
這種操作看起來可能不會怎么消耗CPU,但是實際上它是低效的:
- kernel把從disk讀數據;
- 將數據傳輸給application;
- application再次把同樣的內容再傳回給處于kernel級的socket。
這種場景下,application實際上只是作為一種低效的中間介質,用來把磁盤文件的數據傳給socket。
數據每次傳輸都會經過user和kernel空間都會被copy,這會消耗cpu,并且占用RAM的帶寬。
傳統的數據傳輸方式
像這種從文件讀取數據然后將數據通過網絡傳輸給其他的程序的方式其核心操作就是如下兩個調用:
File.read(fileDesc,buf,len);
Socket.send(socket,buf,len);
其上操作看上去只有兩個簡單的調用,但是其內部過程卻要經歷四次用戶態和內核態的切換以及四次的數據復制操作:
上圖展示了數據從文件到socket的內部流程。
下面看下用戶態和內核態的切換過程:
步驟如下:
- read()的調用引起了從用戶態到內核態的切換(看圖二),內部是通過sys_read()(或者類似的方法)發起對文件數據的讀取。數據的第一次復制是通過DMA(直接內存訪問)將磁盤上的數據復制到內核空間的緩沖區中;
- 數據從內核空間的緩沖區復制到用戶空間的緩沖區后,read()方法也就返回了。此時內核態又切換回用戶態,現在數據也已經復制到了用戶地址空間的緩存中;
- socket的send()方法的調用又會引起用戶態到內核的切換,第三次數據復制又將數據從用戶空間緩沖區復制到了內核空間的緩沖區,這次數據被放在了不同于之前的內核緩沖區中,這個緩沖區與數據將要被傳輸到的socket關聯;
- send()系統調用返回后,就產生了第四次用戶態和內核態的切換。隨著DMA單獨異步的將數據從內核態的緩沖區中傳輸到協議引擎發送到網絡上,有了第四次數據復制。
Zero Copy的數據傳輸方式
java.nio.channels.FileChannel
中定義了兩個方法:transferTo( )和 transferFrom( )。
transferTo( )和 transferFrom( )方法允許將一個通道交叉連接到另一個通道,而不需要通過一個中間緩沖區來傳遞數據。只有 FileChannel 類有這兩個方法,因此 channel-to-channel 傳輸中通道之一必須是 FileChannel。您不能在 socket 通道之間直接傳輸數據,不過 socket 通道實現 WritableByteChannel
和 ReadableByteChannel
接口,因此文件的內容可以用 transferTo( )
方法傳輸給一個 socket 通道,或者也可以用 transferFrom( )方法將數據從一個 socket 通道直接讀取到一個文件中。
下面根據transferTo()
方法來說明。
根據上文可知,transferTo()
方法可以把bytes直接從調用它的channel傳輸到另一個WritableByteChannel,中間不經過應用程序。
下面看下該方法的定義:
public abstract long transferTo(long position, long count,
WritableByteChannel target)
throws IOException;
下圖展示了通過transferTo實現數據傳輸的路徑:
下圖展示了內核態、用戶態的切換情況:
使用transferTo()方式所經歷的步驟:
- transferTo調用會引起DMA將文件內容復制到讀緩沖區(內核空間的緩沖區),然后數據從這個緩沖區復制到另一個與socket輸出相關的內核緩沖區中;
- 第三次數據復制就是DMA把socket關聯的緩沖區中的數據復制到協議引擎上發送到網絡上。
這次改善,我們是通過將內核、用戶態切換的次數從四次減少到兩次,將數據的復制次數從四次減少到三次(只有一次用到cpu資源)。但這并沒有達到我們零復制的目標。如果底層網絡適配器支持收集操作的話,我們可以進一步減少內核對數據的復制次數。在內核為2.4或者以上版本的linux系統上,socket緩沖區描述符將被用來滿足這個需求。這個方式不僅減少了內核用戶態間的切換,而且也省去了那次需要cpu參與的復制過程。從用戶角度來看依舊是調用transferTo()方法,但是其本質發生了變化:
- 調用transferTo方法后數據被DMA從文件復制到了內核的一個緩沖區中;
- 數據不再被復制到socket關聯的緩沖區中了,僅僅是將一個描述符(包含了數據的位置和長度等信息)追加到socket關聯的緩沖區中。DMA直接將內核中的緩沖區中的數據傳輸給協議引擎,消除了僅剩的一次需要cpu周期的數據復制。
NIO存在的問題
使用NIO != 高性能,當連接數<1000,并發程度不高或者局域網環境下NIO并沒有顯著的性能優勢。
NIO并沒有完全屏蔽平臺差異,它仍然是基于各個操作系統的I/O系統實現的,差異仍然存在。使用NIO做網絡編程構建事件驅動模型并不容易,陷阱重重。
推薦大家使用成熟的NIO框架,如Netty,MINA等。解決了很多NIO的陷阱,并屏蔽了操作系統的差異,有較好的性能和編程模型。
總結
最后總結一下NIO有哪些優勢:
- 事件驅動模型
- 避免多線程
- 單線程處理多任務
- 非阻塞I/O,I/O讀寫不再阻塞
- 基于block的傳輸,通常比基于流的傳輸更高效
- 更高級的IO函數,Zero Copy
- I/O多路復用大大提高了Java網絡應用的可伸縮性和實用性
參考鏈接:
高性能Server---Reactor模型
Java NIO淺析
Scalable IO in Java
Linux AIO
怎樣理解阻塞非阻塞與同步異步的區別?
也談BIO | NIO | AIO (Java版)
epoll和select區別
Epoll在Nio中的實現
通過zero copy來實現高效的數據傳輸