《跟閃電俠學Netty》閱讀筆記 - 開篇入門Netty

Xander發表於2023-04-14

引言

《跟閃電俠學Netty》 並不是個人接觸的第一本Netty書籍,但個人更推薦讀者把它作為作為第一本Netty入門的書籍。

《Netty In Action》 不同,這本書直接從Netty入門程式程式碼開始引入Netty框架,前半部分教你如何用Netty搭建簡易的通訊系統,整體難度比較低,後半部分直接從服務端原始碼、客戶端原始碼、ChannelPipeline開始介紹,和前半部分割裂較為嚴重。

相較於入門的程式,原始碼分析毫無疑問是比較有乾貨的部分,但是和前面入門程式相比有點學完了99乘法表就讓你去做微積分的卷子一樣,如果Netty使用生疏原始碼部分講解肯定是十分難懂的,所以更建議只看前半截。

個人比較推薦這本書吃透Netty編寫的簡單通訊“專案”之後,直接去看《Netty In Action》做一個更為系統的深入和基礎鞏固。等《Netty In Action》看明白之後,再回過頭來看《跟閃電俠學Netty》的原始碼分析部分。

拋開原始碼分析部分,這本書是“我奶奶都能學會”的優秀入門書籍,用程式碼實戰加講解方式學起來輕鬆印象深刻。

開篇入門部分先不引入專案,這裡先對於過去JDK的網路IO模型作為引子介紹為什麼我們需要用Netty,學習Netty帶來的好處等。

思維導圖

《跟閃電俠學Netty》閱讀筆記 - 實戰入門篇.png

Netty 依賴版本(4.1.6.Final)

本書使用的Netty版本為 4.1.6,為了避免後面閱讀原始碼的時候產生誤解,建議以此版本為基準。

    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.6.Final</version>
    </dependency>

JDK 原生程式設計模型

到目前為止,JDK一共實現了三種網路IO程式設計模型:BIO、NIO和AIO。三種模型不僅產生的間隔時間跨度大,並且由三組完全不同程式設計風格的開發人員設計API,不同程式設計模型和設計思路之間的切換十分複雜,開發者的學習成本也比較大。

針對這些問題,我們直接瞭解Netty如何統一這些模型以及如何降低併發程式設計的開發難度,這裡先對過去的JDK網路IO程式設計模型做一個瞭解。

洗衣機案例理解阻塞非阻塞,同步非同步概念

在瞭解JDK的網路IO模型之前,必須先了解的阻塞非阻塞同步非同步的概念。

同步和非同步指的是任務之間是否需要等待其它任務完成或者等待某個事件的發生。如果一個任務必須等待另一個任務完成才能繼續執行,那麼這兩個任務就是同步的;如果一個任務可以直接繼續執行而無需等待另一個任務的完成,那麼這兩個任務就是非同步的。

阻塞和非阻塞指的是任務在等待結果時是否會一直佔用CPU資源。如果一個任務在等待結果時會一直佔用CPU資源,那麼這個任務就是阻塞的;如果一個任務在等待結果時不會佔用CPU資源,那麼這個任務就是非阻塞的。

這裡給一個生活中洗衣服的例子幫助完全沒有了解過這些概念的讀者加深印象,這個例子來源於某個網課,個人覺得十分貼切和易懂就拿過來用了。

同步阻塞

理解:

洗衣服丟到洗衣機,全程看著洗衣機洗完,洗好之後晾衣服。

類比 :

  • 請求介面
  • 等待介面返回結果,中間不能做其他事情。
  • 拿到結果處理資料

分析:
同步:全程看著洗衣機洗完。
阻塞:等待洗衣機洗好衣服之後跑過去晾衣服。

同步非阻塞

理解:

把衣服丟到洗衣機洗,然後回客廳做其他事情,定時看看洗衣機是不是洗完了,洗好後再去晾衣服。(等待期間你可以做其他事情,比如用電腦刷劇看影片)。

這種模式類似日常生活洗衣機洗衣服。

類比:

  • 請求介面。
  • 等待期間切換到其他任務,但是需要定期觀察介面是否有回送資料。
  • 拿到結果處理資料。

分析:

