Threadの割り込みを活用する

確実に一定時間スリープする - terazzoの日記ではThreadの割り込みがあっても確実に一定時間の停止を試みているが、そもそもこのようなコードは書いてはいけない。

Thread.sleep()は一定時間止まるための便利メソッドとしてよく知られているが、そのときに発生するInterruptedExceptionについての理解は広まっていない気がする。割り込みとはなんなのか。どういう時に使うのか。

目覚まし時計

お昼休みに昼寝をしようとする。寝過ごすといけないので15分後にアラームを鳴らす設定をした。

さて、ひと眠りするか、というところに友人がやってきた。昼寝はやめて売店に行くことにした。果たして売店でアラームが鳴り始めた。

さて、このとき、アラームは15分間の待機を命じられたわけだけども、お昼寝がキャンセルされたことで、もう待機しなくてよくなってしまった。むしろ、さっさと待機をやめてくれたほうがいい。僕らはセットしたアラームをキャンセルすることだろう。これがThread.interrupt()なのだ。

Thread.sleep()やObject.wait()など、スレッドを止めて待機状態になる機能はいくつかある。このようなThreadが止まっている場合、もう止まってなくていいんだよ、と伝える場合、その止まっているThreadオブジェクトに対してinterrupt()を呼んでやることで、彼らは止まるのをやめる。そのときThreadはコードのどこか途中で止まっているわけだ。半端な状態になる。正常に待機完了したわけでもないのでそのまま処理続行してよいものか…?なので、このような状態のときの分岐のためにInterruptedExceptionがthrowされる。

try {
	Thread.sleep(500);
	// このあとは正常に待機を完了した場合に処理される
} catch (InterruptedException e) {
	// ここは待機するのを途中でやめたときに処理される
}

なので、InterruptedException がthrowされたということは「お前、もう待機しなくていいから。それもうヤメてくれる?」というシチュエーションなわけで、これを無視してかたくなに待機を続けるというのは無作法なのだ。

事例:バッチ処理

たとえば、バッチ処理的な、時間のかかるプログラムを別Threadで動かすとしよう。

public class Batch extends Thread {
	/** 処理対象データ */
	List<Target> list;
	@Override
	public void run() {
		for (Target target : list) {
			// ループでひとつずつ処理する
		}
	}
}

このようなコードを書いた時、処理を中断してプログラムを終わりたいとする。Javaのプログラムが終了するのはすべての非デーモンThreadが完了した場合か、System.exit()した場合なのだが、exit()を使わなかった場合、このバッチ処理的なものが動いていると終わっているようで終わっていない状態になる。*1

例えば、GUIのxボタンが押され、プログラム全体を止めたいとユーザが思ったとして、GUIは閉じられても背後で処理が続くようなプログラムになってしまう。これは良くない。いちユーザとして使っていてイラッとする。

なので、こういうシチュエーションではxボタンが押された時の処理としてこのBatchのThreadに対してinterrupt()を呼んでやる。

JFrame frame = new JFrame();
frame.addWindowListener(new WindowAdapter() {
	@Override
	public void windowClosed(WindowEvent e) {
		batch.interrupt();
	}
});

これでウィンドウを閉じるときにバッチ処理も停止して安心…と思いきや、実は止まってくれない。

割り込まれたときの挙動

Threadのrun()中にいるとき、他のThreadからinterrupt()されたとき、実は通常は何も起きない。自動的にInterruptedExceptionが発生して中断してくれるわけではない。

InterruptedExceptionが発生して中断してくれるのはThread.sleep()やObject.wait()などの場合に限られる*2。それ以外の場合、プログラムが自分で割りこまれたのかをチェックしてその後の身の振り方を決めなくてはならない。

このとき、割りこまれたかを判別するためのフラグを取得するのがThread.interrupted()Thread.isInterrupted()だ。

interrupted()は一度呼ぶと割り込みフラグがクリアされる。2度目に呼んだ時にはfalseになる。この点がisInterrupted()との違い。

Threadでぐるぐると処理を行うようなループというのはこの割り込みフラグを確認して適度なところで処理を中断する処理を書くのがよい作法だ。

class Batch extends Thread {
	/** 処理対象データ */
	List<Target> list;
	@Override
	public void run() {
		try {
			for (Target target : list) {
				if (interrupted()) {
					throw new InterruptedException();
				}
				// ループでひとつずつ処理する
				// ...
			}
		} catch (InterruptedException e) {
			// 途中中断の場合の後始末処理
		}
	}
}

あるべき契約は?

さて、ここまで実装を見てきたわけだが、これを踏まえてThread.sleepのチェック例外InterruptedExceptionの扱い - torutkのブログの考察を見てみよう。

ケース2

Thread.sleep(n * 1000)を使用するメソッドm2があって、このm2の事後条件にはn秒待つのが必須ではないとします。たとえば、少し待ってからリトライする処理が例です。n秒待ちますが、それより早くリトライ処理をしても事後条件違反にはならない場合です。

この場合、InterruptedExceptionが発生してThread.sleepがn秒間より前に中断されたとしても、リトライ回数に達していなければリトライを続ける必要があります。

