博客 / 詳情

返回

異步Servlet學習筆記(一)

兩週沒更新了,感覺不寫點什麼,有點不舒服的感覺。

前言

回憶一下學Java的歷程,當時是看JavaSE(基本語法、線程、泛型),然後是JavaEE,JavaEE也基本就是圍繞着Servlet的使用、JSP、JDBC來學習,當時看的是B站up主顏羣的教學視頻:

  • JavaWeb視頻教程(JSP/Servlet/上傳/下載/分頁/MVC/三層架構/Ajax)https://www.bilibili.com/video/BV18s411u7EH?p=6&vd_source=aae...

現在一看這個播放量破百萬了,當初我看的時候應該播放量很少,現在這麼多倒是有點昨舌。學完了這個之後,開始學習框架:Spring、SpringMVC、MyBatis、SpringBoot。雖然Spring MVC本質上也是基於Servlet做封裝,但後面基本就轉型成Spring 工程師了,最近碰到一些問題,又看了一篇文章,覺得一些問題之前自己還是沒考慮到,頗有種離了Spring家族,不會寫後端一樣。本來今天的行文最初是什麼是異步Servlet,異步Servlet該如何使用。但是想想沒有切入本質,所以將其換成了對話體。

正文

我們接着有請之前的實習生小陳,每當我們需要用到對話體、故事體這樣的行文。實習生小陳就會出場。今天的小陳呢覺得行情有些不好,但是還是覺得想出去看看,畢竟金三銀四,於是下午就向領導請假去面試了。進到面試的地方,一番自我介紹,面試官首先問了這樣一個問題:

一個請求是怎麼被Tomcat所處理的呢?

小陳回答到:

我目前用的都是Spring Boot工程,我看都是啓動都是在main函數裏面啓動整個項目的,而main函數又被main線程執行,所以我想應該是請求過來之後,被main線程所處理,給出響應的。

面試官:

╮(╯▽╰)╭,main函數的確是被main線程執行,但都是被main線程處理的? 這不合理吧,假設某個請求佔用了main線程三秒,那這三秒內,系統都無法再回應請求了。你要不再想想?

小陳撓了撓頭,接着答到:

確實是,瀏覽器和Tomcat通訊用的是HTTP協議,我也學過網絡編程,所以我覺得應該是一個線程一個請求吧。像下面這樣:
public class ServerSocketDemo {

