netty(四)nio之網絡編程 一、阻塞與非阻塞 二、多路複用

一、阻塞與非阻塞

1.1 阻塞

1.1.1 阻塞模式會存在哪些問題?

1)在阻塞模式下,以下的方法都會導致線程暫停

  • ServerSocketChannel.accept 會在沒有連接建立時讓線程暫停
  • SocketChannel.read 會在沒有數據可讀時讓線程暫停
  • 阻塞的表現其實就是線程暫停了,暫停期間不會佔用 cpu,但線程處於閒置狀態

2)單線程下,阻塞方法之間相互影響,幾乎不能正常工作,需要多線程支持

3)多線程下,有新的問題,體現在以下方面

  • 32 位 jvm 一個線程最大堆棧是 320k,64 位 jvm 一個線程 最大堆棧是1024k,如果連接數過多,必然導致 OOM,並且線程太多,反而會因爲頻繁上下文切換導致性能降低。

  • 可以採用線程池技術來減少線程數和線程上下文切換,但治標不治本,如果有很多連接建立,但長時間 inactive(不活躍),會阻塞線程池中所有線程,因此不適合長連接,只適合短連接

1.1.2 測試代碼:

服務端代碼:

public class BioServerTest {

    public static void main(String[] args) throws IOException {
        // 使用 nio 來理解阻塞模式, 單線程
        // 0. ByteBuffer
        ByteBuffer buffer = ByteBuffer.allocate(16);
        // 1. 創建了服務器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        // 2. 綁定監聽端口
        ssc.bind(new InetSocketAddress(8080));
        // 3. 連接集合
        List<SocketChannel> channels = new ArrayList<>();
        while (true) {
            // 4. accept 建立與客戶端連接, SocketChannel 用來與客戶端之間通信
            System.out.println("connecting...");
            // 阻塞方法,線程停止運行
            SocketChannel sc = ssc.accept();
            System.out.println("connected... " + sc);
            channels.add(sc);
            for (SocketChannel channel : channels) {
                // 5. 接收客戶端發送的數據
                System.out.println("before read..."+channel);
                try {
                    // 阻塞方法,線程停止運行
                    channel.read(buffer);
                } catch (IOException e) {
                }
                buffer.flip();
                System.out.println(print(buffer));
                buffer.clear();
                System.out.println("after read..."+channel);
            }
        }
    }

    static String print(ByteBuffer b) {
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < b.limit(); i++) {
            stringBuilder.append((char) b.get(i));
        }
        return stringBuilder.toString();
    }
}

客戶端代碼:

public class SocketClientTest{

    public static void main(String[] args) throws Exception {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("localhost", 8080));
        System.out.println("waiting...");
        //模擬長連接一直存在
        while (true) {
        }
    }
}

啓動服務端看結果,一直在connecting,此時線程阻塞了:

connecting...

啓動客戶端看結果,此時連接成功,又阻塞到收到消息之前:

connecting...
connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:64800]
before read...java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:64800]

1.2 非阻塞

1.2.1 相比阻塞改變了什麼?

非阻塞模式下,相關方法都不會讓線程暫停

  • 在 ServerSocketChannel.accept 在沒有連接建立時,會返回 null,繼續運行
  • SocketChannel.read 在沒有數據可讀時,會返回 0,但線程不必阻塞,可以去執行其它 SocketChannel 的 read 或是去執行 ServerSocketChannel.accept
  • 寫數據時,線程只是等待數據寫入 Channel 即可,無需等 Channel 通過網絡把數據發送出去

1.2.2 非阻塞模型存在哪些問題?

1)但非阻塞模式下,即使沒有連接建立,和可讀數據,線程仍然在不斷運行,白白浪費了 cpu

2)數據複製過程中,線程實際還是阻塞的(AIO 改進的地方)

1.2.3 測試代碼

服務端代碼:

public class NonIoServerTest {

