SIer だけど技術やりたいブログ

Spring WebFlux の概要を理解する

Spring reactive

Spring WebFluxに関する、いまの理解を整理する。

Spring WebFlux 以前の問題点

Tomcatは1リクエスト処理するのに1スレッドを割り当てる。リクエストが終わるまでスレッドは解放されないため、処理にブロッキングな部分があったとしてもスレッドを占有し続ける

ネットワークI/O、外部サービス呼び出し、DBアクセスなどが典型的なブロッキング箇所で、例えば、低速なネットワークからのリクエストをひとたびreadすると、のんきにスレッドを占有してリクエストの到着を待つことになる。

このとき、同時に多くのリクエストを捌くためには多くのスレッドが必要になる。

Java ブロッキングとかノンブロッキングを理解したい - SIerだけど技術やりたいブログwww.kimullaa.com

ただしスレッド生成には(少なくともスレッドごとに確保するスタック領域分の)メモリが必要。
参考: Javaはどのように動くのか~図解でわかるJVMの仕組み
参考: [調査]JVMのスタックサイズについて

そのため、スレッドを増やせば増やすほど、メモリが必要になる。

また、CPUが実行するスレッドを切り替えるときに、コンテキストスイッチが必要になる。

Javaスレッドでコンテキストスイッチ?

Linux上で動作するJVMの場合、基本的にJavaスレッドはLinuxのネイティブスレッド(NPTL)を利用する。
参考: 【新・言語進化論】プロの言語仕様の読み方

以下、確認してみる。

スレッドを止めるだけのコード。

import java.util.concurrent.*;

public class Main {
  public static void main(String[] args)  throws Exception {
    TimeUnit.MINUTES.sleep(10);
  }
}

上記コードを実行し、jcmdでJava上のスレッドを表示する。

]# jcmd 4368 Thread.print | grep nid
"Attach Listener" #8 daemon prio=9 os_prio=0 tid=0x00007f4924000b70 nid=0x1143 waiting on condition [0x0000000000000000]
"Service Thread" #7 daemon prio=9 os_prio=0 tid=0x00007f494c0c3640 nid=0x1118 runnable [0x0000000000000000]
"C1 CompilerThread1" #6 daemon prio=9 os_prio=0 tid=0x00007f494c0b4ba0 nid=0x1117 waiting on condition [0x0000000000000000]
"C2 CompilerThread0" #5 daemon prio=9 os_prio=0 tid=0x00007f494c0b3120 nid=0x1116 waiting on condition [0x0000000000000000]
"Signal Dispatcher" #4 daemon prio=9 os_prio=0 tid=0x00007f494c0b18c0 nid=0x1115 runnable [0x0000000000000000]
"Finalizer" #3 daemon prio=8 os_prio=0 tid=0x00007f494c088260 nid=0x1114 in Object.wait() [0x00007f4951243000]
"Reference Handler" #2 daemon prio=10 os_prio=0 tid=0x00007f494c0841b0 nid=0x1113 in Object.wait() [0x00007f4951344000]
"main" #1 prio=5 os_prio=0 tid=0x00007f494c009330 nid=0x1111 waiting on condition [0x00007f49558fe000]
"VM Thread" os_prio=0 tid=0x00007f494c07ad70 nid=0x1112 runnable
"VM Periodic Task Thread" os_prio=0 tid=0x00007f494c01b260 nid=0x1119 waiting on condition

また、Linux上のスレッドを表示する。

]# ps  -efL | grep -E "java|PID"
UID         PID   PPID    LWP  C NLWP STIME TTY          TIME CMD
root       4368   3607   4368  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4369  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4370  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4371  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4372  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4373  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4374  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4375  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4376  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4377  0   11 09:26 pts/0    00:00:00 java Main
root       4368   3607   4419  0   11 09:26 pts/0    00:00:00 java Main
root       4432   3770   4432  0    1 09:26 pts/1    00:00:00 grep --color=auto -E java|PID

ポイント
jcmdのnid(16進数)がpsのLWP(10進数)と一致しており、JavaのスレッドはLinuxのNPTLを利用しているとわかる。

また、(プロセス切り替えと比較するとコストは低いけど)Linuxのスレッド切り替えでもコンテキストスイッチは発生する。
参考: マルチスレッドのコンテキスト切り替えに伴うコスト

