非同步化,你的高併發大殺器

咖啡拿鐵發表於2018-07-16

今天來聊聊如何讓專案非同步化的一些事。

1.同步和非同步,阻塞和非阻塞

同步和非同步,阻塞和非阻塞, 這個幾個詞已經是老生常談,但是常常還是有很多同學分不清楚,以為同步肯定就是阻塞,非同步肯定就是非阻塞,其實他們不是一回事。

同步和非同步關注的是結果訊息的通訊機制

  • 同步:同步的意思就是呼叫方需要主動等待結果的返回
  • 非同步:非同步的意思就是不需要主動等待結果的返回,而是通過其他手段比如,狀態通知,回撥函式等。

阻塞和非阻塞主要關注的是等待結果返回撥用方的狀態

  • 阻塞:是指結果返回之前,當前執行緒被掛起,不做任何事
  • 非阻塞:是指結果在返回之前,執行緒可以做一些其他事,不會被掛起。

可以看見同步和非同步,阻塞和非阻塞主要關注的點不同,有人會問同步還能非阻塞,非同步還能阻塞?當然是可以的,下面為了更好的說明他們的組合之間的意思,用幾個簡單的例子說明: 1.同步阻塞:同步阻塞基本也是程式設計中最常見的模型,打個比方你去商店買衣服,你去了之後發現衣服賣完了,那你就在店裡面一直等,期間不做任何事(包括看手機),等著商家進貨,直到有貨為止,這個效率很低。

2.同步非阻塞:同步非阻塞在程式設計中可以抽象為一個輪詢模式,你去了商店之後,發現衣服賣完了,這個時候不需要傻傻的等著,你可以去其他地方比如奶茶店,買杯水,但是你還是需要時不時的去商店問老闆新衣服到了嗎。

3.非同步阻塞:非同步阻塞這個程式設計裡面用的較少,有點類似你寫了個執行緒池,submit然後馬上future.get(),這樣執行緒其實還是掛起的。有點像你去商店買衣服,這個時候發現衣服沒有了,這個時候你就給老闆留給電話,說衣服到了就給我打電話,然後你就守著這個電話,一直等著他響什麼事也不做。這樣感覺的確有點傻,所以這個模式用得比較少。

4.非同步非阻塞:非同步非阻塞這也是現在高併發程式設計的一個核心,也是今天主要講的一個核心。好比你去商店買衣服,衣服沒了,你只需要給老闆說這是我的電話,衣服到了就打。然後你就隨心所欲的去玩,也不用操心衣服什麼時候到,衣服一到,電話一響就可以去買衣服了。

2.同步阻塞 PK 非同步非阻塞

上面已經看到了同步阻塞的效率是多麼的低,如果使用同步阻塞的方式去買衣服,你有可能一天只能買一件衣服,其他什麼事都不能幹,如果用非同步非阻塞的方式去買,買衣服只是你一天中進行的一個小事。

我們把這個對映到我們程式碼中,當我們的執行緒發生一次rpc呼叫或者http呼叫,又或者其他的一些耗時的IO呼叫,發起之後,如果是同步阻塞,我們的這個執行緒就會被阻塞掛起,直到結果返回,試想一下如果IO呼叫很頻繁那我們的CPU使用率其實是很低很低。正所謂是物盡其用,既然CPU的使用率被IO呼叫搞得很低,那我們就可以使用非同步非阻塞,當發生IO呼叫時我並不馬上關心結果,我只需要把回撥函式寫入這次IO呼叫,我這個時候執行緒可以繼續處理新的請求,當IO呼叫結束結束時,會呼叫回撥函式。而我們的執行緒始終處於忙碌之中,這樣就能做更多的有意義的事了。

這裡首先要說明的是,非同步化不是萬能,非同步化並不能縮短你整個鏈路呼叫時間長的問題,但是他能極大的提升你的最大qps。一般我們的業務中有兩處比較耗時:

  • cpu: cpu耗時指的是我們的一般的業務處理邏輯,比如一些資料的運算,物件的序列化。這些非同步化是不能解決的,得需要靠一些演算法的優化,或者一些高效能框架。
  • iowait: io耗時就像我們上面說的,一般發生在網路呼叫,檔案傳輸中等等,這個時候執行緒一般會掛起阻塞。而我們的非同步化通常用於解決這部分的問題。

