Zamykanie aplikacji PyQt

Wsparcie dla Qt4.8 kończy się za pół roku, więc czas najwyższy przesiąść się w starszych aplikacjach na nowszą wersję. Portowanie Subconverta do PyQt5 nie nastręczyło większych kłopotów poza jednym, drobnym crashem, którego w Qt 4.8 nie doświadczyłem…

Jedną z ważnych funkcji każdej aplikacji jest sposób, w jaki się ona zamyka. Poza niektórymi wyjątkami (kill -9, crashe) tzw. graceful shutdown powinien m.in. zapisywać stan aplikacji, zwalniać zasoby itp. Obsługę wszystkich scenariuszy, które jest w stanie obsłużyć Python zapewnia użycie modułów atexit i signal. Dzięki nim jesteśmy dostarczyć jednolitą implementację kończenia działania aplikacji po prostu definiując funkcję obsługującą cleanup. No dobrze, napiszmy zatem prostą aplikację, która zilustruje problem:

def interruptHandler(signum, frame):
    sys.exit(1) # wywoła atexit

def cleanup(w):
    print("cleanup")
    w.cleanup()

class Window(QMainWindow):
    def __init__(self):
        super(Window, self).__init__()
        self.widget = QWidget(self)

    def cleanup(self):
        print("Window::cleanup()")
        self.widget.close()

def main():
    app = QApplication(sys.argv)
    gui = Window()

    # Rejestracja funkcji, która wywoła się z końcem aplikacji
    atexit.register(cleanup, gui)

    # Pozwala interpreterowi uruchomić się co 500 ms,
    # dzięki czemu możemy przechwycić nadchodzące sygnały
    # (w przeciwnym wypadku Qt będzie je blokować).
    timer = QTimer()
    timer.start(500)
    timer.timeout.connect(lambda: None)

    gui.show()
    sys.exit(app.exec_())

# Obsługa SIGINT (ctrl-c)
signal.signal(signal.SIGINT, interruptHandler)
main()

Uruchomienie powyższego programu spowoduje wyświetlenie się pustego okna. Zamknąć je możemy dwojako: wysyłając mu sygnał np. SIGINT (większość terminali wysyła go po wciśnięciu sekwencji ctrl-c) lub pozwalając to zrobić managerowi okien (alt-f4, kliknięcie przycisku “X”, itp). Moglibyśmy również dodać przycisk zamykający, który po naciśnięciu wywoływałby qApp.quit().

Jaka jest różnica? Otóż pierwszy sposób (ctrl-c) zadziała bezbłędnie, a drugi wysypie aplikację.

Dlaczego tak się dzieje?

Jeśli uruchomimy naszą aplikację poprzez GDB1, dowiemy się, że aplikacja wykrzacza się w metodzie QWidgetPrivate::close_helper. Okazuje się, że obiekt typu QCoreApplication (po którym dziedziczy QApplication) przestał już istnieć, więc wywołanie na nim operacji maybeQuit2 w oczywisty sposób spowoduje błąd. W jaki sposób Qt otrzymuje ten obiekt? Otóż QCoreApplication jest globalnym “czymś w rodzaju singletona3”, więc jest on dostępny z każdego miejsca aplikacji. Deweloperzy Qt stworzyli nawet specjalne makro: qApp, które generalnie zwraca coś w stylu static_cast<QApplication*>(QCoreApplication::instance()). Specjalnie je zapisali camel casem, aby przeciętnemu programiście nie ciążyło na sumieniu używanie makr.

Dlaczego jednak nasz obiekt aplikacji raz jest usuwany, a raz nie? Oczywiście powodem jest dualny sposób zamykania aplikacji. Jeśli chodzi o wysłanie sygnału przerwania SIGINT, to musimy się odwołać do modułu signal. Moduł ten nie jest wywoływany w niskopoziomowym kodzie C. Zamiast tego Python ustawia odpowiednią flagę, która pozwala na wywołanie interrupt handlera w dowolnym późniejszym momencie czasu, np. w następnej instrukcji bytecode’u. Dzięki temu długie operacje napisane w C (np. sprawdzanie wyrażeń regularnych) mogą nieprzerwane zakończyć swoje działanie. Z naszej perspektywy powoduje to “wciśnięcie” wywołania funkcji interruptHandler gdzieś w środek naszego kodu, najprawdopodobniej gdzieś między dwa obroty głównej pętli programu (event-loop Qt). W takim wypadku oczywiście QCoreApplication ciągle żyje, gdyż właśnie wykonuje swój kod.

