從BIO和NIO到Netty實踐

約定291天后發表於2020-11-11


一、什麼是BIO

傳統的BIO模型使用是java.net包中API實現Socket通訊,而傳統的BIO通訊中阻塞共發現在兩個 地方。

服務端ServerSocker::accept
客戶端Socket資料讀寫

這種IO的弊端即是無法處理併發的客戶端請求,因此可以通過為每個客戶端單獨分配一個執行緒,則客戶的端的Socket資料讀寫不在阻塞,可以滿足併發的客戶端請求。

同樣的在高併發,大量客戶端連線造成大量執行緒,容易產生執行緒OOM,同時也有大量的執行緒上下文切換影響效能。

二、什麼是NIO

  1. 在JAVA中NIO是指new IO,是JDK為實現非阻塞IO實現的一套新API
  2. 在linux中NIO是指非阻塞的IO,主要與poll和epoll核心呼叫有關

JAVA的NIO一種重要的方法為configureBlocking,示例程式碼如下:

package com.zte.sunquan.nio;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;

public class SocketServerNIO {
    public static void main(String[] args) throws Exception {
        List<SocketChannel> channels = new ArrayList<>();
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(9090));
        ssc.configureBlocking(false);
        //非阻塞
        while (true) {
            Thread.sleep(1000);
            //不阻塞,但需要每次都問一下核心
            SocketChannel clientSocket = ssc.accept();
            if (clientSocket == null) {
                System.out.println("No client....");
            } else {
                //將clientSocket保留
                clientSocket.configureBlocking(false);
                System.out.println("client connected in:" + clientSocket.socket().getPort());
                channels.add(clientSocket);
            }

            ByteBuffer buffer = ByteBuffer.allocate(4096);
            //不阻塞,但這裡每次也需要問一下核心,即使有些客戶端沒有事件
            for (SocketChannel channel : channels) {
                int num = channel.read(buffer);
                if (num > 0) {
                    buffer.flip();
                    byte[] content=new byte[buffer.limit()];
                    buffer.get(content);
                    String s = new String(content);
                    System.out.println(channel.socket().getPort()+" Read client msg:"+s);
                    buffer.clear();
                }
            }

        }
    }
}

如上程式碼中一個執行緒負責了IO讀寫與連線建立,當然可以優雅一點的方法,即將連線建立與IO讀寫分執行緒處理,讓IO的讀寫不影響高併發下連線請求與建立。但上述程式碼仍有明顯的弊端。

for (SocketChannel channel : channels) {
int num = channel.read(buffer);

在上述程式碼中,每次迴圈都要與所有建立的客戶端進行一次read操作,涉及使用者究竟與核心空間的切換,考慮到一個連線數特別多背景下,一次可能只會有幾個連線有IO事件,如上的實現會造成大量的效能浪費。
那有沒有一種可能,讓核心主動告知我們哪些連線有IO事件,這樣應用精確地去指定的連線上進行IO事件的處理,而不是傻傻地每個連線read一遍?

三、多路複用器

多路複用器使用,可以解決第二節最後的問題,通過selector.select,核心只會將有事件的socket返回,避免了應用迴圈遍歷嘗試。

下面示例程式碼描述了使用JAVA中NIO的API實現的服務端程式碼

package com.zte.sdn.nio;

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


/**
 * 多路複用器示例程式碼
 **/
public class NioServer {

    private Selector startServer() throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(9090));
        ssc.configureBlocking(false);
        //開啟一個多路複用器
        //poll系統呼叫:
        //epoll系統呼叫:
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("Start server at port:9090");
        return selector;
    }


    public static void main(String[] args) throws IOException {
        NioServer nioServer = new NioServer();
        Selector selector = nioServer.startServer();
        while (true) {
            System.out.println("ask");
            //使用select向核心詢問是否有事件(由於一開始只註冊了ServerSocket的OP_ACCEPT)
            //所以第一次只判斷是否有連線事件
            //後面由於註冊客戶端OP_READ,從面判斷是否有可讀事件
            while (selector.select(10) > 0) {
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey selectionKey = iterator.next();
                    iterator.remove();
                    nioServer.handler(selectionKey, selector);
                }
            }
        }
    }

    private void handler(SelectionKey selectionKey, Selector selector) throws IOException {
        if (selectionKey.isAcceptable()) {
            //一個連線事件
            ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();
            SocketChannel client = channel.accept();
            client.configureBlocking(false);
            //再註冊進去
            client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(8192));
            System.out.println("receive connect:" + client.getRemoteAddress());
        } else if (selectionKey.isReadable()) {
            //可讀事件
            SocketChannel client = (SocketChannel) selectionKey.channel();
            ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
            buffer.clear();
            int read = 0;
            while (true) {
                read = client.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    while (buffer.hasRemaining()) {
                        //講到內容寫回客戶端
                        client.write(buffer);
                    }
                    System.out.println("receive client msg:" + new String(buffer.array()));
                    buffer.clear();
                } else if (read == 0) {
                    break;
                } else {
                    client.close();
                    break;
                }
            }
        }
    }
}

