Let's Hack異步Servlet | Servlet3.0新特性
前天在扒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是父子關系,我先畫一張圖:
解釋下這張圖,Connector是作為Service容器的組件,當Service被父容器啟動的時候同事會啟動Connector組件,Connector組件關聯一個ProtocolHandler,Connector會啟動這個ProtocolHandler,ProtocolHandler關聯著一個Endpoint,ProtocolHandler同樣也會啟動這個Endpoint。Endpoint是干嘛的呢,Tomcat定義Endpoint作為網絡層的組件,用于綁定及監聽服務端的端口,將接收到的客戶端的連接分發到工作線程去處理,Endpoint啟動的時候做些什么事情以及包括哪些內容呢?Endpoint具體有多個實現,我拿最簡單的JIoEndpoint來扒一扒,它啟動的時候會做下面這些事情:
-
bind本地指定的端口,我們最熟悉的就是8080了。
-
初始化內部工作線程池。
-
啟動Acceptor線程,Acceptor線程是用來接受客戶端socket并包裝交給工作線程處理了,Acceptor線程只負責接客,接完之后就包裝成SocketProcessor丟給工作線程池去處理了。
-
啟動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)); } }
這里面需要注意的有幾點:
-
將@WebServlet注解的asyncSupported的值設置為true,代表這個Servlet是異步Servlet。
-
通過req.startAsync獲取異步上下文。
-
設置上文中自定義的Listener。
-
設置超時時間。
-
以異步上下文為參數構造線程丟進工作線程池中。
到此,我們自己的異步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版本,由作者原創,歡迎補充或糾正。
作者:陸晨
2016年1月3日