linux的五種IO模型


概念:

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

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

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

異步:調用發出后,調用者不能立刻得到結果,而是實際處理這個調用的函數完成之后,通過狀態、通知和回調來通知調用者。

  比如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、1、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內部定義的等待隊列)。這也能節省不少的開銷。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM