pthread のキャンセル

●キャンセルとは

pthread には、他のスレッド (あるいは自分自身でもよい --- 普通は pthread_exit しますが) の実行を終了するための手段としてキャンセルが用意されています。 キャンセルは pthread_cancel() 関数を呼び出すことによって 要求することができます。 この関数を呼び出すと、引数で指定されたスレッドに対してキャンセルを要求します。 pthread_cancel() 関数自体は次のようなシンプルな関数なので、 取り消したいスレッドの ID さえ分かれば苦もなく呼び出せます。

int pthread_cancel(pthread_t thread);

なお、pthread_cancel() 関数は、対象スレッドに対してキャンセルの要求を出すと、 そのスレッドが終了したかどうかとは無関係に、すぐに戻ってきます。 もし、そのスレッドが確実に終了するまで待ちたければ、 明示的に pthread_join() 関数を呼び出す必要があります。

●キャンセルの協調性

ここで一つ憶えておくべきことは、 「キャンセルは基本的に協調的に行われる」ということです。 「基本的に」というのは、この振る舞いを変えることもできるからなのですが、 以下しばらくはデフォルトの状態であると仮定します。 「協調的」というのはどういうことかというと、 「あるスレッドに対してキャンセルの要求が行われても、 そのスレッドがキャンセルを受け入れる明示的な処理部分に到達するまでは キャンセルが行われない」 ということで、もっと簡単に言いかえれば 「キャンセル要求されたスレッドがすぐに(あるいは永久に)終了するとは限らない」 ということを意味します。 たとえば次のようなコードを実行しているスレッドがあったとします:

	while (1) {
		/* からっぽ */
	}

このスレッドに対して別のスレッドから pthread_cancel() を実行したとしても、 デフォルトではこのスレッドがキャンセルされることはありません (したらそれは pthread 実装のバグです)。 よって、プログラマは意識的にこの協調性の制御を行う必要があります。

一般的に、あるスレッドに対してキャンセルが要求された場合、 (デフォルトでは)そのキャンセルはすぐに実行されず、 実際にスレッドがキャンセルされるのは 「キャンセルポイント」というものに指定されたライブラリ関数が 呼び出された(あるいは呼び出されている)タイミングに限られます。

キャンセルポイントとなる関数がブロックするたぐいの関数の場合、 ブロック中にキャンセル要求が行われるとその待ち状態を解除して キャンセルを実行します。

なお、待ち状態の解消と同時にキャンセル要求が行われた場合の 動作については規定していません。つまりこの場合、

のどちらかが行われます。この場合の副作用についての規定もありませんが、 少なくとも(協調的な)キャンセルが安全に行えることは 保証されていると期待できます。

POSIX 標準は、すべてのライブラリ関数を次の 3 つに分類しています:

これらは POSIX 標準の System Interfaces Volume の第 2 章の 第 9 節「Threads」で明確に リスト化 されています。

●キャンセルポイントの取り扱い

ところで、キャンセルポイントのリストを見てみるとわかりますが、 その量が多いため暗記するのはちょっと難しそうです。 一方で、プログラムを組むときにいちいちこのリストを参照するのも面倒です。 したがって、極力このリストに依存せずに済むよういくつかのポイントを 押さえておくことが重要でしょう。

必ずキャンセルポイントになる関数を把握する

これはそれほど多くはないのですが、 個々の関数を憶えていると大変なので、次のように憶えると良いでしょう:

特に最後の pthread_testcancel() 関数は キャンセルポイントとなるためだけに存在する関数です。

キャンセルポイントに依存しないプログラムを心がける

どの関数がキャンセルポイントかを把握するのが困難である以上、 できるだけ暗黙の仮定に左右されないようにプログラムを書くのがよいでしょう。 他のスレッドからキャンセルされる可能性のあるスレッドに対して プログラマが期待する振る舞いとしては、 大きく分けて次のようなケースが考えられます:

まず、「絶対にキャンセルされては困るケース」では、 後述する方法でキャンセルを禁止してしまうという方法がよいでしょう。 キャンセルが禁止されたスレッドは、いかなるキャンセルポイントでも (pthread_testcancel() 関数ですらも)キャンセルされることはありません。 これは簡単ですね。