    private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(4);

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while (true){
            // 一個socket對象代表一個連接
            // 等待TCP連接請求的建立,在TCP連接請求建立完成之前,會陷入阻塞
            Socket socket = serverSocket.accept();
            System.out.println("當前連接建立:"+ socket.getInetAddress().getHostName()+socket);
            EXECUTOR_SERVICE.submit(()->{
                try {
                    // 從輸入流中讀取客户端發送的內容
                    InputStream inputStream = socket.getInputStream();
                    // 從輸出流裏向客户端寫入數據
                    OutputStream outPutStream = socket.getOutputStream();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}
serverSocket的accept在連接建立起來會陷入阻塞。

面試官點了點頭, 接着問到:

你這個main線程負責檢測連接是否建立,然後建立之後將後續的業務處理放入線程池,這個是NIO吧。

小陳笑了笑説道:

雖然我對NIO瞭解不多,但這應該也不是NIO,因為後面的線程在等待數據可讀可寫的過程中會陷入阻塞。在操作系統中,線程是一個相當昂貴的資源,我們一般使用線程池,可以讓線程的創建和回收成本相對較低,在活動連接數不是特別高的情況下(單機小於1000),這種,模型是比較不錯的,可以讓每一個連接專注於自己的I/O並且編程模型簡單。但要命的就是在連接上來之後,這種模型出現了問題。我們來分析一下我們上面的BIO模型存在的問題,主線程在接受連接之後返回一個Socket對象,將Socket對象提交給線程池處理。由這個線程池的線程來執行讀寫操作,那事實上這個線程承擔的工作有判斷數據可讀、判斷數據可寫,對可讀數據進行業務操作之後,將需要寫入的數據進行寫入。 那陷入阻塞的就是在等待數據可寫、等待數據可讀的過程,在NIO模型下對原本一個線程的任務進行了拆分,將判斷可讀可寫任務進行了分離或者對原先的模型進行了改造,原先的業務處理就只做業務處理,將判斷可讀還是可寫、以及寫入這個任務專門進行分離。

我們將判斷可讀、可寫、有新連接建立的線程姑且就稱之為I/O主線程吧,這個主線程在不斷輪詢這三個事件是否發生,如果發生了就將其就給對應的處理器。這也就是最簡單的Reactor模式: 註冊所有感興趣的事件處理器,單線程輪詢選擇就緒事件,執行事件處理器。

現在我們就可以大致總結出來NIO是怎麼解決掉線程的瓶頸並處理海量連接的: 由原來的阻塞讀寫變成了單線程輪詢事件,找到可以進行讀寫的網絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有滿足的事件就必須要要阻塞),剩餘的I/O操作都是純CPU操作,沒有必要開啓多線程。

面試官點了點頭,説道:

還可以嘛,小夥子,剛剛問怎麼卡(qia)殼了?

小陳不好意思的撓撓頭, 笑道:

其實之前看過這部分內容,只不過可能知識不用就想不起來,您提示了一下,我才想起來。

面試官笑了一下,接着問:

那現在的服務器,一般都是多核處理,如果能夠利用多核心進行I/O, 無疑對效率會有更大的提高。 能否對上面的模型進行持續優化呢?

小陳想了想答道:

仔細分一下我們需要的線程,其實主要包括以下幾種:

  1. 事件分發器,單線程選擇就緒的事件。
  2. I/O處理器,包括connect、read、writer等,這種純CPU操作,一般開啓CPU核心個線程就可以了
  3. 業務線程,在處理完I/O後,業務一般還會有自己的業務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的線程。

面試官點了點頭,接着問道:

不錯,不錯。那Java的NIO知道嘛。

小陳點了點頭説道:

知道,Java引入了Selector、Channel 、Buffer,來實現我們新建立的模型,Selector字面意思是選擇器,負責感應事件,也就是我們上面提到的事件分發器。Channel是一種對I/O操作的抽象,可以用於讀取和寫入數據。Buffer則是一種用於存儲數據的緩衝區,提供統一存取的操作。

面試官又問道:

有了解過Java的Selector在Linux系統下的限制嘛?

小陳答道:

Java的Selector對於Linux系統來説,有一個致命的限制: 同一個channel的select不能被併發的調用。因此,如果有多個I/O線程,必須保證: 一個socket只能屬於一個IO線程,而一個IO線程可以管理多個socket。

面試官點了點頭:

不錯,不錯。Tomcat有常用的默認配置參數有: acceptorThreadCount 、 maxConnections、maxThreads 。解釋一下這幾個參數的意義,並且給出一個請求在到達Tomcat之後是怎麼被處理的,要求結合Servlet來進行説明。

小陳沉思了一下道:

acceptorThreadCount 用來控制接收連接的線程數,如果服務器是多核心,可以調大一點。但是Tomcat的官方文檔建議不要超過2個。控制接收連接這部分的代碼在Acceptor這個類裏,你可以看到這個類是Runnable的實現類。在Tomcat的8.0版本,你還能查到這個參數的説明,但是在8.5這個版本就查不到,我沒找到對應的説明,但是在Tomcat 9.0源碼的AbstractProtocol類中的setAcceptorThreadCount方法可以看到,這個參數被廢棄,上面還有説明,説這個參數將在Tomcat的10.0被移除。maxConnections用於控制Tomcat能夠承受的TCP連接數,當達到最大連接數時,操作系統會將請求的連接放入到隊列裏面,這個隊列的數目由acceptCount這個參數控制,默認值為100,如果超過了操作系統能承受的連接數目,這個參數也會不起作用,TCP連接會被操作系統拒絕。maxConnections在NIO和NIO2下, 默認值是10000,在APR/native模式下,默認值是8192.

maxThreads控制最大線程數,一個HTTP請求默認會被一個線程處理,也就是一個Servlet一個線程,可以這麼理解maxThreads的數目決定了Tomcat能夠同時處理的HTTP請求數。默認為200。

面試官似乎很滿意,點了點頭,接着道:

小夥子,看的還挺多,NIO上面你已經講了, NIO2和APR是什麼,你有了解過嘛?

小陳思索了一下回答到:

我先來介紹APR吧,APR是 Apache Portable Runtime的縮寫,是一個為Tomcat提供擴展能力的庫,之所以帶上native的原因是APR不使用Java編寫的連接器,而是選擇直接調用操作系統,避免了JVM級別的開銷,理論上性能會更好。NIO2增強了NIO,我們先在只討論網絡方面的增強,NIO上面我們是啓用了輪詢來判斷對應的事件是否可以進行,NIO2則引入了異步IO,我們不用再輪詢,只用接收操作系統給我們的通知。

面試官:

現在我們將上面的問題連接在一起,向Tomcat應用服務器發出HTTP請求,在NIO模式下,這個請求是如何被Tomcat所處理的。

小陳道:

請求會首先到達操作系統,建立TCP連接,這個過程由操作系統完成,我們暫時忽略,現在這個連接請求完成到達了Acceptor(連接器),連接器在NIO模式下會藉助NIO中的channel,將其設置為非阻塞模式,然後將NioChannel註冊到輪詢線程上,輪詢工作由Poller這個類來完成,然後由Poller將就緒的事件生成SocketProcessor, 交給Excutor去執行,Excutor這是一個線程池,線程池的大小就是在Connector 節點配置的 maxThreads 的值,這個線程池處理的任務為:

  1. 從socket中讀取http request
  2. 解析生成HttpServletRequest對象
  3. 分派到相應的servlet並完成邏輯
  4. 將response通過socket發回client。

面試官:

這個線程池,你有了解過嘛?

小陳道:

這個線程池不是JDK的線程池,繼承了JDK的ThreadPoolExecutor, 自身做了一些擴寫,我看網上的一些博客是説的是這個ThreadPoolExecutor跟JDK的ThreadPoolExecutor行為不太一致,JDK裏面的ThreadPoolExecutor在接收到任務的時候是,看當前線程池活躍的線程數目是否小於核心線程數,如果小於就創建一個線程來執行當前提交的任務,如果當前活躍的線程數目等於核心線程數,那麼就將這個任務放到阻塞隊列中,如果阻塞隊列滿了,判斷當前活躍的線程數目是否到達最大線程數目,如果沒達到,就創建新線程去執行提交的任務。當任務處理完畢,線程池中活躍的線程數超過核心線程池數,超出的在存活keepAliveTime和unit的時間,就會被回收。 簡單的説,就是JDK的線程池是先核心線程,再隊列,最後是最大線程數。我看到的一些博客説Tomcat是先核心線程,再最大線程數,最後是隊列。但是我看了Tomcat的源碼,在StandardThreadExecutor執行任務的時候還是調用父類的方法,這讓我很不解,先核心線程,再最大線程數,最後是隊列,這個結論是怎麼得出來的。

面試官點了點頭:

還不錯,蠻有實證精神的,看了博客還會自己去驗證。我還是蠻欣賞你的,你過來一下,我們看着源碼看看能不能得出這個結論:
@Override
protected void startInternal() throws LifecycleException {

        taskqueue = new TaskQueue(maxQueueSize);
        TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
    executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
        executor.setThreadRenewalDelay(threadRenewalDelay);
        if (prestartminSpareThreads) {
            executor.prestartAllCoreThreads();
        }
        taskqueue.setParent(executor);

        setState(LifecycleState.STARTING);
 }
你説的那個線程池在StandardThreadExecutor這個類的startInternal裏面被初始化,我們看看有沒有什麼生面孔,恐怕唯一的生面孔就是這個TaskQueue,我們簡單的看下這個隊列。從源碼裏面我們可以看出來,這個類繼承了LinkedBlockingQueue,我們重點看入隊和出隊的方法
@Override
public boolean offer(Runnable o) {
  //we can't do any checks
    if (parent==null) {
        return super.offer(o);
    }
    //we are maxed out on threads, simply queue the object
    if (parent.getPoolSize() == parent.getMaximumPoolSize()) {
        return super.offer(o);
    }
    //we have idle threads, just add it to the queue
    if (parent.getSubmittedCount()<=(parent.getPoolSize())) {
        return super.offer(o);
    }
    //if we have less threads than maximum force creation of a new thread
    if (parent.getPoolSize()<parent.getMaximumPoolSize()) {
        return false;
    }
    //if we reached here, we need to add it to the queue
    return super.offer(o);
}
  @Override
 public Runnable poll(long timeout, TimeUnit unit)
            throws InterruptedException {
        Runnable runnable = super.poll(timeout, unit);
        if (runnable == null && parent != null) {
            // the poll timed out, it gives an opportunity to stop the current
            // thread if needed to avoid memory leaks.
            parent.stopCurrentThreadIfNeeded();
        }
        return runnable;
    }

    @Override
    public Runnable take() throws InterruptedException {
        if (parent != null && parent.currentThreadShouldBeStopped()) {
            return poll(parent.getKeepAliveTime(TimeUnit.MILLISECONDS),
                    TimeUnit.MILLISECONDS);
            // yes, this may return null (in case of timeout) which normally
            // does not occur with take()
            // but the ThreadPoolExecutor implementation allows this
        }
        return super.take();
    }

通過上文我們可以知道,如果在線程池的線程數量和最大線程數相等,才會入隊。當前未完成的任務小於當前線程池的線程數目也會入隊。如果當前線程池的線程數目小於最大線程數,入隊失敗返回false。Tomcat的ThreadPoolExecutor繼承了JDK的線程池,但在執行任務的時候依然調用的是父類的方法,看下面的代碼:

  public void execute(Runnable command, long timeout, TimeUnit unit) {
        submittedCount.incrementAndGet();
        try {
            super.execute(command);
        } catch (RejectedExecutionException rx) {
            if (super.getQueue() instanceof TaskQueue) {
                final TaskQueue queue = (TaskQueue)super.getQueue();
                try {
                    if (!queue.force(command, timeout, unit)) {
                        submittedCount.decrementAndGet();
                        throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));
                    }
                } catch (InterruptedException x) {
                    submittedCount.decrementAndGet();
                    throw new RejectedExecutionException(x);
                }
            } else {
                submittedCount.decrementAndGet();
                throw rx;
            }

        }
   }

所以我們還是要進JDK的線程池看這個execute方法是怎麼執行的:

 public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }

這個代碼也比較直觀,不如你提交了一個null值,拋空指針異常。然後判斷當前線程池的線程數是否小於核心線程數,小於則添加線程。如果不小於核心線程數,判斷當前線程池是否還在運行,如果還在運行,就嘗試將任務添加進隊列,走到這個判斷説明當前線程池的線程已經達到核心線程數,但是還小於最大線程數,然後TaskQueue返回false,就接着向線程池添加線程。那麼現在整個Tomcat處理請求的流程,我們心裏就大致有數了,現在我想問一個問題,現在已知的是,我可以認為執行我們controller方法的是線程池的線程,但是如果方法裏面執行時間比較長,那麼線程池的線程就會一直被佔用,我們的系統現在隨着業務的增長剛好面臨着這樣的問題,一些文件上傳碰上流量高峯期,就會一直佔用這個線程,導致整個系統處於一種不可用的狀態。請問該如何解決?

小陳道:

通過異步可以解決嘛,就是將這類任務進行隔離,碰上這類任務先進行返回,等到執行完畢再給響應?我的意思是説使用線程池。

面試官道:

但用户怎麼知道我上傳的圖片是否成功呢,你返回的結果是什麼呢,是未知,然後讓用户過幾分鐘再看看上傳結果? 這看起來有些不友好哦。 你能分析一下核心問題在哪裏嘛?

