Java 異步編程:從 Future 到 Loom

原文鏈接:https://www.jianshu.com/p/5db701a764cb

Java 異步編程:從 Future 到 Loom

    本文對我們瞭解異步編程有很好的指導性,稍長,希望大家耐心閱讀。

    衆所周知,Java 開始方法執行到結束,都是由同一個線程完成的。這種方式雖易於開發調試,但容易因爲鎖、IO 等原因導致線程掛起,產生線程上下文切換。隨着對應用併發能力要求越來越高,頻繁的線程上下文切換所帶來的成本變得難以忽視。同時,線程也是相對寶貴的資源,無限制的增加線程是不可能的。優秀的技術人員應該能讓應用使用更少的線程資源實現更高的併發能力。這便是我們今天要討論的話題 —— Java 異步編程技術。

    異步編程其實並沒有清晰定義。通常我們認爲,從方法開始到結束都必須在同一個線程內調度執行的編程方式可被認爲是同步編程方式。但因爲這樣的方式是我們習以爲常的,所以也就沒有專門名字去稱呼它。與這種同步方式相對的,便是異步。即方法的開始到結束可以由不同的線程調度執行的編程方式,被成爲異步編程。

    異步編程技術目的,重點並非提高併發能力,而是提高伸縮性 (Scalability)。現在的 Web 服務,應付 QPS 幾百上千,甚至上萬的場景並沒有太大問題,但問題是如何在併發請求量突增的場景中提供穩定服務呢?如果一個應用能穩定提供 QPS 1000的服務。假如在某一個大促活動中,這個應用的 QPS 突然增加到10000怎麼辦?或者 QPS 沒變,但這個應用所依賴的服務發生故障,或網絡超時。當這些情況發生時,服務還能穩定提供嗎?雖然熔斷、限流等技術能夠解決這種場景下服務的可用性問題,但這畢竟是一種捨車保帥的做法。是否能在流量突增時仍保證服務質量呢?答案是肯定的,那就是異步編程 + NIO。NIO 技術本身現在已經很成熟了,關鍵是用一種什麼樣的異步編程技術將 NIO 落地到系統,尤其是業務快速迭代的前臺、中臺系統中。

    這就是本文討論 Java 異步編程的原因。Java 應用開發領域究竟有哪些技術可以用來提升系統的伸縮性?本文將按照這些技術的演化歷程,介紹一下這些技術的意義和演化過程:

  • Future
  • Callback
  • Servlet 3.0
  • Reactive響應式編程
  • Kotlin 協程
  • Project Loom

一、Future

    J.U.C 中的 Future 算是 Java 對異步編程的第一個解決方案。當向線程池 submit 一個任務後,這個任務便被另一個線程執行了:

Future future = threadPool.submit(() -> {
  foobar();
  return result;
});

Object result = future.get();

但這個解決方案有很多缺陷:

無法方便得知任務何時完成
無法方便獲得任務結果
在主線程獲得任務結果會導致主線程阻塞

