攜程基於Quasar協程的NIO實踐

IO密集型系統在高併發場景下,會有大量線程處於阻塞狀態,性能低下,JAVA上成熟的非阻塞IO(NIO)技術可解決該問題。目前Java項目對接NIO的方式主要依靠回調,代碼複雜度高,降低了代碼可讀性與可維護性。近年來Golang、Kotlin等語言的協程(Coroutine)能達到高性能與可讀性的兼顧。

本文利用開源的Quasar框架提供的協程對系統進行NIO改造,解決以下兩個問題:

1)提升單機任務的吞吐量,保證業務請求突增時系統的可伸縮性。

2)使用更輕量的協程同步等待IO,替代處理NIO常用的異步回調。

一、Java異步編程與非阻塞IO

本文改造的系統處理來自前臺的任務,通過HTTP請求對端服務,還通過RPC調用內部服務。當業務高峯時,系統會遇到瞬時併發任務量數十倍激增的情況,系統的線程數量急劇增加造成性能下降。爲此,不得不擴容以保證業務高峯時期的性能。

基於epoll的NIO框架Netty在一些框架級別的應用中已經得到了廣泛使用,但在快速迭代的業務系統中的應用依然有一定的侷限性。NIO 消除了線程的同步阻塞,意味着只能異步處理IO的結果,這與業務開發者順序化的思維模式有一定差異。當業務邏輯複雜以及出現多次遠程調用的情況下,多級回調難以實現和維護。

1.1 Java中的異步工具

Java項目大多使用JDK8,除線程外可以獲得的異步的編程支持包括CompletableFuture,以及開源的RxJava、Vert.x等反應式編程框架等。這些工具使用了基於響應式編程的鏈式調用逐級傳遞事件,未從根本解決回調問題。

如下爲將一段簡單的邏輯判斷使用CompletableFuture進行異步改造後的對比。原始版本使用getA方法獲得第一步的請求結果,根據其相應選擇使用getB1還是getB2獲取第二步的響應作爲結果。

HttpResponse a = getA();

HttpResponse b ;
if(a.getBody().equals("1")){
    b=getB1();
}
else{
    b=getB2();
}

String ans=b.getBody();

首先將三個獲取響應的方法改爲異步。此處假設getB1與getB2內部已經具有複雜邏輯,且不屬於同一領域,不適合合併爲一個方法。

private CompletableFuture<HttpResponse> getA();
private CompletableFuture<HttpResponse> getB1();
private CompletableFuture<HttpResponse> getB2();

然後使用CompletableFuture的鏈式調用,將兩個步驟組合起來:

String ans = getA()
        .thenCompose(a -> {
            if (a.getBody().equals("1")) {
                return getB1();
            } else {
                return getB2();
            }
        }).get()
        .getBody();

使用CompletableFuture的鏈式回調後,代碼變得不友好。RxJava等框架同樣具有這個問題。這類反應式的編程工具更適合於數據流的傳遞。對於if/else、switch/case,乃至while/for、break/continue這類過程控制語句,實現與維護的難度都很大。業務系統需要類似於線程的同步等待,同時具有低資源消耗的編碼工具,配合 NIO使用。當時使用NIO時,由於可以不佔用線程,可以使用一種資源消耗更小的協程來等待。

1.2 協程

協程是一種進程自身來調度任務的調度模式。協程與線程不同之處在於,線程由內核調度,而協程的調度是進程自身完成的。協程只是一種抽象,最終的執行者是線程,每個線程只能同時執行一個協程,但大量的協程可以只擁有少量幾個線程執行者,協程的調度器負責決定當前線程在執行那個協程,其餘協程處於休眠並被調度器保存在內存中。

和線程類似,協程掛起時需要記錄棧信息,以及方法執行的位置,這些信息會被協程調度器保存。協程從掛起到重新被執行不需要執行重量級的內核調用,而是直接將狀態信息還原到執行線程的棧,高併發場景下,協程極大地避免了切換線程的開銷。下圖展示了協程調度器內部任務的流轉。