「ちゃんとキャンセルされることを保証したいケース」でも、 前述したようなビジーループでキャンセルが行われることを 保証したい場合には簡単で、pthread_testcancel() を適当な間隔で呼べばよい だけです:

	while (1) {
		...
		pthread_testcancel();
		...
	}

pthread_testcancel() 関数は、引数も戻り値もありません。

一方で、唯一難しいのが、 「何らかの待ち状態(ブロッキング)の最中にキャンセルが行われることを 保証したい場合」です。 これは、どうやっても「ブロックする関数がキャンセルポイントかどうか」 という属性への依存から逃れられないため難しいのです。 少なくとも前述したリストを参照する必要がありますね。

このようなケースの非常にありがちなものは、I/O 操作を行う関数です。 このうち、システムコールである read() や write() などは 確実にキャンセルポイントであることが保証されていますが、 標準 I/O 関数である getc() や putc() などは必ずしもキャンセルできるとは 限りません(pthread 実装に依存します)。 よって、getc() や putc() 相当の機能が必要だけれども、 同時にスレッドのキャンセルも行わなければならないような場合、 次のような難しい選択を迫られます:

前者は移植性が高い方法ですが、read() や write() で同様のことを 実現せねばならず、少し面倒です。 後者は(もしそれらが目的通りキャンセルポイントであれば)プログラムは 楽になりますが、移植性は損なわれるでしょう --- もっとも、getc() や putc() あたりは多くのシステムでキャンセル可能なのでは ないかとは思いますが --- この辺はバランスだと思ってください。 ただ、この問題にかかわるバグはやっかいなことになる可能性が高いため、 後者の方法を自分のアプリケーションで使いたいのならば、 「キャンセルポイントとして期待される関数の範囲をあらかじめ決めておき、 それをドキュメント化する」 などの方法でリスクを減らしておくと良いかもしれません。

あるいは第 3 の方法として、 「ファイルポインタに割りつけられたデスクリプタをノンブロッキングに設定し、 読み書きが可能になるのを待つためには select システムコールや poll システムコールを利用する」という方法も考えられますが、 これはスレッドを利用する目的からすると少々スマートとは言えません。

●mutex とキャンセル

上で述べたリストをみるとわかりますが、 mutex のロック待ち関数 (pthread_mutex_lock() 関数と pthread_mutex_timedlock() 関数) は決してキャンセルポイントにはなりません。

mutex 利用の原則として、 各スレッドの並列性を高めるために「mutex をロックする時間は極力短くする」 というのがありますが、これはキャンセルとの兼ね合いという点でも重要です。 つまり、ロックされている mutex をスレッドが待っている間は、 ずっとそのスレッドに対するキャンセルが実施されませんから、 キャンセルへの応答性を高めるという観点でもロック時間の短縮というのは 重要なポイントなのです。

また、mutex をロックしている区間内にキャンセルポイントが存在する場合、 そこでキャンセルが発生すると当然その mutex はロックされたままになります。 このような mutex は後述するクリーンアップハンドラで明示的にロックを 開放してやる必要があります。そのため、mutex でロックしている間は極力、 キャンセルポイントとなる関数を呼び出さないか、あるいはキャンセルを 禁止してしまうのが良いでしょう。

以上のようなを考慮すると、「mutex ロックはデータ構造の整合性を保つための、 (粒度の細かい)クリティカルセクションとしてのみ利用すべき」 であると言えるでしょう。mutex は二値セマフォの変種ではあるものの、 (えてして長時間のロックが必要になりがちな)資源のロックなどの目的のためには 向いていません。このような目的のためには POSIX リアルタイム拡張のセマフォか、 それが利用できない場合には条件変数でセマフォをエミュレートするのが よいでしょう。

●条件変数とキャンセル

mutex とは異なり、条件変数の通知待ち関数 (pthread_cond_wait() 関数と pthread_cond_timedwait() 関数) は常にキャンセル可能です。 一つ注意するべきことは、これらの待ち状態が解除されるときは常に、 この条件変数と関連づけされている mutex がロックされるということで、 これはキャンセルによって待ち状態が解除されたときも例外ではありません。 したがって、これらの関数を実行中にキャンセルが行われた場合、 その mutex は必ずロックされた状態でキャンセル処理に突入します。 よって、スレッドのキャンセル処理でこの mutex のロックを 外す必要があるでしょう。この目的のためには、 後述するクリーンアップハンドラを利用することができます。