    public static void main(String[] args) throws IOException {
        // 使用 nio 來理解非阻塞模式, 單線程
        // 0. ByteBuffer
        ByteBuffer buffer = ByteBuffer.allocate(16);
        // 1. 創建了服務器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        // 非阻塞模式
        ssc.configureBlocking(false);
        // 2. 綁定監聽端口
        ssc.bind(new InetSocketAddress(8080));
        // 3. 連接集合
        List<SocketChannel> channels = new ArrayList<>();
        while (true) {
            // 4. accept 建立與客戶端連接, SocketChannel 用來與客戶端之間通信
            // 非阻塞,線程還會繼續運行,如果沒有連接建立,但sc是null
            SocketChannel sc = ssc.accept();
            if (sc != null) {
                System.out.println("connected... " + sc);
                // 非阻塞模式
                sc.configureBlocking(false);
                channels.add(sc);
            }
            for (SocketChannel channel : channels) {
                // 5. 接收客戶端發送的數據
                // 非阻塞,線程仍然會繼續運行,如果沒有讀到數據,read 返回 0
                int read = channel.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    System.out.println(print(buffer));
                    buffer.clear();
                    System.out.println("after read..."+channel);
                }
            }
            // 用於查非阻塞狀態,連接客戶端時可關閉,便於觀看結果
            System.out.println("wait connecting...");
        }
    }

    static String print(ByteBuffer b) {
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < b.limit(); i++) {
            stringBuilder.append((char) b.get(i));
        }
        return stringBuilder.toString();
    }
}
啓動服務端結果如下,不斷刷新:
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
wait connecting...
... ...

服務端與前面測試阻塞時一樣,我們將服務端的System.out.println("wait connecting...");這行代碼註釋掉,方便看結果。

啓動客戶端,看服務端結果:

connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:61254]

二、多路複用

單線程可以配合 Selector 完成對多個 Channel 可讀寫事件的監控,這稱之爲多路複用。

  • 多路複用僅針對網絡 IO,普通文件 IO 沒法利用多路複用

  • 如果使用非阻塞模式,而不使用selector,則線程大部分時間都在做無用功,使用Selector 能夠保證以下三點:

    • 有可連接事件時纔去連接
    • 有可讀事件纔去讀取
    • 有可寫事件纔去寫入( 限於網絡傳輸能力,Channel 未必時時可寫,一旦 Channel 可寫,會觸發 Selector 的可寫事件)

2.1 Selector

上述方案的好處:

  • 一個線程配合 selector 就可以監控多個 channel 的事件,事件發生線程纔去處理。避免非阻塞模式下所做無用功。
  • 讓這個線程能夠被充分利用
  • 節約了線程的數量
  • 減少了線程上下文切換

2.1.1 如何使用Selector?

如下代碼及註釋描述:

public class SelectorTest {

    public static void main(String[] args) throws IOException {
        // 創建Selector
        Selector selector = Selector.open();

        // 綁定channel事件(SelectionKey當中有四種事件:OP_ACCEPT,OP_CONNECT, OP_READ, OP_WRITE)
        SocketChannel channel = SocketChannel.open();
        channel.configureBlocking(false);
        SelectionKey selectionKey = channel.register(selector, SelectionKey.OP_ACCEPT);

        // 監聽channel事件,返回值是發生事件的channel數
        // 監聽方法1,阻塞直到綁定事件發生
        int count1 = selector.select();
        // 監聽方法2,阻塞直到綁定事件發生,或是超時(時間單位爲 ms)
        int count2 = selector.select(1000);
        // 監聽方法3,不會阻塞,也就是不管有沒有事件,立刻返回,自己根據返回值檢查是否有事件
        int count3 = selector.selectNow();
    }
}

上述代碼當中,在selector進行channel時間監聽時,會發生阻塞,直到時間發生,那麼有哪些情況會使線程變成不阻塞狀態呢?如下所示:

1)事件發生時(SelectionKey當中有四種事件:OP_ACCEPT,OP_CONNECT, OP_READ, OP_WRITE)

  • 客戶端發起連接請求,會觸發 accept 事件
  • 客戶端發送數據過來,客戶端正常、異常關閉時,都會觸發 read 事件,另外如果發送的數據大於 buffer 緩衝區,會觸發多次讀取事件
  • channel 可寫,會觸發 write 事件
  • 在 linux 下 nio bug 發生時