3.哪些可以非同步化?

上面說了非同步化是用於解決IO阻塞的問題,而我們一般專案中可以使用非同步化如下:

  • servlet非同步化,springmvc非同步化
  • rpc呼叫如(dubbo,thrift),http呼叫非同步化
  • 資料庫呼叫,快取呼叫非同步化

下面我會從上面幾個方面進行非同步化的介紹.

4.servlet非同步化

對於Java開發程式設計師來說servlet並不陌生吧,在專案中不論你使用struts2,還是使用的springmvc,本質上都是封裝的servlet。但是我們的一般的開發,其實都是使用的同步阻塞模式如下:

這裡寫圖片描述

上面的模式優點在於編碼簡單,適合在專案啟動初期,訪問量較少,或者是CPU運算較多的專案

缺點在於,業務邏輯執行緒和servlet容器執行緒是同一個,一般的業務邏輯總得發生點IO,比如查詢資料庫,比如產生RPC呼叫,這個時候就會發生阻塞,而我們的servlet容器執行緒肯定是有限的,當servlet容器執行緒都被阻塞的時候我們的服務這個時候就會發生拒絕訪問,執行緒不然我當然們可以通過增加機器的一系列手段來解決這個問題,但是俗話說得好靠人不如靠自己,靠別人替我分擔請求,還不如我自己搞定。所以在servlet3.0之後支援了非同步化,我們採用非同步化之後就會變成如下:

這裡寫圖片描述

在這裡我們採用新的執行緒處理業務邏輯,IO呼叫的阻塞就不會影響我們的serlvet了,實現非同步serlvet的程式碼也比較簡單,如下:

@WebServlet(name = "WorkServlet",urlPatterns = "/work",asyncSupported =true)
public class WorkServlet extends HttpServlet{
    private static final long serialVersionUID = 1L;
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        this.doPost(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //設定ContentType,關閉快取
        resp.setContentType("text/plain;charset=UTF-8");
        resp.setHeader("Cache-Control","private");
        resp.setHeader("Pragma","no-cache");
        final PrintWriter writer= resp.getWriter();
        writer.println("老師檢查作業了");
        writer.flush();
        List<String> zuoyes=new ArrayList<String>();
        for (int i = 0; i < 10; i++) {
            zuoyes.add("zuoye"+i);;
        }
        //開啟非同步請求
        final AsyncContext ac=req.startAsync();
        doZuoye(ac, zuoyes);
        writer.println("老師佈置作業");
        writer.flush();
    }

