非同步Servlet學習筆記(一)

北冥有隻魚發表於2023-03-10
兩週沒更新了,感覺不寫點什麼,有點不舒服的感覺。

前言

回憶一下學Java的歷程,當時是看JavaSE(基本語法、執行緒、泛型),然後是JavaEE,JavaEE也基本就是圍繞著Servlet的使用、JSP、JDBC來學習,當時看的是B站up主顏群的教學影片:

現在一看這個播放量破百萬了,當初我看的時候應該播放量很少,現在這麼多倒是有點昨舌。學完了這個之後,開始學習框架: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處理請求的流程,更細一點感覺還是沒辦法回答出來。還是要自己去看的。

參考資料

相關文章