二、Callback

    爲了解決使用 Future 所存在的問題,人們提出了一個叫 Callback 的解決方案。比如 Google Guava 包中的 ListenableFuture 就是基於此實現的:

ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10));
ListenableFuture<Explosion> explosion = service.submit(new Callable<Explosion>() {
  public Explosion call() {
    return pushBigRedButton();
  }
});
Futures.addCallback(explosion, new FutureCallback<Explosion>() {
  // we want this handler to run immediately after we push the big red button!
  public void onSuccess(Explosion explosion) {
    walkAwayFrom(explosion);
  }
  public void onFailure(Throwable thrown) {
    battleArchNemesis(); // escaped the explosion!
  }
});

    通過執行 ListenableFuture<Explosion> explosion = service.submit(new Callable<Explosion>() {}) 創建異步任務。通過 Futures.addCallback(explosion, new FutureCallback<Explosion>() {} 添加處理結果的回調函數。這樣避免獲取並處理異步任務執行結果阻塞調起線程的問題。Callback 是將任務執行結果作爲接口的入參,在任務完成時回調 Callback 接口,執行後續任務,從而解決純 Future 方案無法方便獲得任務執行結果的問題。

    但 Callback 產生了新的問題,那就是代碼可讀性的問題。因爲使用 Callback 之後,代碼的字面形式和其所表達的業務含義不匹配,即業務的先後關係到了代碼層面變成了包含和被包含的關係。

    因此,如果大量使用 Callback 機制,將使大量的應該是先後的業務邏輯在代碼形式上表現爲層層嵌套。這會導致代碼難以理解和維護。這便是所謂的 Callback Hell(回調地獄)問題。

    Callback Hell 問題可以從兩個方向進行一定的解決:一是事件驅動機制、二是鏈式調用。前者被如 Vert.x 所使用,後者被 CompletableFuture、反應式編程等技術採用。但這些優化的效果有限,不能根本上解決 Callback 機制所帶來的代碼可維護性的下降。

Callback 與 NIO

    Callback 真正體現價值,是它與 NIO 技術結合之後。原因也很簡單:對於 CPU 密集型應用,採用 Callback 風格沒有意義;對於 IO 密集型應用,如果是使用 BIO,Callback 同樣沒有意義,因爲最終會有一個線程是因爲 IO 而阻塞。而只有使用 NIO 才能避免線程阻塞,也必須使用 Callback 風格,才能使應用得以被開發出來。NIO 的廣泛應用是在 Apache Mina、JBoss Netty 等技術出現之後。這些技術很大程度地簡化了 NIO 技術的使用,但直接使用它們開發業務系統還是很繁瑣。

    下面看一個真實的例子。這個例子背後的完整應用的功能是將微軟 Exchange 服務接口(Exchange Web Service)轉換爲 Rest 風格的接口,下面這段代碼是這個應用的一部分。

public class EwsCalendarHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(final ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof HttpRequest) {
            final HttpRequest origReq = (HttpRequest) msg;

            HttpRequest request = translateRequest(origReq);

            if (backendChannel == null) {
                connectBackendFuture = connectBackend(ctx, StaticConfiguration.EXCHANGE_PORT);
                sendMessageAfterConnected(ctx, request);
            } else if (backendChannel.isActive()) {
                setHttpRequestToBackendHandler(request);
                sendObjectAndFlush(ctx, request);
            } else {
                sendMessageAfterConnected(ctx, request);
            }
        } else if (msg instanceof HttpContent) {
            HttpContent content = (HttpContent) msg;
            if (backendChannel == null || !backendChannel.isActive()) {
                sendMessageAfterConnected(ctx, content);
            } else {
                sendObjectAndFlush(ctx, content);
            }
        }
    }
    
    private void sendMessageAfterConnected(final ChannelHandlerContext ctx, final HttpObject message) {
        if (connectBackendFuture == null) {
            LOGGER.warn("next hop connect future is null, drop the message and return: {}", message);
            return;
        }

        connectBackendFuture.addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                ChannelFuture f = sendObjectAndFlush(ctx, message);
                if (f != null) {
                    f.addListener((future1) ->
                            backendChannel.attr(FIND_ITEM_START_ATTR_KEY).set(System.currentTimeMillis())
                    );
                }

            }
        });
    }
}

    在方法 sendMessageAfterConnected 中,我們已經能看到嵌套兩層的 Callback。而上面實例中的 EwsCalendarHandler 所實現的 ChannelInboundHandler 接口,本質上也是一個回調接口。

    其實上面的例子只有一級服務調用。在微服務流行的今天,多級服務調用很常見,一個服務先調 A,再用結果 A 調 B,然後用結果 B 調用 C,等等。這樣的場景,如果直接用 Netty 開發,技術難度會比傳統方式增加很多。這其中的難度來自兩方面,一是 NIO 和 Netty 本身的技術難度,二是 Callback 風格所導致的代碼理解和維護的困難。

    因此,直接使用 Netty,通常侷限在基礎架構層面,在前臺和中臺業務系統中,應用較少。

三、Servlet 3.0

    上面講到,如果直接使用 Netty 開發應用,將不可避免地遇到 Netty 和 NIO 本身的技術挑戰,以及 Callback Hell 問題。對於前者,Servlet 3.0 提供了一個解決方案。

▼ 示例:Servlet 3.0 ▼

