Let’s Hack非同步Servlet及Servlet3.0新特性

陸晨發表於2016-01-04

前天在扒Tomcat原始碼的時候在裝配Servlet的時候我們除了看見了比較熟悉的loadOnStartup引數之外,另外一個不太熟悉的引數asyncSupported就是我們今天要討論的主題,我們的關注點隨即也從Servlet上下文轉向了Tomcat對請求的處理與分發,也就是更底層一些的東西,待會會涉及Tomcat Endpoint相關的東西,很開心和大家一起分享。

背景知識一:tomcat的容器架構

我們先看下conf/server.xml裡面的一端配置:

<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           redirectPort="8443" />

這個配置位於Service元件標籤的裡面,在Tomcat的容器架構圖中Connector和Service是父子關係,我先畫一張圖:

Let&#039;s Hack非同步Servlet | Servlet3.0新特性

解釋下這張圖,Connector是作為Service容器的元件,當Service被父容器啟動的時候同事會啟動Connector元件,Connector元件關聯一個ProtocolHandler,Connector會啟動這個ProtocolHandler,ProtocolHandler關聯著一個Endpoint,ProtocolHandler同樣也會啟動這個Endpoint。Endpoint是幹嘛的呢,Tomcat定義Endpoint作為網路層的元件,用於繫結及監聽服務端的埠,將接收到的客戶端的連線分發到工作執行緒去處理,Endpoint啟動的時候做些什麼事情以及包括哪些內容呢?Endpoint具體有多個實現,我拿最簡單的JIoEndpoint來扒一扒,它啟動的時候會做下面這些事情:

  1. bind本地指定的埠,我們最熟悉的就是8080了。
  2. 初始化內部工作執行緒池。
  3. 啟動Acceptor執行緒,Acceptor執行緒是用來接受客戶端socket幷包裝交給工作執行緒處理了,Acceptor執行緒只負責接客,接完之後就包裝成SocketProcessor丟給工作執行緒池去處理了。
  4. 啟動Timeout執行緒,用來非同步檢查超時連線。

好了,下面繼續看看Tomcat對請求處理的邏輯。

背景知識二:Tomcat對非同步請求的處理邏輯

我們在SocketProcessor的實現裡面找到了一個程式碼片段:

if (state == SocketState.CLOSED) {
    // Close socket
    if (log.isTraceEnabled()) {
        log.trace("Closing socket:"+socket);
    }
    countDownConnection();
    try {
        socket.getSocket().close();
    } catch (IOException e) {
        // Ignore
    }
} else if (state == SocketState.OPEN ||
        state == SocketState.UPGRADING ||
        state == SocketState.UPGRADING_TOMCAT  ||
        state == SocketState.UPGRADED){
    socket.setKeptAlive(true);
    socket.access();
    launch = true;
} else if (state == SocketState.LONG) {
    socket.access();
    waitingRequests.add(socket);
}

上面可以看出,第一個if分支是當狀態等於CLOSED的時候,這裡會將連線數減1並且關閉伺服器與客戶端的socket連線,其他兩個分支並沒有斷開連線。再看看SocketProcessor的實現中另一個程式碼片段:

if ((state != SocketState.CLOSED)) {
    if (status == null) {
        state = handler.process(socket, SocketStatus.OPEN_READ);
    } else {
        state = handler.process(socket,status);
    }
}

(下面我想用記流水賬的形式描述邏輯程式碼的執行堆疊)上面的handler process是具體處理socket的分支,相關實現由AbstractProtocol下沉到AbstractHttp11Processor的asyncDispatch中,在asyncDispatch會呼叫adapter的asyncDispatch方法來處理,這個adapter的具體實現在Connector被啟動的時候初始化的,具體是CoyoteAdapter類,在CoyoteAdapter的實現中會去呼叫StandardWrapperValve的invoke方法,再具體一點就會呼叫使用者在WebXML中配置的過濾器鏈以及Servlet啦。

上面講了那麼一連串的原始碼堆疊邏輯,其實是想連貫Tomcat從接收到客戶端請求與呼叫Servlet這條線。

簡單來說,Tomcat對非同步Servlet的處理邏輯即Tomcat接收客戶端的請求之後,如果這個請求對應的Servlet是非同步的,那麼Tomcat會將請求委託給非同步執行緒來處理,並會保持與客戶端的連線,當請求處理完成之後再由委託執行緒來通知監聽器非同步處理已經完成,於此同時Tomcat的工作執行緒已經被Tomcat工作執行緒池回收。

下面我們就可以繼續看看上層是如何寫非同步Servlet的了。

利用Servlet3的API實現非同步Servlet

在這一節,我們主要看看如何從零開始實現一個非同步的Servlet,為了不讓篇幅過長,我儘量精簡一下例子。

一、實現一個ServletContextListener來初始化我們自己的執行緒池,這個池子和Tomcat的工作執行緒池是完全獨立的:

/**
 * @author float.lu
 */
@WebListener
public class AppContextListener implements ServletContextListener {