在上述示例中,一個執行緒完成了服務端介面客戶端連線以及IO讀寫動作。思考上面程式設計思路的弊端是什麼?
考慮到一個IO的讀寫如何非常耗時,必然會影響客戶端建立連線併發效能,以及大量IO讀寫的效能。
自然地針對客戶端連線與IO讀寫可以分不同selector單獨處理,各司其職,所以改進的實現如下:

  else if (selectionKey.isReadable()) {
            executorService.submit(()->{
                //可讀事件
                try {
                    SocketChannel client = (SocketChannel) selectionKey.channel();
                    ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
                    buffer.clear();
                    int read = 0;
                    while (true) {
                        read = client.read(buffer);
                        if (read > 0) {
                            buffer.flip();
                            while (buffer.hasRemaining()) {
                                //講到內容寫回客戶端
                                client.write(buffer);
                            }
                            System.out.println("receive client msg:" + new String(buffer.array()));
                            buffer.clear();
                        } else if (read == 0) {
                            break;
                        } else {
                            client.close();
                            break;
                        }
                    }
                }catch (Exception e){
                    e.printStackTrace();
                }
            });

        }

如上所示,實現了IO的處理非同步化。但上述實現雖然緩解了大量長時間IO帶來的效能問題,但不能從根本上解決,那有沒有辦法,將客戶端連線事件與IO事件完全分離開?當然如果IO讀取的資料業務處理比較耗時,則可以另起執行緒再進行非同步處理。

四、多Selector版本

程式碼:

package com.zte.sdn.nio;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class IOHandler extends Thread {
    private Selector selector;

    public IOHandler(Selector selector, String name) {
        this.selector = selector;
        this.setName(name);
    }

    @Override
    public void run() {
        while (true) {
            try {
                handler();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }


    private void handler() throws IOException {
        while (selector.select(10) > 0) {
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                iterator.remove();
                if (selectionKey.isReadable()) {
                    SocketChannel client = (SocketChannel) selectionKey.channel();
                    ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
                    buffer.clear();
                    int read = 0;
                    while (true) {
                        try {
                            read = client.read(buffer);
                            if (read > 0) {
                                buffer.flip();
                                while (buffer.hasRemaining()) {
                                    //講到內容寫回客戶端
                                    client.write(buffer);
                                }
                                System.out.println(Thread.currentThread().getName() + " receive client msg:" + new String(buffer.array()));
                                buffer.clear();
                            } else if (read == 0) {
                                break;
                            } else {
                                client.close();
                                break;
                            }
                        } catch (Exception e) {
                            try {
                                client.close();
                            } catch (IOException ex) {
                                ex.printStackTrace();
                            }
                            break;
                        }

                    }
                }
            }
        }
    }
}

MultiNioServer

package com.zte.sdn.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


/**
 * 多路複用器示例程式碼
 **/
public class MultiNioServer {

    private ExecutorService executorService = Executors.newFixedThreadPool(5);

    private Selector startServer() throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(9090));
        ssc.configureBlocking(false);
        //開啟一個多路複用器
        //poll系統呼叫:
        //epoll系統呼叫:
        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("Start server at port:9090");
        return selector;
    }


    public static void main(String[] args) throws IOException {
        MultiNioServer nioServer = new MultiNioServer();
        Selector selector = nioServer.startServer();
        Selector selector1 = Selector.open();
        Selector selector2 = Selector.open();
        Selector[] selectors = new Selector[]{selector1, selector2};
        new IOHandler(selector1, "A").start();
        new IOHandler(selector2, "B").start();
        int i = 0;
        while (true) {
            while (selector.select(10) > 0) {
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey selectionKey = iterator.next();
                    iterator.remove();
                    if (selectionKey.isAcceptable()) {
                        //一個連線事件
                        ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();
                        SocketChannel client = channel.accept();
                        client.configureBlocking(false);
                        //迴圈註冊至另外的Selector
                        client.register(selectors[i++ % 2], SelectionKey.OP_READ, ByteBuffer.allocate(8192));
                        System.out.println("receive connect:" + client.getRemoteAddress());
                    }
                }
            }
        }
    }

}