    private void doZuoye(final AsyncContext ac, final List<String> zuoyes) {
        ac.setTimeout(1*60*60*1000L);
        ac.start(new Runnable() {
            @Override
            public void run() {
                //通過response獲得字元輸出流
                try {
                    PrintWriter writer=ac.getResponse().getWriter();
                    for (String zuoye:zuoyes) {
                        writer.println("\""+zuoye+"\"請求處理中");
                        Thread.sleep(1*1000L);
                        writer.flush();
                    }
                    ac.complete();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }
}

複製程式碼

實現serlvet的關鍵在於http採取了長連線,也就是當請求打過來的時候就算有返回也不會關閉,因為可能還會有資料,直到返回關閉指令。 AsyncContext ac=req.startAsync(); 用於獲取非同步上下文,後續我們通過這個非同步上下文進行回撥返回資料,有點像我們買衣服的時候,給老闆一個電話,而這個上下文也是一個電話,當有衣服到的時候,也就是當有資料準備好的時候就可以打電話傳送資料了。 ac.complete(); 用來進行長連結的關閉。

4.1springmvc非同步化

現在其實很少人來進行serlvet程式設計,都是直接採用現成的一些框架,比如struts2,springmvc。下面介紹下使用springmvc如何進行非同步化:

  • 首先確認你的專案中的Servlet是3.0以上的!!,其次springMVC4.0+
<dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>3.1.0</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>4.2.3.RELEASE</version>
    </dependency>
複製程式碼
  • web.xml頭部宣告,必須要3.0,filter和serverlet設定為非同步
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.0" xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
    http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd">
    <filter>
        <filter-name>testFilter</filter-name>
        <filter-class>com.TestFilter</filter-class>
        <async-supported>true</async-supported>
    </filter>
   
    <servlet>
        <servlet-name>mvc-dispatcher</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        .........
        <async-supported>true</async-supported>
    </servlet>
複製程式碼
  • 使用springmvc封裝了servlet的AsyncContext,使用起來比較簡單。以前我們同步的模式的Controller是返回額ModelAndView,而非同步模式直接生成一個defrredResult(支援我們超時擴充套件)即可儲存上下文,下面給出如何和我們HttpClient搭配的簡單demo
@RequestMapping(value="/asynctask", method = RequestMethod.GET)
    public DeferredResult<String> asyncTask() throws IOReactorException {
        IOReactorConfig ioReactorConfig = IOReactorConfig.custom().setIoThreadCount(1).build();
        ConnectingIOReactor ioReactor = new DefaultConnectingIOReactor(ioReactorConfig);
        PoolingNHttpClientConnectionManager conManager = new PoolingNHttpClientConnectionManager(ioReactor);
        conManager.setMaxTotal(100);
        conManager.setDefaultMaxPerRoute(100);
        CloseableHttpAsyncClient httpclient = HttpAsyncClients.custom().setConnectionManager(conManager).build();
        // Start the client
        httpclient.start();
        //設定超時時間200ms
        final DeferredResult<String> deferredResult = new DeferredResult<String>(200L);
        deferredResult.onTimeout(new Runnable() {
            @Override
            public void run() {
                System.out.println("非同步呼叫執行超時!thread id is : " + Thread.currentThread().getId());
                deferredResult.setResult("超時了");
            }
        });
        System.out.println("/asynctask 呼叫!thread id is : " + Thread.currentThread().getId());
        final HttpGet request2 = new HttpGet("http://www.apache.org/");
        httpclient.execute(request2, new FutureCallback<HttpResponse>() {
​
            public void completed(final HttpResponse response2) {
                System.out.println(request2.getRequestLine() + "->" + response2.getStatusLine());
                deferredResult.setResult(request2.getRequestLine() + "->" + response2.getStatusLine());
            }
​
            public void failed(final Exception ex) {
                System.out.println(request2.getRequestLine() + "->" + ex);
            }
​
            public void cancelled() {
                System.out.println(request2.getRequestLine() + " cancelled");
            }
​
        });
        return deferredResult;
    }
​
複製程式碼

注意: 在serlvet非同步化中有個問題是filter的後置結果處理,沒法使用,對於我們一些打點,結果統計直接使用serlvet非同步是沒法用的。在springmvc中就很好的解決了這個問題,springmvc採用了一個比較取巧的方式通過請求轉發,能讓請求再次過濾器。但是又引入了新的一個問題那就是過濾器會處理兩次,這裡可以通過SpringMVC原始碼中自身判斷的方法,我們可以在filter中使用下面這句話來進行判斷是不是屬於springmvc轉發過來的請求,從而不處理filter的前置事件,只處理後置事件:

Object asyncManagerAttr = servletRequest.getAttribute(WEB_ASYNC_MANAGER_ATTRIBUTE);
return asyncManagerAttr instanceof WebAsyncManager ;
複製程式碼

5.全鏈路非同步化

上面我們介紹了serlvet的非同步化,相信細心的同學都看出來似乎並沒有解決根本的問題,我的IO阻塞依然存在,只是換了個位置而已,當IO呼叫頻繁同樣會讓業務執行緒池快速變滿,雖然serlvet容器執行緒不被阻塞,但是這個業務依然會變得不可用。

非同步化,你的高併發大殺器

那麼怎麼才能解決上面的問題呢?答案就是全鏈路非同步化,全鏈路非同步追求的是沒有阻塞,打滿你的CPU,把機器的效能壓榨到極致模型圖如下:

非同步化,你的高併發大殺器
具體的NIO client到底做了什麼事呢,具體如下面模型:

非同步化,你的高併發大殺器

上面就是我們全鏈路非同步的圖了(部分執行緒池可以優化)。全鏈路的核心在於只要我們遇到IO呼叫的時候,我們就可以使用NIO,從而避免阻塞,也就解決了之前說的業務執行緒池被打滿得到尷尬場景。

5.1遠端呼叫非同步化

我們一般遠端呼叫使用rpc或者http。對於rpc來說一般thrift,http,motan等支援都非同步呼叫,其內部原理也都是採用事件驅動的NIO模型,對於http來說一般的apachehttpclient和okhttp也都提供了非同步呼叫。 下面簡單介紹下Http非同步化呼叫是怎麼做的: 首先來看一個例子:

public class HTTPAsyncClientDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException, IOReactorException {
      //具體引數含義下文會講
       //apache提供了ioReactor的引數配置,這裡我們配置IO 執行緒為1
        IOReactorConfig ioReactorConfig = IOReactorConfig.custom().setIoThreadCount(1).build();
      //根據這個配置建立一個ioReactor
        ConnectingIOReactor ioReactor = new DefaultConnectingIOReactor(ioReactorConfig);
      //asyncHttpClient使用PoolingNHttpClientConnectionManager管理我們客戶端連線
        PoolingNHttpClientConnectionManager conManager = new PoolingNHttpClientConnectionManager(ioReactor);
      //設定總共的連線的最大數量
        conManager.setMaxTotal(100);
      //設定每個路由的連線的最大數量
        conManager.setDefaultMaxPerRoute(100);
      //建立一個Client
        CloseableHttpAsyncClient httpclient = HttpAsyncClients.custom().setConnectionManager(conManager).build();
        // Start the client
        httpclient.start();

        // Execute request
        final HttpGet request1 = new HttpGet("http://www.apache.org/");
        Future<HttpResponse> future = httpclient.execute(request1, null);
        // and wait until a response is received
        HttpResponse response1 = future.get();
        System.out.println(request1.getRequestLine() + "->" + response1.getStatusLine());

        // One most likely would want to use a callback for operation result
        final HttpGet request2 = new HttpGet("http://www.apache.org/");
        httpclient.execute(request2, new FutureCallback<HttpResponse>() {
						//Complete成功後會回撥這個方法
            public void completed(final HttpResponse response2) {
                System.out.println(request2.getRequestLine() + "->" + response2.getStatusLine());
            }

            public void failed(final Exception ex) {
                System.out.println(request2.getRequestLine() + "->" + ex);
            }

            public void cancelled() {
                System.out.println(request2.getRequestLine() + " cancelled");
            }

        });
    }
}
複製程式碼

下面給出httpAsync的整個類圖:

非同步化,你的高併發大殺器

對於我們的HTTPAysncClient 其實最後使用的是InternalHttpAsyncClient,在InternalHttpAsyncClient中有個ConnectionManager,這個就是我們管理連線的管理器,而在httpAsync中只有一個實現那就是PoolingNHttpClientConnectionManager,這個連線管理器中有兩個我們比較關心的一個是Reactor,一個是Cpool。

  • Reactor :所有的Reactor這裡都是實現了IOReactor介面。在PoolingNHttpClientConnectionManager中會有擁有一個Reactor,那就是DefaultConnectingIOReactor,這個DefaultConnectingIOReactor,負責處理Acceptor。在DefaultConnectingIOReactor有個excutor方法,生成IOReactor也就是我們圖中的BaseIOReactor,進行IO的操作。這個模型就是我們上面的1.2.2的模型

  • CPool :在PoolingNHttpClientConnectionManager中有個CPool,主要是負責控制我們連線,我們上面所說的maxTotal和defaultMaxPerRoute,都是由其進行控制,如果每個路由的滿了他會斷開最老的一個連結,如果總共的total滿了他會放入leased佇列,釋放空間的時候就會將其重新連線。

5.2資料庫呼叫非同步化

對於資料庫呼叫一般的框架並沒有提供非同步化的方法,這裡推薦自己封裝或者使用網上開源的,這裡我們公司有個開源的 github.com/ainilife/ze… 能很好的支援非同步化

6.最後

非同步化並不是高併發的銀彈,但是有了非同步化的確能提高你機器的qps,吞吐量等等。上述講的一些模型如果能合理的做一些優化,然後進行應用,相信能對你的服務有很大的幫助的。

想要獲取更多資訊請關注我的公眾號吧

最後這篇文章被我收錄於JGrowing,一個全面,優秀,由社群一起共建的Java學習路線,如果您想參與開源專案的維護,可以一起共建,github地址為:github.com/javagrowing… 麻煩給個小星星喲。

非同步化,你的高併發大殺器

相關文章