和阻塞方式的最大區別是不需要一直盯著洗衣機,期間可以抽空幹其他的事情。

同步:等待洗衣機洗完這個事情沒有本質變化,洗好衣服之後還是要跑過去晾衣服。
非阻塞:拿到衣服之前可以幹別的事情,只不過需要每次隔一段時間檢視能不能拿到洗好的衣服。

非同步阻塞

理解:

把衣服丟到洗衣機洗,然後看著洗衣機洗完,洗好後再去晾衣服(沒這個情況,幾乎沒這個說法,可以忽略)。

類比:

  • 請求介面,不需要關心結果。
  • 客戶端可以抽空幹其他事情,但是非得等待介面返回結果
  • 拿到服務端的處理結果

分析:

難以描述,幾乎不存在這種說法。

非同步非阻塞

理解:

把衣服丟到洗衣機洗,然後回客廳做其他事情,洗衣機洗好後會自動去晾衣服,晾完成後放個音樂告訴你洗好衣服並晾好了

類比 :

  • 請求介面,此時客戶端可以繼續執行程式碼。
  • 服務端準備並且處理資料,在處理完成之後在合適的時間通知客戶端
  • 客戶端收到服務端處理完成的結果。

分析:
非同步:洗衣機自己不僅把衣服洗好了還幫我們把衣服晾好了。
非阻塞:拿到“衣服”結果之前可以幹別的事情。

注意非同步非阻塞情況下,“我們”對待洗衣服這件事情的“態度”完全變了。

BIO 程式設計模型

BIO叫做阻塞IO模型,在阻塞IO模型中兩個任務之間需要等待響應結果,應用程式需要等待核心把整個資料準備好之後才能開始進行處理。

BIO是入門網路程式設計的第一個程式,從JDK1.0開始便存在了,存在於java.net包當中。下面的程式也是入門Tomcat原始碼的基礎程式。

image.png

Java實現程式碼

在BIO的實現程式碼中,服務端透過accept一直阻塞等待直到有客戶端連線。首先是服務端程式碼。

public static void main(String[] args) throws IOException {  
    ServerSocket serverSocket = new ServerSocket(8000);  
  
    // 接受連線  
    new Thread(() -> {  
        while (true) {  
            // 1. 阻塞獲取連線  
            try {  
                Socket socket = serverSocket.accept();  
  
                // 2. 為每一個新連線使用一個新執行緒  
                new Thread(() -> {  
                    try {  
                        int len;  
                        byte[] data = new byte[1024];  
                        InputStream inputStream = socket.getInputStream();  
                        // 位元組流讀取資料  
                        while ((-1 != (len = inputStream.read()))) {  
                            System.err.println(new String(data, 0, len));  
                        }  
                    } catch (IOException ioException) {  
                        ioException.printStackTrace();  
                    }  
                }).start();  
            } catch (IOException e) {  
                e.printStackTrace();  
  
            }  
        }  
    }).start();  
}

較為核心的部分是serverSocket.accept()這一串程式碼,會導致服務端阻塞等待客戶端的連線請求,即使沒有連線也會一直阻塞。

服務端啟動之後會監聽8000埠,等待客戶端連線,此時需要一直佔用CPU資源,獲取到客戶端連線之將會開闢一個新的執行緒單獨為客戶端提供服務。

image.png

然後是客戶端程式碼。

