2023/07/04

JJUG Java仕様勉強会 「Javaの並列/並行処理の基本」

このエントリーをはてなブックマークに追加

6/29にJJUGのJava資料勉強会で並列/並行処理についてプレゼンしてきました。資料はこちら。

 

Javaの並列/並行処理についてですが、Virtual Thread以前の仕様に関してまとめた感じです。

Javaの並列/並行処理のAPIはjava.util.concurrentパッケージ、いわゆるConcurrency Utilitiesにまとめられており、それほどいっぱいあるわけではないです。ただ、使う上でのノウハウ的なものがいっぱいあるので、言語仕様やAPIの仕様とは離れますが、そのノウハウ的なものもさわりだけ紹介しました。

 

Threadの基本

Javaで並列/並行処理を行う時に必ず使うことになるのが、Threadクラスです。

ThreadクラスはOSスレッドのラッパーであり、ThreadオブジェクトとOSスレッドは1対1に対応しています。ただし、Java 21で導入されるVirtual ThreadはJVMが管理する軽量スレッドなので、OSスレッドとは直接結びつきません。

このセッションでは、Virtual Threadについては触れないので、Threadと行った時はOSスレッドに結びついている従来のThreadをあつかいます。なお、この従来からあるThreadをVirtual Threadと区別するためにPlatform Threadと呼びます。

 

並列処理と並行処理という2つの言葉がありますが、Javaの世界からはこの2つは区別されません。使用できるCPUのコアが少なく、それに対しスレッドが多く存在する場合は並列(Concurrent)として実行されます。一方で、コアが十分にあるのであれば、並行(Parallel)として処理されます。

つまり、同じThreadクラスで書いておけば並列にも並行にも処理されることがあるということです。

そして、Threadオブジェクトに対して行う操作は、OSスレッドに対する操作になります。

 

Threadを使う上で意識しなければいけないのが、Threadのライフサイクルと、Threadの実行順序(スレッドスケジューリング)です。

Threadのライフサイクルとは、生成から廃棄まで。

new演算子でThreadオブジェクトを生成するとOSのスレッドも作成されます。このこともあって、Threadオブジェクトの生成には時間もかかりますし、メモリ使用量も大きくなります。

一方のスレッドスケジューリングはどのような順番でスレッドを実行するかを決めます。使用できるコア数がスレッドよりも少ない場合、どこかでスレッドの切り替え(コンテキストスイッチ)が発生します。

コンテキストスイッチも時間もかかり、メモリも多く使用する処理です。このため、初期のJavaではなるべくコンテキススイッチが発生しないようにスケジューリングされていました。

しかし、プログラム内でThread.yeildやObject.waitなどが使用されると、コンテキストスイッチが発生します(本当にコンテキストスイッチを行うかどうかはスケジューラーが決めます)。頻繁なコンテキストスイッチはパフォーマンス劣化の原因になります。

このようなことから、スレッドの生成/廃棄の管理や、スレッドスケジューリングはなるべくJVM側で管理させ、私たち開発者は非同期に処理されるタスクを記述することに注力すべきです。

そこで、登場したのがConcurrency Utilitiesというわけです。

 

Conccurency Utilities

Conccurency Utilitiesが導入されたのはJava 5。2004年なので、もう20年近く前です。当時はマルチコアのCPUがようやく出てきたころで、ParallelよりはConcurrentの時代です。

Concurrency Utilitesが提供しているのは大別して次の4種類のAPIです。

  • 非同期タスクの実行・管理
  • 並列コレクション
  • アトミック操作
  • ロック

この時は非同期タスクの実行・管理に絞って説明しました。

非同期タスクの実行・管理に関する主なインタフェースとクラスは以下の4種類だけです。

  • ExecutorService: 非同期タスクの実行
  • Executors: ExectuorServiceのファクトリ
  • Runnable/Callable: 非同期タスク
  • Future: 非同期タスクの管理

Executorsだけがクラスで、他はインタフェースです。

他にもExecutorインタフェースやFutureTaskクラスなどがありますが、直接使うことは稀です。

ExecutorsクラスはExecutorServiceオブジェクトのファクトリメソッドを定義したクラスです。主に使われるファクトリメソッドは

  • newFixedThreadPool
  • newChacedThreadPool

の2メソッドです。いずれもスレッドを使いまわすスレッドプールを提供するExecutorServiceオブジェクトを生成します。

newFixedThreadPoolメソッドは固定のスレッド数だけでスレッドプールを作成します。スレッド数は引数で指定します。どちらかというと非同期タスクの応答性を重視したスレッドプールになります。

一方のnewCachedThreadPoolメソッドで生成するスレッドプールは、必要に応じてスレッドを生成するスレッドプールです。もし、使われていないスレッドがあれば使いまわします。こちらはスループット重視型のスレッドプールです。

