linux的五種IO模型

概念:java

同步、異步、阻塞、非阻塞的概念react

同步:所謂同步,發起一個功能調用的時候,在沒有獲得結果以前,該調用不返回,也就是必須一件事一件事的作,等前一件作完了,才能作下一件。ajax

    提交請求->等待服務器處理->處理完畢返回 這個期間客戶端瀏覽器不能幹任何事編程

異步:調用發出後,調用者不能馬上獲得結果,而是實際處理這個調用的函數完成以後,經過狀態、通知和回調來通知調用者。設計模式

  好比ajax:請求經過事件觸發->服務器處理(這是瀏覽器仍然能夠做其餘事情)->處理完畢數組

  (在服務器處理的時候,客戶端還能夠幹其餘的事)瀏覽器

阻塞:指調用結果返回以前,當前線程會被掛起(CPU不給線程分配時間片),函數只能在獲得結果以後纔會返回。緩存

(阻塞調用和同步調用的區別)同步調用的時候,當前線程仍然多是激活的,只是在邏輯上當前函數沒有返回。例如:在Socket中調用recv函數,若是緩衝區沒有數據,這個函數會一直等待,知道數據返回。而在此時,這個線程仍是能夠處理其餘消息的。服務器

非阻塞:當調用後,不能直接獲得結果以前,該函數不能阻塞當前線程,而是會馬上返回。網絡

總結:

同步是指A調用了B函數,B函數須要等處理完事情纔會給A返回一個結果。A拿到結果繼續執行。

異步是指A調用了B函數,A的任務就完成了,去繼續執行別的事了,等B處理完了事情,纔會通知A。

阻塞是指,A調用了B函數,在B沒有返回結果的時候,A線程被CPU掛起,不能執行任何操做(這個線程不會被分配時間片)

非阻塞是指,A調用了B函數,A不用一直等待B返回結果,能夠先去幹別的事。

 

Linux下的五種IO模型:

1.阻塞IO

2.非阻塞IO

3.IO複用

4.信號驅動IO

5.異步IO

 

阻塞IO模型:

 

 

 從上圖可知,由於socket接口是阻塞型的,用戶進程會調用recvfrom函數,查看內核裏有沒有數據報準備好,若是沒有,那麼只能繼續等待,此時用戶進程什麼也不能作,一直等內核的數據報準備好了,纔會將數據報從內核空間複製到用戶空間裏面,用戶進程獲得了數據,這個任務纔算結束。這就是阻塞型的IO。

 

非阻塞型IO

 

 

 用戶進程調用了recvfrom函數,向內核要數據報,內核會馬上返回一個結果,若是告訴用戶進程沒有數據報,那麼用戶進程還須要繼續發送調用請求。。。知道有了數據報,而後複製到用戶空間,這樣就結束了調用。

非阻塞的IO可能並不會當即知足,須要應用程序調用許屢次來等待操做完成。這可能效率不高,由於在不少狀況下,當內核執行這個命令時,應用程序必需要進行忙碌等待,直到數據可用爲止。

另外一個問題,在循環調用非阻塞IO的時候,將大幅度佔用CPU,因此通常使用select等來檢測」是否能夠操做「。

 

多路複用IO

 

 

 

前面說過非阻塞型IO的缺點,就是佔用CPU的資源,使用select函數能夠避免非阻塞IO中的輪詢等待問題

 

 

 

能夠看出用戶首先要進行IO操做的socket添加到select中,而後阻塞等待select系統調用返回,當數據到達時,socket被激活,select函數返回。這時socket可讀了,而後用戶線程正式發起read請求,讀取數據並繼續執行。

這個模型在流程上和同步阻塞模型好像沒有區別,甚至還須要監聽socket,但使用了select之後最大的優點就是用戶能夠在一個線程內同時處理多個socket的IO請求,用戶能夠註冊多個socket,而後不斷的調用select讀取被激活的socket,能夠達到在同一個線程內同時處理多個IO請求的目的。而在同步阻塞模型中,必需要使用多線程,線程池技術來實現。