public static void main(String[] args) {  
    new Thread(()->{  
        try {  
            Socket socket = new Socket("127.0.0.1", 8000);  
            while (true){  
                socket.getOutputStream().write((new Date() + ":"+ "hellow world").getBytes(StandardCharsets.ISO_8859_1));  
                try {  
                    Thread.sleep(2000);  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        } catch (IOException ioException) {  
            ioException.printStackTrace();  
        }  
    }).start();  
}

客戶端的核心程式碼如下,透過建立Socket和服務端建立連線。

Socket socket = new Socket("127.0.0.1", 8000);
Connected to the target VM, address: '127.0.0.1:5540', transport: 'socket'

客戶端啟動之後會間隔兩秒傳送資料給服務端,服務端收到請求之後列印客戶端傳遞的內容。

image.png

Connected to the target VM, address: '127.0.0.1:5548', transport: 'socket'
Disconnected from the target VM, address: '127.0.0.1:5548', transport: 'socket'

Process finished with exit code 130

優缺點分析

傳統的IO模型有如下優缺點:

  • 優點

    • 實現簡單 。
    • 客戶端較少情況下執行良好。
  • 缺點

    • 每次連線都需要一個單獨的執行緒。
    • 單機單核心執行緒上下文切換代價巨大 。
    • 資料讀寫只能以位元組流為單位。
    • while(true) 死迴圈非常浪費CPU資源 。
    • API 晦澀難懂,對於程式設計人員需要考慮非常多的內容。

結論:

在傳統的IO模型中,每個連線建立成功之後都需要一個執行緒來維護,每個執行緒包含一個while死迴圈,那麼1w個連線對應1w個執行緒,繼而1w個while死迴圈。

單機是不可能完成同時支撐1W個執行緒的,但是在客戶端連線數量較少的時候,這種方式效率很高並且實現非常簡單。

NIO 程式設計模型

NIO 程式設計模型是 JDK1.4 出現的全新API,它實現的是同步非阻塞IO程式設計模型。以下面的模型為例,第二階段依然需要等待結果之後主動處理資料,主要的區別在第一階段(紅線部分)輪詢的時候可以幹別的事情,只需多次呼叫檢查是否有資料可以開始讀取。

image.png

Java 實現程式碼

NIO程式設計模型中,新來一個連線不再建立一個新的執行緒,而是可以把這條連線直接繫結到某個指定執行緒

概念上理解NIO並不難,但是要寫出JDK的NIO程式設計模板程式碼卻不容易。

public static void main(String[] args) throws IOException {  
    Selector serverSelector = Selector.open();
        Selector clientSelector = Selector.open();

        new Thread(() -> {
            try {
                // 對應IO程式設計中服務端啟動
                ServerSocketChannel listenerChannel = ServerSocketChannel.open();
                listenerChannel.socket().bind(new InetSocketAddress(8000));
                listenerChannel.configureBlocking(false);
                listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);

                while (true) {
                    // 監測是否有新的連線,這裡的1指的是阻塞的時間為1ms
                    if (serverSelector.select(1) > 0) {
                        Set<SelectionKey> set = serverSelector.selectedKeys();
                        Iterator<SelectionKey> keyIterator = set.iterator();

                        while (keyIterator.hasNext()) {
                            SelectionKey key = keyIterator.next();

                            if (key.isAcceptable()) {
                                try {
                                    // (1) 每來一個新連線,不需要建立一個執行緒,而是直接註冊到clientSelector
                                    SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
                                    clientChannel.configureBlocking(false);
                                    clientChannel.register(clientSelector, SelectionKey.OP_READ);
                                } finally {
                                    keyIterator.remove();
                                }
                            }

                        }
                    }
                }
            } catch (IOException ignored) {
            }

        }).start();


        new Thread(() -> {
            try {
                while (true) {
                    // (2) 批次輪詢是否有哪些連線有資料可讀,這裡的1指的是阻塞的時間為1ms
                    if (clientSelector.select(1) > 0) {
                        Set<SelectionKey> set = clientSelector.selectedKeys();
                        Iterator<SelectionKey> keyIterator = set.iterator();

                        while (keyIterator.hasNext()) {
                            SelectionKey key = keyIterator.next();

                            if (key.isReadable()) {
                                try {
                                    SocketChannel clientChannel = (SocketChannel) key.channel();
                                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                                    // (3) 讀取資料以塊為單位批次讀取
                                    clientChannel.read(byteBuffer);
                                    byteBuffer.flip();
                                    System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer)
                                            .toString());
                                } finally {
                                    keyIterator.remove();
                                    key.interestOps(SelectionKey.OP_READ);
                                }
                            }

                        }
                    }
                }
            } catch (IOException ignored) {
            }
        }).start();
}

上面的程式碼不需要過多糾結,NIO的程式碼模板確實非常複雜,我們可以把上面的兩個執行緒看作是兩個傳送帶,第一條傳送帶只負責接收外部的連線請求,收到請求資料之後直接丟給第二條傳送帶處理。第二條傳送帶收到任務之後進行解析和處理,最後把結果返回即可。