2)調用 selector.wakeup()
3)調用 selector.close()
4)selector 所在線程 interrupt

2.2 處理accept事件

服務端如下所示:

public class AcceptEventServerTest {

    public static void main(String[] args) {
        try {
            ServerSocketChannel channel = ServerSocketChannel.open();
            channel.bind(new InetSocketAddress(8080));
            System.out.println(channel);
            Selector selector = Selector.open();
            channel.configureBlocking(false);
            channel.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                int count = selector.select();
//                int count = selector.selectNow();
                System.out.println("select count: " + count);
//                if(count <= 0) {
//                    continue;
//                }

                // 獲取所有事件
                Set<SelectionKey> keys = selector.selectedKeys();

                // 遍歷所有事件,逐一處理
                Iterator<SelectionKey> iter = keys.iterator();
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                    // 判斷事件類型
                    if (key.isAcceptable()) {
                        ServerSocketChannel c = (ServerSocketChannel) key.channel();
                        // 必須處理
                        SocketChannel sc = c.accept();
                        System.out.println(sc);
                    }
                    // 處理完畢,必須將事件移除
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

客戶端如下:

public class ClientTest {

    public static void main(String[] args) {
        // accept事件
        try (Socket socket = new Socket("localhost", 8080)) {
            System.out.println(socket);
            // read事件
            socket.getOutputStream().write("world".getBytes());
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服務端打印結果:

sun.nio.ch.ServerSocketChannelImpl[/0:0:0:0:0:0:0:0:8080]
select count: 1
java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:60469]

上述代碼中服務端註釋掉了使用selector.selectNow()的方法,如果使用該方法,需要自己去判斷返回值是否爲0。

事件發生後,要麼處理,要麼取消(cancel),不能什麼都不做,否則下次該事件仍會觸發,這是因爲 nio 底層使用的是水平觸發。

2.3 處理read事件

此處仍然使用代碼的方式講解,客戶端與前面的客戶端相同,只是此處會同時啓動兩個客戶端,其中發送的內容分別是“hello” 和 “world”。

服務端代碼如下所示:

public class ReadEventServerTest {
    public static void main(String[] args) {
        try {
            ServerSocketChannel channel = ServerSocketChannel.open();
            channel.bind(new InetSocketAddress(8080));
            System.out.println(channel);
            Selector selector = Selector.open();
            channel.configureBlocking(false);
            channel.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                int count = selector.select();
                System.out.println("select count:" + count);

                // 獲取所有事件
                Set<SelectionKey> keys = selector.selectedKeys();

                // 遍歷所有事件,逐一處理
                Iterator<SelectionKey> iter = keys.iterator();
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                    // 判斷事件類型
                    if (key.isAcceptable()) {
                        ServerSocketChannel c = (ServerSocketChannel) key.channel();
                        // 必須處理
                        SocketChannel sc = c.accept();
                        sc.configureBlocking(false);
                        sc.register(selector, SelectionKey.OP_READ);
                        System.out.println("連接已建立:" + sc);
                    } else if (key.isReadable()) {
                        SocketChannel sc = (SocketChannel) key.channel();
                        ByteBuffer buffer = ByteBuffer.allocate(128);
                        int read = sc.read(buffer);
                        if (read == -1) {
                            key.cancel();
                            sc.close();
                        } else {
                            buffer.flip();
                            System.out.println(print(buffer));
                        }
                    }
                    // 處理完畢,必須將事件移除
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static String print(ByteBuffer b) {
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < b.limit(); i++) {
            stringBuilder.append((char) b.get(i));
        }
        return stringBuilder.toString();
    }
}

啓動服務端,並先後啓動兩個客戶端,看結果,首先服務端channel自己註冊到selector,客戶端1發送accept事件,服務端接收到後,繼續while循環,監聽到read事件,打印內容爲“hello”,客戶端2步驟相同。

sun.nio.ch.ServerSocketChannelImpl[/0:0:0:0:0:0:0:0:8080]
select count:1
連接已建立:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:61693]
select count:1
hello
select count:1
連接已建立:java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:51495]
select count:1
world

注意:最後的iter.remove(),爲什麼要移除?
因爲 select 在事件發生後,就會將相關的 key 放入 selectedKeys 集合,但不會在處理完後從 selectedKeys 集合中移除,需要我們自己編碼刪除。例如

  • 第一次觸發了 ssckey 上的 accept 事件,沒有移除 ssckey
  • 第二次觸發了 ssckey 上的 read 事件,但這時 selectedKeys 中還有上次的 ssckey ,在處理時因爲沒有真正的 serverSocket 連上了,就會導致空指針異常

上述代碼中cancel有什麼作用?
cancel 會取消註冊在 selector 上的 channel,並從 keys 集合中刪除 key 後續不會再監聽事件

2.3.1 關注消息邊界

首先看如下的代碼是否有問題,客戶端代碼如下:

public class ServerTest {

    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(9000);
        while (true) {
            Socket s = ss.accept();
            InputStream in = s.getInputStream();
            // 每次讀取4個字節
            byte[] arr = new byte[4];
            while (true) {
                int read = in.read(arr);
                // 讀取長度是-1,則不讀取了
                if (read == -1) {
                    break;
                }
                System.out.println(new String(arr, 0, read));
            }
        }
    }
}

服務端代碼如下:

public class ClientTest {

    public static void main(String[] args) throws IOException {
        Socket max = new Socket("localhost", 9000);
        OutputStream out = max.getOutputStream();
        out.write("hello".getBytes());
        out.write("world".getBytes());
        out.write("你好".getBytes());
        out.write("世界".getBytes());
        max.close();
    }
}

結果:

hell
owor
ld�
�好
世�
��

爲什麼會產生上述的問題?
這裏面涉及到消息邊界的問題。消息的長短是不同的,當我們指定相同長度的ByteBuffer去接收消息時,必然存在不同時間段存在很多種情況,如下所示:

由於buffer長度固定,必然存在消息被截斷的情況,那麼如何解決這些問題呢?

1)一種思路是固定消息長度,數據包大小一樣,服務器按預定長度讀取,缺點是浪費帶寬

2)另一種思路是按分隔符拆分,缺點是效率低

3)TLV 格式,即 Type 類型、Length 長度、Value 數據,類型和長度已知的情況下,就可以方便獲取消息大小,分配合適的 buffer,缺點是 buffer 需要提前分配,如果內容過大,則影響 server 吞吐量

  • Http 1.1 是 TLV 格式
  • Http 2.0 是 LTV 格式