したがって、Javaのスレッド切り替え時にはコンテキストスイッチが発生する

Spring WebFluxで課題をどう解決するか?

少ないスレッドで多くのリクエストを捌けるように、イベントループモデルを取り入れる。

Servletを利用する場合は具体例には、

  • Servlet 3.0のAsync Requests を利用して、ハンドラ(@Controller)を実行するスレッドとTomcatのworkerスレッドを分ける
  • Servlet 3.1のnon-blocking I/O を利用して、ネットワークI/Oをノンブロッキングに行う
  • ハンドラ(@Controller)は、Reactive Streamsを利用してノンブロッキングな非同期の実行環境上で処理する

また、Nettyなどのノンブロッキングサーバも利用できる。 HTTP/Reactive Streams の層で、 HttpHandler というクラスが Servlet のAPIを使うか、NettyのAPIを使うかを隠蔽している様子。

image

参考: HttpHandler Javadoc 参考: Spring Framework リファレンス

Spring MVC と Spring WebFlux を比較する

下記の記事の劣化コピーです。 下記の記事のほうが試験バリエーションが多いので、併せて確認してください。
参考: SpringBoot2のBlocking Web vs Reactive WebについてLTしてきた

自分のほうにしか書いてない情報もあるので、いちおう公開します。

検証環境

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.0.1.RELEASE</version>
  <relativePath/> <!-- lookup parent from repository -->
</parent>
$ cat /etc/redhat-release
CentOS Linux release 7.1.1503 (Core)
$ java -version
openjdk version "1.8.0_161"
OpenJDK Runtime Environment (build 1.8.0_161-b14)
OpenJDK 64-Bit Server VM (build 25.161-b14, mixed mode)

シナリオ

  • 下記のアプリケーションに、 200 req/s の負荷を60秒かける。
  • クライアントはアプリケーションにリクエストを投げる
  • アプリケーションは外部サービスにリクエストを投げる
  • 外部サービスは1000ms待機したあとにレスポンスを返す
  • 外部サービスからレスポンスを受け取ったアプリケーションは、クライアントに結果をそのまま返す

実装の違い

Spring MVC

@RestController
@SpringBootApplication
public class BlockingAppApplication {
  RestTemplate restTemplate = new RestTemplate();

  @GetMapping("blocking")
  public String blocking() {
    return restTemplate.getForObject("http://localhost:8081/slow", String.class);
  }

  public static void main(String[] args) {
    SpringApplication.run(BlockingAppApplication.class, args);
  }
}

Spring WebFlux

@SpringBootApplication
@RestController
public class NonBlockingAppApplication {
  WebClient client = WebClient.create("http://localhost:8081");
  
  @GetMapping("nonblocking")
  public Mono<String> nonblocking() {
    return client.get().uri("/slow")
      .exchange()
      .flatMap(clientResponse -> clientResponse.bodyToMono(String.class));
  }
  
  public static void main(String[] args) {
    SpringApplication.run(NonBlockingAppApplication.class, args);
  }
}

1000ms遅延するアプリケーション