@WebServlet(urlPatterns = "/demo", asyncSupported = true)
public class AsyncDemoServlet extends HttpServlet {
    @Override
    public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
        // Do Something
 
        AsyncContext ctx = req.startAsync();
        startAsyncTask(ctx);
    }
}
 
private void startAsyncTask(AsyncContext ctx) {
    requestRpcService(result -> {
        try {
            PrintWriter out = ctx.getResponse().getWriter();
            out.println(result);
            out.flush();
            ctx.complete();
        } catch (Exception e) {
            e.printStackTrace();
        }
    });
}

    Servlet 3.0 的出現,解決了在過去基於 Servlet 的 Web 應用中,接受請求和返回響應必須在同一個線程的問題,實現瞭如下目標:

可以避免了 Web 容器的線程被阻塞掛起
使請求接收之後的任務處理可由專門線程完成
不同任務可以實現線程池隔離
結合 NIO 技術實現更高效的 Web 服務
除了直接使用 Servlet 3.0,也可以選擇 Spring MVC 的 Deferred Result。

▼ 示例:Spring MVC DeferredResult ▼

@GetMapping("/async-deferredresult")
public DeferredResult<ResponseEntity<?>> handleReqDefResult(Model model) {
    LOG.info("Received async-deferredresult request");
    DeferredResult<ResponseEntity<?>> output = new DeferredResult<>();
     
    ForkJoinPool.commonPool().submit(() -> {
        LOG.info("Processing in separate thread");
        try {
            Thread.sleep(6000);
        } catch (InterruptedException e) {
        }
        output.setResult(ResponseEntity.ok("ok"));
    });
     
    LOG.info("servlet thread freed");
    return output;
}

Servlet 3.0 的技術侷限

    Servlet 3.0 並不是用來解決前面提到的 Callback Hell 問題的,它只是降低了異步 Web 編程的技術門檻。對於 Callback Hell 問題,使用 Servlet 3.0 或類似技術時同樣會遇到。解決 Callback Hell 還需另尋他法。

四、響(反)應式編程

    現在擋在異步編程最大的障礙就是 Callback Hell,因爲 Callback Hell 對代碼可讀性有很大殺傷力。而本節介紹的反應式編程技術,除了響應性、伸縮性、容錯性以外,從開發人員的角度來講,就是代碼可讀性要比 Callback 提升了許多。

▼ 圖:反應式編程的特性 ▼
在這裏插入圖片描述

▼ 反應式編程簡單示例 ▼

userService.getFavorites(userId)
           .flatMap(favoriteService::getDetails)
           .switchIfEmpty(suggestionService.getSuggestions())
           .take(5)
           .publishOn(UiUtils.uiThreadScheduler())
           .subscribe(uiList::show, UiUtils::errorPopup);

    可讀性的提高原因在於反應式編程可讓開發人員將實現業務的各種方法使用鏈式算子串聯起來,而串聯起來的各種方法的先後關係與執行順序大體一致。

    這其實是採用了函數式編程的設計,通過函數式編程解決了之前 Callback 設計存在的代碼可讀性問題。

    雖然相對於 Callback,代碼可讀性是反應式編程的優點,但這種優點是相對的,相對於傳統代碼,可讀性就成了反應式編程的缺點。上面的例子代碼看上去還容易理解,但換成下面的例子,大家就又能重新看到 Callback Hell 的影子了:

▼ 示例:查詢最近郵件數(反應式編程版) ▼

@GetMapping("/reactive/{personId}")
fun getMessagesFor(@PathVariable personId: String): Mono<String> {
  return peopleRepository.findById(personId)
      .switchIfEmpty(Mono.error(NoSuchElementException()))
      .flatMap { person ->
          auditRepository.findByEmail(person.email)
              .flatMap { lastLogin ->
                  messageRepository.countByMessageDateGreaterThanAndEmail(lastLogin.eventDate, person.email)
                      .map { numberOfMessages ->
                          "Hello ${person.name}, you have $numberOfMessages messages since ${lastLogin.eventDate}"
                      }
              }
      }
}

    因此,反應式編程只看代碼形式,可以被視爲 Callback 2.0。解決了之前的一些問題,但並不徹底。

    目前,在 Java 領域實現了反應式編程的技術有 Spring 的 Project Reactor、Netflix RxJava 1/2 等。前者的 3.0 版本作爲 Spring 5 的基礎,在17年底發佈,推動了後端領域反應式編程的發展。後者出現時間更早,在前端開發領域應用的比後端更要廣泛一些。

    除了開源框架,JDK 也提供了對反應式編程解決方案:JDK 8 的 CompletableFuture 不算是反應式編程,但是它在形式上帶有一些反應式編程的函數式代碼風格。JDK 9 Flow 實現了 Reactive Streams 規範,但是實施反應式編程需要完整的解決方案,單靠 Flow 是不夠的,還是需要 Project Reactor 這樣的完整解決方案。但 JDK 層面的技術能提供統一的技術抽象和實現,在統一技術方面還是有積極意義的。

