Pytanie Wydajność podprocesu.check_output vs subprocess.call


Używałem subprocess.check_output() przez pewien czas, aby wychwycić dane wyjściowe z podprocesów, ale w pewnych okolicznościach wystąpiły pewne problemy z wydajnością. Używam tego na maszynie RHEL6.

Wywoływane środowisko Python jest skompilowane linuxem i 64-bitowe. Podproces, który wykonuję, jest skryptem powłoki, który ostatecznie uruchamia proces Windows python.exe poprzez Wine (dlaczego ta głupota jest wymagana to inna historia). Jako dane wejściowe do skryptu powłoki używam małego fragmentu kodu Pythona, który zostanie przekazany do python.exe.

Podczas gdy system znajduje się pod umiarkowanym / dużym obciążeniem (od 40 do 70% wykorzystania procesora), zauważyłem, że używanie subprocess.check_output(cmd, shell=True) może spowodować znaczące opóźnienie (do ~ 45 sekund) po zakończeniu wykonywania podprocesu przed poleceniem check_output. Patrząc na wynik z ps -efH w tym czasie pokazuje wywoływany podproces jako sh <defunct>, aż w końcu powróci z normalnym zerowym kodem wyjścia.

I odwrotnie, używając subprocess.call(cmd, shell=True) uruchomienie tej samej komendy przy takim samym obciążeniu średnim / dużym spowoduje, że podprocesowanie nastąpi natychmiast bez opóźnienia, wszystkie wydruki zostaną wydrukowane na STDOUT / STDERR (zamiast zwracane z wywołania funkcji).

Dlaczego tak znaczące opóźnienie występuje tylko wtedy, gdy check_output() przekierowuje wyjście STDOUT / STDERR do jego wartości zwracanej, a nie gdy call() po prostu drukuje z powrotem do STDOUT / STDERR rodzica?


18
2017-08-15 20:14


pochodzenie


czy wypróbowałeś ten sam kod na nowszej wersji Pythona lub z subprocess32 moduł, aby sprawdzić, czy nietypowe opóźnienie zniknie, np. występuje błąd w starszej wersji? - jfs
Nie, nie, ponieważ mój skrypt wymaga kilku pakietów dostępnych tylko dla wersji 2.7.x. Próbowałem odtworzyć problem bez mojego pełnego scenariusza, ale jeszcze nie byłem w stanie. Jeśli uda mi się wyizolować i odtworzyć problem bez zależności między bibliotekami, wypróbuję Twoją sugestię. - greenlaw
subprocess32 działa na Pythonie 2.7 (systemy POSIX) - jfs


Odpowiedzi:


Czytanie dokumentów, zarówno subprocess.call i subprocess.check_output są przypadki użycia subprocess.Popen. Jedna mała różnica jest taka check_output podniesie błąd w Pythonie, jeśli podprocesor zwróci niezerowy status wyjścia. Większa różnica jest uwydatniona w bitach check_output (mój nacisk):

Pełna sygnatura funkcji jest w dużej mierze taka sama jak w przypadku konstruktora Popen, z tym wyjątkiem, że standardowe wyjście nie jest dozwolone, ponieważ jest używane wewnętrznie. Wszystkie inne dostarczone argumenty są przekazywane bezpośrednio do konstruktora Popen.

Jak to jest stdout "używane wewnętrznie"? Porównajmy call i check_output:

połączenie

def call(*popenargs, **kwargs):
    return Popen(*popenargs, **kwargs).wait() 

check_output

def check_output(*popenargs, **kwargs):
    if 'stdout' in kwargs:
        raise ValueError('stdout argument not allowed, it will be overridden.')
    process = Popen(stdout=PIPE, *popenargs, **kwargs)
    output, unused_err = process.communicate()
    retcode = process.poll()
    if retcode:
        cmd = kwargs.get("args")
        if cmd is None:
            cmd = popenargs[0]
        raise CalledProcessError(retcode, cmd, output=output)
    return output

komunikować się

Teraz musimy patrzeć Popen.communicate także. Robiąc to, zauważamy, że dla jednej rury, communicate robi kilka rzeczy, które po prostu zabierają więcej czasu niż po prostu powrót Popen().wait(), tak jak call robi.

Dla jednej rzeczy, communicate procesy stdout=PIPE czy ustawisz shell=True albo nie. Wyraźnie, call nie. Po prostu pozwala twojemu muszlowi wylewać cokolwiek ... czyniąc to ryzykiem bezpieczeństwa, jak opisuje Python.

Po drugie, w przypadku check_output(cmd, shell=True) (tylko jedna rura) ... bez względu na to, do czego wysyła twój podproces stdout jest przetwarzany przez a wątek w _communicate metoda. I Popen musi dołączyć do wątku (czekać na niego) przed dodatkowym czekaniem na sam podproces do zakończenia!

Dodatkowo, bardziej trywialnie, przetwarza stdout jak list które następnie muszą zostać połączone w łańcuch.