書中並沒有給NIO的客戶端案例,但是有意思的是Netty的客戶端啟動連線程式碼可以完美銜接JDK的NIO Server服務端,從這一點上可以發現Netty的NIO程式設計模型實際上就是對於JDK NIO模型的改良和最佳化。

PS:後續篇章的原始碼閱讀可以看到Netty和JDK的API的關係密不可分。
public static void main(String[] args) throws InterruptedException {  
    Bootstrap bootstrap = new Bootstrap();  
    NioEventLoopGroup eventExecutors = new NioEventLoopGroup();  
    // 引導器引導啟動  
    bootstrap.group(eventExecutors)  
            .channel(NioSocketChannel.class)  
            .handler(new ChannelInitializer<Channel>() {  
                @Override  
                protected void initChannel(Channel channel) throws Exception {  
                    channel.pipeline().addLast(new StringEncoder());  
                }  
            });  
  
    // 建立通道  
    Channel channel = bootstrap.connect("127.0.0.1", 8000).channel();  
  
    while (true){  
        channel.writeAndFlush(new Date() + " Hello world");  
        Thread.sleep(2000);  
    }  
}

Netty無論是客戶端啟動還是服務端啟動都會列印一堆日誌,下面是客戶端啟動日誌。

14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.cacheTrimIntervalMillis: 0
14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.useCacheForAllThreads: false
14:42:24.020 [main] DEBUG i.n.buffer.PooledByteBufAllocator - -Dio.netty.allocator.maxCachedByteBuffersPerChunk: 1023
14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.allocator.type: pooled
14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.threadLocalDirectBufferSize: 0
14:42:24.027 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.maxThreadLocalCharBufferSize: 16384
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.maxCapacityPerThread: 4096
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.ratio: 8
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.chunkSize: 32
14:42:24.052 [main] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.blocking: false
14:42:24.060 [nioEventLoopGroup-2-1] DEBUG io.netty.buffer.AbstractByteBuf - -Dio.netty.buffer.checkAccessible: true
14:42:24.060 [nioEventLoopGroup-2-1] DEBUG io.netty.buffer.AbstractByteBuf - -Dio.netty.buffer.checkBounds: true
14:42:24.060 [nioEventLoopGroup-2-1] DEBUG i.n.util.ResourceLeakDetectorFactory - Loaded default ResourceLeakDetector: io.netty.util.ResourceLeakDetector@310af49
Disconnected from the target VM, address: '127.0.0.1:13875', transport: 'socket'

Process finished with exit code 130

客戶端連線之後會間隔2S向服務端推送當前時間。

Connected to the target VM, address: '127.0.0.1:13714', transport: 'socket'
Tue Apr 11 14:42:24 CST 2023 Hello world
Tue Apr 11 14:42:26 CST 2023 Hello world
Tue Apr 11 14:42:28 CST 2023 Hello world

JDK的NIO針對BIO的改良點

NIO模型工作上有了“分工”的細節,即兩個Selector,一個負責接受新連線,另一個負責處理連線傳遞的資料。

對比BIO模型一個連線就分配一個執行緒的策略,NIO模型的策略是讓所有的連線註冊過程變為由一個Selector完成,Selector會定期輪詢檢查哪個客戶端連線可以接入,如果可以接入就註冊到當前的Selector,後續遇到資料讀取只需要輪詢一個Selector就行了。

執行緒資源受限問題透過Selector將每個客戶端的while(true) 轉為只有一個 while(true) 死迴圈得以解決,它的“副作執行緒用”是執行緒的減少直接帶來了切換效率的提升。不僅如此NIO還提供了面向Buffer的快取 ByteBuffer,提高讀寫效率,移動指標任意讀寫。

JDK的NIO程式設計模型缺點

看起來無非就是程式碼複雜了一點,其實NIO模型看起來也“還不錯”?

NO!NO!NO!JDK的NIO實際上還有很多其他問題:

    1. API複雜難用,需要理解非常多的底層概念 。(尤其是臭名昭著的 ByteBuffer)
    1. JDK沒有執行緒模型,使用者需要自己設計底層NIO模型。
    1. 自定義協議也要拆包 。
    1. JDK的NIO是由於Epoll實現的,底層存在空輪詢的BUG
    1. 自行實現NIO模型會存在很多問題。
    1. 程式設計人員的程式設計水平層次不齊,個人定製的NIO模型難以通用,替換性也很差。
