INTRODUCTION
「非同期って何をしているの?」
まず、 普通のJavaプログラムを考えてみます。
step1();
step2();
step3();
基本的には、
step1()
が終わってから、
step2()、
その後に
step3()
が実行されます。
これは、 同期的な処理 と考えることができます。
step1
↓
step2
↓
step3
一方で、 ある処理を別のスレッドで実行して、 元の処理を先に進めることもできます。
メイン処理
↓
処理Aを開始
↓
メイン処理は次へ
└─ 処理Aは別で実行中
非同期処理は「処理を待たずに先へ進める」ための考え方
01
まずは同期処理を理解する
非同期処理を理解するには、 まず同期処理との違いを見るのが分かりやすいです。
System.out.println("A");
doSomething();
System.out.println("B");
この場合、
doSomething()
が終わるまで、
"B"
は出力されません。
これは悪いことではありません。
むしろ、 処理の順番が明確なので、 通常の業務処理では同期処理が基本になることも多いです。
非同期にすれば必ず速くなるわけではない
ここは意外と重要です。
非同期処理は、 「待っている時間を有効に使う」 「複数の独立した処理を並行して進める」 といった場面で効果を発揮します。
02
Thread。処理を別のスレッドで動かす
Javaには、
処理を別スレッドで実行するための
Thread
があります。
Thread thread = new Thread(() -> {
System.out.println("別スレッドで実行");
});
thread.start();
ここで重要なのは、
run()
ではなく、
start()
を呼んでいることです。
ただし、
実際の業務システムで
new Thread()
を大量に書くことは、
あまり一般的ではありません。
スレッドを自分で管理するのではなく、 スレッドを管理してくれる仕組みを利用することが多くなります。
03
ExecutorService。スレッドを管理する
複数の処理を非同期で実行したい場合、
ExecutorService
を使う方法があります。
ExecutorService executor =
Executors.newFixedThreadPool(3);
executor.submit(() -> {
System.out.println("処理を実行");
});
executor.shutdown();
ここでは、 自分でスレッドを作るのではなく、 Executorに処理を渡しています。
ExecutorService
↓
スレッドを管理
↓
渡された処理を実行
実際の業務コードを読むときは、
「このExecutorは何個くらいの処理を並行して動かすためのものか?」
という視点を持つと理解しやすくなります。
スレッドを増やせば、
必ず処理性能が上がるわけではありません。
CPU、メモリ、DB接続数、
外部APIなどとの関係もあるため、
実際のシステムでは適切な設計が必要です。
04
Future。非同期処理の結果を受け取る
非同期処理を開始したあと、 「その処理の結果が欲しい」 という場面があります。
そこで登場するのが
Future
です。
Future<Integer> future =
executor.submit(() -> {
return 100;
});
Integer result = future.get();
submit()
で処理を開始し、
Future
を受け取ります。
そして、
get()
で結果を取得します。
非同期処理を開始
↓
Futureを受け取る
↓
処理が終わる
↓
get()で結果を取得
ただし、
get()
を呼んだ時点で結果がまだ出ていなければ、
その場で待つことになります。
非同期で開始しても、結果を待つ場所では待つ
この感覚は、 非同期処理を理解するうえでかなり重要です。
05
CompletableFuture。非同期処理をつなげる
Javaの業務コードで、
特に目にすることがあるのが
CompletableFuture
です。
Futureだけでは、 「処理が終わったら次の処理をする」 という流れを扱いにくい場合があります。
CompletableFutureでは、 非同期処理の後続処理をつなげて書くことができます。
CompletableFuture
.supplyAsync(() -> {
return "Hello";
})
.thenApply(value -> {
return value + " Java";
})
.thenAccept(value -> {
System.out.println(value);
});
処理の流れは、
supplyAsync
↓
"Hello"
↓ thenApply
"Hello Java"
↓ thenAccept
出力
つまり、
CompletableFuture
は、
「非同期処理と、その後の処理をつなげて表現する」
ための仕組みとして見ると分かりやすいです。
06
supplyAsyncとrunAsyncの違い
CompletableFuture
には、
supplyAsync()
と
runAsync()
があります。
supplyAsync()
は、
非同期処理の結果を後続処理へ渡したい場合に使います。
CompletableFuture<User> future =
CompletableFuture.supplyAsync(() -> {
return findUser();
});
この場合、
findUser()
の戻り値である
User
が、
CompletableFuture<User>
として扱われます。
一方、
runAsync()
は戻り値を必要としない処理を
非同期で実行する場合に使います。
CompletableFuture.runAsync(() -> {
writeLog();
});
まずは、
戻り値が必要 → supplyAsync
戻り値が不要 → runAsync
と覚えておけば十分です。
07
thenApply。前の処理の結果を変換する
thenApply()
は、
前の処理の結果を受け取り、
別の値へ変換するときに使います。
CompletableFuture<String> future =
CompletableFuture
.supplyAsync(() -> "100")
.thenApply(value -> {
return value + "円";
});
ここでは、
"100"
を受け取って、
"100円"
に変換しています。
これは、
Streamの
map()
と少し似た感覚で捉えることもできます。
Streamでは、
データを変換するために
map()
を使いました。
CompletableFutureでは、
前の非同期処理の結果を変換するために
thenApply()
を使います。
08
複数の非同期処理を組み合わせる
実際のシステムでは、 一つの処理だけを非同期で実行するとは限りません。
例えば、 「ユーザー情報」と「注文情報」を 別々に取得したい場合があります。
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> {
return findUser();
});
CompletableFuture<Order> orderFuture =
CompletableFuture.supplyAsync(() -> {
return findOrder();
});
それぞれ独立した処理であれば、 並行して進めることができます。
userFuture
↓
ユーザー情報取得
orderFuture
↓
注文情報取得
↓
両方の結果を利用
こうした複数処理の組み合わせでは、
thenCombine()
などが利用できます。
userFuture.thenCombine(
orderFuture,
(user, order) -> {
return createResult(user, order);
}
);
ここで重要なのは、 メソッド名を暗記することではありません。
「この2つの非同期処理が終わったあと、何をするのか?」
という処理の流れを見ることです。
09
非同期処理のエラーをどう扱うのか
非同期処理でも、 もちろんエラーは発生します。
CompletableFuture
では、
exceptionally()
などを使って
エラー時の処理をつなげることができます。
CompletableFuture
.supplyAsync(() -> {
return callApi();
})
.exceptionally(e -> {
System.out.println("エラー発生");
return null;
});
通常の処理だけを見ると、 非同期処理は簡単に見えます。
しかし実際の業務システムでは、 むしろ、
「失敗した場合にどうなるのか?」
を確認することが重要です。
非同期処理を見つけたら、
成功時の処理だけでなく、
・例外をどこで処理しているか
・失敗した場合に何を返すか
・呼び出し元がどう判断するか
まで確認すると安全です。
10
allOf。複数の処理が終わるのを待つ
複数の非同期処理を開始したあと、 「全部終わってから次へ進みたい」 というケースがあります。
そのような場合に、
CompletableFuture.allOf()
を利用できます。
CompletableFuture<Void> all =
CompletableFuture.allOf(
userFuture,
orderFuture
);
all.thenRun(() -> {
System.out.println("すべて完了");
});
userFuture ──┐
orderFuture ─┼→ allOf → 次の処理
productFuture ─┘
つまり、 複数の処理を並行して開始し、 全部終わったことを確認してから 次の処理へ進む、 という流れを作ることができます。
FIELD NOTE
CompletableFutureを見たら、まず処理の流れを追う
実際の業務コードでは、
CompletableFuture
が何段にもつながっていることがあります。
CompletableFuture
.supplyAsync(() -> findUser())
.thenApply(user -> createRequest(user))
.thenApply(request -> callApi(request))
.thenAccept(result -> saveResult(result))
.exceptionally(e -> {
logError(e);
return null;
});
最初は、 これを一気に理解しようとしなくて大丈夫です。
supplyAsync
→ 最初に何を取得・実行している?
thenApply
→ 結果を何に変換している?
thenAccept
→ 最終的に何をしている?
exceptionally
→ エラー時はどうなる?
Streamの記事でも、 「処理を一つずつ追う」 という考え方を紹介しました。
非同期処理も同じです。
「次に何が実行されるのか?」を追えばいい
メソッド名を全部暗記するより、 処理の流れを理解することの方が重要です。
SUMMARY
今回のポイント
Javaの非同期処理は、 単純に「別スレッドで動かす」という話だけではありません。
実際には、
同期処理
→ 処理が終わるまで待つ
Thread
→ 別スレッドで処理を実行する
ExecutorService
→ スレッドを管理して処理を実行する
Future
→ 非同期処理の結果を受け取る
CompletableFuture
→ 非同期処理と後続処理をつなげる
そして、
CompletableFuture
を読むときは、
最初から、 すべてのAPIを覚える必要はありません。
まずは、
「どの処理を非同期で動かして、次に何をしているのか?」
という視点を持つこと。
これだけでも、
実際の業務システムで見かける
CompletableFuture
のコードは、かなり読みやすくなります。
非同期処理は、 「難しい構文を覚える」よりも、 処理の流れと待つ場所を理解する ことが大切です。