協程中調用的方法是可以掛起的。不同於線程的阻塞會使線程休眠,協程在等待異步任務的結果時,會通知調度器將自己放入掛起隊列,釋放佔用的線程以處理其他的協程。異步任務完畢後,通過回調將異步結果告知協程,並通知調度器將協程重新加入就緒隊列執行。

1.3 Quasar任務調度原理

Quasar(https://github.com/puniverse/quasar)是一個開源的Java協程框架,通過利用Java instrument技術對字節碼進行修改,使方法掛起前後可以保存和恢復JVM棧幀,方法內部已執行到的字節碼位置也通過增加狀態機的方式記錄,在下次恢復執行可直接跳轉至最新位置。以如下方法爲例,該方法分爲兩步,第一步爲initial初始化,第二部爲通過NIO獲取網絡響應。

public String instrumentDemo(){
    initial();
    String ans = getFromNIO();
    return ans;
}

Quasar會在initial前增加一個flag字段,表明當前方法執行的位置。第一次執行方法時,檢查到flag爲0,修改flag爲1並繼續往下執行initial方法。執行getFromNIO方法前插入字節碼指令將棧幀中的數據全部保存在一個Quasar自定義的棧結構中,在執行getFromNIO後,掛起協程,讓出線程資源。直至NIO異步完成後,協程調度器將第二次執行該方法,檢測到flag爲1,將會調用jump指令跳轉到returnans語句前,並將保存的棧結構還原到當前棧中,最後調用人return ans語句,方法執行完畢。

二、系統異步IO改造

在項目中添加Quasar依賴後,可以使用Fiber類新建協程。建立的方法與線程類似。

new Fiber(()->{
    //方法體
}).start();

2.1 整合Netty與Quasar

系統使用的Http框架是基於Netty的async-http-client(https://github.com/AsyncHttpClient/async-http-client),該框架提供了異步回調和CompletableFuture兩種對響應的異步處理方式。

CompletableFuture自JDK8推出,與之前的Future類最大的不同在於,提供了異步任務跨線程的通知和控制機制。即,任務的等待者可以在CompletableFuture註冊任務完成或異常時的回調,而執行者也可以通過它通知等待者。Quaasr框架對它也做了支持,提供了API用於在協程中等待CompletableFuture的結果。調用後,協程將掛起,直至future狀態爲已完成。

AsyncCompletionStage.get(future)

通過CompletableFuture作爲通知中介,我們可以將AsyncHttpClient與Quasar做整合,掛起協程等待IO結果。

//創建HttpClient
AsyncHttpClient httpClient = Dsl.asyncHttpClient();
//創建請求
Request request = createRequest();
//將網絡請求交給HttpClient執行
CompletableFuture<Response> future = httpClient.executeRequest(request)
.toCompletableFuture();
//通過Quasar掛起協程
Response response = AsyncCompletionStage.get(future);
//獲取網絡結果後,通過future傳遞response並喚醒協程重新執行
deal(response);

過程可由下圖表示。

Quasar框架AsyncCompletionStage.get內部完成的工作相當於,在HttpClient返回的future上註冊回調,回調的內容是“IO操作完成後通知調度器喚醒協程”,這樣將NIO異步回調全部操作封裝在協程調度器中,用戶代碼看起來是同步等待的形式,避免了自行實現回調處理帶來的繁瑣,解決了前文所述的回調地獄。

2.2 聲明掛起方法

Quasar需要織入字節碼接管掛起方法的調度,在項目主pom下添加quasar-maven-plugin插件,該插件將在編譯後的class文件中修改字節碼。

<plugin>
    <groupId>com.vlkan</groupId>
    <artifactId>quasar-maven-plugin</artifactId>
    <version>0.7.9</version>
    <executions>
        <execution>
            <goals>
                <goal>instrument</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Quasar通過識別方法是否拋出了該框架定義的SuspendExecution異常決定是否修改字節碼。Quasar框架在AsyncCompletionStage.get方法上聲明瞭SuspendExceution異常,該異常是捕獲異常,但僅作爲識別掛起方法的聲明,在運行時不會實際拋出。使用者必須逐層拋出該異常直至新建協程的一層。當方法內部存在try/catch語句時,也必須拋出該異常。

public void startFiber() throws ExecutionException, InterruptedException {
    Fiber<Void> fiber = new Fiber<Void>(() -> {
        //不用繼續拋出異常
        Response response = waitNextLayer1();
        deal(response);
    }).start();
}

private Response waitNextLayer1() throws SuspendExecution {
    return waitNextLayer2();
}

private Response waitNextLayer2() throws SuspendExecution {
       CompletableFuture<Response> future = httpClient.executeRequest(request)
.toCompletableFuture();
    try {
        // Quasar框架工具類拋出SuspendExecution
        return AsyncCompletionStage.get(future);
    } catch (Exception e) {
        return null;
    }
}

2.3 異步RPC調用

目前主流的RPC框架都基於NIO實現,支持異步回調,有的RPC框架已經直接提供了返回CompletableFuture或ListenableFuture(Guava工具類提供)的異步接口,通過使用ComplatableFuture,可以按前文類似的方法將Quasar與RPC框架結合起來。當RPC框架沒有該返回類型時,一般會提供如下類似的帶泛型的異步回調接口:

interface Callback<TResponse> {
    void callback(TResponse TResponse, Exception e);
}

這種情況,可以使用者自己創建ComplatableFuture,在回調中設置其狀態,並調用AsyncCompletionStage.get等待這個future。

CompletableFuture<Response> future=new CompletableFuture<>();
//調用hello接口的異步API
new RpcClient().helloAsync(request, new Callback<Response>() {
    public void callback(Response response, Exception e) {
        if (e == null) future.complete(response);
        else future.completeExceptionally(e);
    }
});
//在此處調用Quasar的API,掛起直至RPC調用完成
Response response = AsyncCompletionStage.get(future);

上述代碼依然具有異步回調不直觀的缺點,通過JDK8的函數式接口可以實現一個通用的調用模板,將異步回調變爲同步等待的形式。

@FunctionalInterface
private interface RpcAsyncCall<TRequest, TResponse> {
    void request(TRequest request, Callback<TResponse> callback);
}
public <TRequest, TResponse> TResponse waitRpc(RpcAsyncCall<TRequest, TResponse> call, TRequest request) throws SuspendExecution {
    CompletableFuture<TResponse> future = new CompletableFuture<>();

    call.request(request, (response, e) -> {
        if (e == null) future.complete(response);
        else future.completeExceptionally(e);
    });

    try {
  //使用Quasar等待Future結果
        return AsyncCompletionStage.get(future);
    } catch (Exception e) {
        return null;
    }
}

最後的調用可簡化一行代碼,該方法適用於所有該Rpc框架提供的異步接口。

Response response= waitRpc(new RpcClient()::helloAsync, request);

2.4 阻塞操作的處理

Quasar協程使用的時候有一定的限制,由於調度器線程池大小固定,在協程中不能阻塞線程,執行線程將被佔用。對於某些暫時只能依靠阻塞IO的調用,如數據庫,消息隊列等,無法使用協程等待其結果,當這些阻塞操作量不大的情況下,可使用另一個可伸縮的線程池等待結果,避免對協程調度器的影響。

public void waitBlocking() throws SuspendExecution {
    //從DB獲取結果
    String ans = waitBlocking(this::selectFromDB);
}

private ExecutorService threadPool = Executors.newCachedThreadPool();

private <T> T waitBlocking(Supplier<T> supplier) throws SuspendExecution {
    CompletableFuture<T> future = new CompletableFuture<>();
    threadPool.submit(() -> {
        T ans = supplier.get();
        future.complete(ans);
    });

    try {
        return AsyncCompletionStage.get(future);
    } catch (Exception e) {
        return null;
    }
}

2.5 併發工具的使用

協程對併發鎖的使用有比較大的限制,需要使用者理解線程鎖與協程的調度機制。在synchronized同步塊的內部,不能包含掛起協程的語句。當持有鎖的協程掛起後會讓出線程資源,由於鎖的可重入性,另一個運行在同一個線程上的協程再加鎖時同樣會成功。另一方面,協程掛起後恢復執行時,也可能會在另一個線程上運行。出現兩個線程操作共享資源的異常。同時未持有鎖的線程釋放時,會出現IllegalMonitorStateException異常。

但如果同步塊的內部沒有掛起協程的語句,則線程鎖的機制仍然有效。線程的在執行過程中可能切換,而協程的調度在每個執行線程上是串行的,協程持有的鎖在不包含掛起操作時,會在佔用線程執行完畢直到退出同步塊爲止,不會發生鎖失效的情況。

JDK併發包中的工具可分爲兩類,一類是Lock、Semaphore、CountDownLatch等具有線程可重入性的工具,不能在未釋放資源前使用掛起協程的操作,而另一類則是原子變量、併發容器等不會讓出線程的工具,仍可正常使用,但要注意高併發的情況下鎖的性能。此外,在使用併發工具的阻塞方法,如await時,可能導致協程的執行線程中發生阻塞。

三、總結

系統運行在4核心的主機上,線程池構成如下。

業務邏輯運行在Quasar的協程調度線程池中,線程池大小爲CPU核數。HTTP請求與RPC調用均通過內部的NIO線程池管理。此外定義了一個core size爲8的可伸縮的線程池用於少量消息隊列、DB等阻塞IO的操作。其餘的線程是系統中引入的其他組件所新建的線程,正常情況下不會成爲系統性能的瓶頸。

改造後,在業務高峯流量激增數十倍的情況下線程數量依然穩定,而CPU利用率也從平均5%以下提升至10%-60%,在瞬時與高峯流量下能保持穩定。集羣CPU核數在保留一定的業務冗餘以應對業務高峯的情況下,縮減至1/5。

3.1 限制與風險

Quasar協程不是Java的語言標準,沒有JVM層面的支持,使用時必須手動拋出異常聲明每一個掛起方法,對代碼有一定的侵入性。使用不當時,可能出現異常。

代碼的try/catch時可能同時捕獲SuspendExecution異常,從而忘記標記方法,此方法字節碼不會被修改,結合Quasar的原理不難看出,當沒有織入字節碼時,掛起方法恢復執行,無法還原方法棧幀和執行狀態,將會出現語句被重複執行、空指針等錯誤。運行時空指針、死循環的症狀,排查的重點是是否漏加SuspendExecution標記。

在新線程而不是新協程中使用掛起方法時,會出現同樣的問題。Thread的構造方法中傳入的是Runnable接口對象,其run方法沒有聲明SuspendExecution異常,run內部的語句不會被織入字節碼,造成上述異常。

3.2 總結與展望

協程使得NIO能夠更好地應用在Java中,比回調方法更易讀易維護。對系統的改造集中在底層通信封裝和對方法的標記上,業務邏輯無需修改。雖然具有一定的代碼侵入性和理解成本,但這種學習成本能逐漸被代碼的可維護性優勢抵消。

異步編程最佳的實現方式是:“Codes Like Sync,Works Like Async”,即以同步的方式編碼,達到異步的效果與性能,兼顧可維護性與可伸縮性。OpenJDK 在2018年創建了Loom 項目(https://wiki.openjdk.java.net/display/loom),目標是在JVM上實現輕量級的線程,並解除JVM線程與內核線程的映射。相信會給Java生態帶來巨大的改變。

作者介紹

Ryan,攜程Java開發工程師,對高併發、網絡編程等領域有濃厚興趣。

本文轉載自公衆號攜程技術(ID:ctriptech)。

原文鏈接

攜程基於Quasar協程的NIO實踐

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章