基於以上種種問題,Netty 統統都有解決方案。

簡單介紹AIO

JDK的AIO不是很成熟,AIO底層依然因為Epoll的遺留問題存在臭名昭著的空輪詢BUG,這裡並不推薦讀者使用JDK的AIO進行程式設計。

image.png

Java AIO 的核心在於兩個關鍵類:AsynchronousSocketChannelAsynchronousServerSocketChannel

AsynchronousSocketChannel 實現非同步套接字通訊,可以讓我們在不同的客戶端連線之間切換,而無需建立新的執行緒或執行緒池。

AsynchronousServerSocketChannel 則用於非同步地監聽客戶端的連線請求。

Java 實現程式碼

這裡用ChatGPT生成了一段JDK的AIO程式碼,為了更好理解順帶讓它把註釋一塊生成了。

public class AIOServer {  
  
  
    public static void main(String[] args) throws IOException {  
        // 建立一個 ExecutorService,用於處理非同步操作的執行緒池  
        ExecutorService executor = Executors.newFixedThreadPool(10);  
        // 建立一個 AsynchronousChannelGroup,將執行緒池與該 Channel 組關聯  
        AsynchronousChannelGroup channelGroup = AsynchronousChannelGroup.withThreadPool(executor);  
  
        // 建立 AsynchronousServerSocketChannel,並繫結到指定地址和埠  
        final AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open(channelGroup);  
        InetSocketAddress address = new InetSocketAddress("localhost", 12345);  
        serverSocketChannel.bind(address);  
  
        System.out.println("Server started on port " + address.getPort());  
  
        // 呼叫 accept 方法接收客戶端連線,同時傳入一個 CompletionHandler 處理連線結果  
        serverSocketChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {  
            // 當連線成功時會呼叫 completed 方法,傳入客戶端的 SocketChannel 例項作為引數  
            @Override  
            public void completed(AsynchronousSocketChannel clientSocketChannel, Object attachment) {  
                // 繼續接受下一個客戶端連線,並處理當前客戶端的請求  
                serverSocketChannel.accept(null, this);  
                handleClient(clientSocketChannel);  
            }  
  
            // 當連線失敗時會呼叫 failed 方法,傳入異常資訊作為引數  
            @Override  
            public void failed(Throwable exc, Object attachment) {  
                System.out.println("Error accepting connection: " + exc.getMessage());  
            }  
        });  
  
        // 在主執行緒中等待,防止程式退出  
        while (true) {  
            try {  
                Thread.sleep(Long.MAX_VALUE);  
            } catch (InterruptedException e) {  
                break;  
            }  
        }  
    }  
  
    private static void handleClient(AsynchronousSocketChannel clientSocketChannel) {  
        ByteBuffer buffer = ByteBuffer.allocate(1024);  
        // 讀取客戶端傳送的資料,同時傳入一個 CompletionHandler 處理讀取結果  
        clientSocketChannel.read(buffer, null, new CompletionHandler<Integer, Object>() {  
            // 當讀取成功時會呼叫 completed 方法,傳入讀取到的位元組數和附件物件(此處不需要)  
            @Override  
            public void completed(Integer bytesRead, Object attachment) {  
                if (bytesRead > 0) {  
                    // 將 Buffer 翻轉,以便進行讀取操作  
                    buffer.flip();  
                    byte[] data = new byte[bytesRead];  
                    buffer.get(data, 0, bytesRead);  
                    String message = new String(data);  
                    System.out.println("Received message: " + message);  
                    // 向客戶端傳送資料  
                    clientSocketChannel.write(ByteBuffer.wrap(("Hello, " + message).getBytes()));  
                    buffer.clear();  
                    // 繼續讀取下一批資料,並傳入當前的 CompletionHandler 以處理讀取結果  
                    clientSocketChannel.read(buffer, null, this);  
                } else {  
                    try {  
                        // 當客戶端關閉連線時,關閉該 SocketChannel                        clientSocketChannel.close();  
                    } catch (IOException e) {  
                        System.out.println("Error closing client socket channel: " + e.getMessage());  
                    }  
                }  
            }  
  
            // 當讀取失敗時會呼叫 failed 方法,傳入異常資訊和附件物件(此處不需要)  
            @Override  
            public void failed(Throwable exc, Object attachment) {  
                System.out.println("Error reading from client socket channel: " + exc.getMessage());  
            }  
        });  
    }  
  
  
}

