Java後端自頂向下方法——TCP程式設計與I/O模型

.SOLO.發表於2020-09-27

Java後端自頂向下方法——TCP程式設計與I/O模型

(一)簡述

這篇文章是Tomcat高階篇的前奏曲,因為網路程式設計是Tomcat高階篇的基礎之一,同時是我們必須要掌握的一項基本技能。很多人不重視網路程式設計,因為感覺他離我們很遙遠,認為做開發不需要去關注這些過於底層的東西。其實不然,瞭解網路程式設計,可以幫助我們更好的理解Tomcat乃至於其他的WEB伺服器的原理。

在這裡插入圖片描述

我們經常會看到類似這種層次圖,其中Socket、TCP和部分IP的功能都是由作業系統提供的,不同的程式語言只是提供了對作業系統呼叫的簡單的封裝。Java提供的幾個Socket相關的類就封裝了作業系統提供的介面。為什麼需要Socket進行網路通訊?因為僅僅通過IP地址進行通訊是不夠的,同一臺計算機同一時間會執行多個網路應用程式,如果只有IP地址,它沒法判斷應該發給哪個應用程式,所以,作業系統抽象出Socket介面,每個應用程式需要各自對應到不同的Socket,資料包才能根據Socket正確地發到對應的應用程式。一個Socket就是由IP地址和埠號(範圍是0~65535)組成,我們暫時可以把Socket簡單理解為IP地址加埠號。

Socket是我們學習網路程式設計中最重要的一個部分,我們就先從Socket講起。

(二)Socket(套接字)

說到套接字,可能很多人都在文章或者書上看過這個名詞,我敢相信幾乎沒有人能從字面上理解這個詞的意思。實際上,我很討厭“套接字”這個翻譯,感覺他非常反人類。如果硬要翻譯,我寧願翻譯為“插座”,這樣更加生動形象,因為Socket確實非常像插座的功能。以後再看到套接字,直接把他想象成插座即可,方便理解。

Socket是基於應用服務與TCP/IP通訊之間的一個抽象,他將TCP/IP協議裡面複雜的通訊邏輯進行分裝,對使用者來說,只要通過一組簡單的API就可以實現網路的連線。不同的程式語言都有自己的Socket實現,我們這裡當然以Java為例來講述Socket給我們開發者帶來的便利。

在這裡插入圖片描述

使用Socket進行網路程式設計時,本質上就是兩個程式之間的網路通訊。其中一個程式充當伺服器端,它會主動監聽某個指定的埠,另一個程式充當客戶端,它主動連線伺服器的IP地址和指定埠,如果連線成功,伺服器端和客戶端就成功地建立了一個TCP連線,雙方後續就可以隨時傳送和接收資料。因此,當Socket連線成功地在伺服器端和客戶端之間建立後,對伺服器端來說,它的Socket是指定的IP地址和指定的埠號。對客戶端來說,它的Socket是它所在計算機的IP地址和一個由作業系統分配的隨機埠號。

我們先來看一個經典的例子,來看一下我們在Java中如何使用Socket程式設計,來構建一個簡單的伺服器和一個簡單的客戶端。首先是伺服器的程式碼:

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(6666);
        System.out.println("server is running...");
        for (;;) {
            Socket sock = ss.accept();
            System.out.println("connected from " + sock.getRemoteSocketAddress());
            Thread t = new Handler(sock);
            t.start();
        }
    }
}

class Handler extends Thread {
    Socket sock;

    public Handler(Socket sock) {
        this.sock = sock;
    }

    @Override
    public void run() {
        try (InputStream input = this.sock.getInputStream()) {
            try (OutputStream output = this.sock.getOutputStream()) {
                handle(input, output);
            }
        } catch (Exception e) {
            try {
                this.sock.close();
            } catch (IOException ioe) {
            }
            System.out.println("client disconnected.");
        }
    }

    private void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        writer.write("hello\n");
        writer.flush();
        for (;;) {
            String s = reader.readLine();
            System.out.println("[client] " + s);
            if (s.equals("bye")) {
                writer.write("bye\n");
                writer.flush();
                break;
            }
            writer.write("ok: " + s + "\n");
            writer.flush();
        }
    }
}