通過面給出的答案都不是最好的解決方案,重點的問題在於如何分配Bytebuffer的大小?

buffer是給一個channel獨立使用的,不能被多個channel共同使用,因爲存在粘包、半包的問題。

buffer的大小又不能太大,如果要支持很大的連接數,同時又設置很大的buffer,則必然需要龐大的內存。

所以我們需要設置一個大小可變的ByteBuffer

目前有兩種較爲簡單實現方案,其都有其優缺點:
1)預先分配一個較小的buffer,例如4k,如果發現不能裝下全部內容,則創建一個更大的buffer,比如8k,將已寫入的4k拷貝到新分配的8kbuffer,將剩下的內容繼續寫入。

其優點是消息必然是連續的,但是不斷的分配和拷貝,必然會對性能造成較大的影響。

2)使用多個數組的形式組成buffer,當一個數組存不下數據內容,就將剩餘數據放入下一個數組當中。在netty中的CompositeByteBuf類,就是這種方式。

其缺點是數據不連續,需要再次解析整合,優點是解決了上一個方案的造成性能損耗的問題。

2.4 處理write事件

什麼是兩階段策略?

其出現原因有如下兩個:

1)在非阻塞模式下,我們無法保證將buffer中的所有數據全部寫入channel當中,所以我們需要追蹤寫入後的返回值,也就是實際寫入字節的數值。

int write = channel.write(buffer);

2)我們可以使所有的selector監聽channel的可寫事件,每個channel都會有一個key用來跟蹤buffer,這樣會佔用過多的內存。(關於此點不太理解的,下面可以通過代碼來理解)

鑑於以上問題,出現的兩階段策略:

1)當第一次寫入消息時,我們纔將channel註冊到selector

