Tareas en segundo plano con PyQt

Tareas en segundo plano con PyQt



Todas las librerías para desarrollar aplicaciones de escritorio trabajan con un bucle principal que se ocupa de manejar eventos tales como mostrar la ventana en la pantalla, moverla, redimensionarla, responder a la presión de un botón; en general, toda interacción con la interfaz. Algunos de esos eventos acaso estarán asociados con una función que proporcionamos nosotros; por ejemplo, un método button1_pressed() que es invocado por dicha librería cuando el usuario presiona el control button1. Cuando trabajamos con Qt, la forma de responder a esos eventos es típicamente conectar una señal con un slot.

El problema surge cuando, en respuesta a alguno de esos eventos o bien durante la creación de la interfaz, ejecutamos una operación cuya duración no es despreciable (podríamos decir que cualquier tarea que tarde más de un segundo deja de ser despreciable). Esto hace que el procesador esté ocupado ejecutando nuestra tarea y no pueda atender al bucle principal de la aplicación; por ende, la interfaz deja de responder: no podemos moverla, cerrarla, redimensionarla, ni efectuar cualquier otro tipo de interacción con ella.

Vayamos a un caso concreto. El siguiente código dubuja una ventana con una etiqueta (QLabel) y un botón (QPushButton) que al ser presionado descarga un archivo usando el módulo estándar urllib.request.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from urllib.request import urlopen

from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton


class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Ejemplo de descarga de archivo")
        self.resize(400, 300)
        self.label = QLabel("Presione el botón para iniciar la descarga.",
            self)
        self.label.setGeometry(20, 20, 200, 25)
        self.button = QPushButton("Iniciar descarga", self)
        self.button.move(20, 60)
        self.button.pressed.connect(self.downloadFile)

    def downloadFile(self):
        self.label.setText("Descargando archivo...")
        # Deshabilitar el botón mientras se descarga el archivo.
        self.button.setEnabled(False)
        url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
        filename = "python-3.7.2.exe"
        # Abrir la dirección de URL.
        with urlopen(url) as r:
            with open(filename, "wb") as f:
                # Leer el archivo remoto y escribir el fichero local.
                f.write(r.read())
        self.label.setText("¡El archivo se ha descargado!")
        # Restablecer el botón.
        self.button.setEnabled(True)


if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec_()

Notarás que durante el curso de la descarga, que en mi caso es de unos cinco segundos, la interfaz queda totalmente congelada. Ya mencionamos la razón de este comportamiento: en este código en particular, la línea que está bloqueando la ejecución es la número 32 en donde se llama al método r.read(), que lee el contenido del archivo remoto.

Analizaremos tres soluciones para este mismo problema con sus virtudes y defectos.

Primera solución: hilos

Esta solución implica lanzar un nuevo hilo (thread) para que ejecute nuestra tarea pesada. Como el bucle principal de Qt corre en el hilo principal del programa y nuestra operación corre en un hilo secundario, la interfaz se mantiene activa mientras el archivo se descarga en segundo plano. Para ello empleamos la clase QThread, que provee una API multiplataforma para crear hilos.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from urllib.request import urlopen

from PyQt5.QtCore import QThread
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton


class Downloader(QThread):

    def __init__(self, url, filename):
        super().__init__()
        self._url = url
        self._filename = filename

    def run(self):
        # Abrir la dirección de URL.
        with urlopen(self._url) as r:
            with open(self._filename, "wb") as f:
                # Leer el contenido y escribirlo en un nuevo archivo.
                f.write(r.read())


class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Ejemplo de descarga de archivo")
        self.resize(400, 300)
        self.label = QLabel("Presione el botón para iniciar la descarga.",
            self)
        self.label.setGeometry(20, 20, 200, 25)
        self.button = QPushButton("Iniciar descarga", self)
        self.button.move(20, 60)
        self.button.pressed.connect(self.initDownload)
    
    def initDownload(self):
        self.label.setText("Descargando archivo...")
        # Deshabilitar el botón mientras se descarga el archivo.
        self.button.setEnabled(False)
        # Ejecutar la descarga en un nuevo hilo.
        self.downloader = Downloader(
            "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe",
            "python-3.7.2.exe"
        )
        # Qt invocará el método `downloadFinished()` cuando el hilo 
        # haya terminado.
        self.downloader.finished.connect(self.downloadFinished)
        self.downloader.start()
    
    def downloadFinished(self):
        self.label.setText("¡El archivo se ha descargado!")
        # Restablecer el botón.
        self.button.setEnabled(True)
        # Eliminar el hilo una vez que fue utilizado.
        del self.downloader