首先我們先看ServerSocket ss = new ServerSocket(6666),這段程式碼建立了一個ServerSocket物件,並且指定了監聽的埠號,他的作用就是監聽我們指定的埠是否有客戶端請求傳送過來。Socket sock = ss.accept()位於一段死迴圈中,代表不停的監聽。ss.accept()表示每當有新的客戶端連線進來後,就返回一個Socket例項,這個Socket例項就是用來和剛連線的客戶端進行通訊的。由於客戶端很多,要實現併發處理,我們就必須為每個新的Socket建立一個新執行緒來處理,這樣主執行緒的作用就是接收新的連線,每當收到新連線後,就建立一個新執行緒進行處理,對應的程式碼是Thread t = new Handler(sock)。

客戶端比伺服器要簡單一點,程式碼如下:

public class Client {
    public static void main(String[] args) throws IOException {
        Socket sock = new Socket("localhost", 6666);
        try (InputStream input = sock.getInputStream()) {
            try (OutputStream output = sock.getOutputStream()) {
                handle(input, output);
            }
        }
        sock.close();
        System.out.println("disconnected.");
    }

    private static void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        Scanner scanner = new Scanner(System.in);
        System.out.println("[server] " + reader.readLine());
        for (;;) {
            System.out.print(">>> ");
            String s = scanner.nextLine();
            writer.write(s);
            writer.newLine();
            writer.flush();
            String resp = reader.readLine();
            System.out.println("<<< " + resp);
            if (resp.equals("bye")) {
                break;
            }
        }
    }
}

最核心的程式碼就是Socket sock = new Socket(“localhost”, 6666),指定埠號建立Socket例項,然後就可以與伺服器進行連線了。當Socket連線建立成功後,無論是伺服器端,還是客戶端,我們都使用Socket例項進行網路通訊。TCP是一種基於流的協議,因此Java標準庫使用InputStream和OutputStream來封裝Socket的資料流。講到資料流,就必須瞭解一下Java的I/O模型。

(三)I/O模型

我們先來了解一下Java中的I/O讀寫原理,無論是Socket的讀寫還是檔案的讀寫,在Java層面的應用開發或者是linux系統底層開發,都屬於input和output的處理,簡稱為I/O讀寫。使用者程式進行IO的讀寫,基本上會用到read和write兩大系統呼叫。read系統呼叫,並不是把資料直接從物理裝置,讀資料到記憶體。write系統呼叫,也不是直接把資料,寫入到物理裝置。read系統呼叫,是把資料從核心緩衝區複製到程式緩衝區。而write系統呼叫,是把資料從程式緩衝區複製到核心緩衝區。這個兩個系統呼叫,都不負責資料在核心緩衝區和磁碟之間的交換,底層的讀寫交換,是由作業系統核心完成的。

設計緩衝區的目的,是為了減少頻繁的系統I/O呼叫。有了緩衝區,作業系統使用read函式把資料從核心緩衝區複製到程式緩衝區,write把資料從程式緩衝區複製到核心緩衝區中。等待緩衝區達到一定數量的時候,再進行IO的呼叫,提升效能。至於什麼時候讀取和儲存則由核心來決定,使用者程式不需要關心。在linux系統中,系統核心也有個緩衝區叫做核心緩衝區。每個使用者程式有自己獨立的緩衝區,叫做程式緩衝區(也有的地方稱為使用者緩衝區,都是一個東西)。

在這裡插入圖片描述
我們可以來看一下一個網路請求的流程:

  1. 客戶端請求:Linux通過網路卡,讀取客戶斷的請求資料,將資料讀取到核心緩衝區。
  2. 獲取請求資料:伺服器從核心緩衝區讀取資料到Java程式緩衝區。
  3. 伺服器端業務處理:Java服務端在自己的使用者空間中,處理客戶端的請求。
  4. 伺服器端返回資料:Java服務端已構建好的響應,從使用者緩衝區寫入核心緩衝區。
  5. 傳送給客戶端:Linux核心通過網路 I/O ,將核心緩衝區中的資料,寫入網路卡,網路卡通過底層的通訊協議,會將資料傳送給目標客戶端。

