Subprocess Popen закрывает stdout/stderr filedescriptors, используемые в другом потоке, когда ошибки Popen

Внутренняя библиотека, которая сильно использует подпроцесс. Popen() запустил автоматические тесты при обновлении с Python 2.7.3 до Python 2.7.5. Эта библиотека используется в потоковой среде. После отладки этой проблемы мне удалось создать короткий Python script, который демонстрирует ошибку, наблюдаемую как в неудачных тестах.

Это script (называемый "threadedsubprocess.py" ):

import time
import threading
import subprocess

def subprocesscall():
    p = subprocess.Popen(
        ['ls', '-l'],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        )
    time.sleep(2) # simulate the Popen call takes some time to complete.
    out, err = p.communicate()
    print 'succeeding command in thread:', threading.current_thread().ident

def failingsubprocesscall():
    try:
        p = subprocess.Popen(
            ['thiscommandsurelydoesnotexist'],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            )
    except Exception as e:
        print 'failing command:', e, 'in thread:', threading.current_thread().ident

print 'main thread is:', threading.current_thread().ident

subprocesscall_thread = threading.Thread(target=subprocesscall)
subprocesscall_thread.start()
failingsubprocesscall()
subprocesscall_thread.join()

Примечание: этот script не выходит с IOError при запуске с Python 2.7.3. Это происходит не по меньшей мере в 50% случаев при запуске с Python 2.7.5 (как на той же 64-битной виртуальной машине Ubuntu 12.04).

Ошибка, возникающая на Python 2.7.5, такова:

/opt/python/2.7.5/bin/python ./threadedsubprocess.py 
main thread is: 139899583563520
failing command: [Errno 2] No such file or directory 139899583563520
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/opt/python/2.7.5/lib/python2.7/threading.py", line 808, in __bootstrap_inner
    self.run()
  File "/opt/python/2.7.5/lib/python2.7/threading.py", line 761, in run
    self.__target(*self.__args, **self.__kwargs)
  File "./threadedsubprocess.py", line 13, in subprocesscall
    out, err = p.communicate()
  File "/opt/python/2.7.5/lib/python2.7/subprocess.py", line 806, in communicate
    return self._communicate(input)
  File "/opt/python/2.7.5/lib/python2.7/subprocess.py", line 1379, in _communicate
    self.stdin.close()
IOError: [Errno 9] Bad file descriptor

close failed in file object destructor:
IOError: [Errno 9] Bad file descriptor

При сравнении модуля подпроцесса от Python 2.7.3 до Python 2.7.5 я вижу, что вызов Popen() __init __() действительно явно закрывает дескрипторы файла stdin, stdout и stderr в случае, если выполнение команды каким-то образом завершается с ошибкой. Это, по-видимому, предназначенное исправление, применяемое в Python 2.7.4 для предотвращения утечки дескрипторов файлов (http://hg.python.org/cpython/file/ab05e7dd2788/Misc/NEWS#l629).

Разница между Python 2.7.3 и Python 2.7.5, которая, по-видимому, имеет отношение к этой проблеме, находится в Popen __init __():

@@ -671,12 +702,33 @@
          c2pread, c2pwrite,
          errread, errwrite) = self._get_handles(stdin, stdout, stderr)

-        self._execute_child(args, executable, preexec_fn, close_fds,
-                            cwd, env, universal_newlines,
-                            startupinfo, creationflags, shell,
-                            p2cread, p2cwrite,
-                            c2pread, c2pwrite,
-                            errread, errwrite)
+        try:
+            self._execute_child(args, executable, preexec_fn, close_fds,
+                                cwd, env, universal_newlines,
+                                startupinfo, creationflags, shell,
+                                p2cread, p2cwrite,
+                                c2pread, c2pwrite,
+                                errread, errwrite)
+        except Exception:
+            # Preserve original exception in case os.close raises.
+            exc_type, exc_value, exc_trace = sys.exc_info()
+
+            to_close = []
+            # Only close the pipes we created.
+            if stdin == PIPE:
+                to_close.extend((p2cread, p2cwrite))
+            if stdout == PIPE:
+                to_close.extend((c2pread, c2pwrite))
+            if stderr == PIPE:
+                to_close.extend((errread, errwrite))
+
+            for fd in to_close:
+                try:
+                    os.close(fd)
+                except EnvironmentError:
+                    pass
+
+            raise exc_type, exc_value, exc_trace

Я думаю, у меня есть три вопроса:

1) Верно ли это, что в принципе возможно использовать подпроцесс .Popen, с PIPE для stdin, stdout и stderr, в потоковой среде?

2) Как предотвратить, чтобы дескрипторы файлов для stdin, stdout и stderr были закрыты, когда Popen() не удалось выполнить в одном из потоков?

3) Я делаю что-то неправильно здесь?

Ответ 1

Я хотел бы ответить на ваши вопросы:

  • Да.
  • Вам не нужно.
  • Нет.

Ошибка также возникает в Python 2.7.4.

Я думаю, что это ошибка в коде библиотеки. Если вы добавите блокировку в свою программу и убедитесь, что два вызова subprocess.Popen выполняются атомарно, ошибка не возникает.

@@ -1,32 +1,40 @@
 import time
 import threading
 import subprocess

+lock = threading.Lock()
+
 def subprocesscall():
+    lock.acquire()
     p = subprocess.Popen(
         ['ls', '-l'],
         stdin=subprocess.PIPE,
         stdout=subprocess.PIPE,
         stderr=subprocess.PIPE,
         )
+    lock.release()
     time.sleep(2) # simulate the Popen call takes some time to complete.
     out, err = p.communicate()
     print 'succeeding command in thread:', threading.current_thread().ident

 def failingsubprocesscall():
     try:
+        lock.acquire()
         p = subprocess.Popen(
             ['thiscommandsurelydoesnotexist'],
             stdin=subprocess.PIPE,
             stdout=subprocess.PIPE,
             stderr=subprocess.PIPE,
             )
     except Exception as e:
         print 'failing command:', e, 'in thread:', threading.current_thread().ident
+    finally:
+        lock.release()
+

 print 'main thread is:', threading.current_thread().ident

 subprocesscall_thread = threading.Thread(target=subprocesscall)
 subprocesscall_thread.start()
 failingsubprocesscall()
 subprocesscall_thread.join()

Это означает, что это, скорее всего, связано с некоторыми расами данных при реализации Popen. Я рискну предположить: ошибка может быть в реализации pipe_cloexec, вызванной _get_handles, которая (в версии 2.7.4):

def pipe_cloexec(self):
    """Create a pipe with FDs set CLOEXEC."""
    # Pipes' FDs are set CLOEXEC by default because we don't want them
    # to be inherited by other subprocesses: the CLOEXEC flag is removed
    # from the child FDs by _dup2(), between fork() and exec().
    # This is not atomic: we would need the pipe2() syscall for that.
    r, w = os.pipe()
    self._set_cloexec_flag(r)
    self._set_cloexec_flag(w)
    return r, w

и комментарий явно указывает на то, что он не является атомарным... Это определенно вызывает гонку данных, но, без экспериментов, я не знаю, вызвало ли это проблему.

Ответ 2

Другое решение, если вы не обрабатываете файлы, которые были открыты (например, при создании API).

Я нашел обходной путь к проблеме, выполнив вызовы API windll, чтобы пометить все уже открытые дескрипторы файлов как "не наследуемые". Это немного взломан, и Q & A доступен здесь:

Howto: обходной путь close_fds = True и перенаправление stdout/stderr на окна

Он обходит ошибку Python 2.7.

Другим решением было бы использовать Python 3.4+:) Он был исправлен