また、当然のことながら、この mutex が他のスレッドによって ロックされていると、そのロックが解除されるまでキャンセルは遅延されます。 したがって、条件変数と関連づけされている mutex についても、 ロック時間を極力短くするというのは重要なことです。

●IEEE Std 1003.1j 拡張スレッド機能とキャンセル

rwlock

rwlock は mutex とよく似たロック機構ですが、rwlock の待ち関数 (pthread_rwlock_rdlock() / pthread_rwlock_timedrdlock() / pthread_rwlock_wrlock() / pthread_rwlock_timedwrlock() 各関数) は「キャンセルポイントかもしれないしそうでないかもしれない」関数です。 これらの関数のキャンセル可能性に期待すると移植性の問題が発生するため、 基本的には

という方針で対処するのが良いでしょう。

バリア

バリアは、複数のスレッドがあるポイントで勢揃いするのを待つための仕組みですが、 その待ち関数(pthread_barrier_wait() 関数)はキャンセルポイントではありません。 したがって、キャンセルが発生する場面でバリアを利用するときは、 mutex と同様になるべく待ちが短期間になるよう注意すべきです (実際には、これはキャンセルが発生しない場面でも心がけるべきですが)。

バリアは通常、スレッドの開始待ちのようなところでのみ使われるべきものなので、 あまりキャンセルが問題になることはないでしょう。しかしながら、 バリアの待ちが発生している間はキャンセル要求が飛ばないように注意するか、 あるいはキャンセルを禁止する必要があるでしょう。

スピン

スピンは、(その実装方法が規定されているという点を除いて) mutex そのものなので(そしてそのロック待ち関数である pthread_spin_lock() 関数もキャンセルポイントではないため)、 mutex と全く同じ扱いをすればよいでしょう。

●キャンセルの許可不許可および、キャンセルの協調性の設定

キャンセルの許可不許可

あるスレッドに対するキャンセルを許可するかどうかは pthread_setcancelstate() 関数によって設定することができます:

int pthread_setcancelstate(int newstate, int *oldstate);

この関数を呼び出すと、そのスレッドは newstate によって指示されている許可状態に設定され、 oldstate が指す変数にそれまで設定されていた許可状態が格納されます (規格では oldstate に NULL を渡すことは許されていません --- 明示的に「駄目だ」とも書いてませんが)。

許可状態として設定できる値は、次の二つのマクロで定義されています:

キャンセルの協調性

一方で、キャンセルを協調的に行うかどうかは pthread_setcanceltype() 関数によって設定することができます:

int pthread_setcanceltype(int newtype, int *oldtype);

この関数を呼び出すと、そのスレッドは newtype によって指示されている協調方式に設定され、 oldtype が指す変数にそれまで設定されていた協調方式が格納されます (先ほどと同様に oldtype に NULL を渡すことは許されていません)。

協調方式として設定できる値は、次の二つのマクロで定義されています:

なお、いずれの場合でも、キャンセルが禁止されている場合には キャンセルされません。

キャンセルの協調状態が非同期に設定されている場合に キャンセルの要求があると、キャンセルポイントであろうがなかろうが、 ほぼすぐにそのスレッドのキャンセルが実行されます。 ただし、非同期のキャンセルを安全に行うためには、 そのスレッドが次の条件を満たしている必要があります:

この条件を無視した場合、非常にやっかいなバグに巻き込まれる可能性があります。 現実的には、この条件を満たすことができるケースはそれほど多くないため、 基本的に非同期キャンセルは使わないほうが良いでしょう。

キャンセル禁止中のキャンセル要求の扱い

キャンセル禁止中に行われたキャンセル要求の扱いについては、 規格では「ペンディングされる」という表現がされています。 これは禁止中に行われたキャンセル要求はそのまま保持され、 再びキャンセルが許可されてキャンセルポイントに 到達した時に(あるいは非同期の場合には許可された直後に) キャンセルが実行されることを意味します。

新規スレッド

新規のスレッドは常に「キャンセル許可なおかつ協調的」という状態で作成されます。

