P本文使用jdk1.8.0_45

spring boot 2.1.4.RELEASE 

涉及源碼都放在

https://github.com/sabersword/Nio

前因

這周遇到一個連接斷開的問題,便沿着這條線學習了一下Java NIO,順便驗證一下Tomcat作爲spring boot默認的web容器,是怎樣管理空閒連接的。

Java NIO(new IO/non-blockingIO)不同於BIO,BIO是堵塞型的,並且每一條學習路線的IO章節都會從BIO說起,因此大家非常熟悉。而NIO涉及Linux底層的select,poll,epoll等,要求對Linux的網絡編程有紮實功底,反正我是沒有搞清楚,在此推薦一篇通俗易懂的入門文章:

https://www.jianshu.com/p/ef418ccf2f7d

此處先引用文章的結論:

  • 對於socket的文件描述符纔有所謂BIO和NIO。

  • 多線程+BIO模式會帶來大量的資源浪費,而NIO+IO多路複用可以解決這個問題。

  • 在Linux下,基於epoll的IO多路複用是解決這個問題的最佳方案;epoll相比select和poll有很大的性能優勢和功能優勢,適合實現高性能網絡服務。

底層的技術先交給大神們解決,我們着重從 Java 上層應用的角度瞭解一下。

JDK 1.5 起使用 epoll 代替了傳統的 select/poll ,極大提升了 NIO 的通信性能,因此下文提到 Java NIO 都是使用 epoll 的。

Java NIO 涉及到的三大核心部分 Channel Buffer Selector ,它們都十分複雜,單單其中一部分都能寫成一篇文章,就不班門弄斧了。此處貼上一個自己學習 NIO 時設計的樣例,功能是服務器發佈服務,客戶端連上服務器,客戶端向服務器發送若干次請求,達到若干次答覆後,服務器率先斷開連接,隨後客戶端也斷開連接。

NIO服務器端關鍵代碼

public void handleRead(SelectionKey key) {
    SocketChannel sc = (SocketChannel) key.channel();
    ByteBuffer buf = (ByteBuffer) key.attachment();
    try {
        long bytesRead = sc.read(buf);
        StringBuffer sb = new StringBuffer();
        while (bytesRead > 0) {
            buf.flip();
            while (buf.hasRemaining()) {
                sb.append((char) buf.get());
            }
            buf.clear();
            bytesRead = sc.read(buf);
        }
        LOGGER.info("收到客戶端的消息:{}", sb.toString());
        writeResponse(sc, sb.toString());
        if (sb.toString().contains("3")) {
            sc.close();
        }
    } catch (IOException e) {
        key.cancel();
        e.printStackTrace();
        LOGGER.info("疑似一個客戶端斷開連接");
        try {
            sc.close();
        } catch (IOException e1) {
            LOGGER.info("SocketChannel 關閉異常");
        }
    }
}

NIO客戶端關鍵代碼

Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
    SelectionKey key = iter.next();
    if (key.isConnectable()) {
        while (!socketChannel.finishConnect()) ;
        socketChannel.configureBlocking(false);
        socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024));
        LOGGER.info("與服務器連接成功,使用本地端口{}", socketChannel.socket().getLocalPort());
    }
    if (key.isReadable()) {
        SocketChannel sc = (SocketChannel) key.channel();
        ByteBuffer buf = (ByteBuffer) key.attachment();
        long bytesRead;
        try {
            bytesRead = sc.read(buf);
        } catch (IOException e) {
            e.printStackTrace();
            LOGGER.info("遠程服務器斷開了與本機的連接,本機也進行斷開");
            sc.close();
            continue;
        }
        while (bytesRead > 0) {
            buf.flip();
            while (buf.hasRemaining()) {
                System.out.print((char) buf.get());
            }
            System.out.println();
            buf.clear();
            bytesRead = sc.read(buf);
        }
        TimeUnit.SECONDS.sleep(2);
        String info = "I'm " + i++ + "-th information from client";
        buffer.clear();
        buffer.put(info.getBytes());
        buffer.flip();
        while (buffer.hasRemaining()) {
            sc.write(buffer);
        }
    }
    iter.remove();
}