小陳陷入了沉思,想了一會説道:

是的,您説的對,這確實有些不友好,我想核心問題還是釋放執行controller層方法線程,同時保持TCP連接。

面試官點了點頭:

還可以,其實這個可以通過異步Servlet來解決,Servlet 3.0 引入了異步Servlet,解決了我們上面的問題,我們可以將這種任務專門交付給一個線程池處理的同事,也保持着原本的HTTP連接。具體的使用如下:
@WebServlet(urlPatterns = "/asyncServlet",asyncSupported = true)
public class AsynchronousServlet extends HttpServlet {

    private static final ExecutorService BIG_FILE_POOL = Executors.newFixedThreadPool(10);

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        AsyncContext asyncContext = req.startAsync(req,resp);
        BIG_FILE_POOL.submit(()->{
            try {
                TimeUnit.SECONDS.sleep(10);
                ServletOutputStream outputStream = resp.getOutputStream();
                outputStream.write("task complete".getBytes(StandardCharsets.UTF_8));
                outputStream.flush();
            } catch (Exception e) {
                e.printStackTrace();
            }
            asyncContext.complete();
        });
    }
}

在Spring MVC下 該如何使用呢, Spring MVC對異步Servlet進行了封裝,只需要返回DeferredResult,就能簡便的使用異步Servlet:

@RequestMapping("/quotes")
@ResponseBody
public DeferredResult<String> quotes() {
  DeferredResult<String> deferredResult = new DeferredResult<String>();
  // Add deferredResult to a Queue or a Map...
  return deferredResult;
}
// In some other thread...
deferredResult.setResult(data);
// Remove deferredResult from the Queue or Map
@RestController
public class AsyncTestController {

    private ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4,4,4L,TimeUnit.SECONDS,new LinkedBlockingQueue<>());

    @GetMapping("/asnc")
    public DeferredResult<String> pictureUrl(){
        DeferredResult<String> deferredResult = new DeferredResult<>();
        threadPoolExecutor.execute(()->{
            try {
                // 模擬耗時操作
                TimeUnit.SECONDS.sleep(6);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            deferredResult.setResult("hello world");
        });
        return deferredResult;
    }
}

哈哈哈哈,感覺不是面試,感覺我在給你上課一樣。我對你的感覺還可以,等二面吧。

小陳:

啊,好的。

寫在最後

寫本文的時候用到了 chatGPT來查資料,但是chatGPT給的資料存在很多錯誤,chatGPT出現了認知偏差,比如將Jetty處理請求流程當成了Tomcat處理請求的流程,更細一點感覺還是沒辦法回答出來。還是要自己去看的。

參考資料

  • Java NIO淺析 https://zhuanlan.zhihu.com/p/23488863
  • 深度解讀 Tomcat 中的 NIO 模型 https://klose911.github.io/html/nio/tomcat.html
  • Tomcat - maxThreads vs. maxConnections https://stackoverflow.com/questions/24678661/tomcat-maxthreads-vs-maxconnections
  • 從一次線上問題説起,詳解 TCP 半連接隊列、全連接隊列 https://developer.aliyun.com/article/804896
  • 就是要你懂TCP--半連接隊列和全連接隊列 https://plantegg.github.io/2017/06/07/%E5%B0%B1%E6%98%AF%E8%A...
  • Tomcat 配置文檔 https://tomcat.apache.org/tomcat-8.5-doc/config/http.html
  • Java NIO 系列文章之 淺析Reactor模式 https://pjmike.github.io/2018/09/20/Java-NIO-%E7%B3%BB%E5%88%...
  • Java NIO - non-blocking channels vs AsynchronousChannels https://stackoverflow.com/questions/22177722/java-nio-non-blocking-channels-vs-asynchronouschannels
  • asynchronous and non-blocking calls? also between blocking and synchronous https://stackoverflow.com/questions/2625493/asynchronous-and-non-blocking-calls-also-between-blocking-and-synchronous
  • Java AIO 源碼解析 https://cdf.wiki/posts/2976168065/
  • 每天都在用,但你知道 Tomcat 的線程池有多努力嗎? https://www.cnblogs.com/thisiswhy/p/12782548.html
  • 異步Servlet在轉轉圖片服務的實踐 https://juejin.cn/post/7124116514382774286
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.