AIO 程式設計模型優缺點

優點

併發性高、CPU利用率高、執行緒利用率高 。

缺點

不適合輕量級資料傳輸,因為程式之間頻繁的通訊在追錯、管理,資源消耗上不是很可觀。

適用場景

對併發有需求的重量級資料傳輸。

從上面的程式碼也可以看出,AIO的API和NIO又是截然不同的寫法,為了不繼續增加學習成本,這裡點到為止,不再深入AIO程式設計模型的部分了,讓我們繼續回到Netty,瞭解Netty的程式設計模型。

使用Netty 帶來的好處

  • Netty不需要了解過多概念
  • 底層IO模型隨意切換
  • 自帶粘包拆包的問題處理
  • 解決了空輪詢問題
  • 自帶協議棧,支援通用協議切換
  • 社群活躍,各種問題都有解決方案
  • RPC、訊息中介軟體實踐,健壯性極強

網路IO通訊框架過程

一個網路IO通訊框架從客戶端發出請求到接受到結果,基本包含了下面這8個操作:

    1. 解析指令
    1. 構建指令物件
    1. 編碼
    1. 等待響應
    1. 解碼
    1. 翻譯指令物件
    1. 解析指令
    1. 執行

下面來看看Netty的程式設計模型。

Netty 啟動模板程式碼(重要)

經過上面一長串的鋪墊,現在來到整體Netty的程式碼部分:

服務端

首先是服務端程式碼:

 public static void main(String[] args) {
        ServerBootstrap serverBootstrap = new ServerBootstrap();

        NioEventLoopGroup boos = new NioEventLoopGroup();
        NioEventLoopGroup worker = new NioEventLoopGroup();
        serverBootstrap
                .group(boos, worker)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    protected void initChannel(NioSocketChannel ch) {
                        ch.pipeline().addLast(new StringDecoder());
                        ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
                            @Override
                            protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                System.out.println(msg);
                            }
                        });
                    }
                })
                .bind(8000);
    }

初學Netty的時候可能沒有NIO的經驗,所以我們簡單做個類比:

NioEventLoopGroup boos = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();

可以直接看作

Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();

其中boss負責處理連線,worker負責讀取請求和處理資料。兩者的工作模式也是類似的,boss就像是老闆負責“接單”,worker 打工仔負責接收單子的內容然後開始打工幹活。

客戶端

客戶端的啟動程式碼如下。


public static void main(String[] args) throws InterruptedException {  
    Bootstrap bootstrap = new Bootstrap();  
    NioEventLoopGroup eventExecutors = new NioEventLoopGroup();  
    // 引導器引導啟動  
    bootstrap.group(eventExecutors)  
            .channel(NioSocketChannel.class)  
            .handler(new ChannelInitializer<Channel>() {  
                @Override  
                protected void initChannel(Channel channel) throws Exception {  
                    channel.pipeline().addLast(new StringEncoder());  
                }  
            });  
  
    // 建立通道  
    Channel channel = bootstrap.connect("127.0.0.1", 8000).channel();  
  
    while (true){  
        channel.writeAndFlush(new Date() + " Hello world");  
        Thread.sleep(2000);  
    }  
  
}

客戶端的程式碼中的NioEventLoopGroup實際對應了main函式單獨開啟的執行緒。上面的程式碼可以完美的替代調JDK的NIO、AIO、BIO 的API,學習成本大大降低,Netty為使用者做了大量的“準備”工作,提供了很多"開箱即用"的功能,非常方便。

Netty的服務端和客戶端的入門程式程式碼是分析原始碼的開始,這部分程式碼需要有較深的印象。

問題

摘錄部分Netty入門級別的八股。

Linux網路程式設計中的五種I/O模型

關鍵點:

