select モジュールの基本

呼称: select モジュールの基本
目的: select モジュールの動作を学ぶ
特徴: 標準出力/エラーの判別をファイルディスクリプタで行う
用例: 複数のソケットやパイプから非同期にデータのやり取りをする
備考: subprocess の PIPE 経由でデータをやり取りする際、デッドロック回避の1つ方法として使用できる

pokarim さんのコメント で select モジュールを知りました。非同期な I/O を実装する1つの方法になります。複数のソケットに対して非同期にデータの送受信の到着を受け付けるときによく使用されるようです。ここで select で扱えるオブジェクトは Windows ではソケットのみですが、Linux ではファイルオブジェクトも扱えるのでプロセスを実行する Popen の入出力にも使用できます。

公式マニュアル、クイックリファレンス、クックブックと色々読んでみたのですが、説明されている内容がよく分からなかったので、実際にどのような動作になるかを調べてみました。文章だけで知らない概念を理解するのは難しいです(> <)。

先ず、標準出力/エラーに文字列を出力するプログラムを作成します。引数が奇数のとき、つまり出力する文字数が奇数のときは標準エラーを出力して3秒間 sleep した後に標準出力を出力します。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void print_string(int num, char c, FILE *fp);

int
main(int argc, char **argv)
{
    int num = 1;
    if (argc == 2) {
        num = atoi(argv[1]);
    }
    
    if (num % 2 == 0) {
        print_string(num, 's', stdout);
        print_string(num, 'e', stderr);
    } else {
        print_string(num, 'e', stderr);
        sleep(3);
        print_string(num, 's', stdout);
    }
    return EXIT_SUCCESS;
}

void
print_string(int num, char c, FILE *fp)
{
    int i;
    char *s;
    s = malloc(sizeof(char) * num + 1);
    for (i=0; i<num; i++) {
        s[i] = c;
    }
    fprintf(fp, s);
    free(s);
}

実行結果。

$ gcc -Wall return_str.c -o return_str
$ ./return_str 1 # e を出力して3秒間 sleep する
es
$ ./return_str 2 
eess

このプログラムを subprocess モジュールで実行し、その標準出力/エラーを PIPE 経由で受け取ります。select モジュールには、読み込み、書き込み、例外を待機する3つのオブジェクトと待機時間を引数に与えることができます。このサンプルでは読み込みオブジェクト(プロセスの標準出力/エラー)のみセットします。待機時間は1秒待つようにします。Popen のやり取りで select を使用する1つのメリットとしてこの待機時間を設定することができます。

#!/bin/env python

import time
from subprocess import Popen, PIPE
from select import select

CMD_NAME = './return_str'

def main():
    for size in range(1, 5):
        out_flag = err_flag = False
        cmd = (CMD_NAME, str(size))
        print '#' * 30
        print '# cmd: %s' % str(cmd)
        p = Popen(cmd, stdout=PIPE, stderr=PIPE)
        read_set = ( p.stdout, p.stderr )
        while True:
            rfd, wfd, efd = select(read_set, (), (), 1)
            for fd in rfd:
                fd_fno = fd.fileno()
                if not out_flag and p.stdout.fileno() == fd_fno:
                    print 'fd no: ', fd_fno, ', stdout: ', fd.read()
                    out_flag = True
                if not err_flag and p.stderr.fileno() == fd_fno:
                    print 'fd no: ', fd_fno, ', stderr: ', fd.read()
                    err_flag = True
            if out_flag and err_flag:
                break
            print 'select again to get both output'
            time.sleep(0.1) # wait for buffering
        print '#' * 30

if __name__ == '__main__':
    main()

少し補足します。read_set として Popen の標準出力 p.stdout と標準エラー p.stderr をセットしています。select で返される rfd には標準出力/エラーのファイルディスクリプタがセットされますが、必ずしも同時に受け取るわけではなく、またセットした順番通りに返されるわけでもありません。そこで、ファイルディスクリプタ(の番号)を調べることで、標準出力/エラーを判別します。同時に受けとれなかった場合、return_str の標準出力/エラーは1度しか出力しないので read() がブロッキングしてしまうのを回避するためにフラグを設けています。
さらに Python クックブックによると time.sleep(0.1) を入れることで多くのデータをバッファに入れられるので高速に動作するようです(おそらく、この例ではあまり意味がないと思います)。

実行結果。

$ ./subprocess_select.py 
##############################
# cmd: ('./return_str', '1')
fd no:  5 , stderr:  e
select again to get both output
fd no:  3 , stdout:  s
##############################
##############################
# cmd: ('./return_str', '2')
fd no:  7 , stderr:  ee
select again to get both output
fd no:  4 , stdout:  ss
##############################
##############################
# cmd: ('./return_str', '3')
fd no:  6 , stderr:  eee
select again to get both output
fd no:  3 , stdout:  sss
##############################
##############################
# cmd: ('./return_str', '4')
fd no:  4 , stdout:  ssss
fd no:  7 , stderr:  eeee
##############################

return_str プログラムに 1 と 3 の引数を与えたときに3秒間の sleep が行われますが "select again ..." という文字列が1回しか出力されていないことから、select にセットする待機時間はプログラムの実行中には影響しないことが分かります。先に select で受け取ったファイルディスクリプタは標準エラーのみで、その次のループで標準出力を受け取ります。
また、引数に 2 と 4 を与えたとき、2 は先に標準エラーのみを受けとっていますが、4 は同時に標準出力/エラーを受けとっていることも分かります。2 と 4 は実行する度に同時に受け取れるか否かの結果が変わることがあります。select モジュールを使用するプログラムが、ループを繰り返したり、ファイルディスクリプタを調べたりと、やや煩雑になる理由は、どのタイミングで受け取れるか分からないからです。今回は実際に動作を確認して理解できましたが、非同期プログラミングは難しいです。

リファレンス:
15.1 select -- I/O 処理の完了を待機する
Geekなぺーじ:selectを使う
select は偉大だけど使いづらい。 - Twisted Mind
http://f59.aaa.livedoor.jp/~ookini/pukiwiki.php?socket%2Bselect%A4%F2%BB%C8%A4%C3%A4%BF%C2%BF%BD%C5IO%A5%B5%A1%BC%A5%D0
subprocess モジュールでパイプによる入出力を扱うときの注意事項 - forest book