    private static final String EXECUTOR_KEY = AppContextListener.class.getName();
    @Override
    public void contextInitialized(ServletContextEvent servletContextEvent) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 200, 50000L,
                TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(100));
        servletContextEvent.getServletContext().setAttribute(EXECUTOR_KEY,
                executor);
    }

    @Override
    public void contextDestroyed(ServletContextEvent servletContextEvent) {
        ThreadPoolExecutor executor = (ThreadPoolExecutor) servletContextEvent
                .getServletContext().getAttribute(EXECUTOR_KEY);
        executor.shutdown();
    }
}

這裡只做兩件事情,第一、在Servlet容器初始化完成的時候初始化執行緒池,這個時候Servlet還沒有被初始化,這是上篇文章的知識了。第二,在Servlet容器銷燬的時候銷燬執行緒池。

二、實現一個AsyncListener介面的類,這個介面是Servlet3 API提供的介面,用於監聽工作執行緒的執行情況從而正確的響應非同步處理結果,因為我的例子實現程式碼沒有什麼意義這裡就不貼了,記住實現javax.servlet.AsyncListener這個介面就好。

三、自定義一個實現Runnable介面的類,我的實現是這樣的:

/**
 * @author float.lu
 */
public class AsyncRequestProcessor implements Runnable {

    private AsyncContext asyncContext;

    public AsyncRequestProcessor(AsyncContext asyncCtx) {
        this.asyncContext = asyncCtx;
    }

    @Override
    public void run() {
        try {
            PrintWriter out = this.asyncContext.getResponse().getWriter();
            out.write("Async servlet started !/n");
            out.flush();
        } catch (Exception e) {

        }
        asyncContext.complete();
    }
}

主要是通過構造方法拿到了非同步上下文AsyncContext對應於ServletContext。然後執行緒實現裡面可以拿到請求進行響應的處理。

四,最後一個是非同步Servlet的實現:

/**
 * @author float.lu
 */
@WebServlet(value = "/asyncservlet", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        AsyncContext asyncContext = req.startAsync();
        asyncContext.addListener(new AppAsyncListener());
        asyncContext.setTimeout(2000);

        ThreadPoolExecutor executor = (ThreadPoolExecutor) req
                .getServletContext().getAttribute("executor");
        executor.execute(new AsyncRequestProcessor(asyncContext));
    }
}

這裡面需要注意的有幾點:

  1. 將@WebServlet註解的asyncSupported的值設定為true,代表這個Servlet是非同步Servlet。
  2. 通過req.startAsync獲取非同步上下文。
  3. 設定上文中自定義的Listener。
  4. 設定超時時間。
  5. 以非同步上下文為引數構造執行緒丟進工作執行緒池中。

到此,我們自己的非同步Servlet實現就結束了,其實這只是其中一種實現方式,具體可以根據實際情況巧妙設計。舉個例子,如果使用單執行緒模型的話我們可以維護著一個佇列來儲存非同步上下文,一個工作執行緒不斷的從佇列中拿到非同步上下文進行處理,完了之後呼叫AsyncContext定義的complete介面告知監聽器處理完成即可。第一種模型其實只是將原來可能附加給Tomcat工作執行緒池的任務拿到自定義的執行緒池處理而已,而第二種模型是隻用一個工作執行緒去利用佇列來處理非同步任務。具體應用要看實際情況來定。

非同步還是不非同步?

現在知道了Tomcat對非同步Servlet的支援,有知道了如何實現非同步Servlet,那麼問題來了,非同步Servlet適合什麼樣的場景呢?

我們分析下並設想一下,當然下面可能是我自己在YY,不正確的歡迎指出,也歡迎讀者能夠舉一些其他的應用場景。首先問題肯定出現在當請求處理時間可能很長的時候,這讓我想到了報表匯出功能。報表匯出其實是一個非常常見的功能,我們需要通過查詢資料庫,對資料進行處理,然後根據處理完的資料生成Excel並匯出。這個過程時間一般都是相對比較長的,通常會引發資料庫連線數不夠這種問題,當然這是另外一個話題了,資料層相關問題我可能會通過為報表匯出任務建立單獨的資料來源來處理,或者是其他方法。而我們現在討論的是比較上層的請求佔用問題,這個時候我們可以使用非同步Servlet來處理這個耗時比較長的任務,從而不會長時間佔用Tomcat寶貴的工作執行緒,因為Tomcat工作執行緒被佔用完的後果將是不接受任何請求。

無論場景如何,結果是我們可以用自己的執行緒代理工作執行緒來處理請求了,當然用單執行緒還是用多執行緒模型這個也要看實際情況,如果你能拿出實驗資料來證明具體的應用場景下哪種模型更好,這是再好不過的了,

擴充套件

上面的例子都是直接使用Servlet來實現的,實際應用中這種方式可能很少有人用了,不過沒關係。Spring MVC從3.2版本就支援非同步Servlet了,可能上層的表現形式不一樣也就是具體碼的姿勢不一樣,但是都知道原理了,可以直接Hack起。Struts貌似還不支援???另外提一下,對於非同步Servlet,其實tomcat支援的comet Servlet就是一種非同步Servlet。comet的原理是請求到達Servlet之後客戶端就和伺服器保持著長連線,這樣服務端可以隨時將內容推送到客戶端。

本文相關程式碼基於tomcat7.0.56和servlet3.1.0版本,由作者原創,歡迎補充或糾正。

相關文章