手寫NIO版tomcat并Jmeter壓測

前言

上文不使用第三方工具, 純java搭建web服務(wù)完成了一個web服務(wù),并封裝實現(xiàn)了一個內(nèi)嵌的tomcat,今天在上文基礎(chǔ)上對性能做優(yōu)化和jmeter壓測

阻塞

上文中最終實現(xiàn)的非多線程版本tomcat代碼如下:

public void run() throws IOException {
    // 開啟一個socket服務(wù),綁定端口號8888
    ServerSocket serverSocket = new ServerSocket(8888);
    System.out.println("===server start listen 8888===");
    while (true) {
        Socket clientSocket = serverSocket.accept();
        try {
            // 解析請求信息為HttpRequest對象
            HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
            // 根據(jù)path獲取servlet
            Servlet servlet = servletMap.get(request.getPathInfo());
            if (servlet == null) {
                continue;
            }
            // 執(zhí)行業(yè)務(wù)
            String data = servlet.service(request);
            // 響應(yīng)
            HttpResponse response = new HttpResponse(data);
            // 返回數(shù)據(jù)
            clientSocket.getOutputStream().write(response.getBytes());
            clientSocket.getOutputStream().flush();
            clientSocket.getOutputStream().close();
            clientSocket.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

邏輯就是一個死循環(huán),調(diào)用serverSocket.accept方法阻塞等待連接,連接成功之后根據(jù)/path調(diào)用對應(yīng)的servlet執(zhí)行對應(yīng)的服務(wù),最后返回結(jié)果

這種寫法顯然有個致命問題:一次只能處理一個請求

下面使用jmeter工具進行壓測試一下,為了效果明顯,我們把Order服務(wù)的執(zhí)行時間加長500ms:

public class UserController implements Servlet {
    public String service(HttpRequest request) {
        try {
            Thread.sleep(500); // 模擬處理時間
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "{\"message\": \"user done\"}";
    }
}

使用jmeter建立10個線程訪問/user接口,最終結(jié)果如下

單線程

結(jié)果吞吐量是2.0/sec,一個請求0.5秒,一個接一個的做,一秒鐘確實只能處理2個請求,相當于每個請求排著隊一個個執(zhí)行,服務(wù)器也只有一個線程在工作,效率肯定是級低的

BIO

BIO模型就是一個請求一個線程,相當于本來一個人干的活分給多個人干,效率必然大大提升

修改tomcat代碼如下:

public void run() throws IOException {
    // 開啟一個socket服務(wù),綁定端口號8888
    ServerSocket serverSocket = new ServerSocket(8888);
    System.out.println("===server start listen 8888===");
    while (true) {
        Socket clientSocket = serverSocket.accept();
        // 開啟新線程處理請求
        new Thread(()->{
            try {
                // 解析請求信息為HttpRequest對象
                HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
                // 根據(jù)path獲取servlet
                Servlet servlet = servletMap.get(request.getPathInfo());
                if (servlet == null) {
                    return;
                }
                // 執(zhí)行業(yè)務(wù)
                String data = servlet.service(request);
                // 響應(yīng)
                HttpResponse response = new HttpResponse(data);
                // 返回數(shù)據(jù)
                try {
                    clientSocket.getOutputStream().write(response.getBytes());
                    clientSocket.getOutputStream().flush();
                } finally {
                    clientSocket.getOutputStream().close();
                }
                clientSocket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

相當于每次accpet一個請求,就新開一個線程處理業(yè)務(wù),jmeter測試一下

多線程

優(yōu)化效果極其顯著,原來每秒處理2個,現(xiàn)在每秒能處理7個請求,線程數(shù)調(diào)整至100

多線程100

100個請求同時發(fā)起每秒能處理67個請求,并發(fā)量一下就上來了

繼續(xù)調(diào)整請求數(shù)測試結(jié)果如下

并發(fā)請求數(shù) 吞吐量
10 7.1/sec
100 67.0/sec
1000 666.7/sec
10000 1948.6/sec
100000 2028.8/sec

可以看到線程1000以下吞吞吐量基本都是幾何倍增長,但線程過萬后明顯增長不上去了,100000和10000的吞吐量已經(jīng)差不多了

所以并不是請求越多,吞吐量越高,如果線程過多,服務(wù)器線程切換開銷就會很大,這就是著名的c10k問題

線程池

所以BIO這種模式還是要使用線程池進行優(yōu)化,不能肆無忌憚的創(chuàng)建線程,比如說實際場景一般同時并發(fā)請求最多也就100個左右,那就設(shè)個100大小的線程池(真實tomcat默認好像是200),代碼修改如下

public void run() throws IOException {
    // 開啟一個socket服務(wù),綁定端口號8888
    ServerSocket serverSocket = new ServerSocket(8888);
    System.out.println("===server start listen 8888===");
    // 創(chuàng)建一個線程池
    ExecutorService pool = Executors.newFixedThreadPool(100);
    while (true) {
        Socket clientSocket = serverSocket.accept();
        // 線程池處理請求
        pool.execute(()->{
            try {
                // 解析請求信息為HttpRequest對象
                HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
                // 根據(jù)path獲取servlet
                Servlet servlet = servletMap.get(request.getPathInfo());
                if (servlet == null) {
                    return;
                }
                // 執(zhí)行業(yè)務(wù)
                String data = servlet.service(request);
                // 響應(yīng)
                HttpResponse response = new HttpResponse(data);
                // 返回數(shù)據(jù)
                try {
                    clientSocket.getOutputStream().write(response.getBytes());
                    clientSocket.getOutputStream().flush();
                } finally {
                    clientSocket.getOutputStream().close();
                }
                clientSocket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}

jmeter測試結(jié)果如下

并發(fā)請求數(shù) 吞吐量
10 7.1/sec
100 67.0/sec
1000 196.1/sec

100以內(nèi)和之前完全一樣,100以上明顯降低(等待線程池釋放空閑線程),但線程池可以有效避免c10k,而且可以結(jié)合實際場景設(shè)置線程池大小

NIO

上文介紹過BIO模型的缺點,主要在inputStream的read上(讀取客戶端發(fā)來的數(shù)據(jù)),這個過程服務(wù)端線程是阻塞的,換句話說這段時間線程占用的cpu就干等著,是一種資源浪費(本來線程池干活的線程固定的,還有幾個線程傻等著,效率能高嗎),而NIO模型就是為了解決這個問題

NIO的最大特點是當客戶端連接建立好后,可以注冊可讀取事件,當客戶端數(shù)據(jù)發(fā)送過來后再去執(zhí)行讀操作,而整個過程是不阻塞的

我們繼續(xù)用線程池處理請求,原來是一個連接建立就分一個線程等待數(shù)據(jù)并處理,現(xiàn)在是某個連接數(shù)據(jù)準備好了,才分線程去處理,可預(yù)見在某些情況下這種分配是更合理且高效的

public class NioWebServer {

    /**
     * 存儲path到服務(wù)的映射
     */
    private Map<String, Servlet> servletMap;

    /**
     * 初始化
     *
     * @param servletMap
     */
    public NioWebServer(Map<String, Servlet> servletMap) {
        this.servletMap = servletMap;
    }

    /**
     * 運行tomcat
     *
     * @throws IOException
     */
    public void run() throws IOException {
        // 開啟一個socket服務(wù),綁定端口號8888
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(new InetSocketAddress(8888));
        // 設(shè)置ServerSocketChannel為非阻塞
        serverSocket.configureBlocking(false);
        // 打開Selector處理Channel,即創(chuàng)建epoll
        Selector selector = Selector.open();
        // 把ServerSocketChannel注冊到selector上,并且selector對客戶端accept連接操作感興趣
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("===server start listen 8888===");
        // 創(chuàng)建一個業(yè)務(wù)處理線程池
        ExecutorService pool = Executors.newFixedThreadPool(100);
        while (true) {
            // 阻塞等待需要處理的事件發(fā)生
            selector.select();
            // 獲取selector中注冊的全部事件的 SelectionKey 實例
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            // 遍歷SelectionKey對事件進行處理
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                // 如果是OP_ACCEPT事件,則進行連接獲取和事件注冊
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false);
                    // 這里只注冊了讀事件,如果需要給客戶端發(fā)送數(shù)據(jù)可以注冊寫事件
                    socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(10*1024));
                } else if (key.isReadable()) {  // 如果是OP_READ事件,則進行讀取和處理
                    key.cancel();
                    pool.execute(() -> {
                        try {
                            SocketChannel socketChannel = (SocketChannel) key.channel();
                            ByteBuffer buffer = (ByteBuffer) key.attachment();
                            socketChannel.read(buffer);
                            // 解析請求信息為HttpRequest對象
                            HttpRequest request = new HttpRequest(new StringReader(new String(buffer.array())));
                            // 根據(jù)path獲取servlet
                            Servlet servlet = servletMap.get(request.getPathInfo());
                            if (servlet == null) {
                                return;
                            }
                            // 執(zhí)行業(yè)務(wù)
                            String data = servlet.service(request);
                            // 響應(yīng)
                            HttpResponse response = new HttpResponse(data);
                            // 返回
                            socketChannel.write(ByteBuffer.wrap(response.getBytes()));
                            // 關(guān)閉連接
                            socketChannel.close();
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    });
                }
                //從事件集合里刪除本次處理的key,防止下次select重復(fù)處理
                iterator.remove();
            }
        }
    }
}

jmeter測試結(jié)果如下

并發(fā)請求數(shù) 吞吐量
10 7.1/sec
100 67.0/sec
1000 196.1/sec

可以發(fā)現(xiàn)和我們的BIO模型基本上性能一樣,沒啥太大區(qū)別,這結(jié)果一度讓我非常費解,完全感受不到NIO的優(yōu)勢在哪里

NIO的優(yōu)勢

仔細的想了一下,結(jié)合代碼,發(fā)現(xiàn)NIO的優(yōu)勢也就在于讀取網(wǎng)絡(luò)IO請求時不阻塞,而我本地測試,一個http請求過來數(shù)據(jù)基本上立刻就到,所以即使阻塞阻塞的時間也微乎其微,基本上就可以忽略了

為了證明,寫了個代碼計時,計算從InputStream轉(zhuǎn)換為Request對象所執(zhí)行的時間

// BIO中
long  startTime = System.currentTimeMillis();    //獲取開始時間
HttpRequest request = new HttpRequest(new InputStreamReader(clientSocket.getInputStream(), "utf-8"));
long endTime = System.currentTimeMillis();    //獲取結(jié)束時間
System.out.println("IO:" + (endTime - startTime) + "ms"); // 輸出
// NIO中
long  startTime = System.currentTimeMillis();    //獲取開始時間
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
socketChannel.read(buffer);
// 解析請求信息為HttpRequest對象
String receive = new String(buffer.array());
HttpRequest request = new HttpRequest(new StringReader(receive));
long endTime = System.currentTimeMillis();    //獲取結(jié)束時間
System.out.println("IO:" + (endTime - startTime) + "ms");    //輸出

一個測試請求過來,輸出IO:0ms,基本上證明了幾乎沒有IO阻塞的猜想

為了讓IO阻塞時間明顯起來,首先增加請求體的數(shù)據(jù)

請求體

但還是沒啥效果~本地就是快,所以只能放大招,修改jmeter的測試帶寬:
jmeter.properties中增加

httpclient.socket.http.cps=5472
httpclient.socket.https.cps=5472

限制了帶寬,再次測試
BIO中輸出:IO: 462ms,而NIO中輸出IO:0ms,對比一下就明顯起來了,也就是說BIO處理線程接收到請求需要阻塞將近半秒的時間才能接受數(shù)據(jù),而NIO是等內(nèi)核準備好數(shù)據(jù)后才接受數(shù)據(jù)幾乎不花費線程的時間

再次測試,這次參數(shù)準備如下:

  • 測試線程數(shù)修改為1000,Ramp-up時間改為10(如果用1的話后續(xù)BIO的IO時間會減少,這個具體原因暫時還不太清除,可能是jmeter本身的一些優(yōu)化)
  • UserController sleep時間設(shè)為1,代表業(yè)務(wù)代碼執(zhí)行1ms結(jié)束
  • worker線程數(shù)改小至10,這樣線程容易占滿,方便呈現(xiàn)差異

結(jié)果又失敗了~測試數(shù)據(jù)BIO和NIO依然是沒啥大差異

又仔細想一下,發(fā)現(xiàn)問題~NIO雖然不阻塞線程,但這段數(shù)據(jù)IO的時間一點沒省,只不過內(nèi)核準備好數(shù)據(jù)才通知線程去讀,比如當前帶寬讀取數(shù)據(jù)時間是500ms,BIO是直接分配線程去等,NIO是內(nèi)核等完之后再交給線程去做,所以當前的場景,NIO雖然不需要線程去阻塞,但網(wǎng)絡(luò)IO時間線程也無事可做,等不等效果都一樣

比如現(xiàn)在有個大眾浴池,BIO是來了一個客人就分配一個搓澡工,等著客人洗完澡他就開始給搓,而NIO是等客人洗完過來搓澡的時候再分配搓澡工

所以以上的測試案例就好比一次來了1000個人洗澡,此時NIO是沒有優(yōu)勢的,因為就算剛開始不分配搓澡工,也得等人洗完澡才能開始搓澡

而NIO的優(yōu)勢也明了了,在客人洗澡的時候,搓澡工可以干點別的活,比如掃掃地,而BIO由于提前分配了搓澡工,搓澡工只能干等著客戶洗完澡,這個過程干不了別的活

為了測試這個場景,我開啟兩個jmeter客戶端,一個帶寬限制(IO時間長),一個不限制(IO時間短),兩個客戶端同時發(fā)出1000個請求測試結(jié)果如下(限制帶寬Ramp-up為10,不限制帶寬Ramp-up為1)

  • BIO:
    | 帶寬 | 吞吐量 | 最大時間 |
    | ---- | ---- | ---- |
    | 限制 | 74.4/sec | 3486 |
    | 不限制 | 732.1/sec | 389 |
  • NIO:
    | 帶寬 | 吞吐量 | 最大時間 |
    | ---- | ---- | ---- |
    | 限制 | 74.4/sec | 3460 |
    | 不限制 | 1002.0/sec | 4 |

可以發(fā)現(xiàn)明顯的差異,NIO的不限制帶寬請求吞吐量1002.0/sec,基本上1秒就全處理完了,最大響應(yīng)時間是4,而BIO吞吐量732.1/sec,最大的請求需要389ms才響應(yīng)

這也證明了以上猜想,NIO由于線程不阻塞,網(wǎng)路IO數(shù)據(jù)準備時可以去處理其他快請求,而BIO由于阻塞及時來了不限制帶寬的請求也不能分出線程去處理,在以上場景下NIO的優(yōu)勢就體現(xiàn)出來了

總結(jié)

NIO對線程的分配相較于BIO肯定更加合理,充分的壓榨了CPU(但這種優(yōu)勢的測試真的很費勁)

就像浴池的例子,生活中一定是有客人洗完澡才分配搓澡工,而不是客人來了就分配一個搓澡工等著他洗完再搓澡,后者客戶量一上來搓澡效率就會很低,所以NIO顯然更接近現(xiàn)實的工作流程,所以也更加合理。NIO這種方式就相當于老板對搓澡工的壓榨,保證大部分時間搓澡工時可用狀態(tài),就好比我們對CPU的壓榨,而正因為這種壓榨,才能在高并發(fā)下處理更多的請求

總結(jié)一下,NIO不會提升單個請求的速度,也不會提高IO效率,只是在讀取IO數(shù)據(jù)時不占用線程,使更多線程可用,在某個請求IO數(shù)據(jù)準備好后會更快的分到線程處理具體業(yè)務(wù)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容