●キャンセルによるスレッドの終了処理

クリーンアップハンドラ

あるスレッドに対するキャンセル要求が受理されてキャンセル処理に入ると、 設定されているすべてのクリーンアップハンドラが呼ばれます。 クリーンアップハンドラの設定と解除は、 pthread_cleanup_push() および pthread_cleanup_pop() 関数によって行います:

void pthread_cleanup_push(void (*handler)(void *), void *closure);
void pthread_cleanup_pop(int isexec);

ここで、hanlder はクリーンアップハンドラ関数へのポインタであり、 handler が呼び出されるときには closure が引数として渡されます。 また、isexec が 0 でなければ、クリーンアップハンドラの解除時に そのハンドラが呼ばれます。0 ならば呼ばれません。

キャンセル要求によってクリーンアップハンドラが呼ばれるときには、 当然そのクリーンアップハンドラはハンドラを設定したスレッドの コンテキストで呼び出されます。呼び出されている間は、 そのスレッドに対する別のキャンセル要求が禁止されます。pthread_cleanup_pop() によってクリーンアップハンドラが呼ばれた場合のキャンセル許可不許可については、 規格は何も規定していませんので、 必要ならば事前に不許可にしておくとよいでしょう。

また、クリーンアップハンドラは pthread_exit() によってスレッドが 終了される場合にも呼び出されます。 このときのキャンセル許可不許可についても pthread_cleanup_pop() と同様です。

関数名からわかるとおり、これらは入れ子にして複数設定することができます。 その場合、クリーンアップハンドラは設定されたのと逆順に呼び出されます。

なお、「関数」と呼んでいますが、 実際にはこれらはマクロで実装されることが多く、 たとえば NetBSD では次のように実装されています:

#define pthread_cleanup_push(routine, arg)			\
        {							\
		struct pthread_cleanup_store __store;		\
		pthread__cleanup_push((routine),(arg), &__store);

#define pthread_cleanup_pop(execute)				\
		pthread__cleanup_pop((execute), &__store);	\
	}

このような実装を許すため、 規格では利用者に対し「ある pthread_cleanup_push() に対して かならず唯一に対応する pthread_clean_pop() が存在し、 それは同じ構文ブロックに属さなければならない」 と規定しています。つまり、次のような例は、 最後のものを除いて正しくありません:

/* (1) 違う関数で pop している */
void
f1(void)
{
	void f2(void);

	pthread_cleanup_push(&hoge, NULL);
...
	f2();
}
void
f2(void)
{
	pthread_cleanup_pop(0);  /* 間違い */
}

/* (2) ブロック階層が違う / 唯一ではない */
void
f3(void)
{
	pthread_cleanup_push(&hoge, NULL);
...
	if (pred)
		pthread_cleanup_pop(1);  /* 間違い */
	else
		pthread_cleanup_pop(0);  /* 間違い */
}

/* (3) 同一ブロックにない */
void
f4(void)
{
	/* 意図しない形でコンパイルが通ることがあるので非常に危険 */
	if (pred)
		pthread_cleanup_push(&hoge, NULL);
...
	if (pred)
		pthread_cleanup_pop(0);
}

/* (4) 正しい例 */
void
f5(void)
{
	pthread_cleanup_push(&hoge, NULL);
...
	pthread_cleanup_pop(pred);
}

また、ある pthread_cleanup_push() に対応する pthread_cleanup_pop() を 飛び越えるような goto 文や longjmp() 関数などの利用も禁止されています。 同様に、クリーンアップハンドラからの longjmp() などによる大域脱出も 禁止されています。つまり、一度キャンセル処理に入った場合、 そのスレッドの終了を取りやめることはできないということです。

pthread_join() 関数

キャンセルが要求されたスレッドに対しては、 そのスレッドがデタッチされていなければ pthread_join() 関数によってその終了を待つことができます。 キャンセルによって終了したスレッドの「戻り値」は PTHREAD_CANCELED というマクロで定義された 特別な void ポインタ値となります。 pthread_join() の呼び出し元は、 これによってそのスレッドがキャンセルによって終了したのか、 それとも pthread_exit() 関数によって終了したのかを判定することができます。


Takuya SHIOZAKI <tshiozak@bsdclub.org>