講完了網路I/O的基本原理,就來講最重要的I/O模型了。在JDK1.4出來之前,我們建立網路連線的時候採用BIO(Blocking I/O)模型,需要先在服務端啟動一個ServerSocket,然後在客戶端啟動Socket來對服務端進行通訊,預設情況下服務端需要對每個請求建立很多執行緒等待請求,而客戶端傳送請求後,先諮詢服務端是否有執行緒相應,如果沒有則會一直等待或者遭到拒絕請求,如果有的話,客戶端會執行緒會等待請求結束後才繼續執行。

比如上面的例子,用的就是最原始的BIO,也就是阻塞I/O。我們不難發現,上面的例子在處理請求時建立了一個新執行緒,那麼我們為什麼要單獨建立一個執行緒來處理請求呢?之所以使用多執行緒,主要原因在於accept()、read()、write()三個主要函式都是同步阻塞的,當一個連線在處理I/O的時候,系統是阻塞的,如果只用一個執行緒的話必然會阻塞在那裡。但CPU是被釋放出來的,開啟多執行緒,就可以讓CPU去處理更多的事情。這就是新建一個執行緒的好處,保證一個連線是一個執行緒。一切看起來都很完美,那麼這樣的I/O模型有什麼問題呢?

1. 同步阻塞BIO(Blocking I/O)

在linux中的Java程式中,預設情況下所有的socket都是blocking I/O(上面的例子就是如此)。在阻塞式 I/O 模型中,應用程式在從I/O系統呼叫開始,一直到到系統呼叫返回,這段時間是阻塞的。返回成功後,應用程式開始處理使用者空間的快取資料。

在這裡插入圖片描述

在活動連線數不是特別高的情況下,這種模型是比較不錯的,可以讓每一個連線專注於自己的I/O,並且程式設計模型簡單,也不用過多考慮系統的過載、限流等問題。但是,這個模型最本質的問題在於,嚴重依賴於執行緒,但執行緒是很珍貴的資源。你可能覺得,可以用執行緒池來優化啊。確實,使用執行緒池可以進行一定的優化,但是治標不治本,原因如下:

  • 執行緒的建立和銷燬成本很高,在Linux這樣的作業系統中,執行緒本質上就是一個程式,建立和銷燬都是重量級的系統函式。
  • 執行緒本身佔用較大記憶體,過多的執行緒會帶來極大的記憶體消耗。
  • 執行緒的切換的成本很高,作業系統發生執行緒切換的時候,需要保留執行緒的上下文,然後執行系統呼叫。如果執行緒數過高,可能執行執行緒切換的時間甚至會大於執行緒執行的時間,造成資源浪費。

這是傳統BIO執行緒模型圖:

在這裡插入圖片描述

這是使用執行緒池優化後的傳統BIO執行緒模型圖:

在這裡插入圖片描述

因此,即使有執行緒池加持,當系統面對百萬級別的併發量的時候,這種傳統的BIO是完全應付不了的。所以我們需要尋找其他的解決方案,解決BIO存在的問題。

2. 同步非阻塞NIO(None Blocking IO)

在linux系統下,可以通過設定Socket使其變為non-blocking。NIO 模型中應用程式在一旦開始IO系統呼叫,會出現以下兩種情況:

  • 在核心緩衝區沒有資料的情況下,系統呼叫會立即返回,返回一個呼叫失敗的資訊。
  • 在核心緩衝區有資料的情況下,是阻塞的,直到資料從核心緩衝複製到使用者程式緩衝。複製完成後,系統呼叫返回成功,應用程式開始處理使用者空間的快取資料。

在這裡插入圖片描述
簡單來說,就是應用程式的執行緒需要不斷的進行I/O系統呼叫,輪詢資料是否已經準備好,如果沒有準備好,繼續輪詢,直到完成系統呼叫為止。也就是說,如果執行緒發現資料已經準備完畢,使用者執行緒發起系統呼叫,使用者執行緒依舊是會被阻塞的,這裡和BIO是一樣的,唯一不同的就是NIO比BIO節約了資料準備的阻塞時間,但是反覆的進行輪詢也會造成資源的浪費。