不同的角度理解IO模型的概念會有變化。注意本部分站在使用者程式和核心的網路IO互動的角度理解的。

權威:

  • RFC標準
  • 書籍 《UNIX Network Programming》(中文名《UNIX網路程式設計-卷一》)第六章。

下面部分總結自:《UNIX Network Programming》(中文名《UNIX網路程式設計-卷一》)

1)阻塞式I/O

注意原書中阻塞式I/O給出的例子是UDP而不是TCP的例子。recvfrom 函式可以看作是系統呼叫,在阻塞I/O模型中,recvfrom 的系統呼叫要等待核心把資料從核心態複製到使用者的緩衝池或者發生錯誤的時候(比如訊號中斷)才進行返回。recvfrom 收到資料之後再執行資料處理。

image.png

2)非阻塞式I/O

recvfrom 的系統呼叫會在設定非阻塞的時候,會要求核心在無資料的時候返回錯誤,所以前面三次都是錯誤呼叫,在第四次呼叫之後此時recvfrom輪詢到資料,於是開始正常的等待核心把資料複製到使用者程式快取。

此處輪詢的定義為:對於描述符進行recvfrom迴圈呼叫,會增加CPU的開銷。注意非阻塞的輪詢不一定要比阻塞等待要強,有時候甚至會有無意義的開銷反而不如阻塞。

image.png

3)I/O複用(select,poll,epoll...)

I/O多路複用是阻塞在select,epoll這樣的系統呼叫,沒有阻塞在真正的I/O系統呼叫如recvfrom。程式受阻於select,等待可能多個套介面中的任一個變為可讀

IO多路複用最大的區別是使用兩個系統呼叫(select和recvfrom)。Blocking IO(BIO)只呼叫了一個系統呼叫(recvfrom)。

select/epoll 核心是可以同時處理多個 connection,但是這並不一定提升效率,連線數不高的話效能不一定比多執行緒+阻塞IO好。但是連線數比較龐大之後會有顯著的差距。

多路複用模型中,每一個socket都需要設定為non-blocking,否則是無法進行elect的。

listenerChannel.configureBlocking(false);這個設定的意義就在於此。

image.png

4)訊號驅動式I/O(SIGIO)

訊號驅動的優勢是等待資料包到之前程式不被阻塞,主迴圈可以繼續執行,等待訊號到來即可,注意這裡有可能是資料已經準備好被處理,或者資料複製完成可以準備讀取。

訊號驅動IO 也是同步模型,雖然可以透過訊號的方式減少互動,但是系統呼叫過程當中依然需要進行等待,核心也依然是通知何時開啟一個IO操作,和前面介紹的IO模型對比發現優勢並不明顯。

image.png

5)非同步I/O(POSIX的aio_系列函式)

核心: Future-Listener機制

  • IO操作分為兩步

    • 發起IO請求,等待資料準備(Waiting for the data to be ready)
    • 實際的IO操作,將資料從核心複製到程式中(Copying the data from the kernel to the process)

前四種IO模型都是同步IO操作,主要的區別在於第一階段處理方式,而他們的第二階段是一樣的:在資料從核心複製到應用緩衝區期間(使用者空間),程式阻塞於recvfrom呼叫或者select() 函式。 非同步I/O模型內在這兩個階段都要(自行)處理。

阻塞IO和非阻塞IO區別在於第一步,發起IO請求是否會被阻塞,如果阻塞直到完成那麼就是傳統的阻塞IO,如果不阻塞,那麼就是非阻塞IO。

同步IO和非同步IO的區別就在於第二個步驟是否阻塞,如果實際的IO讀寫阻塞請求程式,那麼就是同步IO,因此阻塞IO、非阻塞IO、IO複用、訊號驅動IO都是同步IO,如果不阻塞,而是作業系統幫你做完IO操作再將結果返回給你,那麼就是非同步IO

非同步IO模型非常像是我們日常點外賣,我們時不時看看配送進度就是在“輪詢”,當外賣員把外賣送到指定位置打電話通知我們去拿即可。

互動幾個核心點

