home ホーム search 検索 -  login ログイン  | reload edit datainfo version cmd icon diff delete  | help ヘルプ

C言語系/「デーモン君のソース探検」読書メモ/A10, popen(3)

C言語系/「デーモン君のソース探検」読書メモ/A10, popen(3)

C言語系 / 「デーモン君のソース探検」読書メモ / A10, popen(3)
id: 850 所有者: msakamoto-sf    作成日: 2010-11-24 11:41:21
カテゴリ: BSD C言語 

お題:popen(3)でNetBSDが"r+"モードをサポートしている仕組みを調査せよ

※この章は「デーモン君のソース探検」に載っていませんが、msakamoto-sf自身が個人的に興味を持って調べ、"Appendix"として読書メモシリーズに入れてありますのでご注意下さい。


popen(3)とモード

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+"対応の実装方法を調べることにする。

popen(3)を使ってみる

"r", "w", "r+"の三種類のモードを使った簡単なサンプルで動作を確認する。

popen_child.c

まず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;
}
popen_parent_r.c ("r")

"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
popen_parent_w.c ("w")

"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
popen_parent_rw.c ("r+")

"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

popen(3)のソースコード

場所の確認:

$ 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"は省略する。

popen()を読んでみる

最初に"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()のソースを読んでみる。シンプルで、処理内容としては以下のとおり。

  1. 引数のファイルポインタを元に、struct pidのリストを辿って対応するPIDを取得。
  2. fclose()
  3. waitpid(取得したPID)
  4. struct pidをリストから外してfree()。

特に解説は不要だろう。

/*
 * 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"の判定で、文字列全体を比較しているのではなく、

*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+"モードをサポートしている仕組みが判明した。

  1. "r+"の場合はsocketpair(2)によりファイル記述子の対が作られ、子プロセス側ではSTDIN, STDOUTで同じファイル記述子が使われる。
  2. socketpair(2)の場合、端点のread/writeの区別が無い。このため、端点に対する読み書き両方の操作が可能となり、"r+"の機能が実現される。

今回のお題については、ここまで。



プレーンテキスト形式でダウンロード
現在のバージョン : 1
更新者: msakamoto-sf
更新日: 2010-11-24 11:45:35
md5:7f3e861bd92a6a3cdcd41c3a2abcedd2
sha1:783dcbeb5f170759cc66716802d343ed9104447b
コメント
コメントを投稿するにはログインして下さい。