NIO模型在高併發場景下,也是不可用的。一般Web伺服器不使用這種I/O模型。我們一般很少直接使用這種模型,而是在其他I/O模型中使用非阻塞I/O這一特性。另外需要注意的是,Java中的NIO包指的並不是這個NIO,他實際上是New IO的縮寫,用的是我們下面會講的IO多路複用模型,千萬不要混淆。

3. IO多路複用模型(I/O multiplexing)

IO多路複用模型的基本原理就是使用select/epoll系統呼叫,單個執行緒不斷的輪詢select/epoll系統呼叫所負責的socket連線,當某個或者某些Socket網路連線有資料到達了,就返回這些可以讀寫的連線。因此,好處也就顯而易見了——通過一次select/epoll系統呼叫,就查詢到到可以讀寫的一個甚至成百上千的網路連線。

在這裡插入圖片描述
看一下大概的流程:

  1. 首先進行select/epoll系統呼叫,將需要進行I/O操作的Socket新增到select中,然後阻塞等待select系統呼叫返回。當資料到達時,Socket被啟用,select函式返回。注意:當使用者程式呼叫了select,那麼整個執行緒會被block。
  2. 使用者執行緒獲得了目標連線後,發起read系統呼叫,使用者執行緒阻塞。核心開始複製資料。它就會將資料從核心緩衝區,拷貝到使用者緩衝區,然後核心返回結果。
  3. 使用者執行緒解除block的狀態,使用者執行緒讀取到資料,繼續執行。

從流程上來看,使用select函式進行I/O請求和同步阻塞模型沒有太大的區別,甚至還多了新增監視Socket,以及呼叫select函式的額外操作,效率可能會更差。但是,使用select以後最大的優勢是使用者可以在一個執行緒內同時處理多個Socket的I/O請求。使用者可以註冊多個Socket,然後不斷地呼叫select讀取被啟用的Socket,即可達到在同一個執行緒內同時處理多個IO請求的目的。而在同步阻塞模型中,必須通過多執行緒的方式才能達到這個目的。

4. 非同步非阻塞AIO(Asynchronous IO)

AIO的基本流程是:使用者執行緒通過系統呼叫,告知核心啟動某個IO操作,使用者執行緒返回。核心在整個IO操作(包括資料準備、資料複製)完成後,通知使用者程式,使用者程式執行後續的業務操作。核心的資料準備是將資料從網路物理裝置(網路卡)讀取到核心緩衝區,核心的資料複製是將資料從核心緩衝區拷貝到使用者程式空間的緩衝區。

在這裡插入圖片描述

  1. 當使用者執行緒呼叫了read系統呼叫,立刻就可以開始執行下面的程式碼,使用者執行緒不阻塞。
  2. 核心開始準備資料,當資料準備好了,它就會將資料從核心緩衝區,拷貝到使用者緩衝區。
  3. 核心會給使用者執行緒傳送一個訊號,或者回撥使用者執行緒註冊的回撥介面,告訴使用者執行緒read操作已經完成。
  4. 使用者執行緒讀取使用者緩衝區的資料,完成後續的業務操作。

在核心的等待資料和複製資料的兩個階段,使用者執行緒都不是阻塞的。使用者執行緒需要接受核心的I/O操作完成的事件,或者說註冊I/O操作完成的回撥函式。但是,完成事件的註冊與傳遞,這裡邊需要作業系統提供大量的支援,做大量的工作。Java中的NIO2包裡的非同步API就是基於非同步非阻塞IO。

下面我們來對比一下這四種I/O模型,可以說就這幾個模型而言,從上到下阻塞程度基本是依次遞減的,最後一個非同步非阻塞I/O直接做到了0阻塞。換句話說,前三個模型都是有阻塞的,只是阻塞的程度不一樣罷了(這裡注意千萬不要被名字誤導,同步非阻塞依舊是有阻塞的)。有些人將I/O多路複用稱為非同步阻塞I/O也是不準確的,因為他算不上真正的非同步,只有最後一個非同步非阻塞才是真正的非同步模型,因此各種模型的名字可以記住,但千萬不要被誤導。

2020年9月27日

相關文章