UNIX上でのC++ソフトウェア設計の定石 (5)

鉄則5: スレッドの「遅延キャンセル」も出来る限り避けて通ろう

  • スレッドの非同期キャンセルとは:あるスレッドが別のスレッドに処理の中断を依頼すること
  • 遅延キャンセルは、規格の自由度が比較的高いため、OSやCライブラリのバージョンにより動作がまちまち
    • 環境によらず安定した動作を得るには、使用する環境の詳しい調査や、Cライブラリの抽象化作業、条件コンパイルなどが必要
    • C++では、「キャンセル発生時のオブジェクトの解体」を、移植性のある方法で実現できない
  • 慎重に使用すること。C++では使用しないこと


説明:


スレッドのキャンセルに「非同期」「遅延」の二種類があることはすでに述べた通りで、またこの「非同期キャンセル」が非常に厄介な数々の問題を引き起こす元凶であることも、既に述べました。


さて今回は「遅延キャンセル」を扱います。遅延キャンセルは、非同期キャンセルほど様々な問題は引き起こさないのですが、が、それでも注意事項は沢山あります。次に示すような注意事項を全て把握してから使用するようにしましょう。


注意事項1: キャンセルポイントをきちんと把握しよう


非同期キャンセルと異なり、キャンセル処理はコード上に明示されたキャンセルポイントまで遅延されます。遅延キャンセルされる可能性のあるコードを書く場合は、コードのどこがキャンセルポイントになりえるのか、正確に把握しておかなければなりません。


まず、pthread_testcancel関数を呼んだ場所はキャンセルポイントになります。この関数は、「遅延キャンセルポイントになる」目的だけをもつ関数だから当然です。それ以外の、ある標準関数の呼び出しがキャンセルポイントになるかどうかは規格(SUSv3)で決められています。規格を参照していただくと、次のような一覧表があると思います。

次の関数はキャンセルポイントである

accept, aio_suspend, clock_nanosleep, close, connect, creat, fcntl, fdatasync,
fsync, getmsg, getpmsg, lockf, mq_receive, mq_send, mq_timedreceive,
mq_timedsend, msgrcv, msgsnd, msync, nanosleep, open, pause, poll, pread,
pselect, pthread_cond_timedwait, pthread_cond_wait, pthread_join,
pthread_testcancel, putmsg, putpmsg, pwrite, read, readv, recv, recvfrom,
(略)
次の関数はキャンセルポイントとしてよい

access, asctime, asctime_r, catclose, catgets, catopen, closedir, closelog,
ctermid, ctime, ctime_r, dbm_close, dbm_delete, dbm_fetch, dbm_nextkey, dbm_open,
dbm_store, dlclose, dlopen, endgrent, endhostent, endnetent, endprotoent,
endpwent, endservent, endutxent, fclose, fcntl, fflush, fgetc, fgetpos, fgets,
fgetwc, fgetws, fmtmsg, fopen, fpathconf, fprintf, fputc, fputs, fputwc, fputws,
(略)


これを見てもらえばわかると思いますが、「キャンセルポイントとなるかどうかが実装依存である関数」が、規格上多数存在してしまっています。このことが原因で、移植性のある方法で、「一定時間内のスレッドの遅延キャンセル完了」を保証するのは難しくなってしまいます*1。下手をすると、OSのバージョンを上げただけで動かなくなる製品が出来上がるかもしれません。


それでも遅延キャンセルを使いたいですか?


注意事項2: クリーンナップ関数の必要性について知っておこう


遅延キャンセルされる可能性のあるスレッドの処理中に、リソースの確保を行う場合、いくつかの配慮を行わないと、リソースリークやデッドロックを起こすプログラムができあがってしまいます。


例えば次の自作関数は安全に遅延キャンセルできません。

void* cancel_unsafe(void*) {
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    pthread_mutex_lock(&mutex);                     // キャンセルポイントではない
    struct timespec ts = {3, 0}; nanosleep(&ts, 0); // 常にキャンセルポイント
    pthread_mutex_unlock(&mutex);                   // キャンセルポイントではない
    return 0;
}
int main(void) {
    pthread_t t;
    // pthread_create直後は「キャンセル有効、種別は遅延キャンセル」である
    pthread_create(&t, 0, cancel_unsafe, 0);
    pthread_cancel(t);
    pthread_join(t, 0);
    cancel_unsafe(0); // デッドロック!
    return 0;
}

上のサンプルコードでは、nanosleep実行中に遅延キャンセルが起こる(場合が多い)ですが、この時mutexはロックされています。そして、スレッドが遅延キャンセルされてしまうとロックの開放者がいなくなります*2。そのため、次回にmain関数から同関数を呼び出した際にデッドロックが起きます。


この問題を回避するには、pthread_cleanup_push関数を用いてキャンセル時にロックを開放してやればOK,デッドロックしなくなります。

// 新設
void cleanup(void* mutex) { 
    pthread_mutex_unlock((pthread_mutex_t*)mutex);
}

// 太字部分を追記
void* cancel_unsafe(void*) {
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    pthread_cleanup_push(cleanup, &mutex);
    pthread_mutex_lock(&mutex);
    struct timespec ts = {3, 0}; nanosleep(&ts, 0);
    pthread_mutex_unlock(&mutex);
    pthread_cleanup_pop(0);
    return 0;
}


注意事項3: 遅延キャンセルとC++の相性について知っておこう


C言語を使用している場合、上記pthread_cleanup_push/pop関数を用いることで安全な遅延キャンセルをおこなうことが可能となりますが、C++言語を用いている場合は別の問題が出てきます。C++と遅延キャンセルは最悪の相性です。具体的には次の2点が問題です:

  1. 遅延キャンセルが実行される際、スタック上のオブジェクトのデストラクタが呼ばれるかどうかは環境依存
  2. pthread_cleanup_push/pop関数とC++の例外機構がどう相互作用するかも環境依存

デストラクタが呼ばれない、あるいは例外がthrowされた時にcleanupが行われないのは、メモリリーク、リソースリーク、クラッシュ、デッドロックなどの原因となります。この問題は意外に根が深いようで、彼のBoost C++ライブラリも匙を投げているほどです。

[Q] Why isn't thread cancellation or termination provided?
[A] There's a valid need for thread termination, so at some point Boost.Threads probably will include it, but only after we can find a truly safe (and portable) mechanism for this concept.

オブジェクトを必ずフリーストア上に確保し、解体を全て、クリーンナップハンドラに行わせる手もありますが、今度は例外安全性が犠牲になるでしょう。


C++を使用するプロジェクトでは、スレッドの遅延キャンセルすら行わないのが現実的と言えます。

*1:よく問題になるのは gethostbyname() 関数

*2:非同期キャンセルとmalloc関数の例に似ていますね