if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec_()

Lo fundamental del código es la clase Downloader que hereda de QThread y reimplementa el método run() (línea 17), cuyo contenido será ejecutado en un nuevo hilo cuando creemos una instancia e invoquemos el método start() (líneas 43 y 50). En la línea 49 conectamos la señal finished, que es emitida por Qt cuando el hilo ha terminado ejecución, con nuestro método downloadFinished().

Si bien el ejemplo versa sobre la descarga de un archivo, este método permite mover cualquier tarea pesada a un nuevo hilo: basta con ubicarla dentro del método run().

Por otro lado, la programación con múltiples hilos debe realizarse con extremo cuidado. Únicamente el método run() se ejecuta en el nuevo hilo, mientras que todos los demás (incluido el mismo Downloader.__init__()) lo hacen en el principal. Además, es importante procurar no compartir objetos que puedan ser accedidos simultáneamente por dos o más hilos.

Segunda solución: processEvents()

Una alternativa a lanzar un nuevo hilo de ejecución es hacer todo el trabajo en el hilo principal, pero permitiendo periódicamente a Qt procesar los eventos de la aplicación para que la interfaz no deje de responder. La función que se ocupa de eso es QCoreApplication.processEvents().

En nuestros códigos anteriores, la función que realiza el trabajo pesado y bloquea la ejecución por unos segundos dijimos que era r.read(). Puesto que este método no nos devuelve el control del programa hasta tanto no se haya descargado el archivo por completo, deberemos crear nuestro propio bucle que obtenga el archivo remoto en pequeños paquetes (128 bytes) y al mismo tiempo deje a Qt procesar sus eventos. Para nuestra suerte, read() recibe opcionalmente como argumento la cantidad de bytes de información que queremos leer.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from urllib.request import urlopen

from PyQt5.QtCore import QCoreApplication
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton


class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Ejemplo de descarga de archivo")
        self.resize(400, 300)
        self.label = QLabel("Presione el botón para iniciar la descarga.",
            self)
        self.label.setGeometry(20, 20, 200, 25)
        self.button = QPushButton("Iniciar descarga", self)
        self.button.move(20, 60)
        self.button.pressed.connect(self.downloadFile)

    def downloadFile(self):
        self.label.setText("Descargando archivo...")
        # Deshabilitar el botón mientras se descarga el archivo.
        self.button.setEnabled(False)
        # Abrir la dirección de URL.
        url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
        filename = "python-3.7.2.exe"
        with urlopen(url) as r:
            with open(filename, "ab") as f:
                while True:
                    # Dejar que Qt procese sus eventos para que la
                    # ventana siga respondiendo.
                    QCoreApplication.processEvents()
                    # Leer una porción del archivo que estamos descargando.
                    chunk = r.read(128)
                    # Si el resultado es `None` quiere decir que todavía
                    # no se han descargado los datos. Simplemente
                    # seguimos esperando.
                    if chunk is None:
                        continue
                    # Si el resultado es una instancia de `bytes` vacía
                    # quiere decir que el archivo está completo.
                    elif chunk == b"":
                        break
                    # Escribir la porción descargada en el archivo local.
                    f.write(chunk)
        self.label.setText("¡El archivo se ha descargado!")
        # Restablecer el botón.
        self.button.setEnabled(True)


if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec_()

Aquí la clave radica entre las líneas 32-48, donde se erige el bucle que procesa los eventos de Qt, consume un pedazo de información de la red y lo arroja en un fichero local hasta que no haya más datos por extraer.

Como todo el código corre en un mismo hilo, no tenemos que preocuparnos por los problemas relativos al acceso y modificación de objetos como en el caso anterior. No obstante, r.read(128) sigue siendo una llamada que bloquea la ejecución del código aunque sea por un brevísimo intervalo de tiempo, casi imperceptible. Si la velocidad de la conexión a internet fuese exageradamente lenta, incluso extraer esa pequeña cantidad de bytes podría congelar la interfaz de usuario.

Tercera solución: Twisted