もしリトライ回数に達していないのに処理を抜けてしまうと、それが事後条件違反となります。

したがって3.のようにcatch節で何もせず繰り返しリトライ処理を続けるのが望ましいことになります。

Thread.sleepのチェック例外InterruptedExceptionの扱い - torutkのブログ

本当だろうか?

例えばネットワークからなんらかの情報を取得する処理を行ったとしよう。その処理はネットワークの断絶などに備えて失敗時に3回のリトライをすることとする。その処理が走っている状態でユーザがアプリをxボタンで閉じた。通信スレッドにinterrupt()する。通信のリトライの間の待機Thread.sleep()からInterruptedExceptionが投げられる。これを無視して、リトライ処理を続け、ネットワークから情報を取得してそのアプリケーションは終了する

これは望む動作だろうか?

視野をThread.sleepからメソッド全体(引用文にあるm2)に広げよう。これはリトライ処理をするメソッドということだろう。リトライ処理の「契約」とは何か。異常系処理なので見落とされがちだが、リトライを中断したならそこでリトライを切り上げることがあるべき「契約」だと僕は思う。

とすれば、このリトライ処理メソッド m2 はThreadが割り込まれた時、リトライ処理を切り上げて中断したことをInterruptedExceptionを投げることで示すという作りにするのが妥当ではなかろうか。

同様に、冒頭に挙げた「確実に一定時間スリープする」というのはその契約そのものが誤りであろうと考える。撤収の号令がかかる中、それでも待機時間を死守するというのは、多分、契約するにあたって撤収についての条件を考慮漏れしているだけだと思う。契約に基づくプログラミングをするのであれば、その契約が妥当か注意を払わなくてはならない。

Threadに対する割り込みというのは、なんだかよく分からないけど発生するもの、という訳ではなく、一般的なプログラムの文脈でいうと、処理を中断して切り上げろという指示である。

大元の記事であるJavaの理論と実践: 割り込み例外の処理でも割り込みによってキャンセルを表現するのにどのような実装にするべきかが書かれている。

もっとも「言語仕様では、割り込みに具体的な意味合い(セマンティクス)は何も与えられていません」と断られているが、常識的な合意としてはInterruptedExceptionというのは処理の中断のための機構である、と捉えて差し支えはない。

事例:ネットワーク通信

インターネットやLANを使って通信を行うプログラムを書く場合、通信を担当するThreadは終了時に適切に停止することが望ましい。

特に、一定時間ごとに動くクローラーであるとか、あるいは一定時間ごとにサーバにデータを取りに行くポーリング処理などは、それ用にThreadを立てるとかScheduledThreadPoolExecutorで一定時間ごとにタスクを動かすとかすると思うが、shutdownNow() すると各々のタスクには割り込みがされるので、適切に中断できることが望ましい*3

そうでなくとも、ネットワーク通信は単体でも相当の時間がかかるし、連続的な通信が行われることも多い。なので通信処理は別スレッドに分けて行うということは多い。例えばブラウザのようなモノを作ったとしたならば、最初に本体のHTMLを取得したあと、画像リソースなどを順に取得するわけだ。だが、すべての画像リソースを読み終わる前に、ユーザがそのブラウザを(あるいはブラウザ内のタブを)閉じたならどうだろう?残りの画像リソースの読込み処理なんて続けるだけ無駄だし、むしろ足を引っ張るので速やかに中断して終わって貰いたい。

このような事情から、リトライ処理を行いつつ、中断も適切に行うようなユーティリティを設計することは難しい。例えば僕が通信に使う汎用リトライ処理は以下のようなコードだが、これも改善の余地がある。

public <T> T doRetry(Process<T> process) throws IOException, InterruptedException {
	IOException e = null;
	int i = 0;
	do {
		if (i!=0) { // リトライ時の待機
			Thread.sleep(retryWait);
		}
		try {
			return process.execute();
		} catch (IOException ioe) {
			e = ioe;
		} finally {
			i++;
		}
	} while (i < retryMax);
	throw e;
}
interface Process<T> {
	T execute() throws IOException, InterruptedException;
}

先に挙げたようなブラウザのように画像リソースを取得するような場合、リトライに一定時間のsleepをするのは適切ではない。キューに取得対象をつっこみ、失敗したら次のリソース取得に移り、失敗したものはキューの末尾にでも入れなおして後から取り直すような仕掛けが妥当だろう。

まとめ

  • InterruptedExceptionは処理を中断する場合に投げられる
  • Threadの割り込みに備えて作法よくプログラムしておけばキャンセルがスムーズに行える
  • なんらかの時間のかかる処理をThreadを立てて行うならキャンセルすることも考慮して設計を行おう

このあたりを考慮してプログラムすると設計に頭をかかえることになるかもしれないが、わかりだすとこれがまた楽しい。プログラミング作業に対する割り込み仕事は勘弁願いたいが、割り込みのプログラミングについては毛嫌いせずぜひ楽しんでもらいたい。

*1:かといってexit()ではどこで終わるかわかったもんじゃなくて怖い

*2:Oracle Technology Network for Java Developers | Oracle Technology Network | Oracleを参照

*3:shutdown()の場合は割り込みはされず、自然とタスクが終わるのを待つ