
>> (1)よりつづく
本記事のサンプルコードは、以下のリンクよりダウンロードすることができます。記事と合わせてご参照ください。
[ サンプルコード ]
子プロセスの同期/非同期
4epoll、5epoll-multiへのダミー処理追加には、いくつかの注意点があります。1baseと同じように、epollによるイベントループがその場で子プロセスの終了を待つようにすると、1つのダミー処理の終了を他のセッションが待つことになってしまいます。 これはepollによる、イベントループのI/Oの多重性を損なう大きな問題です( 図1.9 )。
図1.9 epoll の多重性を損ねるダミー処理追加

(セッションCは割愛)
この問題はマルチスレッドを用いず、シングルスレッドで自らI/Oを多重化する構造に由来します。
epollによるイベントループを用いた構造では、子プロセスの終了を同期的に待ち合わせることはせず、親プロセスに対し送信されるSIGCHLDシグナルにより、子プロセスの終了を判断するようにし、 図1.8 と同様のインタリーブを実現する必要があります。
プロセス管理一般の話になりますが、親プロセスは通常、 wait(2) (またはそのファミリ)を発行し、子プロセスがまだ占有しているシステム資源を解放します。 wait(2) はたびたび誤解されることがあり、その機能は子プロセスの終了を「待つ」ことだけではありません。同期的に wait(2) を使用する場合は、待つことが主な目的であることが多く、実際何も問題はありませんが、終了したプロセスはシステム内から完全に居なくなるわけではなく、「ゾンビ」という状態で居座り続けます。一切動作せずプロセス内で使用した資源も解放済みですが、存在している以上、プロセスとして体をなす最低限のシステム資源を確保し続けます。このゾンビプロセスを完全に廃棄し、システム資源を解放するのも wait(2) の重要な機能です。言い換えるとその場で子プロセスの終了を待ち合わせない非同期の場合でも、 wait(2) は発行しなければなりません。
子プロセスをゾンビにさせない方法もありますが、本記事では取り扱いません。
epoll によるイベントループでは子プロセスの終了をその場では待ってはいけませんが、 wait(2) の発行を省略することもできません。省略するとシステムがゾンビプロセスだらけになってしまい、最後には新規プロセスを起動出来なくなってしまうでしょう。
少し話が逸れますが、現実には wait(2) が発行されない子プロセスが発生することは容易にあり得ます。プログラムミスもあるでしょうし、親プロセスが wait(2) する前に強制的に終了させられたなどは普通に起こり得ます。このためシステム側にもゾンビプロセス対策(対策と言っても特別のものではなく、通常機能の範囲です)があり、先に親プロセスが終了してしまった子プロセスは、強制的に init プロセスの子とし、 init が wait(2) を発行し、ゾンビプロセスを廃棄します。
サンプルプログラムではダミー処理用に子プロセスを起動しますが、epollによるイベントループはその終了を非同期に検知し、 wait(2) を発行し、システムからゾンビプロセスを破棄することにします。この検知にはシグナルを用い、前記事でも簡単に触れておいた signalfd(2) を使用します。
signalfd
子プロセスが終了すると親プロセスにはシグナルSIGCHLDが送られます。 sigaction(2) によりシグナルハンドラを登録するのが従来より一般的な処理方法ですが、本記事ではepoll同様、Linuxに比較的新しく実装された signalfd(2) を用い、epollによるイベントループ内でシグナルを処理してみます。
epoll インタフェースには select(2) / pselect(2) 、 poll(2) / ppoll(2) の関係同様に、シグナルマスクを指定できる epoll_pwait(2) も用意されていますが、本記事では取り上げません。
signalfd(2) はファイルの open(2) 同様に、ファイルディスクリプタを返します。このファイルディスクリプタを read(2) すると、シグナルが届いていれば、そのシグナルに関する詳細な情報を読み取れます。届いていなければ、届くまで read(2) は待ち続けます(ブロックする)。epollによるイベントループでsignalfdのファイルディスクリプタを見張り、読み取り可能を検知した時がシグナルが届いた時です。ここで wait(2) を発行すれば、ブロックすることはありませんし、必ず終了した子プロセスのプロセスIDが得られます。
しかし、この方式には注意が必要です。現代のシグナルはシステム内部で「保留」(suspend)されることがあります。シグナルハンドラは通常単純でごく短時間で完了する処理しか行いませんが(多くの処理をすべきではない)、この間に同じシグナルがもう1つ届いた場合には、システム側で保留し、シグナルハンドラ完了後に改めて届けてくれます。
この動作はシグナルを失わず信頼性を高めるものですが、すでにシグナルを保留している時に、さらにもう1つ同じシグナルが発生すると、保留せずに破棄します。つまり(シグナル種類ごとに)複数は保留しないという動作です。
一般にシグナルの宛先はプロセスでもスレッドでも良いのですが、2pthread-unlimited、3pthread-poolでは子プロセスを起動するのがスレッドのため、SIGCHLDはスレッドに届けられます。また同期的に待ち合わせるため保留されることもありません。
しかし、4epollはシングルスレッドで複数の子プロセスを起動します。さらにシグナルハンドラを用いず、signalfdをイベントループで見張るため、発生したSIGCHLDはすぐには処理されず、システム内でまず保留されます。多くのセッションを同時に処理する状態では、続くSIGCHLDが破棄されてしまうことはまず間違いないでしょう。
SIGCHLDは標準シグナルという種類ですが、リアルタイムシグナルという種類はすでに保留済みのシグナルが新たに発生しても破棄されず、順序も維持したまま届けられます。
シグナルの保留、破棄は理屈の上ではいつでもどこでも起こり得るものですが、通常のシグナルハンドラを登録する方式を用い、ハンドラ内の処理も少なければ、破棄される機会はずっと少なくなり、実際にはそれほどお目にかからないと思われます。言い換えるとsignalfdを見張るイベントループでは当り前のように破棄されてしまいます。
注意は必要ですが、この点については、「signalfdが読み取り可能になった時には wait(2) すべきゾンビプロセスが複数存在するかもしれない」ということを意識すれば、対応は困難ではありません。
epollでsignalfdを見張る場合の注意点はもう1つあります。signalfdから読み取れる情報(siginfo)は必ず読み出さなければならないことです。従来の sigaction(2) で登録するシグナルハンドラのパラメータを考えるとわかります。 struct sigaction の sa_handler ではなく、 sa_sigaction の方です。パラメータには siginfo_t * があります。この情報はシステム側が領域を確保し、シグナルハンドラ実行後に破棄されるものです。
epollによるイベントループではシグナルハンドラを使用しないため、この情報はシステム側に留まったままとなり、これを参照可能にすると同時に破棄するのがsignalfdからの読み取りです。signalfdから情報を読み出してやらないと情報がシステムが領域を解放できず、また当然ながらepollがいつまでも読み取り可能を検知し続けてしまいます。
サンプルプログラムの改造では、signalfdが読み取り可能になる度に、signalfdからの読み出しを一度、その後 wait(2) を(その時点の)ゾンビプロセスが居なくなるまで何度でも実行することとします。
セッションソケットの状態遷移
以上の注意点を踏まえ、4epoll.cのどこへどのようにダミー処理を追加するかを考えます。epollによるイベントループでは、セッションソケットはもともと次のように処理されていました。
- acceptしたソケットをepoll対象へ加える
- 読み取り可能(HTTPリクエストが届いた)かを見張る
- 読み取り可能になった
- HTTPリクエストを読み取り、
- 対象ファイルパスを確認する
- 書き込み可能(送信できるか)を見張る
- 書き込み可能になった
- 対象ファイルを送信する
- ソケットをepoll対象から外しcloseする
ダミー処理の追加位置は、リクエスト読み取り後、かつファイル送信前ですが、その間にイベントループが回ります。セッションソケットはすでにepoll対象に含まれており、そのままではepollがI/O可能かどうかを検知して、ダミー処理完了前にイベントループが処理してしまいます。このため、一時的にepoll対象から外すように変更します。
関連して、epoll に指定していたエッジトリガをレベルトリガへ戻します。
- signalfdを見張る
- acceptしたソケットをepoll対象へ加える
- 読み取り可能(HTTPリクエストが届いた)かを見張る
- 読み取り可能になった。
- HTTPリクエストを読み取り
- 対象ファイルパスを確認する
- セッションソケットをepoll対象から外す
- forkしダミー処理実行
- SIGCHLDが到着し、signalfdが読み取り可能になった
- セッションソケットを再びepoll対象へ加える
- 書き込み可能 (送信できるか) を見張る
- 書き込み可能になった
- 対象ファイルを送信する
- ソケットをepoll対象から外しcloseする
ソースコードの変更 (2)
4epoll.cへのダミー処理追加の内容が決定できました。いよいよ変更します。まず、新規関数 dummy_wait() を追加します。内容はsignalfdの読み出し、ゾンビプロセスの破棄、ソケットセッションの復帰です。
前述の 1base.cでは同期的に子プロセスの終了を待つため、 waitpid(2) の option には 0を渡していましたが、こちらではいくつあるかわからない子プロセスを繰り返し waitpid(2) するため、 WNOHANG を渡します。
厳密には、 dummy_wait() が wait(2) してもすでにゾンビプロセスが居なくなっている場合があり得ます。これは前回の dummy_wait() コールですべてのゾンビプロセスを破棄している際中にSIGCHLDが発生した場合に起こります。
wait(2) 発行はゾンビプロセスが居なくなるまで繰り返されますから、ループ中に終了した子プロセスはすぐに wait(2) されますが、siginfoはシステム内に保留されたままになります。これはsignalfdからの読み出しと複数回の wait(2) の順序に起因する状態で、余分な wait(2) 以外に問題はなく、実害はありません。
仮に複数回の wait(2) の後でsignalfdを読み出すようにすると、子プロセスの終了を検知できなくなる恐れがあります。
dummy_wait()要約
/* 子プロセスとセッションソケットの対応 */ int sock_child []; dummy_wait(sigfd, epfd) { /* siginfo 読み出し */ read(sigfd); ev.events = EPOLLOUT; while (1) { /* 子プロセスの破棄 */ pid = waitpid (pid, &status, WNOHANG); if (pid <= 0) break ; /* 子プロセスに対応するソケットを再び見張る */ ev.data.fd = sock_child [ pid ]; epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev); } }
また、前述の dummy_busy() のイベントループ対応も加えます。
dummy_busy()のイベントループ対応要約 (2)
dummy_busy(sock, do_wait) { pid = fork(); if (pid > 0) { /* 親プロセス */ + if (!do_wait) + /* イベントループの場合 */ + /* セッションと子プロセスの対応を記録する */ + /* 子プロセスの終了を待ち合わせずリターンする */ + sock_child [ pid ] = sock ; else /* 子プロセスの終了待ち合わせ */ waitpid (pid, &status, 0); } else /* 子プロセス --- ダミー処理 */ exec "find /tmp > /dev/null 2>&1"; }
4epoll.c では signalfd(2) の発行とイベントループへの追加、および dummy_wait() コールを追加します。セッションソケットのepoll対象から外し、ダミー処理を起動するのは do_read() です。
4epoll.c へのダミー処理追加要約
do_read(sock, epfd) { read(); /* HTTPリクエスト読み取り */ + /* このソケットはもう見張る必要がない */ + epoll_ctl(epfd, EPOLL_CTL_DEL, sock, NULL); + dummy_busy(sock, /* do_wait */0); } main () { /* リスニングポートへのデータ到着を見張る */ epfd = epoll_create(1); ev.events = EPOLLIN; ev.data.fd = lsock; epoll_ctl(epfd, EPOLL_CTL_ADD, lsock, &ev); + /* SIGCHLDを見張る */ + sigemptyset(&mask); + sigaddset (&mask, SIGCHLD); + sigprocmask(SIG_BLOCK, &mask, NULL); + sigfd = signalfd (-1, &mask); + ev.events = EPOLLIN; + ev.data.fd = sigfd ; + epoll_ctl(epfd, EPOLL_CTL_ADD, sigfd, &ev); while (1) { struct epoll_event e[]; /* I/O可能なソケットを処理する */ nfd = epoll_wait(epfd, e); for (i = 0; i < nfd; i++) { sock = e[i].data.fd ; if (sock == lsock) do_accept(sock, epfd); + else if (sock == sigfd) + dummy_wait(sigfd, epfd); else if (e[i].events &EPOLLIN) do_read (sock, epfd); else if (e[i].events & EPOLLOUT ) do_sfc( sock, epfd ); } } }
5epoll-multi.cへのダミー処理追加は4epoll.cと基本的に変りません。
ただし、今回は測定環境がデュアルコアであり、4epoll.cと差をつけるために子プロセス数はCPU数に一致させました(前回は起動する4epoll相当の子プロセスをCPU数-1としていた)。上記の変更を加えたサンプルプログラムは、付属ソースコード一式に含めてあります。