五、IO總結

傳統的NIO(不使用多路利用器),雖然解決了IO阻塞問題,但需要使用者程式遍歷地向核心詢問所有客戶端;
當使用了多路複用器(使用select/poll),每次迴圈需要將客戶端列表傳遞給核心,由核心遍歷將有事件的fd返回,避免使用者態與核心態的頻繁切換。
但使用select/poll仍然存在弊端,即每次迴圈都要進行fd列表的傳遞,如何能夠避免每次迴圈向核心傳遞fd列表?
思考:如核心能提前申請一塊空間儲存(紅黑樹)事件控制程式碼,在有客戶端連線上,則記錄在該空間,如此客戶端程式詢問是否事件時,則只需簡單問一句,而不需要每次都傳遞fd列表。
這說的其實epoll核心呼叫能解決的。

我們老師去教室收作業進行批改的場景為例類比上述實現:

IO型別說明弊端
BIO老師來到教室,依次詢問每個學生寫了作業沒有,寫了則批改,沒寫則需等他寫好再批改,期間有同學入學畢業則需要等老師忙完這一輪兩處阻塞
NIO老師來到教室,依次詢問每個學生寫了作業沒有,寫了則批改,沒寫則直接下一個,期間有同學入學畢業則需要等老師忙完這一輪,相對較快依次詢問,沒寫作業的也要問
NIO-select/poll老師每次來到教室,拿一個名單貼到教室黑板,並告知在名單上同學且完成作業,報給我,後序老師直接拿到報名同學作業,批改即可不同於每個同學依次詢問,但還要每次準備名單
NIO-epoll開班時,則在教室黑板貼上人員名單,有新同學加入畢業,則增加刪除,老師每次來到教室,不用準確名單,只需要告知在名單上同學且完成作業,報給我,後序老師直接拿到報名同學作業,批改即可解決上述所有弊端

ps.select的fd有1024的數量約束,poll無此限制

六、Strace分析

七、Netty的執行緒模型

Netty擁有兩個NIO執行緒池,分別是bossGroupworkerGroup,前者處理新建連線請求,然後將新建立的連線輪詢交給workerGroup中的其中一個NioEventLoop來處理,後續該連線上的讀寫操作都是由同一個NioEventLoop來處理。注意,雖然bossGroup也能指定多個NioEventLoop(一個NioEventLoop對應一個執行緒),但是預設情況下只會有一個執行緒,因為一般情況下應用程式只會使用一個對外監聽埠。

為何不能使用多執行緒來監聽同一個對外埠麼,即多執行緒epoll_wait到同一個epoll例項上?

這裡會引來驚群的問題和epoll設定的是LT模式

現代linux中,多個socker同時監聽同一個埠也是可行的,nginx 1.9.1也支援這一行為。linux 3.9以上核心支援SO_REUSEPORT選項,允許多個socker bind/listen在同一埠上。這樣,多個程式可以各自申請socker監聽同一埠,當連線事件來臨時,核心做負載均衡,喚醒監聽的其中一個程式來處理,reuseport機制有效的解決了epoll驚群問題

單執行緒模型

Reactor 單執行緒模型,是指所有的 I/O 操作都在同一個 NIO 執行緒上面完成的,此時NIO執行緒職責包括:接收新建連線請求、讀寫操作等。

多執行緒模型

Rector 多執行緒模型與單執行緒模型最大的區別就是有一組 NIO 執行緒來處理連線讀寫操作,一個NIO執行緒處理Accept。一個NIO執行緒可以處理多個連線事件,一個連線的事件只能屬於一個NIO執行緒

主從模型

主從 Reactor 執行緒模型的特點是:服務端用於接收客戶端連線的不再是一個單獨的 NIO 執行緒,而是一個獨立的 NIO 執行緒池。Acceptor 接收到客戶端 TCP連線請求並處理完成後(可能包含接入認證等),將新建立的 SocketChannel注 冊 到 I/O 線 程 池(sub reactor 線 程 池)的某個I/O執行緒上, 由它負責SocketChannel 的讀寫和編解碼工作。Acceptor 執行緒池僅僅用於客戶端的登入、握手和安全認證,一旦鏈路建立成功,就將鏈路註冊到後端 subReactor 執行緒池的 I/O 執行緒上,由 I/O 執行緒負責後續的 I/O 操作。

相關文章