はじめに
JavaではThreadクラスのインスタンスを作ることで、メインスレッドから別のスレッドを立ち上げ、マルチスレッドを簡単に作成できます。
ただし、簡単なのは作成くらいなもので、そのライフサイクルの管理やスレッド間の連携、例外処理などを実装しようとするととっても大変です。
Executorフレームワークをつかうことでマルチスレッドの管理がいかに楽になるか、まとめました。
この記事はExecutors#newSingleThreadExecutorに関する内容です。
Executorフレームワークのありがたみを知るシリーズ1弾としてJavaにおけるスレッドの基本から書いています。
関連記事:
参考:
- 3.1 スレッドの仕組みと操作(マルチスレッド、並行処理、並列処理、非同期呼び出し、スレッドセーフ、同期化、デッドロック、ウェイトセット、スレッドローカルなど)~Java Advanced編
- 3.2 並行処理ユーティリティとExecutorフレームワーク(スレッドプール、ExecutorService、Callable、Futureなど)~Java Advanced編
-
ExecutorServiceを使って、Javaでマルチスレッド処理
スレッドの基本
まずは、Javaにおいてメインスレッドとは別にスレッドを立ち上げる場合の基本的な内容です。
いつスレッドを使うか?
スレッドを使いたい場面は、メインの処理とは別に、「裏で」動かしたい処理がある時です。
例えば以下のようなものが挙げられます。
- 時間のかかる処理
- ファイル読み込み
- 外部APIの実行
- 画像処理
- など
- 裏でやるべきこと
- ログの集計・記録・送信
- メトリクスの集計・記録・送信
- など
- 定期的な処理
- 特定の時刻に動く / 特定の間隔で動く処理
- ポーリングや監視など
どうやってスレッドを立ち上げるか?
以下のように書けます。
// スレッドの定義
public class MyThread extends Thread{
@Override
public void run() {
System.out.println("Hello, MyThread");
}
}
// メイン
public class App {
public static void main(String[] args) {
new MyThread().start();
}
}
// コンソールの出力:
// Hello, MyThread
上記はjava.lang.Threadを継承したMyThreadクラスにて、run()メソッドをオーバーライドしています。
このように、run()メソッド内に直接「スレッドで実行する内容」を記述することができます。
※「スレッドで実行する内容」を「タスク」と呼びます。
タスクを別クラスに切り出して記述することもできます。
// タスクの定義
public class MyTask implements Runnable{
@Override
public void run() {
System.out.println("Hello, MyTask");
}
}
// メイン
public class App {
public static void main(String[] args) {
new Thread(new MyTask()).start();
}
}
// コンソールの出力:
// Hello, MyTask
この場合は、スレッドのコンストラクタの引数としてタスクを渡します。
自作スレッドの問題点
例えば、受け取った数値になんらかの計算を施したいとします。
この「なんらかの計算」はとても複雑で時間のかかる処理だとします。
タスクの定義は以下になります。
public class MyTask implements Runnable{
private int value;
public MyTask(int value) {
this.value = value;
}
@Override
public void run() {
System.out.println("[start] value:" + value + " thread-id:" + Thread.currentThread().getId());
// 何か複雑な処理
System.out.println("[ end ] value:" + value+ " thread-id:" + Thread.currentThread().getId());
}
}
以下、Executors#newSingleThreadExecutorを使って解決できる課題を記します。
スレッド生成のコストが高い
処理したい数値が複数個ある時、それぞれ上記のタスクを使って別スレッドで計算しましょう。
以下のように書けます。
public static void main(String[] args) {
List<Integer> valueList = Arrays.asList(1,2,3,4,5);
for(int value: valueList) {
new Thread(new MyTask(value)).start();
}
}
// コンソールの出力:
// [start] value:5 thread-id:24
// [start] value:2 thread-id:21
// [start] value:3 thread-id:22
// [start] value:1 thread-id:20
// [ end ] value:5 thread-id:24
// [ end ] value:2 thread-id:21
// [start] value:4 thread-id:23
// [ end ] value:3 thread-id:22
// [ end ] value:1 thread-id:20
// [ end ] value:4 thread-id:23
ここでの問題は、new Thread()がリストの要素数の数だけ実行されることです。
インスタンス化には一定のコストがかかりますし、インスタンス化した分リソースを消費します。
OOMのリスク、CPUが高負荷で張り付くリスクがあるということです。
また、Javaのスレッドは「使い回し」ができない仕様となっているため、これ以外の書き方はできません
※本当はできますが、それはExecutorServiceの再発明にあたります。
実行順序の保証が難しい
リスト形式の数値を、その順番通りに処理したいとします。
上記のコンソールの出力を見れば分かる通り、実行順序も完了する順序もバラバラなことがわかります。
Thread.start()の呼び出し順とスレッドの実行順番はなんら関係ないので、なんらか工夫が必要です。
Threadにはjoin()メソッドという、「スレッドの処理が終わるまで待つ」メソッドがあります。これを使えば「前のスレッドが終わってから次のスレッドを実行」とできますが、これをメインスレッドでやってしまっては「裏で実行したい」という大前提が果たされません(メインスレッドが止まってしまいます)。
public static void main(String[] args) throws InterruptedException {
List<Integer> valueList = Arrays.asList(1,2,3,4,5);
for(int value: valueList) {
Thread thread = new Thread(new MyTask(value));
thread.start();
thread.join(); // ここで止まる
}
}
// コンソールの出力:
// [start] value:1 thread-id:20
// [ end ] value:1 thread-id:20
// [start] value:2 thread-id:21
// [ end ] value:2 thread-id:21
// [start] value:3 thread-id:22
// [ end ] value:3 thread-id:22
// [start] value:4 thread-id:23
// [ end ] value:4 thread-id:23
// [start] value:5 thread-id:24
// [ end ] value:5 thread-id:24
// ※順番通りではある
では、メインスレッドからは1本のスレッドを呼び出し、そのスレッドから他複数のスレッドを呼び出すことを考えましょう。これならThread#joinを使って「順番通りに裏側で実行」ができそうです。
public static void main(String[] args) throws InterruptedException {
Thread subThread = new Thread(() -> {
List<Integer> valueList = Arrays.asList(1, 2, 3, 4, 5);
for (int value : valueList) {
Thread subSubThread = new Thread(new MyTask(value));
subSubThread.start();
try {
subSubThread.join(); // subスレッドが止まる
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
subThread.start(); // mainスレッドは止まらない
}
// コンソーツの出力:
// [start] value:1 thread-id:22
// [ end ] value:1 thread-id:22
// [start] value:2 thread-id:23
// [ end ] value:2 thread-id:23
// [start] value:3 thread-id:24
// [ end ] value:3 thread-id:24
// [start] value:4 thread-id:25
// [ end ] value:4 thread-id:25
// [start] value:5 thread-id:26
// [ end ] value:5 thread-id:26
// ※mainスレッドは止まらないし、順番通りではある
出来はしますが、かなり複雑度が増しましたね。つまり、「難しい」のです。
ExecutorServiceを使って解決する
さて、上記で挙げた課題をExecutorServiceはどのように解消してくれるでしょうか?
スレッドを使い回す
先に述べた通り、Javaのスレッドは「使い回し」ができない仕様です。
ExecutorServiceは、「使い回し」を可能にします。
メインスレッドで以下のように書くと、一つのスレッドで全ての数値を処理します。
コンソールの出力のthread-idが全て同じことから、一つのスレッドで処理されていることを確認できます。
// メイン
public class App {
public static void main(String[] args) {
// 一つのスレッドを作る
ExecutorService executor = Executors.newSingleThreadExecutor();
List<Integer> valueList = Arrays.asList(1,2,3,4,5);
for(int value: valueList) {
// executorにタスクを渡す
executor.submit(new MyTask(value));
}
}
}
// コンソールの出力:
// [start] value:1 thread-id:20
// [ end ] value:1 thread-id:20
// [start] value:2 thread-id:20
// [ end ] value:2 thread-id:20
// [start] value:3 thread-id:20
// [ end ] value:3 thread-id:20
// [start] value:4 thread-id:20
// [ end ] value:4 thread-id:20
// [start] value:5 thread-id:20
// [ end ] value:5 thread-id:20
実行順序の保証ができる
Executors#newSingleThreadExecutorで生成するスレッドは「1つ」であり、内部機構として「今のタスクが終わったら次のタスクを実行する」が実現されています。
上記のコンソールの出力を見れば分かる通り、呼び出した順に処理されていますね。
おわりに
今回はExecutors#newSingleThreadExecutorを使うと何が嬉しいのかを書きました。
SingleThreadExecutorって要するに、タスク定義が「for文の中身」だけ、つまり本当に「タスク」だけにできるもの、っていう理解をしています。
タスク定義と、タスクをうまく回すための部分が綺麗に分離できる美しい機構ですね。