この記事では、「タイムアウト付きのfgets」を実現する3種類の方法について解説していきます。
具体的な実装内容は以下となります。
-
select
のタイムアウト機能を利用する。 -
fork
を使用して子プロセスで時間を計測する。 -
pthread_create
を使用してスレッドで時間を計測する。
マルチプロセス、マルチスレッド、ファイルディスクリプタの監視についてざっくりと理解できるような内容となっています。
※ こちらは 42 Tokyo Advent Calendar 2022 の 21日目の記事です。
使用するコード
fgetsの動作
char *fgets(char *str, int size, FILE *stream);
-
fgets
は、stream から最大で size - 1 個の文字を読み込み、 str が指すバッファーに格納します。 - 通常のファイル以外にも、streamにstdinを与えることで標準入力から読み込むこともできます。(それはそう)
今回はタイムアウト機能を実装することで、一定時間経過したら処理を中断させるようにします。
select関数で実装する
まずは、select
のタイムアウト機能を利用して実装します。
select
はファイルディスクリプタを監視し、I/O操作(readやwrite)が実行可能かを知らせてくれます。
例えば標準入力では「キー入力」+「エンター」のタイミングで読み込み可能だと判定されます。
select
の引数は以下の通りで、タイムアウト時間を設定することができます。
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
-
nfds
:以下の3つの集合に含まれるファイルディスクリプタの最大値に 1 を足したもの。 -
readfds
:読み込み状態を監視するファイルディスクリプタの集合 -
writefds
:書き込み状態を監視するファイルディスクリプタの集合 -
exceptfds
:例外状態を監視するファイルディスクリプタの集合 -
timeout
:タイムアウト時間
タイムアウト時間を設定した場合は、監視対象に変化がない場合でも、タイムアウト時間が経過した時点で select
がreturnされるようになります。
この仕組みを利用して、実行可能の場合は、fgets
で読み込みをして、タイムアウトした場合は、NULLを返すような実装にします。
ファイルディスクリプタの集合の操作には、以下の2つを使用します。
-
FD_ZERO
:集合(fds)を初期化する。 -
FD_SET
:集合(fds)にファイルディスクリプタ(fd)をセットする。
タイムアウト時間を設定するtimeval構造体は以下の情報を持ちます。
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* マイクロ秒 */
};
コード例
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <unistd.h>
#define TIMEOUT 5
char *tfgets(char *str, int size, FILE *stream)
{
fd_set rfds;
struct timeval tv;
int retval;
// FILE構造体からディスクリプタを取得する
int fd = fileno(stream);
// 監視対象のfdをセットする
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
// 監視する時間を設定する
tv.tv_sec = TIMEOUT;
tv.tv_usec = 0;
// timeoutする -> 0
// fdが読み込み可能になる -> 1 (読み込み可能になったfdの数)
retval = select(1, &rfds, NULL, NULL, &tv);
if (retval > 0)
return fgets(str, size, stream);
else
return NULL;
}
とてもシンプルに実装できました。
実は上記のコード例は、select
の man page に記載されているサンプルコードを数行変更したものです。これは本題ではないですが、使い方が分からない関数と遭遇したときに、一度サンプルコードを写経してみると理解できることがあるのでおすすめです。
この関数のポイント
-
select
を使用したファイルディスクリプタの監視方法 - (おまけ)man pageのサンプルコードは分かりやすい。
マルチプロセスで実装する
次に fork
を使用して、マルチプロセスで実装します。
fork
は呼び出し元のプロセス(親プロセス)を複製して、新しいプロセス(子プロセス)を生成します。
そのため以下の実装が考えられます。
- 親プロセスは子プロセス生成後に、
fgets
を実行する。 - 子プロセスでタイムアウト時間を計測する。
子プロセスが終了すると、親プロセスには終了シグナル(SIGCHLD)が送られます。
そのため、タイムアウト秒経過したことを親プロセスが検知して、fgets
の処理を中断することができそうです。
しかし、ここで一つ問題となるのは、fgets
の処理を中断させる方法がないことです。
それでは、タイムアウト時間経過したことが判定できても意味がありません。
そこで今回は、sigsetjmp
と siglongjmp
を使用します。
-
sigsetjmp
によってスタック環境およびシグナル・マスクを保管する。 -
siglongjmp
で保管した環境とシグナル・マスクを復元する。
上記の仕組みを利用して、タイムアウト時間経過した場合は、fgets
の処理を途中で中断させることができます。
ちなみに fgets
が時間内に行われた場合は、親プロセスで kill
を実行してスリープしている子プロセスを終了させます。
コード例
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <setjmp.h>
#include <sys/wait.h>
#include <unistd.h>
#define TIMEOUT 5
static sigjmp_buf env;
static void handler(int sig)
{
wait(NULL);
// sigsetjmpが1を返す
siglongjmp(env, 1);
}
char *tfgets(char *s, int size, FILE *stream)
{
pid_t pid;
char *str = NULL;
// SIGCHLDを補足するシグナルハンドラを設定する
signal(SIGCHLD, handler);
// 子プロセス -> pid = 0
// 親プロセス -> pid = 子プロセスのID
if ((pid = fork()) == 0)
{
/// 子プロセス
sleep(TIMEOUT);
exit(0);
}
else
{
/// 親プロセス
// 子プロセスによってsiglongjmpが実行された場合は0を返さない
if (sigsetjmp(env, 1) == 0)
{
str = fgets(s, size, stream);
kill(pid, SIGKILL);
// siglongjmpが呼ばれるまで待つ
pause();
}
return str;
}
}
注意点は、 wait
を実行しないと終了した子プロセスがゾンビプロセスとなって残り続けてしまうことです。忘れずに回収してあげましょう。
この関数のポイント
- プロセスの管理方法
- 子プロセスの回収
- シグナルハンドリング
- sigsetjmp、siglongjmpの使い方
マルチスレッドで実装する
最後に pthread_create
を使用して、マルチスレッドで実装します。
pthread_create
は呼び出したプロセス内に新しいスレッドを生成します。
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
-
thread
:スレッドを識別する値 -
attr
:スレッドの属性 -
start_routine
:スレッドが行う処理(第4引数のarg
を受け取って実行する)
2つのスレッドを生成して、start_routine
に fgets
と sleep
それぞれの処理を与えます。
また今回は、fgets
の実行中にタイムアウトしても割り込んで処理を中断させる必要はありません。
なぜなら、スレッドをデタッチ状態にすることによって、処理を待つ必要がなくなるからです。もちろんリソースも解放されます。
逆にスレッドが終わるまで処理を待ちたい場合は、pthread_join
を使います。
そして次のようにスレッドを実行します。
-
fgets
:処理が終わるまで待たない。(デタッチ状態) -
sleep
:処理が終わるまで待つ。
fgets
が時間内に実行された場合は、pthread_cancel
を使って、スリープのスレッドを途中で中断させます。
コード例
#include <unistd.h>
#include <stdio.h>
#include <pthread.h>
#define TIMEOUT 5
// スレッドの処理に与えられる引数が1つのみなので構造体にまとめる
typedef struct
{
char *str;
int size;
FILE *stream;
pthread_t sleep_tid;
} fgets_args_t;
// fgetsの戻り値として使用する
static char *retval;
static void *fgets_thread(void *vargp)
{
// pthread_self:呼び出したスレッドのIDを取得する
// pthread_detach:実行中のスレッドをデタッチ状態にする
pthread_detach(pthread_self());
fgets_args_t *argp = (fgets_args_t *)vargp;
// fgetsを実行する
retval = fgets(argp->str, argp->size, argp->stream);
// スリープを実行しているスレッドにキャンセル要求を送る
pthread_cancel(argp->sleep_tid);
return NULL;
}
static void *sleep_thread(void *vargp)
{
sleep(TIMEOUT);
return NULL;
}
char *tfgets(char *str, int size, FILE *stream)
{
pthread_t fgets_tid;
pthread_t sleep_tid;
// fgetsのスレッドの引数を作成する
fgets_args_t args = {.str = str, .size = size, .stream = stream};
retval = NULL;
// sleepを実行するスレッドを作成する
pthread_create(&sleep_tid, NULL, sleep_thread, NULL);
// sleepスレッドのIDを引数に追加する
args.sleep_tid = sleep_tid;
// fgetsを実行するスレッドを作成する
pthread_create(&fgets_tid, NULL, fgets_thread, &args);
// sleepスレッドの終了を待つ
pthread_join(sleep_tid, NULL);
return retval;
}
スレッドの場合、プロセスと違ってメモリ空間が共有されるので 、fgets
が読み込んだ値をそのままスレッドの生成元に反映されているところがミソですね。
この関数のポイント
- スレッドの使い方
- メモリの空間の共有
- pthread_joinとpthread_detachの使い分け
まとめ
今回は3つの異なる手法でタイムアウト付きのfgetsを実装していきました。
マルチプロセス、マルチスレッド、ファイルディスクリプタの監視についての理解につながれば幸いです。
ちなみに42 Tokyo ではこれらの仕組みについてより詳しく学ぶことができます。
- selectによるファイルディスクリプタの監視:HTTPサーバーの実装
- マルチプロセス:shellの実装
- マルチスレッド:食事をする哲学者の問題
興味ある方はぜひ一緒に学びましょう!お待ちしてます!
おわりに
本記事で使用したソースはGitHubに上げているので参考程度にご覧ください。(簡略化のためエラー処理は省いています)
明日は、@pettei47さんが「フルタイムの社会人でも42は楽しめる」という話を書いてくれるようです。結構ハードそうですが本当なんでしょうか...?これは記事を読んでみないと分かりませんね!お楽しみに!