反應式編程的應用範圍

    正如前面所說,反應式編程仍然存在代碼可讀性的問題,這個問題在加上反應式編程本身的技術門檻,使得用反應式編程技術在業務系統開發領域一直沒有流行普及。但是對於核心系統、底層系統,反應式編程技術所帶來的伸縮性、容錯性的提升同其增加的開發成本相比通常是可以接受。因此核心系統、底層系統是適合採用反應式編程技術的。

五、Kotlin 協程

    前面介紹的各種技術,都有明顯的缺陷:Future 不是真異步;Callback 可讀性差;Servlet 3.0 等技術沒能解決 Callback 的缺陷;反應式編程還是難以編寫複雜業務。到了18年,一種新的 JVM 編程語言開始流行:Kotlin。Kotlin 首先流行在 Android 開發領域,因爲它得到了 Google 的首肯和支持。但對於後端開發領域,因爲一項特性,使得 Kotlin 也非常值得注意。那就是 Kotlin Coroutine(後文稱 Kotlin 協程)。對於這項技術,我已經寫過三篇文章,分別介紹入門、原理和與 Spring Project Reactor 的整合方式。感興趣的同學可以去簡書和微信公衆號上去看這些文章(搜索“編走編想”)。

    協程技術不是什麼新技術,它在很多語言中都有實現,比如大家所熟悉的 Python、Lua、Go 都是支持協程的。在不同語言中,協程的實現方法各有不同。因爲 Kotlin 的運行依賴於 JVM,不能對 JVM 進行修改,因此,Kotlin 不能在底層支持協程。同時,Kotlin 是一門編程語言,需要在語言層面支持協程,而不是像框架那樣在語言層面之上支持。因此,Kotlin 對協程支持最核心的部分是在編譯器中。因爲對這部分原理的解釋在之前文章中都有涉及,因此不在這裏重複。

    使用 Kotlin 協程之後最大的好處是異步代碼的可讀性大大提高。如果上一個示例用 Kotlin 協程實現,那就是下面的樣子:

▼ 示例:查詢最近郵件數(Kotlin 協程版) ▼

@GetMapping("/coroutine/{personId}")
fun getNumberOfMessages(@PathVariable personId: String) = mono(Unconfined) {
    val person = peopleRepository.findById(personId).awaitFirstOrDefault(null)
            ?: throw NoSuchElementException("No person can be found by $personId")

    val lastLoginDate = auditRepository.findByEmail(person.email).awaitSingle().eventDate

    val numberOfMessages =
            messageRepository.countByMessageDateGreaterThanAndEmail(lastLoginDate, person.email).awaitSingle()

    "Hello ${person.name}, you have $numberOfMessages messages since $lastLoginDate"
}

    目前在 Spring 應用中使用 Kotlin 協程還有些小繁瑣,但在 Spring Boot 2.2 中,可以直接在 Spring WebFlux 方法上使用 suspend 關鍵字。

    Kotlin 協程最大的意義就是可以用看似指令式編程方式(Imperative Programming,即傳統編程方式)去寫異步編程代碼。併發和代碼可讀性似乎兩全其美了。

