お題:popen(3)でNetBSDが"r+"モードをサポートしている仕組みを調査せよ
※この章は「デーモン君のソース探検」に載っていませんが、msakamoto-sf自身が個人的に興味を持って調べ、"Appendix"として読書メモシリーズに入れてありますのでご注意下さい。
popen()はファイルポインタ(stream)形式で子プロセスとの間のIPCを実現するライブラリ関数である。UNIXプログラミングの書籍でも、fork()/exec()/pipe()/dup()/dup2()の解説が終わった頃に、それらをまとめて処理してくれるライブラリ関数として紹介される場合が多い。サンプルコードや読者向けの課題として、popen()を自分で実装してみるという内容だったりする。
OpenGroup SUSv3 によるプロトタイプは次のようになっている。
#include <stdio.h> FILE *popen(const char *command, const char *mode); int pclose(FILE *stream);
今回のお題では、popen()の第二引数"mode"に着目している。OpenGroupのSUSv3によると、動作が規定されている"mode"は以下の2種類である。
"r" : 親プロセスが、子プロセスのSTDOUTをreadする。 "w" : 親プロセスが、子プロセスのSTDINへwriteする。
上記2種類以外の"mode"が指定された場合は、その動作は "undefined" となっている。
たとえばCentOS 5.xやSUSE Linux 10.xのmanpageを参照すると、"r", "w" の二種類のみ許可されそれ以外だとerrno = EINVALとなる、と記載されている。
ところが、FreeBSD 8.x/NetBSD 1.6では読み/書き両方を可能とする
"r+"
というモードもサポートされている。(OpenBSD 4.xではサポートされていないようだ)
今回は、"r+"をどのようにサポートしているのか、その仕組みを調査してみる。
ヒントとしては NetBSD1.6 のmanpageで、
RETURN VALUES The popen() function returns NULL if the fork(2), pipe(2), or socketpair(2) calls fail, or if it cannot allocate memory.
とあり、"socketpair(2)"を使っている箇所が関連しているのではないかと推測できる。
なお、FreeBSDのmanではsocketpair(2)には言及されていない。このことからNetBSD/FreeBSDで"r+"対応の実装方法が異なっていることが予想される。とりあえず今回はNetBSD1.6における"r+"対応の実装方法を調べることにする。
"r", "w", "r+"の三種類のモードを使った簡単なサンプルで動作を確認する。
まずpopen(3)から起動する子プロセスを作成する。標準入力をfgets()で読み、子プロセス用のメッセージを先頭に挿入して標準出力に書く。
popen_child.c:
#include <stdio.h> #include <unistd.h> int main(int argc, char *argv[]) { char buf[512]; while (NULL != fgets(buf, sizeof(buf), stdin)) { fprintf(stdout, "popen_child(%d) : %s", getpid(), buf); fflush(stdout); } return 0; }
"r"モードのサンプル, popen_parent_r.c:
#include <stdio.h> #include <unistd.h> #include <errno.h> int main(int argc, char *argv[]) { FILE *fp; char buf[1024]; if (NULL == (fp = popen("./popen_child", "r"))) { perror("popen()"); exit(1); } while (NULL != fgets(buf, sizeof(buf), fp)) { printf("%s(%d) : %s", argv[0], getpid(), buf); } if (ferror(fp)) { perror("fgets()"); } if (-1 == pclose(fp)) { perror("pclose()"); } return 0; }
実行:
$ ./popen_parent_r abc ./popen_parent_r(353) : popen_child(355) : abc def ./popen_parent_r(353) : popen_child(355) : def (EOF入力) $
実行中のプロセス状態:
$ ps wx -o pid,ppid,pgid,jobc,sess,tpgid,tsess,tty,stat,command PID PPID PGID JOBC SESS TGPID TSESS TTY STAT COMMAND ... 209 208 209 0 bf9000 353 c0bf9000 ttyp0 Ss -bash 353 209 353 1 bf9000 353 c0bf9000 ttyp0 S+ ./popen_parent_r 354 353 353 1 bf9000 353 c0bf9000 ttyp0 S+ sh -c ./popen_child 355 354 353 1 bf9000 353 c0bf9000 ttyp0 S+ ./popen_child
"w"モードのサンプル, popen_parent_w.c:
#include <stdio.h> #include <unistd.h> #include <errno.h> int main(int argc, char *argv[]) { FILE *fp; char buf[1024]; if (NULL == (fp = popen("./popen_child", "w"))) { perror("popen()"); exit(1); } while (NULL != fgets(buf, sizeof(buf), stdin)) { fprintf(fp, "%s(%d) : %s", argv[0], getpid(), buf); fflush(fp); } if (-1 == pclose(fp)) { perror("pclose()"); } return 0; }
実行:
$ ./popen_parent_w abc popen_child(361) : ./popen_parent_w(359) : abc def popen_child(361) : ./popen_parent_w(359) : def (EOF入力) $
実行中のプロセス状態:
$ ps wx -o pid,ppid,pgid,jobc,sess,tpgid,tsess,tty,stat,command ... 209 208 209 0 bf9000 359 c0bf9000 ttyp0 Ss -bash 359 209 359 1 bf9000 359 c0bf9000 ttyp0 S+ ./popen_parent_w 360 359 359 1 bf9000 359 c0bf9000 ttyp0 S+ sh -c ./popen_child 361 360 359 1 bf9000 359 c0bf9000 ttyp0 S+ ./popen_child
"r+"モードのサンプル, popen_parent_rw.c:
#include <stdio.h> #include <unistd.h> #include <errno.h> int main(int argc, char *argv[]) { FILE *fp; char buf[1024]; if (NULL == (fp = popen("./popen_child", "r+"))) { perror("popen()"); exit(1); } while (1) { if (NULL == fgets(buf, sizeof(buf), stdin)) break; fprintf(fp, "%s(%d) W: %s", argv[0], getpid(), buf); fflush(fp); if (NULL == fgets(buf, sizeof(buf), fp)) break; printf("%s(%d) R: %s", argv[0], getpid(), buf); } if (ferror(fp)) { perror("fgets(fp)"); } if (-1 == pclose(fp)) { perror("pclose()"); } return 0; }
実行:
$ ./popen_parent_rw abc ./popen_parent_rw(364) R: popen_child(366) : ./popen_parent_rw(364) W: abc def ./popen_parent_rw(364) R: popen_child(366) : ./popen_parent_rw(364) W: def (EOF入力) $
親が子プロセスにwriteし、子プロセスからの出力を親がreadする動きを確認できた。
実行中のプロセス状態:
$ ps wx -o pid,ppid,pgid,jobc,sess,tpgid,tsess,tty,stat,command ... 209 208 209 0 bf9000 364 c0bf9000 ttyp0 Ss -bash 364 209 364 1 bf9000 364 c0bf9000 ttyp0 S+ ./popen_parent_rw 365 364 364 1 bf9000 364 c0bf9000 ttyp0 S+ sh -c ./popen_child 366 365 364 1 bf9000 364 c0bf9000 ttyp0 S+ ./popen_child
場所の確認:
$ locate popen ... /usr/src/lib/libc/gen/popen.3 /usr/src/lib/libc/gen/popen.c ... $ wc -l /usr/src/lib/libc/gen/popen.c 201 /usr/src/lib/libc/gen/popen.c $ man 3 popen
popen.cにはpopen()とpclose()の両方が定義されているが、コメントを入れても201行と短い。
ざっくりと読んでいく。なお簡便のため、DIAGASSERTや__GNUC__マクロの"#ifdef"は省略する。
最初に"struct pid"というstaticな構造体とそのポインタが定義されている。
static struct pid { struct pid *next; FILE *fp; pid_t pid; } *pidlist;
"next", "list"というキーワードから推測できるとおり、これは単方向のリストを作るための構造体とポインタである。
popen()が返すファイルポインタと、それに関連付けられた子プロセスのPIDを保持するために使う。
子プロセスのPIDは、pclose()で子プロセスをwait()する時に使う。
リストにして複数保持できるようにしているのは、ひとつのプロセス内で複数回popen()を呼べるようにするためと想像できる。
続いてpopen()関数の定義が出てくる。最初は変数宣言となる。
FILE * popen(command, type) const char *command, *type; { struct pid *cur, *old; FILE *iop; int pdes[2], pid, twoway, serrno;
"pdes[2]"はpipe()の戻り値、pidは子プロセスのPIDであることが予想できる。"twoway"は、続くコードを読むとその意味が分かる。
if (strchr(type, '+')) { twoway = 1; type = "r+"; if (socketpair(AF_LOCAL, SOCK_STREAM, 0, pdes) < 0) return (NULL); } else { twoway = 0; if ((*type != 'r' && *type != 'w') || type[1] || (pipe(pdes) < 0)) { errno = EINVAL; return (NULL); } }
"type"に"+"文字を含んでいれば、twowayフラグを立てて、socketpair(2) + AF_LOCALでファイル記述子の対を作成している。
自分はAF_LOCALというAddressFamilyをここで初めて見た。おそらくAF_UNIXなのだろうが、念のためsys/socket.hを確認してみた。
sys/socket.h:
... /* * Address families. */ #define AF_UNSPEC 0 /* unspecified */ #define AF_LOCAL 1 /* local to host (pipes, portals) */ #define AF_UNIX AF_LOCAL /* backward compatibility */ ...
どうやら最初に存在したのはAF_LOCALで、その後AF_UNIXに統一されたが後方互換性のためAF_UNIXをAF_LOCALにdefineしたようだ。
popen()に戻る。"+"を含んでいない場合はpipe(2)でファイル記述子の対を作成する。
その後、struct pid構造体をmallocで確保する。
if ((cur = malloc(sizeof(struct pid))) == NULL) { (void)close(pdes[0]); (void)close(pdes[1]); errno = ENOMEM; return (NULL); }
いよいよvfork(2)で子プロセスを作成する。解説はコメントで埋め込む。
switch (pid = vfork()) { case -1: /* Error. */ serrno = errno; free(cur); (void)close(pdes[0]); (void)close(pdes[1]); errno = serrno; return (NULL); /* NOTREACHED */ case 0: /* Child. */ /* POSIX.2 B.3.2.2 "popen() shall ensure that any streams from previous popen() calls that remain open in the parent process are closed in the new child process. */ for (old = pidlist; old; old = old->next) close(fileno(old->fp)); /* don't allow a flush */ if (*type == 'r') { /* "r" or "r+"が指定されたときは、子プロセス側では まずpdes[0]、入力側を使わないので閉じる */ (void)close(pdes[0]); if (pdes[1] != STDOUT_FILENO) { /* STDOUT用にファイル記述子のコピー + 使わなくなるpdes[1]のclose */ (void)dup2(pdes[1], STDOUT_FILENO); (void)close(pdes[1]); } /* "r+"が指定されていたときは、STDINにも STDOUTと同じファイル記述子をコピーする */ if (twoway) (void)dup2(STDOUT_FILENO, STDIN_FILENO); } else { /* "w"の場合は、子プロセス側ではpdes[1]、 出力側を使わないので閉じる */ (void)close(pdes[1]); if (pdes[0] != STDIN_FILENO) { /* STDIN用にファイル記述子のコピー + 使わなくなるpdes[0]のclose */ (void)dup2(pdes[0], STDIN_FILENO); (void)close(pdes[0]); } } /* 準備完了、shellインタプリタ経由でコマンドを実行 */ execl(_PATH_BSHELL, "sh", "-c", command, NULL); _exit(127); /* NOTREACHED */ }
最後に、vfork()後の親プロセスの処理が続く。セオリー通りであり、最後に"struct pid"構造体をリストにリンクしてpopen()は終了となる。
/* Parent; assume fdopen can't fail. */ if (*type == 'r') { iop = fdopen(pdes[0], type); (void)close(pdes[1]); } else { iop = fdopen(pdes[1], type); (void)close(pdes[0]); } /* Link into list of file descriptors. */ cur->fp = iop; cur->pid = pid; cur->next = pidlist; pidlist = cur; return (iop); } /* popen()終了 */
ざっくりと読んできたが、"r+"はsocketpair(2)により実装され、子プロセス側でSTDOUTと同じファイル記述子をSTDIN側にもコピーすることで、子プロセス側は読み書き両方を親プロセスと接続される。
気になるのは"r"の場合も"r+"の場合も、親側では特に気にせずにpdes[1]をclose()し、pdes[0]でfdopen()している点である。
pipe()の場合はファイル記述子の対の端点はread(pdes[0])/write(pdes[1])の区別がある。
"r+"の場合、pdes[0]に対してread/write両方の操作を行うことになるが、エラーとならないのか?
これについては、socketpair(2)の作成するファイル記述子の対は、pipe(2)の作成するそれと異なり、端点のread/writeの区別が無い事が回答になるだろう。このため、親子ともども、"r+"の場合はどちらの端点に対してもread/writeの両方の操作を行える。
最後にpclose()のソースを読んでみる。シンプルで、処理内容としては以下のとおり。
特に解説は不要だろう。
/* * pclose -- * Pclose returns -1 if stream is not associated with a `popened' command, * if already `pclosed', or waitpid returns an error. */ int pclose(iop) FILE *iop; { struct pid *cur, *last; int pstat; pid_t pid; /* Find the appropriate file pointer. */ for (last = NULL, cur = pidlist; cur; last = cur, cur = cur->next) if (cur->fp == iop) break; if (cur == NULL) return (-1); (void)fclose(iop); do { pid = waitpid(cur->pid, &pstat, 0); } while (pid == -1 && errno == EINTR); /* Remove the entry from the linked list. */ if (last == NULL) pidlist = cur->next; else last->next = cur->next; free(cur); return (pid == -1 ? -1 : pstat); }
"type"の判定で、文字列全体を比較しているのではなく、
*type != 'r' && *type != 'w' や if (*type == 'r') {
のように、「先頭一文字」で比較しているのが気になった人もいるかもしれない。
これについては OpenGrup SUSv3 のpopenの"APPLICATION USAGE"で次のように書かれている。
Note that historical implementations of popen() only check to see if the first character of mode is r. Thus, a mode of robert the robot would be treated as mode r, and a mode of anything else would be treated as mode w.
どうやらpopen()での「先頭一文字が"r"か否か」という判定基準は昔から使われてきたようだ。
この判定基準だと"robert"や"robot"なども"r"として処理され、先頭一文字が"r"以外なら"w"として処理される。
過去のソースでそうした挙動に依存したものもあるかもしれないので、互換性を維持するためにもこの判定基準を「より厳しく」することは難しいと推測される。
以上でpopen(3)でNetBSDが"r+"モードをサポートしている仕組みが判明した。
今回のお題については、ここまで。