subprocess モジュールでパイプによる入出力を扱うときの注意事項

Python 2.4 から OS のコマンドを実行したり、それらの標準入出力/エラー出力のやり取りをするのに subprocess モジュールが追加されています。commands モジュールの置き換えとして使用していたところ、あるコマンドの標準出力を受けとる処理がデッドロックする問題に遭遇しました。

subprocess モジュールの Popen クラスに stdout=subprocess.PIPE を指定してその標準出力に 65536 バイトより大きい出力が返される、且つ実行したコマンド(子プロセス)の終了を待つ場合に発生します。実際にその問題を再現させて確認してみます。

引数で指定された数の文字列を返すプログラムを作成します。

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

int
main(int argc, char **argv)
{
    int i, num;
    char *s;
    if (argc < 2) {
        num = 1;
    } else {
        num = atoi(argv[1]);
    }
    s = malloc(sizeof(char) * num + 1);
    for (i=0; i<num; i++) {
        s[i] = 's';
    }
    printf("%s", s);
    free(s);
    return EXIT_SUCCESS;
}

実行結果。

$ gcc -Wall return_str.c -o return_str
$ ./return_str 3
sss$

subprocess モジュールを使用して、以下の3パターンの動作を確認してみます。

  • 標準出力がパイプ、且つ子プロセスの終了を待つ
  • 標準出力がファイル、且つ子プロセスの終了を待つ
  • 標準出力がパイプ、且つ子プロセスの終了を待たない
#!/usr/bin/env python

import signal
import tempfile
import types
from subprocess import Popen, PIPE

CMD_NAME = './return_str'

def signal_handler(*args):
    print 'Get signal Alarm since Popen will deadlock: %s' % str(args)

def run_command_with_popen(cmd, **kwargs):
    signal.signal(signal.SIGALRM, signal_handler)
    signal.alarm(3)
    wait = kwargs.pop('wait')
    p = Popen(cmd, **kwargs)
    if wait:
        p.wait()
        print 'return code: %s' % p.returncode
    if type(kwargs.get('stdout')) == types.FileType:
        kwargs['stdout'].seek(0)
        out = kwargs['stdout'].read()
    else:
        out, err = p.communicate()
    print 'output size: %s' % len(out)
    signal.alarm(0)

def main():
    for size in range(65536, 65538):
        cmd = (CMD_NAME, str(size))
        print '# cmd: %s' % str(cmd)
        print '## wait for terminating'
        try:
            run_command_with_popen(cmd, wait=True, stdout=PIPE)
        except OSError, err:
            print err.args
    
        print '## wait for terminating using tempfile'
        temp = tempfile.TemporaryFile()
        try:
            run_command_with_popen(cmd, wait=True, stdout=temp)
        finally:
            temp.close()
        
        print '## no wait for terminating'
        run_command_with_popen(cmd, wait=False, stdout=PIPE)
        print

if __name__ == '__main__':
    main()

実行結果。

$ python subprocess_stdout.py 
# cmd: ('./return_str', '65536')
## wait for terminating
return code: 0
output size: 65536
## wait for terminating using tempfile
return code: 0
output size: 65536
## no wait for terminating
output size: 65536

# cmd: ('./return_str', '65537')
## wait for terminating
Get signal Alarm since Popen will deadlock: (14, )
(4, 'Interrupted system call')
## wait for terminating using tempfile
return code: 0
output size: 65537
## no wait for terminating
output size: 65537

実行したコマンド(子プロセス)の終了を待ち、65537 バイトの文字列が標準出力として返される場合にそのレスポンスがないためにアラームシグナルが発生しています。
Popen.wait() の説明には以下の警告が記載されています。

子プロセスが標準出力、又は標準エラー出力のパイプに対して、OS のパイプバッファがさらにデータを受け取るためにブロッキングして待ち状態になってしまうぐらいの大きな出力を生成する場合に、デッドロックが発生します。この問題を回避するためには communicate() を使用してください。

コマンドの終了を待つ必要がない、コマンドの返り値を確認する必要がない場合は wait() せずに communicate() のみでやり取りするのも1つの方法のようです。但し communicate() の注意事項としても、メモリ内に直接バッファされるので大きなデータのやり取りには使用しないでくださいとあります。
以下のようにファイルの代わりに StringIO を標準出力に指定すると fileno 属性がないとエラーになるので tempfile を使用するのが良さそうです。

#!/usr/bin/env python

from subprocess import Popen, PIPE
from cStringIO import StringIO

CMD_NAME = './return_str'

def main():
    output = StringIO()
    try:
        p = Popen(CMD_NAME, stdout=output)
        p.wait()
        print output.getvalue()
    finally:
        output.close()

if __name__ == '__main__':
    main()

実行結果。

$ python subprocess_stdout_stringio.py 
Traceback (most recent call last):
  File "subprocess_stdout_stringio.py", line 18, in 
    main()
  File "subprocess_stdout_stringio.py", line 11, in main
    p = Popen(CMD_NAME, stdout=output)
  File "/usr/lib/python2.6/subprocess.py", line 588, in __init__
    errread, errwrite) = self._get_handles(stdin, stdout, stderr)
  File "/usr/lib/python2.6/subprocess.py", line 945, in _get_handles
    c2pwrite = stdout.fileno()
AttributeError: 'cStringIO.StringO' object has no attribute 'fileno'

リファレンス:
thraxil.org: Subprocess Hanging: PIPE is your enemy
http://python.matrix.jp/tips/compatibility/subprocess.html#id6