{

    select(socket);

    while(1) {

        sockets = select();

        for(socket in sockets) {

        if(can_read(socket)) {

            read(socket, buffer);

            process(buffer);

        }

    }

}

}                    

 

可是上面的模型仍然有很大的問題,雖然單個線程能夠處理多個IO請求,但每一個IO請求也是阻塞的。所以可讓用戶線程註冊本身感興趣的socket或者Io請求,而後去作本身的事情,等到數據來到的時候,再進行處理

這裏是使用Reactor設計模式來實現。

 

 經過Reactor方式,將用戶線程輪詢IO操做狀態的工做統一交給handle_event事件循環進行處理,用戶註冊事件處理器以後就能夠繼續執行其餘的工做了,而Reactor線程負責調用內核的select函數來檢查socket狀態。當socket被激活以後,通知響應的用戶線程,執行handle_event進行數據讀取。因爲select函數是阻塞的,所以多路IO複用模型也被稱爲異步阻塞IO模型。

 

後面兩種IO模型就先不說了。。。


 

而後來介紹java中的IO模型怎麼實現。

BIO(Blocking IO)

同步阻塞IO模型,數據的讀取寫入必須阻塞在一個線程內等待完成。

在BIO通訊模型的服務端,由一個獨立的Acceptor線程負責監聽客戶端的鏈接,咱們通常經過在while(true) 循環中服務端會調用 accept() 方法等待接收客戶端的鏈接的方式監聽請求,請求一旦接收到一個鏈接請求,就能夠創建通訊套接字在這個通訊套接字上進行讀寫操做,此時不能再接收其餘客戶端鏈接請求,只能等待同當前鏈接的客戶端的操做執行完成, 不過能夠經過多線程來支持多個客戶端的鏈接,如上圖所示。

 

 如上圖所示,若是想要處理多個線程,則必須使用多線程,由於socket.accept()、socket.read()、socket.write()這三個函數都是同步阻塞的。

在使用了多線程以後,服務端接收到客戶端的鏈接請求以後,會爲每個客戶端建立一個新的線程進行鏈路處理。處理完成後,經過輸出流返回應答客戶端,而後線程銷燬。也能夠經過線程池來改善性能。利用線程池能夠實現N(客戶端請求數量):M(處理客戶端請求的線程數量)的僞異步I/O模型(N 能夠遠遠大於 M)。

 

 Acceptor監聽客戶端請求,每有一個新的請求都會經過線程池建立一個新的線程,而後將socket套接字封裝成一個task繼承runnable,丟到線程裏去執行。線程池維護一個消息隊列和 N 個活躍線程,對消息隊列中的任務進行處理,因爲線程池能夠設置消息隊列的大小和最大線程數,所以,它的資源佔用是可控的,不管多少個客戶端併發訪問,都不會致使資源的耗盡和宕機。

但問題也很明顯,仍然佔用了大量的資源。其底層是BIO的事實仍是沒有改變。

在活動鏈接數不是特別高(小於單機1000)的狀況下,這種模型是比較不錯的,可讓每個鏈接專一於本身的 I/O 而且編程模型簡單,也不用過多考慮系統的過載、限流等問題。線程池自己就是一個自然的漏斗,能夠緩衝一些系統處理不了的鏈接或請求。可是,當面對十萬甚至百萬級鏈接的時候,傳統的 BIO 模型是無能爲力的。所以,咱們須要一種更高效的 I/O 處理模型來應對更高的併發量。

public class ServerMain {