再次強調是針對使用者程式和核心的網路IO互動角度理解的。

  • 阻塞非阻塞說的是執行緒的狀態(重要)
  • 同步和非同步說的是訊息的通知機制(重要)
     - 同步需要主動讀寫資料,非同步是不需要主動讀寫資料
     - 同步IO和非同步IO是針對使用者應用程式和核心的互動

為什麼Netty使用NIO而不是AIO?

  1. Netty不看重Windows上的使用,在Linux系統上,AIO的底層實現仍使用EPOLL,沒有很好實現AIO,因此在效能上沒有明顯的優勢,而且被JDK封裝了一層不容易深度最佳化
  2. Netty整體架構是reactor模型, 而AIO是proactor模型, 混合在一起會非常混亂,把AIO也改造成reactor模型看起來是把epoll繞個彎又繞回來
  3. AIO還有個缺點是接收資料需要預先分配快取, 而不是NIO那種需要接收時才需要分配快取, 所以對連線數量非常大但流量小的情況, 記憶體浪費很多
  4. Linux上AIO不夠成熟,處理回撥結果速度跟不到處理需求,比如外賣員太少,顧客太多,供不應求,造成處理速度有瓶頸(待驗證)。

結論

Netty整體架構是reactor模型,採用epoll機制,所以往深的說,還是IO多路複用模式,所以也可說netty是同步非阻塞模型(看的層次不一樣),只不過實際是非同步IO。

Netty 應用場景瞭解麼?

  • 作為 RPC 框架的網路通訊工具。分散式系統之間的伺服器通訊可以使用Netty完成,雖然是Java編寫的框架,但是效能非常接近 C 和C++ 執行效率。
  • 作為 RPC 框架的網路通訊工具,Netty本身就是
  • 訊息佇列:比如大名鼎鼎的RocketMq底層完全依賴Netty,程式設計人員不需要很強的併發程式設計功底也可以快速上手和維護程式碼。
  • 實現一個即時通訊系統:正好和本書應用場景重合了。

介紹Netty

簡短介紹

Netty是一個高效能非同步NIO程式設計模型的網路程式設計框架。它提供了簡單易用的API,可以快速地開發各種網路應用程式,如客戶端、伺服器和協議實現等。同時,Netty還具有良好的可擴充套件性和靈活性,支援多種傳輸協議和編解碼器。

稍微複雜一點

Netty是由JBOSS提供的一個java開源框架, 是業界最流行的NIO框架,整合了多種協議( 包括FTP、SMTP、HTTP等各種二進位制文字協議)的實現經驗,精心設計的框架,在多個大型商業專案中得到充分驗證。 1)API使用簡單 2)成熟、穩定 3)社群活躍 有很多種NIO框架 如mina 4)經過大規模的驗證(網際網路、大資料、網路遊戲、電信通訊行業)。

總結

  • 開篇簡單介紹了JDK的BIO、NIO和AIO,三者不僅出現時間跨度大,三個團隊編寫,和JDK的IO程式設計一樣晦澀難懂和不好用,開發人員需要花大量事件學習底層細節。
  • 用洗衣機的例子,理解網路程式設計模型的重要概念:同步、非同步、阻塞、非阻塞。從入門的角度來看,同步和非同步可以認為是否是由客戶端主動獲取資料,而阻塞和非阻塞則是客戶端是否需要拿到結果進行處理,兩者是相輔相成的。
  • Netty 程式設計模型統一了JDK的程式設計模型,降低了學習成本,同時效率比原生JDK更高,並且解決了NIO 中的空輪詢問題。
  • Netty 底層實際上和JDK的網路程式設計模型密切相關,從案例程式碼可以看到Netty的客戶端API程式碼可以直接往NIO的Server傳送資料。
  • 補充書中沒有介紹的AIO程式設計模型,用ChatGPT 生成的程式碼簡單易懂。
  • 最後補充有關Netty的問題。

寫在最後

開篇部分補充了書中沒介紹的一些網路程式設計模型的基本概念,以及在最後關聯了些相關書籍的知識點和,最後順帶歸納了一些八股問題,當然最為重要的部分是熟悉Netty的入門程式程式碼。

開篇入門篇到此就結束了,如果內容描述有誤,歡迎評論或者私信留言。

參考

Netty 書籍推薦

  • 《Netty權威指南》
  • 《Netty進階之路》

相關文章