他にもシングルスレッドで動作するnewSingleThreadExecutorメソッドや、タイマーなど周期的なタスク実行を行うためのnewScheduledThreadPoolメソッドがあります。

また、Java 8で、後述するWork-Stealingを使用したスレッドプールを提供するnewWorkStealingPoolメソッドが追加されました。次のJava 21ではVirtual Threadを使用するnewVirtualThreadPerTaskExecutorメソッドも提供される予定です。

 

ExecutorServiceインタフェースは非同期タスクの実行を行うためのインタフェースです。どのようにタスクの実行を行うかは実装クラスによって異なります。

基本的には使用するメソッドは2種類だけです。

  • Future<T> submit(Callable<T> task)
  • Future<T> submit(Runnable task, T result)
  • Future<T> submit(RUnnable task)
  • void close()

タスクの実行を行うのがsubmitメソッドです。タスクがCallableインタフェースかRunnableインタフェースの違いでオーバーロードが3種類あります。

closeメソッドはJava 19で追加されたメソッドです。なぜ今ごろになってcloseメソッドが追加されたかというと、try-with-resources構文が使えるようになったからです。

これまではshutdownメソッドもしくはshutdownNowメソッドを使う必要がありましたが、これからはtry-with-resourcesですね。

また、複数のタスクをまとめて登録できるinvokeAllメソッドとinvokeAnyメソッドもありますが、これらのメソッドは結果がでるまでブロックします。このため、ちょっと使いにくいメソッドでした。

Virtual Threadの導入に合わせてStructured Concurrencyが提案されており、invokeAll/invokeAnyメソッドはこちらに置き換えられていくと思います。ただ、Java 21ではまだPreview JEPなので、次の次のLTSに間に合うぐらいです。

 

さて、非同期タスクを記述するのがRunnableインタフェース/Callableインタフェースです。

両者の違いは戻り値があるかないかだけと思っている方が多いと思いますが、もう1点大きな違いがあります。

それは、RunnableインタフェースのrunメソッドはChecked Exceptionをスローできないのですが、Callableインタフェースのcallメソッドはできるという点です。

このため、Runnableインタフェースを使う場合、例外をスローさせるにはRuntime Exceptionでくるんでスローするしかありませんでした。しかも、Runtime Exceptionがスローされると、その例外はUncaught Exceptionとして扱われ、スレッドは何も言わずに死んでしまうのです。

UncaughtExceptionHandlerが登録してあれば検知できますが、それでも直接例外が発生したことは分かりません。

これに対し、CallableインタフェースであればChecked Exceptionもスローできます。また、例外が発生した場合、FutureインタフェースのgetメソッドなどでExecutionException例外としてcatchすることができます。

 

最後がFutureインタフェースです。

FutureインタフェースはExecutorServiceインタフェースのsubmitメソッドの戻り値になり、submitメソッドで登録した非同期タスクの管理を行います。

型パラメータはタスクの戻り値の型です。Runnableインタフェースを使用した場合はFuture<?>となります。

主に使うメソッドは

  • T get()
  • cancel()
  • Future.State state()

ぐらいでしょうか。

一番使うメソッドは、タスクの結果を取得するgetメソッドだと思います。ただ、getメソッドは結果が出るまでブロックすることに注意が必要です。

cancelメソッドはタスクのキャンセルのためのメソッドです。ただし、cancelメソッドでキャンセルできるかどうかは、タスクの書き方に依存します。

stateメソッドはJava 19で追加されたメソッドで、それまではisDone/isCancelledメソッドで状態を調べていました。stateメソッドを使うことで、もう少し詳しく状態を調べることができます。

Java 19では、resultNowメソッドとexceptionNowメソッドも追加されました。

この2つのメソッドはブロックをしないのですが、タスクが完了していないとIllegalStateException例外がスローされます。

 

後半は非同期タスクの書き方。

非同期タスクを書く上で安全性とスケーラビリティが重要になります。並列度が低い時代には安全性が重視されていましたけど、現在のように多くのコアが使えるようになるとスケーラビリティが重要になってきます。

ようするにスレッドセーフなクラスであったとしても、スケールできないものは使いものにならないわけです。とはいうものの、安全性がないがしろにされていいわけではありません。

安全性を確保しつつ、スケールするタスクを記述する必要があります。

まぁ、言うのは簡単ですけど、実際に書くのはむずかしいですね。

 

おまけでFork/Join FrameworkとCompletableFutureについても紹介しました。

特にFork/Join Frameworkは開発者が直接使うことはほぼないと思うので、使いかたよりも動作原理について紹介しました。

また、CompletableFutureは関数を連ねて処理を記述できる人であればとても使いやすいAPIなのですが、例外が扱いにくかったり、デバッグが難しいという点もあります。Virtual Threadであれば、例外やデバッグが容易になるので、書きやすい方で書けばよいと思います。