2)如果第一次沒寫完,再次添加寫事件, 檢查 channel 上的可寫事件,如果所有的數據寫完了,就取消 channel 的註冊(不取消則每次都會出現寫事件)。

下面通過代碼的方式演示:
服務端:

public class ServerTest {

    public static void main(String[] args) throws IOException {
        // 開啓一個服務channel
        ServerSocketChannel ssc = ServerSocketChannel.open();
        // 設置非阻塞
        ssc.configureBlocking(false);
        // 綁定端口
        ssc.bind(new InetSocketAddress(8080));

        //初始化selector
        Selector selector = Selector.open();
        //註冊服務端到selector
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            //監聽事件,此處會阻塞
            selector.select();

            //獲取所有事件key,遍歷
            Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                //不管處理成功與否,移除key
                iter.remove();
                //如果是建立連接事件
                if (key.isAcceptable()) {
                    //處理accept事件
                    SocketChannel sc = ssc.accept();
                    //設置非阻塞
                    sc.configureBlocking(false);
                    // 此處是第一階段
                    //註冊一個read事件到selector
                    SelectionKey sckey = sc.register(selector, SelectionKey.OP_READ);
                    // 1. 向客戶端發送內容
                    StringBuilder sb = new StringBuilder();
                    for (int i = 0; i < 30000000; i++) {
                        sb.append("a");
                    }
                    //字符串轉buffer
                    ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
                    //2. 寫入數據到客戶端channel
                    int write = sc.write(buffer);
                    // 3. write 表示實際寫了多少字節
                    System.out.println("實際寫入字節:" + write);
                    // 4. 如果有剩餘未讀字節,才需要關注寫事件
                    // 此處是第二階段
                    if (buffer.hasRemaining()) {
                        // 在原有關注事件的基礎上,多關注 寫事件
                        sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE);
                        // 把 buffer 作爲附件加入 sckey
                        sckey.attach(buffer);
                    }
                } else if (key.isWritable()) {
                    // 檢索key中的附件buffer
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    // 獲取客戶端channel
                    SocketChannel sc = (SocketChannel) key.channel();
                    // 根據上次的position繼續寫
                    int write = sc.write(buffer);
                    System.out.println("實際寫入字節:" + write);
                    // 如果寫完了,需要將綁定的附件buffer去掉,並且去掉寫事件
                    // 如果沒寫完將會繼續while,執行寫事件,知道完成爲止
                    if (!buffer.hasRemaining()) {
                        key.interestOps(key.interestOps() - SelectionKey.OP_WRITE);
                        key.attach(null);
                    }
                }
            }
        }
    }
}

客戶端:

public class ClientTest {

    public static void main(String[] args) throws IOException {
        // 開啓selector
        Selector selector = Selector.open();
        // 開啓客戶端channel
        SocketChannel sc = SocketChannel.open();
        //設置非阻塞
        sc.configureBlocking(false);
        // 註冊一個連接事件和讀事件
        sc.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ);
        //連接到服務端
        sc.connect(new InetSocketAddress("localhost", 8080));
        int count = 0;
        while (true) {
            //此處監測事件,阻塞
            selector.select();
            //獲取時間key集合,並遍歷
            Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                //無論成功與否,都要移除
                iter.remove();
                //連接
                if (key.isConnectable()) {
                    System.out.println(sc.finishConnect());
                } else if (key.isReadable()) {
                    //分配內存buffer
                    ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
                    //讀數據到buffer
                    count += sc.read(buffer);
                    //清空buffer
                    buffer.clear();
                    //打印總字節數
                    System.out.println(count);
                }
            }
        }
    }
}

分別啓動服務端和客戶端,結果如下:

實際寫入字節:3801059
實際寫入字節:3014633
實際寫入字節:4063201
實際寫入字節:4718556
實際寫入字節:2490349
實際寫入字節:2621420
實際寫入字節:2621420
實際寫入字節:2621420
實際寫入字節:2621420
實際寫入字節:1426522
true
131071
262142
393213
524284
655355
... ...
29753117
29884188
30000000

兄弟們,看到這了就給個讚唄,感謝

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章