マルチスレッドのプログラミング

入出力の問題

マルチスレッドプログラミングの利点の 1 つは、入出力の性能を高めることができることです。従来の UNIX の API では、この点に関してほとんどサポートされていませんでした。つまり、ファイルシステムに用意されている機構を利用するか、ファイルシステムを完全にバイパスするかのどちらかの選択肢しかありませんでした。

この節では、入出力の並行化やマルチバッファによって入出力の柔軟性を向上させるためのスレッドの使用方法を説明します。また、同期入出力 (スレッドを使用) と非同期入出力 (スレッドを使用することも使用しないこともある) の相違点と類似点についても説明します。

遠隔手続き呼び出しとしての入出力

従来の UNIX のモデルでは、入出力は同期的に行われるように見え、入出力装置に対して、あたかも遠隔手続き呼び出しを行なっているように見えました。入出力の呼び出しが復帰した時点では、入出力は完了しているか、少なくとも完了しているように見えます (たとえば、書き込み要求はオペレーティング環境内のバッファにデータを転送しただけで戻ることがあります)。

このモデルの利点は、プログラマは手続き呼び出しの考え方に馴れているので、簡単に理解できることです。

従来の UNIX システムにはなかった代替モデルに非同期モデルがあります。このモデルでは、入出力要求は操作を開始させるだけで、プログラム側がなんらかの方法で操作の完了を検出しなければなりません。

この方法は同期モデルほど簡単ではありませんが、従来のシングルスレッドの UNIX プロセスでも並行入出力などの処理が可能であるという利点があります。

非同期性の管理

非同期入出力のほとんどの機能は、マルチスレッドプログラムによる同期入出力で実現できます。具体的には、要求を出した後でその要求の完了をチェックするという非同期入出力の操作を行う代わりに、独立したスレッドで同期入出力を実行します。メインスレッド側は、pthread_join(3THR) などによって入出力操作の完了を確認します。

非同期入出力

各スレッドの同期入出力で同じ効果を実現できるため、非同期入出力が必要になることはほとんどありません。ただし、スレッドで実現できない非同期入出力機能もあります。

簡単な例は、ストリームとしてテープドライブへ書き込みを行う場合です。この場合、テープに書き込まれている間はテープドライブを停止させないようにし、テープに書き込むデータをストリームとして送っている間は高速にテープを先送りします。

これを行うためにカーネル内のテープドライバは、以前のテープへの書き込み操作が完了したことを知らせる割り込みに応答する時に、待ち行列に入っている書き込み要求を発行する必要があります。

スレッドでは、書き込み順序を保証できません。スレッドの実行される順序が不定だからです。たとえば、テープに対して順番どおり書き込みを行おうとしても不可能です。

非同期入出力操作


#include <sys/asynch.h>

int aioread(int fildes, char *bufp, int bufs, off_t offset,
    int whence, aio_result_t *resultp);

int aiowrite(int filedes, const char *bufp, int bufs,
    off_t offset, int whence, aio_result_t *resultp);

aio_result_t *aiowait(const struct timeval *timeout);

int aiocancel(aio_result_t *resultp);

aioread(3AIO)aiowrite(3AIO) の形式は、pread(2)pwrite(2) の形式にそれぞれ似ています。違いは、引数リストの最後に引数が 1 つ追加されていることです。aioread() または aiowrite() を呼び出すと、入出力操作が開始されます (あるいは、入出力要求が待ち行列に入れられます)。

この呼び出しはブロックされずに復帰し、resultp の指す構造体に終了状態が戻されます。これは aio_result_t 型の項目で、次のフィールドで構成されています。


int aio_return;
int aio_errno;

呼び出しが失敗すると、aio_errno にエラーコードが設定されます。そうでない場合は、このフィールドには操作要求が正常に待ち行列に入れられたことを示す AIO_INPROGRESS が設定されます。

非同期入出力操作の完了は、aiowait(3AIO) で待つことができます。この関数は、最初の aioread(3AIO)、または aiowrite(3) で指定した aio_result_t 構造体へのポインタを返します。

この時点で aio_result_t には、read(2) または write(2) のどちらかが非同期バージョン以外で呼ばれた時と同じ情報が設定されます。この read または write が正常終了した場合、aio_return には読み書きされたバイト数が設定されます。異常終了した場合、aio_return には -1、aio_errno にはエラーコードが設定されます。

aiowait() には timeout 引数があり、呼び出し側の待ち時間を設定できます。ここに NULL ポインタを指定すれば、無期限に待つという意味になります。また、値 0 が設定されている構造体を指すポインタの場合は、まったく待たないという意味になります。

非同期入出力操作を開始し別の処理を行なって aiowait() で操作の完了を待つ、あるいは操作完了時に非同期的に送られてくる SIGIO を利用するという方法もあります。

保留状態の入出力操作を取り消すときは、aiocancel() を使用します。このルーチンを呼び出すときは、取り消そうとする非同期入出力操作の結果を格納するアドレスを引数で指定します。

共有入出力と新しい入出力システムコール

複数のスレッドが同じファイル記述子を使って同時に入出力操作を行う場合、従来の UNIX の入出力インタフェースがスレッドに対して安全ではない場合があります。この問題が生じるのは、入出力が逐次的に行われない場合です。システムコール lseek(2) でファイルオフセットを設定し、そのオフセットで次の read(2) または write(2) を呼び出してファイル内の操作開始位置を指定する場合です。このとき、同じファイル記述子に対して複数のスレッドが lseeks() を実行してしまうと矛盾が生じます。

この矛盾は、新しいシステムコール pread(2)pwrite(2) で回避できます。


#include <sys/types.h>
#include <unistd.h>

ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);

ssize_t pwrite(int filedes, void *buf, size_t nbyte,
    off_t offset);

これらのシステムコールの動作は、ファイルオフセットを指定するための引数が追加されていることを除いて、read(2)write(2) とそれぞれ同じです。lseek(2) の代わりに、この引数でオフセットを指定すれば、複数のスレッドから同じファイル記述子に対して安全に入出力操作を実行できます。

getc(3C) と putc(3C) の代替

標準入出力に関して、もう 1 つ問題があります。getc(3C)putc(3C) などは、マクロとして実装されているため非常に高速に動作するという理由でよく使用されています。プログラムのループ内で使うときも、効率を気にする必要がないからです。

しかし、これらは、スレッドに対して安全になるよう変更されたため、以前よりも負荷が大きくなっています。変更後、(少なくとも) 2 つの内部サブルーチンが、相互排他のロックと解除のために呼び出されています。

この問題を回避するために、これらの代替マクロとして getc_unlocked(3C)putc_unlocked(3C) が提供されています。

これらの代替マクロは mutex をロックしないので、スレッドに対して安全ではない元の getc(3C) および putc(3C) と同程度に高速です。

しかし、それらをスレッドに対して安全な方法で使うためには、標準入出力ストリームを保護する mutex を flockfile(3C) および funlockfile(3C) で明示的にロックまたは解除しなければなりません。ループの外側を flockfile()funlockfile() で囲み、ループの内側で getc_unlocked()putc_unlocked() を呼び出します。