Natomiast “ładne” zamknięcie programu spowoduje wywołanie funkcji QCoreApplication::exit(0). Metoda ta spowoduje wyjście z metody QCoreApplication::exec ze statusem 0, który zostanie przekazany jako argument do wywołania funkcji sys.exit. Co robi sys.exit? Funkcja ta również nie jest zaimplementowana bardzo niskopoziomowo - po prostu rzuca ona wyjątek SystemExit4. Z naszej perspektywy nie jest on obsługiwany na żadnym etapie naszego programu, a zatem interpreter będzie zakres po zakresie “zwijał stos” (tak to nazwijmy, dla uproszczenia) - najpierw wyjdzie z funkcji main, a następnie z zakresu globalnego. Garbage Collector zauważy wówczas, że nasz obiekt QApplication nie jest już używany, więc go usunie. Pozostawi natomiast obiekt Window wraz ze wszystkimi dziećmi, ponieważ ciągle znajduje się do niego referencja w funkcji zarejestrowanej przez moduł atexit. Jego implementacja przewiduje wywołanie wszystkich zarejestrowanych funkcji (w kolejności odwrotnej do rejestracji - LIFO) dopiero przy wychodzeniu z interpretera. I tak dochodzimy właśnie do miejsca, w którym jeden obiekt przestał istnieć, a drugi nadal istnieje.

Jak temu zapobiec?

Najprościej oczywiście utworzyć obiekt globalny QApplication. Jednak my nie chcemy tego robić, więc stworzymy sobie ładny wrapper, który postawi naszą aplikację na nogi, a gdy trzeba, wykona cleanup:

class Application:
    def __init__(self):
        self.app = QApplication(sys.argv)
        self.gui = Window()

    def run(self):
        timer = QTimer()
        timer.start(500)
        timer.timeout.connect(lambda: None)

        self.gui.show()
        return self.app.exec_()

    def cleanup(self):
        self.gui.cleanup()

def main():
    app = Application()
    atexit.register(cleanup, app)
    sys.exit(app.run())

Dzięki takiemu podejściu mamy pewność, że żadne zasoby nie zostaną przedwcześnie usunięte (przed usunięciem naszego obiektu aplikacji). Co więcej, zdefiniowaliśmy w ten sposób jednolity interfejs umożliwiający tworzenie oddzielnych klas np. dla aplikacji okienkowych i konsolowych, które będą jednakowo traktowane z perspektywy uruchamiania naszego programu:

def main():
    args = parseArgs(sys.argv)
    app = CliApplication() if args.startCli else GuiApplication()
    atexit.register(cleanup, app)
    sys.exit(app.run())

  1. Jeśli chcemy debugować kod C i C++ wywoływany przez interpreter, musimy mieć zainstalowane symbole debugowe dla Pythona oraz kodu, który chcemy debugować (w naszym przypadku Qt). Wówczas możemy uruchomić program przy użyciu komendy gdb --args python3 myapp.py. Interpreter Pythona pozostawia również po wszelkich wywrotkach coredumpy: gdb python3 core.

  2. Naprawdę doskonała nazwa metody.
  3. QCoreApplication nie zapewnia swojej unikalności - jedynie w dokumentacji Qt jest stwierdzenie, że powinna być utworzona tylko jedna instancja tego obiektu.

  4. Oprócz tego, że SystemExit może zostać złapany w dowolnym momencie, to aby poprawnie wyjść z aplikacji sys.exit musi być wywołana z poziomu głównego wątku aplikacji. Na otarcie łez wspomnę, że w celu minimalizacji przypadkowego złapania tego wyjątku SystemExit dziedziczy po BaseException, a nie Exception.