Kotlin 協程的侷限性

    但事情不是那麼完美。Kotlin 協程依賴於各種基於 Callback 的技術。像上面的例子,之所以可以用 Kotlin 協程,是因爲上一個版本使用了反應式編程技術。所以,只有當一段代碼使用了 ListenableFuture、CompletableFuture、Project Reactor、RxJava 等技術時,才能用 Kotlin 協程進行改造優化。那對於其它的會阻塞線程的技術,如 Object.wait、Thread.sleep、Lock、BIO 等,Kotlin 協程就無能爲力了。

    另外一個侷限性源於 Kotlin 本身。雖然 Kotlin 兼容 Java,但這種兼容並非完美。因此,對於組件,尤其是基礎組件的開發,並不推薦使用 Kotlin,而是更推薦使用 Java。這也導致 Kotlin 協程的使用範圍被進一步地限制。

六、Project Loom

    前面講到,雖然 Kotlin 協程看上去很好,但在使用上還是有着種種限制。那有沒有更好的選擇呢?答案是 Project Loom。這個項目在18年底的時候已經達到可初步演示的原型階段。不同於之前的方案,Project Loom 是從 JVM 層面對多線程技術進行徹底的改變。

    Project Loom 設計思想與之前的一個開源 Java 協程技術非常相似。這個技術就是 Quasar Fiber。而現在 Project Loom 的主要設計開發人員 Ron Pressler 就是來自 Quasar Fiber。

    這裏建議大家讀一下 Project Loom 的這篇文檔:Project Loom: Fibers and Continuations for the Java Virtual Machine。這篇文檔介紹了發起 Project Loom 的原因,以及 Java 線程基礎的很多底層設計。

    其實發起 Project Loom 的原因也很簡單:長期以來,Java 的線程是與操作系統的線程一一對應的,這限制了 Java 平臺併發能力的提升。各種框架或其它 JVM 編程語言的解決方案,都在使用場景上有限制。例如 Kotlin 協程必須基於各種 Callback 技術,而 Callback 技術有存在編寫、調試困難的問題。爲了使 Java 併發能力在更大範圍上得到提升,從底層進行改進便是必然。

    下面這幅圖很好地展示了目前 Java 併發編程方面的困境,簡單的代碼併發、伸縮能力差;併發、伸縮能力強的代碼複雜,難以與現有代碼整合。
在這裏插入圖片描述
爲了讓簡單和高併發這兩個目標兼得,我們需要 Project Loom 這個項目。

使用方法

    在引入 Project Loom 之後,JDK 將引入一個新類:java.lang.Fiber。此類與 java.lang.Thread 一起,都成爲了 java.lang.Strand 的子類。即線程變成了一個虛擬的概念,有兩種實現方法:Fiber 所表示的輕量線程和 Thread 所表示的傳統的重量級線程。

對於應用開發人員,使用 Project Loom 很簡單:

Fiber f = Fiber.schedule(() -> {
  println("Hello 1");
  lock.lock(); // 等待鎖不會掛起線程
  try {
      println("Hello 2");
  } finally {
      lock.unlock();
  }
  println("Hello 3");
})

    只需執行 Fiber.schedule(Runnable task) 就能在 Fiber 中執行任務。最重要的是,上面例子中的 lock.lock() 操作將不再掛起底層線程。除了 Lock 不再掛起線程以外,像 Socket BIO 操作也不再掛起線程。 但 synchronized,以及 Native 方法中線程掛起操作無法避免。

synchronized (monitor) {
  // 在 Fiber 中調用這條語句還是會掛起線程。
  socket.getInputStream().read();
}

    如上所示,Fiber 的使用非常簡單。因此,讓現有系統使用 Project Loom 很容易。像 Tomcat、Jetty 這樣的 Web 容器,只需將處理請求操作從使用 ThreadPoolExecutor execute 或 submit 改爲使用 Fiber schedule 即可。這個youtube視頻中的 Demo 展示了 Jetty 使用 Project Loom 改造之後併發吞吐能力的大幅提升。

實現原理

    接下來簡單介紹一下 Project Loom 的實現原理。Project Loom 的使用主要基於 Fiber,而實現則主要基於 Continuation。Contiuation 表示一個可暫停和恢復的計算單元。在 Project Loom 中,Continuationn 使用 java.lang.Continuation 類實現。這個類主要供類庫實現使用,而不是直接被應用開發人員使用。Continuation 主要內容如下所示:

package java.lang;

public class Continuation implements Runnable {
  public Continuation(ContinuationScope scope, Runnable target)
  