服務器日誌

客戶端日誌

從這個樣例可以看到,客戶端和服務器都能根據自身的策略,與對端斷開連接,本例中是服務器首先斷開連接,根據TCP協議,必然有一個時刻服務器處於FIN_WAIT_2狀態,而客戶端處於CLOSE_WAIT狀態

我們通過 netstat 命令找出這個狀態,果不其然。

但是 JDK 提供的 NIO 接口還是很複雜很難寫的,要用好它就必須藉助於 Netty Mina 等第三方庫的封裝,這部分就先不寫了。接下來考慮另外一個問題,在大併發的場景下,成千上萬的客戶端湧入與服務器連接,連接成功後不發送請求,浪費了服務器寶貴的資源,這時服務器該如何應對?

答案當然是設計合適的連接池來管理這些寶貴的資源,爲此我們選用 Tomcat 作爲學習對象,瞭解一下它是如何管理空閒連接的。

Tomcat Connector 組件用於管理連接, Tomcat8 默認使用 Http11NioProtocol ,它有一個屬性 ConnectionTimeout ,註釋如下:

可以簡單理解成空閒超時時間,超時後 Tomcat 會主動關閉該連接來回收資源。

我們將它修改爲 10 秒,得到如下配置類,並將該 spring boot 應用打包成 tomcat-server.jar

@Component
public class MyEmbeddedServletContainerFactory extends TomcatServletWebServerFactory {

    public WebServer getWebServer(ServletContextInitializer... initializers) {
        // 設置端口
        this.setPort(8080);
        return super.getWebServer(initializers);
    }

    protected void customizeConnector(Connector connector) {
        super.customizeConnector(connector);
        Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
        // 設置最大連接數
        protocol.setMaxConnections(2000);
        // 設置最大線程數
        protocol.setMaxThreads(2000);
        // 設置連接空閒超時
        protocol.setConnectionTimeout(10 * 1000);
    }
}

我們將上文的 NIO 客戶端略微修改一下形成 TomcatClient ,功能就是連上服務器後什麼都不做。

Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
    SelectionKey key = iter.next();
    if (key.isConnectable()) {
        while (!socketChannel.finishConnect()) ;
        socketChannel.configureBlocking(false);
        socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocateDirect(1024));
        LOGGER.info("與遠程服務器連接成功,使用本地端口{}", socketChannel.socket().getLocalPort());
    }
    if (key.isReadable()) {
        SocketChannel sc = (SocketChannel) key.channel();
        ByteBuffer buf = (ByteBuffer) key.attachment();
        long readCount;
        readCount = sc.read(buf);
        while (readCount > 0) {
            buf.flip();
            while (buf.hasRemaining()) {
                System.out.print((char) buf.get());
            }
            System.out.println();
            buf.clear();
            readCount = sc.read(buf);
        }
        // 遠程服務器斷開連接後會不停觸發OP_READ,並收到-1代表End-Of-Stream
        if (readCount == -1) {
            LOGGER.info("遠程服務器斷開了與本機的連接,本機也進行斷開");
            sc.close();
        }
    }
    iter.remove();
}

分別運行服務器和客戶端,可以看到客戶端打印如下日誌

30:27 連上服務器,不進行任何請求,經過 10 秒後到 30:37 被服務器斷開了連接。

此時 netstat 會發現還有一個 TIME_WAIT 的連接

根據 TCP 協議主動斷開方必須等待 2MSL 才能關閉連接, Linux 默認的 2MSL=60 秒(順帶說一句網上很多資料說 CentOS /proc/sys/net/ipv4/tcp_fin_timeout 能修改 2MSL 的時間,實際並沒有效果,這個參數應該是被寫進內核,必須重新編譯內核才能修改 2MSL )。持續觀察 netstat 發現 31:36 的時候 TIME_WAIT 連接還在,到了 31:38 連接消失了,可以認爲是 31:37 關閉連接,對比上文 30:37 剛好經過了 2MSL (默認 60 秒)的時間。

相關文章