W skrócie, nawet przy minimalnych argumentach, check_output spędza dużo więcej czasu w procesach Python niż callrobi.


22
2017-09-06 18:50



Nie sądzę, że to jest zagrożenie dla bezpieczeństwa; Dokumentacja Pythona ostrzega przed używaniem powłoki = True podczas budowania poleceń z niesanizowanych danych wejściowych. Ale widzę twój punkt widzenia na dodatkową złożoność uruchamiania check_output. Nie sądzę, że dostanę pełną odpowiedź na to pytanie, nie podając dokładnych przypadków reprodukcji, więc twój jest najbliższy. - greenlaw
@ greeklaw: ta odpowiedź nie wyjaśnia ~ 45 sekund opóźnienia. Podejrzewam również, że wątki są używane tylko w systemie Windows i tylko wtedy, gdy przekierowywany jest więcej niż jeden strumień, np. check_output(cmd, shell=True) robi nie używaj wątków. - jfs


Spójrzmy na kod. Funkcja .check_output ma następujące oczekiwania:

    def _internal_poll(self, _deadstate=None, _waitpid=os.waitpid,
            _WNOHANG=os.WNOHANG, _os_error=os.error, _ECHILD=errno.ECHILD):
        """Check if child process has terminated.  Returns returncode
        attribute.

        This method is called by __del__, so it cannot reference anything
        outside of the local scope (nor can any methods it calls).

        """
        if self.returncode is None:
            try:
                pid, sts = _waitpid(self.pid, _WNOHANG)
                if pid == self.pid:
                    self._handle_exitstatus(sts)
            except _os_error as e:
                if _deadstate is not None:
                    self.returncode = _deadstate
                if e.errno == _ECHILD:
                    # This happens if SIGCLD is set to be ignored or
                    # waiting for child processes has otherwise been
                    # disabled for our process.  This child is dead, we
                    # can't get the status.
                    # http://bugs.python.org/issue15756
                    self.returncode = 0
        return self.returncode

.Call czeka przy użyciu następującego kodu:

    def wait(self):
        """Wait for child process to terminate.  Returns returncode
        attribute."""
        while self.returncode is None:
            try:
                pid, sts = _eintr_retry_call(os.waitpid, self.pid, 0)
            except OSError as e:
                if e.errno != errno.ECHILD:
                    raise
                # This happens if SIGCLD is set to be ignored or waiting
                # for child processes has otherwise been disabled for our
                # process.  This child is dead, we can't get the status.
                pid = self.pid
                sts = 0
            # Check the pid and loop as waitpid has been known to return
            # 0 even without WNOHANG in odd situations.  issue14396.
            if pid == self.pid:
                self._handle_exitstatus(sts)
        return self.returncode

Zauważ, że błąd związany z inner_poll. Można go oglądać pod adresem http://bugs.python.org/issue15756. Prawie dokładnie to, na co się natknąłeś.


Edytować: Innym potencjalnym problemem między .call i .check_output jest to, że .check_output faktycznie dba o stdin i stdout i spróbuje wykonać IO wobec obu potoków. Jeśli korzystasz z procesu, który sam przechodzi w stan zombie, możliwe jest, że odczyt z rury w nieistniejącym stanie powoduje zawieszanie się, którego doświadczasz.

W większości przypadków stany zombie są szybko usuwane, ale nie będą, na przykład, przerywane w trakcie wywołania systemowego (np. Odczyt lub zapis). Oczywiście wywołanie systemowe do odczytu / zapisu powinno zostać przerwane, gdy tylko IO nie może być już wykonane, ale możliwe jest, że uderzasz w jakiś rodzaj wyścigu, w którym rzeczy giną w złej kolejności.

Jedynym sposobem, jaki mogę wymyślić, aby ustalić, która jest przyczyna w tym przypadku, jest dodanie kodu debugowania do pliku podprocesu lub wywołanie debuggera Pythona i zainicjowanie śledzenia wstecznego po napotkaniu stanu, którego doświadczasz.


2
2017-09-02 17:35



No cóż, nie do końca ... komentarze o błędach stwierdzają, że kod, którego dotyczy problem, będzie zawieszony w nieskończoność, podczas gdy mój kod w końcu powróci po znacznym opóźnieniu. - greenlaw
@Claris: proces jest zombie, jeśli wyjdzie, ale jego status nie został jeszcze odczytany (przez rodzica). W tym przypadku, sh jest zombie, ponieważ proces rodzicielskiego python trwa p.stdout.read() połączenie, które może się zdarzyć, jeśli sh spawns własne dzieci, które odziedziczyły standardowe wyjście, np. call('(sleep 5; echo abc) &', shell=True) powinien natychmiast wrócić, ale check_output('(sleep 5; echo abc) &', shell=True) powinien wrócić dopiero za 5 sekund. - jfs
@greenlaw: czy próbowałeś ustaw SIGALRMsprawdzić wskaźnik stosu, jeśli dziecko zawiesza się w celu debugowania? - jfs