@RestController
@SpringBootApplication
public class SlowAppApplication {
  @GetMapping("slow")
  public String slow() {
    try {
      TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    return "ok";
  }

  public static void main(String[] args) {
    SpringApplication.run(SlowAppApplication.class, args);
  }
}

負荷をかける準備をする

Linuxでは同時に処理するリクエスト(Socket)ごとにfdが作られるため、オープンできるfdの最大値を上げる。

$ ulimit -n 50000

またアプリケーション起動時に、max-threadsを上げる。SpringBoot のデフォルトは200のため、同時に200リクエストしか処理できない。今回のシナリオは200req/s だから必要なさそうに思ったけど、外部サービスが1000ms遅延してレスポンスを返すため、すぐに200リクエスト以上たまるので上限を上げる。

$ java -jar xxx.jar --server.tomcat.max-threads=50000

また、スレッドが利用したメモリ量を表示するために、起動引数に -XX:NativeMemoryTracking=summary を付与する。
参考: 2.7 ネイティブ・メモリー・トラッキング

$ java -XX:NativeMemoryTracking=summary  -jar xxx.jar --server.tomcat.max-threads=50000

負荷をかける

Gatlingを利用して負荷をかける。

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class BasicSimulation extends Simulation {

  val httpConf = http
    .baseURL("http://192.168.11.116:8080")
    .acceptHeader("application/json")

    val scn = scenario("request")
    .exec(http("request")
        .get("/blocking"))

  setUp(scn.inject( constantUsersPerSec(200) during (60 seconds) ).protocols(httpConf))
}

レスポンスタイムの違い

Spring MVC

Spring WebFlux

旧来のSpring MVC と Spring WebFlux でレスポンスタイムに大きな差はない

利用するスレッド数の違い

Spring MVC

多くのスレッドを生成している。

Spring WebFlux

比較的少ないスレッドで動作している。

スレッドの使い方の違い

Spring MVC

旧来の Spring MVC は、リクエストを投げている間もスレッドを止めて待つ。また、ネットワークI/Oへの書き込みも、スレッドを止めて待つ。つまり、下記の流れ全てを、1スレッドを占有して行う。

Spring WebFlux

Spring WebFlux は下記スライドの36pのような動きになる。 外部APIからレスポンスが到着したら、onNext処理が呼ばれてクライアントにレスポンスが返る。

スライド: Servlet or Reactive Stacks: The Choice is Yours. Oh No…The Choice is Mine!

スレッドが利用するメモリ量の違い

スレッドとしてのネイティブメモリの利用量は、スレッド数に比例する。

Spring MVC

$  jcmd 19861 VM.native_memory | grep -A 10 Thread
...
-  Thread (reserved=318453KB, committed=318453KB)
          (thread #310)
          (stack: reserved=317652KB, committed=317652KB)
          (malloc=439KB #1547)
          (arena=362KB #618)

Spring WebFlux

$ jcmd 48347 VM.native_memory | grep -A 10 Thread
-  Thread (reserved=66988KB, committed=66988KB)
          (thread #66)
          (stack: reserved=66820KB, committed=66820KB)
          (malloc=92KB #327)
          (arena=76KB #130)

1スレッドごとに利用するメモリ量

ThreadStackSizeがスレッドごとに割り当てるメモリ量。デフォルト1MB。

java -XX:+PrintFlagsFinal  | grep ThreadStackSize
     intx CompilerThreadStackSize = 0    {pd product}
     intx ThreadStackSize         = 1024 {pd product}
     intx VMThreadStackSize       = 1024 {pd product}

結論

少ないスレッドで効率的にリクエストをさばけるのが Spring WebFlux。

注意すべきは、処理自体を細かく分割して並列に処理するわけではないということ。CPU負荷の高い処理は重たいままだし、I/O自体が早くなるわけでもない。(ただしブロッキングに待たなくていい)

処理を高速にさばくための仕組みではなくて、無駄なく1CPUやメモリを使いきりましょう、リソース追加したときにアプリケーションがリソースを使い倒せるようにしましょう、ということだと理解した。

ただし、Reactive Streams(とその実装のReactor)を導入したことで、非同期の処理がわりと気軽に(私には難しいですが)書けるようになった。そのため、同期的に書いていた部分を非同期に書き直せば、待ち時間のトータルが減って処理全体が早くなる可能性はあるかもしれない。

似たようなことがリファレンスにも書いてた。

Performance has many characteristics and meanings. Reactive and non-blocking generally do not make applications run faster. They can, in some cases, for example if using the WebClient to execute remote calls in parallel. On the whole it requires more work to do things the non-blocking way and that can increase slightly the required processing time.

The key expected benefit of reactive and non-blocking is the ability to scale with a small, fixed number of threads and less memory. That makes applications more resilient under load because they scale in a more predictable way. In order to observe those benefits however you need to have some latency including a mix of slow and unpredictable network I/O. That’s where the reactive stack begins to show its strengths and the differences can be dramatic.

なお、近年では project Loom で Java ランタイム上の軽量スレッド(グリーンスレッド)の仕組みが検討されている。1スレッド当たりのコストが下がれば、 Spring webflux のようなケチケチした仕組みを利用せずとも良い世界が来るかもしれない。
Loom Proposal.md

参考

もっと参考

Spring One Platform の発表動画はほとんどが再生数 100-500 位で弱小youtuberみたいになってますが、めちゃくちゃ勉強になるのでおススメです。


  1. CPUやメモリに無駄が生まれやすいのは、1コネクション中のブロッキングが多いアプリケーション(例えば、ネットワークのレイテンシが低かったり、常時接続するアプリ等)