El módulo qt5reactor permite combinar los bucles principales de Twisted y Qt en una misma aplicación, dándonos acceso a todo el arsenal de funciones asincrónicas que provee la librería de red. Para esta tercera solución usaremos, además, la librería treq (similar a Requests pero montada sobre Twisted) para acceder a la dirección de URL del archivo y descargar el contenido. Instalamos estas dos herramientas sencillamente vía pip:

pip install qt5reactor treq

Ahora sí, el código es el siguiente:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from PyQt5.QtCore import QCoreApplication
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton

from twisted.internet.defer import inlineCallbacks


class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Ejemplo de descarga de archivo")
        self.resize(400, 300)
        self.label = QLabel("Presione el botón para iniciar la descarga.",
            self)
        self.label.setGeometry(20, 20, 200, 25)
        self.button = QPushButton("Iniciar descarga", self)
        self.button.move(20, 60)
        self.button.pressed.connect(self.initDownload)
    
    def initDownload(self):
        self.label.setText("Descargando archivo...")
        # Deshabilitar el botón mientras se descarga el archivo.
        self.button.setEnabled(False)
        url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
        # El método `requestSucceeded()` será invocado cuando la conexión
        # con la dirección de URL se haya establecido correctamente.
        treq.get(url).addCallback(self.requestSucceeded)
    
    @inlineCallbacks
    def requestSucceeded(self, response):
        # Obtenemos el contenido del archivo remoto.
        # Nótese que esta operación no bloquea la ejecución.
        content = yield response.content()
        # Lo escribimos en un archivo local.
        with open("python-3.7.2.exe", "wb") as f:
            f.write(content)
        self.label.setText("¡El archivo se ha descargado!")
        # Restablecer el botón.
        self.button.setEnabled(True)
    
    def closeEvent(self, event):
        QCoreApplication.instance().quit()


if __name__ == "__main__":
    app = QApplication([])
    import qt5reactor
    qt5reactor.install()
    window = MainWindow()
    window.show()
    from twisted.internet import reactor
    import treq
    import os
    import certifi
    # Necesario para conexiones vía HTTPS.
    os.environ["SSL_CERT_FILE"] = certifi.where()
    reactor.run()

Probablemente Twisted sea la solución más óptima cuando las tareas que debemos ejecutar implican siempre acceder a algún recurso en la web y son más o menos frecuentes en el código. Las peticiones HTTP de la librería treq están fundadas en la lógica de los diferidos (deferreds, sobre los cuales puedes leer aquí), que son similares a las señales de Qt al momento de invocar un evento definido por el usuario. Aquí tampoco debemos ocuparnos de los problemas de compartir objetos entre hilos, habida cuenta de que Twisted corre siempre en el hilo principal.

Quienes estén algo versados en Twisted hayarán esta solución muy satisfactoria. Y efectivamente lo es: Qt y Twisted congenian muy bien por su estructura, su filosofía e incluso sus convenciones de nombramiento.

Conclusión

Pues bien, hemos revisado las tres soluciones. ¿Cuál se ajusta mejor a tu propósito? Resumimos lo principal de cada una de ellas aquí.

La alternativa de los hilos es eficaz para cualquier tarea pesada, aunque ya dijimos que debe implementarse con prudencia. Si tu aplicación abunda en peticiones HTTP o acceso a cualquier otro recurso en la web (aunque también archivos locales) y estás versado en Twisted (o no, pero te interesaría incursionar en él), te gustará inclinarte por la solución con qt5reactor y treq. Por último, si el código de tu tarea pesada te lo permite, simplemente agregando una llamada a QCoreApplication.processEvents() en el lugar preciso hará que tu interfaz no deje responder ante la interacción del usuario.



2 comentarios.

  1. carlos nuñez says:

    hola, muy buena guía, me ha servido mucho, pero tengo dudas, quiero iniciar una tarea en segundo plano automáticamente cuando se inicia el programa, sin utilizar el botón, mi proyecto es mostrar datos de un arduino en pantalla, y quiero tener solo la pantalla, sin mouse ni nada, me funciona la segunda opción pero con el botón, si le quito el botón y pongo solo self.tarea() no inicia la ventana, si pudieran ayudarme se los agradecería mucho.
    gracias, saludos.

    • Recursos Python says:

      Hola Carlos, me alegro que te haya servido. Te invito a que pases por el foro y nos muestres tu código para poder ayudarte mejor.

      Saludos

Deja un comentario