    public static void main(String[] args) throws IOException {

        //綁定端口
        ServerSocket serverSocket=new ServerSocket(3333);

        new Thread(()->{

            //accept監聽
            while(true) {
                try {
                    Socket socket = serverSocket.accept();

                    //這裏發生了阻塞
                    Thread.sleep(10000);

                    // 按字節流方式讀取數據
                    try {
                        int len;
                        byte[] data = new byte[1024];
                        InputStream inputStream = socket.getInputStream();
                        // 按字節流方式讀取數據
                        while ((len = inputStream.read(data)) != -1) {
                            System.out.println(new String(data, 0, len));
                        }
                    } catch (IOException e) {
                    }

                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}
ServerMain
public class CLientMain {

    public static void main(String[] args) {

        //建立多個線程模擬多個客戶端來鏈接服務器
        new Thread(()->{
            try {

                //建立一個套接字對象
                Socket socket=new Socket("127.0.0.1",3333);

                for(int i=0;i<10;i++) {
                    //發送數據
                    socket.getOutputStream().write((new Date() + ":hello").getBytes());
                    Thread.sleep(2000);
                }

            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();


    }
}
CLientMain

 

 

 

NIO(newIO)

java中的NIO是一種結合了同步非阻塞和IO多路複用的IO模型。

NIO中的N能夠理解爲Non-blocking,不單純是New。它支持面向緩衝的,基於通道的I/O操做方法。 NIO提供了與傳統BIO模型中的 SocketServerSocket 相對應的 SocketChannelServerSocketChannel 兩種不一樣的套接字通道實現,兩種通道都支持阻塞和非阻塞兩種模式。阻塞模式使用就像傳統中的支持同樣,比較簡單,可是性能和可靠性都很差;非阻塞模式正好與之相反。對於低負載、低併發的應用程序,可使用同步阻塞I/O來提高開發速率和更好的維護性;對於高負載、高併發的(網絡)應用,應使用 NIO 的非阻塞模式來開發。

 

NIO和非阻塞模型是有區別的,NIO是java本身的API。即支持阻塞也支持非阻塞。

(1)NIO 適合處理鏈接數目特別多,可是鏈接比較短(輕操做)的場景,Jetty,Mina,ZooKeeper 等都是基於 java nio 實現。服務器須要支持超大量的長時間鏈接。好比 10000 個鏈接以上,而且每一個客戶端並不會頻繁地發送太多數據。

(2)BIO 方式適用於鏈接數目比較小而且一次發送大量數據的場景,這種方式對服務器資源要求比較高,併發侷限於應用中。

 

NIO有三大組件:Channel、BUffer、Selector。

1.CHannel  通道

是對原IO包中流的模擬,流的做用是把磁盤上的數據寫入內存以及讀取內存中的數據到磁盤上。Channel也能夠實現對數據的寫入和讀取。

通道和流的不用之處在於,流只能在一個方向上移動,要麼inputstream,要麼outputstream。而Channel則能夠用於讀也能夠用於寫。

  通道類型包括:

  • FileChannel:從文件中讀寫數據;

  • DatagramChannel:經過 UDP 讀寫網絡中數據;

  • SocketChannel:經過 TCP 讀寫網絡中數據;

  • ServerSocketChannel:能夠監聽新進來的 TCP 鏈接,對每個新進來的鏈接都會建立一個 SocketChannel。

  後面這兩個配合使用。

具體操做:

  • 從通道進行數據讀取 :建立一個緩衝區,而後請求通道讀取數據。

  • 從通道進行數據寫入 :建立一個緩衝區,填充數據,並要求通道寫入數據。

 

2.緩存區

通道讀寫的數據必須都放在緩衝區裏面,通道里面是沒有數據的。

緩衝區包括的類型:

  • ByteBuffer

  • CharBuffer

  • ShortBuffer

  • IntBuffer

  • LongBuffer

  • FloatBuffer

  • DoubleBuffer

 

3.選擇器

NIO是非阻塞模型和多路複用io的結合。

一個線程 Thread 使用一個選擇器 Selector 經過輪詢的方式去監聽多個通道 Channel 上的事件,從而讓一個線程就能夠處理多個事件。

經過配置監聽的通道 Channel 爲非阻塞,那麼當 Channel 上的 IO 事件還未到達時,就不會進入阻塞狀態一直等待,而是繼續輪詢其它 Channel,找到 IO 事件已經到達的 Channel 執行。

由於建立和切換線程的開銷很大,所以使用一個線程來處理多個事件而不是一個線程處理一個事件,對於 IO 密集型的應用具備很好地性能。

NIO在處理單線程的IO時性能並不如BIO,但對於多IO,多客戶端請求,有着很是好的性能。

 

 

 

 如上圖所示,將Channel註冊在Selector裏面,而後selector去輪詢有沒有Channel事件到達。

事實上,NIO是採用一種Reactor模式。

Reactor被稱爲事件分離者,其核心就是一個Selector,負責響應IO事件,一旦發生,就廣播給響應的Handle去處理。具體爲一個Selector和一個ServerSocketChannel,把ServerSocketChannel註冊到Selector裏面去,獲取的SelectionKey綁定一個Acceptor,能夠理解爲一個handle。

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * 反應器模式 用於解決多用戶訪問併發問題
 */
public class Reactor implements Runnable {
    public final Selector selector;
    public final ServerSocketChannel serverSocketChannel;

    public Reactor(int port) throws IOException {
        selector = Selector.open();
        serverSocketChannel = ServerSocketChannel.open();
        InetSocketAddress inetSocketAddress = new InetSocketAddress(InetAddress.getLocalHost(), port);
        serverSocketChannel.socket().bind(inetSocketAddress);
        serverSocketChannel.configureBlocking(false);

        // 向selector註冊該channel
        SelectionKey selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        // 利用selectionKey的attache功能綁定Acceptor 若是有事情,觸發Acceptor
        selectionKey.attach(new Acceptor(this));
    }

    @Override
    public void run() {
        try {
            while (!Thread.interrupted()) {
                selector.select();
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> it = selectionKeys.iterator();
                // Selector若是發現channel有OP_ACCEPT或READ事件發生,下列遍歷就會進行。
                while (it.hasNext()) {
                    // 來一個事件 第一次觸發一個accepter線程,SocketReadHandler
                    SelectionKey selectionKey = it.next();
                    dispatch(selectionKey);
                    selectionKeys.clear();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 運行Acceptor或SocketReadHandler
     * 
     * @param key
     */
    void dispatch(SelectionKey key) {
        Runnable r = (Runnable) (key.attachment());
        if (r != null) {
            r.run();
        }
    }

}
Reactor

 

 Acceptor被理解爲一個handle,這個Handle只負責建立具體處理IO請求的Handle,若是Reactor廣播時SelectionKey建立一個Handler負責綁定相應的SocketChannel到Selector中。下次再次有IO事件時會調用對用的Handler去處理。

public class Acceptor implements Runnable {
    private Reactor reactor;

    public Acceptor(Reactor reactor) {
        this.reactor = reactor;
    }

    @Override
    public void run() {
        try {
            SocketChannel socketChannel = reactor.serverSocketChannel.accept();
            if (socketChannel != null){
                // 調用Handler來處理channel
                new SocketReadHandler(reactor.selector, socketChannel);
            }                
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
Acceptor

 

Handler是具體的事件處理者,例如ReadHandler、SendHandler,ReadHandler負責讀取緩存中的數據,而後再調用一個工做處理線程去處理讀取到的數據。具體爲一個SocketChannel,Acceptor初始化該Handler時會將SocketChannel註冊到Reactor的Selector中,同時將SelectionKey綁定該Handler,這樣下次就會調用本Handler。

public class SocketReadHandler implements Runnable {
    private SocketChannel socketChannel;

    public SocketReadHandler(Selector selector, SocketChannel socketChannel) throws IOException {
        this.socketChannel = socketChannel;
        socketChannel.configureBlocking(false);

        SelectionKey selectionKey = socketChannel.register(selector, 0);

        // 將SelectionKey綁定爲本Handler 下一步有事件觸發時,將調用本類的run方法。
        // 參看dispatch(SelectionKey key)
        selectionKey.attach(this);

        // 同時將SelectionKey標記爲可讀,以便讀取。
        selectionKey.interestOps(SelectionKey.OP_READ);
        selector.wakeup();
    }

    /**
     * 處理讀取數據
     */
    @Override
    public void run() {
        ByteBuffer inputBuffer = ByteBuffer.allocate(1024);
        inputBuffer.clear();
        try {
            socketChannel.read(inputBuffer);
            // 激活線程池 處理這些request
            // requestHandle(new Request(socket,btt));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
SocketReadHandler

 

 爲何不肯意用原生的NIO開發呢?

   JDK 的 NIO 底層由 epoll 實現,該實現飽受詬病的空輪詢 bug 會致使 cpu 飆升 100%

  項目龐大以後,自行實現的 NIO 很容易出現各種 bug,維護成本較高,上面這一坨代碼我都不能保證沒有 bug

 下面是NIO服務端通訊序列圖

 

 select、poll、epoll的區別:

三者都是IO多路複用的機制,IO多路複用就是經過一種機制,去監視多個描述符,一旦某個描述符就緒,(讀就緒或者寫就緒),可以通知程序進行相應的讀寫操做,但select、poll、epoll本質都是同步IO,由於他們都須要在讀寫事件就緒後,本身負責進行讀寫,讀寫過程是阻塞的。而異步IO不須要本身讀寫,異步IO的實現會負責把數據從內核拷貝到用戶空間,

 

1.select函數:

該函數准許進程指示內核等待多個事件中的任何一個發送,並只在有一個或多個事件發生或經歷一段指定的時間後才喚醒。函數原型以下:

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
返回值:就緒描述符的數目,超時返回0,出錯返回-1

 

 函數參數介紹以下:

(1)第一個參數maxfdp1指定待測試的描述字個數,它的值是待測試的最大描述字加1(所以把該參數命名爲maxfdp1),描述字0、一、2...maxfdp1-1均將被測試。

由於文件描述符是從0開始的。

(2)中間的三個參數readset、writeset和exceptset指定咱們要讓內核測試讀、寫和異常條件的描述字。若是對某一個的條件不感興趣,就能夠把它設爲空指針。struct fd_set能夠理解爲一個集合,這個集合中存放的是文件描述符,可經過如下四個宏進行設置:

          void FD_ZERO(fd_set *fdset);           //清空集合

          void FD_SET(int fd, fd_set *fdset);   //將一個給定的文件描述符加入集合之中

          void FD_CLR(int fd, fd_set *fdset);   //將一個給定的文件描述符從集合中刪除

          int FD_ISSET(int fd, fd_set *fdset);   // 檢查集合中指定的文件描述符是否能夠讀寫 

(3)timeout告知內核等待所指定描述字中的任何一個就緒可花多少時間。其timeval結構用於指定這段時間的秒數和微秒數。

         struct timeval{

                   long tv_sec;   //seconds

                   long tv_usec;  //microseconds

       };

這個參數有三種可能:

(1)永遠等待下去:僅在有一個描述字準備好I/O時才返回。爲此,把該參數設置爲空指針NULL。

(2)等待一段固定時間:在有一個描述字準備好I/O時返回,可是不超過由該參數所指向的timeval結構中指定的秒數和微秒數。

(3)根本不等待:檢查描述字後當即返回,這稱爲輪詢。爲此,該參數必須指向一個timeval結構,並且其中的定時器值必須爲0。

 基本原理:

 

 

1)使用copy_from_user從用戶空間拷貝fd_set到內核空間

(2)註冊回調函數__pollwait

(3)遍歷全部fd,調用其對應的poll方法(對於socket,這個poll方法是sock_poll,sock_poll根據狀況會調用到tcp_poll,udp_poll或者datagram_poll)

(4)以tcp_poll爲例,其核心實現就是__pollwait,也就是上面註冊的回調函數。

(5)__pollwait的主要工做就是把current(當前進程)掛到設備的等待隊列中,不一樣的設備有不一樣的等待隊列,對於tcp_poll來講,其等待隊列是sk->sk_sleep(注意把進程掛到等待隊列中並不表明進程已經睡眠了)。在設備收到一條消息(網絡設備)或填寫完文件數據(磁盤設備)後,會喚醒設備等待隊列上睡眠的進程,這時current便被喚醒了。

(6)poll方法返回時會返回一個描述讀寫操做是否就緒的mask掩碼,根據這個mask掩碼給fd_set賦值。

(7)若是遍歷完全部的fd,尚未返回一個可讀寫的mask掩碼,則會調用schedule_timeout是調用select的進程(也就是current)進入睡眠。當設備驅動發生自身資源可讀寫後,會喚醒其等待隊列上睡眠的進程。若是超過必定的超時時間(schedule_timeout指定),仍是沒人喚醒,則調用select的進程會從新被喚醒得到CPU,進而從新遍歷fd,判斷有沒有就緒的fd。

(8)把fd_set從內核空間拷貝到用戶空間。

 

select的幾大缺點,

 

(1)每次調用select,都須要把fd集合從用戶態拷貝到內核態,這個開銷在fd不少時會很大

(2)同時每次調用select都須要在內核遍歷傳遞進來的全部fd,這個開銷在fd不少時也很大

(3)select支持的文件描述符數量過小了,默認是1024

 

2.poll實現

poll的機制與select相似,與select在本質上沒有多大差異,管理多個描述符也是進行輪詢,根據描述符的狀態進行處理,可是poll沒有最大文件描述符數量的限制。poll和select一樣存在一個缺點就是,包含大量文件描述符的數組被總體複製於用戶態和內核的地址空間之間,而不論這些文件描述符是否就緒,它的開銷隨着文件描述符數量的增長而線性增大。

 

3.epoll實現

epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎麼解決的呢?在此以前,咱們先看一下epoll和select和poll的調用接口上的不一樣,select和poll都只提供了一個函數——select或者poll函數。而epoll提供了三個函數,epoll_create,epoll_ctl和epoll_wait,epoll_create是建立一個epoll句柄;epoll_ctl是註冊要監聽的事件類型;epoll_wait則是等待事件的產生。

  對於第一個缺點,epoll的解決方案在epoll_ctl函數中。每次註冊新的事件到epoll句柄中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把全部的fd拷貝進內核,而不是在epoll_wait的時候重複拷貝。epoll保證了每一個fd在整個過程當中只會拷貝一次。

  對於第二個缺點,epoll的解決方案不像select或poll同樣每次都把current輪流加入fd對應的設備等待隊列中,而只在epoll_ctl時把current掛一遍(這一遍必不可少)併爲每一個fd指定一個回調函數,當設備就緒,喚醒等待隊列上的等待者時,就會調用這個回調函數,而這個回調函數會把就緒的fd加入一個就緒鏈表)。epoll_wait的工做實際上就是在這個就緒鏈表中查看有沒有就緒的fd(利用schedule_timeout()實現睡一會,判斷一會的效果,和select實現中的第7步是相似的)。

  對於第三個缺點,epoll沒有這個限制,它所支持的FD上限是最大能夠打開文件的數目,這個數字通常遠大於2048,舉個例子,在1GB內存的機器上大約是10萬左右,具體數目能夠cat /proc/sys/fs/file-max察看,通常來講這個數目和系統內存關係很大。

 

 

總結:

(1)select,poll實現須要本身不斷輪詢全部fd集合,直到設備就緒,期間可能要睡眠和喚醒屢次交替。而epoll其實也須要調用epoll_wait不斷輪詢就緒鏈表,期間也可能屢次睡眠和喚醒交替,可是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,並喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,可是select和poll在「醒着」的時候要遍歷整個fd集合,而epoll在「醒着」的時候只要判斷一下就緒鏈表是否爲空就好了,這節省了大量的CPU時間。這就是回調機制帶來的性能提高。

(2)select,poll每次調用都要把fd集合從用戶態往內核態拷貝一次,而且要把current往設備等待隊列中掛一次,而epoll只要一次拷貝,並且把current往等待隊列上掛也只掛一次(在epoll_wait的開始,注意這裏的等待隊列並非設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省很多的開銷。

相關文章
相關標籤/搜索