  public final void run()
  
  public static void yield(ContinuationScope scope)
  
  public boolean isDone()
}

    Continuation 實現了 Runnable 接口,構造時除了需要提供一個 Runnable 類型的參數以外,還需要提供一個 java.lang.ContinuationScope 的參數。ContinuationScope 顧名思義表示 Continuation 的範圍。Continuation 可以被想象成是一個方法執行過程,方法可以調用其它方法。同時,方法執行也有一定的影響範圍,如 try…catch 就規定了相應的範圍。ContinuationScope 就起到了起到了相應的作用。

    Continuation 有兩個最重要的方法:run 和 yield。run 方法首次被調用時,就會執行 Runnable target 的 run 方法。但是,在調用了 yield 方法後,再次調用 run 方法,Continuation 就不會從頭執行,而是從 yield 的位置開始執行。

爲了更形象的理解,下面看一個例子:

Continuation con = new Continuation(SCOPE, () -> {
  println("A");
  Continuation.yield(SCOPE);
  println("B");
  Continuation.yield(SCOPE);
  println("C");
});

con.run();
con.run();
con.run();

輸出結果:

A
B
C
    上面的例子非常簡單:創建一個 Continuation,其 Runnable target 打印 A、B、C,並在其中 yield 兩次。創建之後調用三次 run() 方法。如果這樣執行一個普通的 Runnable,那應該打印三次 A、B、C,一共打印九次。而 Continuation 在 yield 之後執行 run,會從 yield 的位置往後執行,而不是從頭開始。

Continuation yield 類似 Thread 的 yield,但前者需要顯式調用 run 方法恢復執行。

在 Project Loom 之後,LockSupport 的 park 操作將變爲:

public class LockSupport {
  var strand = Strands.currentStrand();
  if (strand instanceof Fiber) {
    Continuation.yield(FIBER_SCOPE);
  } else {
    Unsafe.park(false, 0L);
  }
}

七、展望
    Java 作爲使用率最高的編程軟件,在包括後端開發、手機應用開發、大數據等衆多領域均有廣泛應用。但畢竟是一門誕生20多年的編程語言,存在一些現在看來設計上的不足和受到後來者的挑戰都是正常。但必須說明,我們口中的 Java 並非一門單純的編程語言。而應該被視爲 Java 語言 + JVM + Java 類庫三部分組成。這三部分中,毫無疑問,JVM 是基礎。但 JVM 設計之初就並非和 Java 語言緊密綁定,緊密綁定的只是字節碼。由任何編程語言編譯得到的合法字節碼都能運行在 JVM 之上。這使得 Java 語言層面設計的不足可有其它編程語言解決,於是出現了 Groovy、Scala、Kotlin、Clojure 等衆多 JVM 語言。這些語言很大程度上彌補了 Java 的不足。

    但像多線程這樣的技術,由於和底層虛機和操作系統有千絲萬縷的聯繫,想要徹底改進,繞不開底層優化。這就是 Project Loom 出現的原因。相信 Project Loom 技術會將 Java 的併發能力提升至和 Golang 一樣的水平,而付出的成本只是對現有項目的少量改動。

    Azul 的 Deputy CTO Simon Ritter 曾透露 Project Loom 很可能在 Java 13 時發佈。 不過可惜Project Loom 未能趕上 Java 13這趟列車,好在 Java 14 的特性還未完全確定,說不定可以上末班車。

    就算 Project Loom 短期沒能出世,但目前反應式編程的趨勢也非常明顯。隨着新版本的 Spring 和 Kotlin 的發佈,反應式編程的使用、調試變得越來越簡單。Dubbo 也明確表示在 3.0 中將會支持 Project Reactor。R2DBC 在不久的未來也會支持 MySQL。因此,Java 異步編程將快速發展,在易用性方面迅速趕上 Go。

    另一方面,開發人員也不要將自己侷限在某種特定技術上,對各種技術都保持開放的態度是開發人員技能不斷提高的前提。只會簡單說某某語言、某某技術比其它技術更好的技術人員永遠不會成爲出色的技術人員。

原文作者:編走編想
鏈接:https://www.jianshu.com/p/5db701a764cb

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