Saltar al contenido
🐍 Curso de Python
/

Módulo 3 · Lección 42 de 50

Tareas largas sin congelar la ventana

Por qué se queda muerta, el update() que parece arreglarlo y es peor, el after que muere con su widget y el que no, y el hilo que toca un widget y revienta cuando le da la gana.

⏱ 28 min de lectura

Todo programa con ventana llega a esto: una tarea que tarda. Leer mil ficheros, pedir algo por la red, procesar una imagen. Y lo que pasa es siempre lo mismo: la ventana se queda blanca, deja de responder, el sistema la marca como no responde, y al acabar vuelve a la vida como si nada.

Esta lección explica por qué, y da las dos herramientas. La primera, after, resuelve el noventa por ciento de los casos y no tiene ningún peligro. La segunda, los hilos, resuelve el resto y tiene un peligro de los malos: Tk no es seguro para hilos, y cuando se incumple el fallo llega cuando le da la gana.

Por qué se congela#

mainloop es un bucle en un solo hilo: saca un evento, llama a tu función, vuelve a por el siguiente. Y mientras tu función corre, el bucle está parado. No procesa clics, no atiende al teclado, y sobre todo no redibuja, porque redibujar también es un evento.

Eso tiene una consecuencia que sorprende:

python
for i in range(3):
        etiqueta.configure(text=str(i))
    trabajar()                        # algo que tarda

La etiqueta cambia tres veces, pero no se ve cambiar ninguna. Los tres configure solo apuntan que hay que redibujar; el dibujo real ocurre cuando el control vuelve al bucle, y entonces ya vale 2. Es la razón de que las barras de progreso hechas así no se muevan nunca.

after, que resuelve casi todo#

after programa una función para dentro de un rato y devuelve el control inmediatamente:

consola
>>> identificador = ventana.after(1000, funcion)
>>> identificador
'after#0'
>>> ventana.after_info()
('after#0',)
>>> ventana.after_cancel(identificador)
>>> ventana.after_info()
()

Dos detalles prácticos. El primero es que los argumentos van sueltos, sin lambda:

python
ventana.after(100, procesar, lote, numero)      # no hace falta lambda

El segundo es el comportamiento de after_cancel, que no es uniforme:

consola
>>> ventana.after_cancel(ya_ejecutado)
>>> # aceptado, sin error
>>> ventana.after_cancel("after#9999")
>>> # aceptado, sin error
>>> ventana.after_cancel(None)
ValueError: id must be a valid identifier returned from after or after_idle

Cancelar algo que ya corrió o que no existe es inofensivo, pero cancelar None lanza excepción. Y None es exactamente lo que vale la variable antes del primer after, así que el guardián es obligatorio:

python
if self.pendiente is not None:
        self.after_cancel(self.pendiente)

Y hay un pariente, after_idle, que espera a que no haya nada más que hacer. El orden entre los dos no es el que uno supone:

consola
>>> ventana.after_idle(lambda: orden.append("idle"))
>>> ventana.after(0, lambda: orden.append("after0"))
>>> ventana.update()
>>> orden
['after0', 'idle']

after(0) se ejecuta antes que un after_idle pedido primero, porque las tareas de reposo son las últimas de la cola. after_idle es para «cuando la ventana esté ya dibujada», que es el caso del sashpos de la lección 29.

La cadena#

Un after solo se ejecuta una vez. Lo que se repite es una cadena: la función se vuelve a programar al acabar.

consola
>>> def tic(n=0):
...     pasos.append(n)
...     if n < 4:
...         ventana.after(1, tic, n + 1)
>>> tic()
>>> pasos
[0, 1, 2, 3, 4]

Ese patrón sirve para las tres cosas que se necesitan:

  • Un reloj o un contador: after(1000, self.actualizar_hora) dentro de actualizar_hora.
  • Una animación: mover un poco y volver a programarse, como el Canvas de la lección 26.
  • Partir trabajo en trozos, que es lo que convierte una tarea larga en algo que responde:
python
def procesar_lote(self, pendientes, hechos=0):
        """Un trozo por vuelta, y entre trozo y trozo la ventana respira."""
    lote, resto = pendientes[:TAMANO_LOTE], pendientes[TAMANO_LOTE:]
    for elemento in lote:
                self.procesar(elemento)
    hechos += len(lote)
    self.progreso.set(hechos / self.total * 100)
    if resto and not self.cancelado:
                self.pendiente = self.after(1, self.procesar_lote, resto, hechos)
    else:
                self.terminar(hechos)

Entre un after y el siguiente, el bucle de eventos recupera el control: dibuja, atiende clics, y por eso el botón de cancelar funciona. El tamaño del lote es el único ajuste: demasiado pequeño y se pierde tiempo en el ida y vuelta, demasiado grande y la ventana da tirones. Unas decenas de milisegundos de trabajo por lote es lo que se siente fluido.

Hilos, cuando after no basta#

after parte trabajo tuyo en trozos. No sirve cuando lo que tarda es una sola llamada que no se puede partir y que se queda parada esperando: una petición de red, una consulta a una base de datos remota, leer un fichero de un disco lento. Ahí no hay trozos: hay una línea que tarda dos segundos.

Para eso está un hilo. Y hay una sola regla, que no admite excepciones:

El patrón completo#

Son tres piezas, y las tres son necesarias.

La cola, que es el único punto de contacto entre los dos mundos. queue.Queue está pensada precisamente para esto y es segura para hilos, así que no hace falta ningún candado.

El trabajador, que corre en el hilo y no sabe nada de la interfaz:

python
def trabajar(cola, carpeta, parar):
        """Corre en el hilo. Ni un widget, ni una variable de control."""
    try:
                for numero, fichero in enumerate(carpeta.rglob("*"), start=1):
                        if parar.is_set():                 # la cancelación
                                cola.put(("cancelado", numero))
                return
            cola.put(("progreso", numero, fichero.name))
        cola.put(("hecho", numero))
    except Exception as error:                 # obligatorio: ver abajo
                cola.put(("error", error))

El vaciador, que corre en el hilo principal llamado por una cadena de after, y es el único que toca la interfaz:

python
def vaciar_cola(self):
        """En el hilo principal: aquí sí se toca la interfaz."""
    try:
                while True:                       # el while, no un solo get
                        mensaje = self.cola.get_nowait()
            self.atender(mensaje)
    except queue.Empty:
                pass
    if self.hilo is not None and self.hilo.is_alive():
                self.pendiente = self.after(RITMO, self.vaciar_cola)

Tres detalles del vaciador que importan. El while en vez de un solo get: si el hilo produce más rápido que el ritmo del after, con un mensaje por vuelta la cola crece sin parar y la interfaz se queda minutos por detrás. El get_nowait y no get(): get() bloquea esperando, y bloquear en el hilo principal es exactamente lo que estábamos evitando. Y el is_alive() para dejar de programarse: si no, la cadena de after sigue viva para siempre consumiendo ciclos.

Cancelar y cerrar#

Un hilo no se puede matar desde fuera en Python, así que la cancelación es cooperativa: se le pide que pare y él comprueba.

python
self.parar = threading.Event()
...
self.parar.set()              # al pulsar Cancelar

Y el hilo mira parar.is_set() en cada vuelta de su bucle. Una bandera booleana normal también funcionaría, pero threading.Event es lo que hay para esto y además permite esperar sobre ella.

Y siempre daemon=True:

python
self.hilo = threading.Thread(target=trabajar, args=(...), daemon=True)

Sin eso, un hilo que no ha terminado mantiene el proceso vivo después de cerrar la ventana: la ventana desaparece, y el programa sigue en la lista de procesos hasta que el hilo acabe. Con daemon=True el hilo muere con el proceso.

Cuál de las dos#

SituaciónHerramienta
Un reloj, un contador, una animaciónafter en cadena
Esperar a que alguien deje de escribirafter con after_cancel, lección 23
Recorrer diez mil elementos tuyosafter por lotes
Una llamada que se queda esperando (red, disco lento)un hilo con cola
Cálculo puro que ocupa la CPU al cien por cienun proceso, no un hilo

La última fila merece una nota: en Python los hilos no aprovechan varios núcleos para cálculo puro, porque solo uno ejecuta código de Python a la vez. Para eso está multiprocessing, que se sale de este curso; pero para lo que hace un programa con ventana (esperar a la red, al disco, a una base de datos) los hilos valen perfectamente, porque mientras esperan no consumen nada.

El ejemplo#

Un contador de ficheros con las dos técnicas a la vez: un reloj con after en cadena, el recuento en un hilo con su cola, progreso real, cancelación y aviso de errores.

tareas.py
"""after para lo periódico y un hilo con cola para lo que espera."""

import pathlib
import queue
import threading
import time
import tkinter as tk
from tkinter import ttk

MARGEN = 12
AIRE = 4

GRIS = "#8E8E93"
VERDE = "#2E7D32"
ROSA = "#C2185B"

ESPANOL = str.maketrans(",.", ".,")   # 1,234.5 -> 1.234,5

RITMO_COLA = 50        # ms entre vaciados de la cola
RITMO_RELOJ = 200      # ms entre actualizaciones del cronómetro
TAMANO_LOTE = 200      # elementos por vuelta en el recuento por lotes


def contar_ficheros(cola, carpeta, parar):
        """Corre en el hilo. Ni un widget, ni una variable de control.

    Todo va por la cola, incluidos los errores: la excepción de un hilo se
    pierde, y el hilo principal se quedaría esperando para siempre.
    """
    try:
                cuantos = 0
        bytes_totales = 0
        for ruta in carpeta.rglob("*"):
                        if parar.is_set():                  # cancelación cooperativa
                                cola.put(("cancelado", cuantos))
                return
            if not ruta.is_file():
                                continue
            cuantos += 1
            try:
                                bytes_totales += ruta.stat().st_size
            except OSError:
                                pass                            # enlaces roto, permisos
            if cuantos % 25 == 0:
                                cola.put(("progreso", cuantos, bytes_totales, ruta.name))
        cola.put(("hecho", cuantos, bytes_totales))
    except Exception as error:
                cola.put(("error", error))


class Cronometro(ttk.Frame):
        """Una cadena de after. Los programa sobre sí mismo, no sobre la ventana.

    Así, al destruirse el widget, el temporizador pendiente no se ejecuta.
    """

    def __init__(self, padre, **opciones):
                super().__init__(padre, **opciones)
        self.texto = tk.StringVar(value="0,0 s")
        self.inicio = None
        self.pendiente = None
        ttk.Label(self, textvariable=self.texto, foreground=GRIS).pack()
        self.bind("<Destroy>", self.al_destruir)

    def arrancar(self):
                self.inicio = time.monotonic()
        self.parar()
        self.latir()

    def latir(self):
                transcurrido = time.monotonic() - self.inicio
        self.texto.set(f"{transcurrido:.1f} s".replace(".", ","))
        # self.after y no ventana.after: el temporizador muere con la pieza.
        self.pendiente = self.after(RITMO_RELOJ, self.latir)

    def parar(self):
        # after_cancel(None) lanza ValueError, así que el guardián no es opcional.
                if self.pendiente is not None:
                        self.after_cancel(self.pendiente)
            self.pendiente = None

    def al_destruir(self, evento):
                if evento.widget is self:       # <Destroy> llega de cada descendiente
                        self.parar()


class Aplicacion(tk.Tk):

        def __init__(self):
                super().__init__()
        self.title("Tareas largas")
        self.minsize(560, 360)

        self.cola = queue.Queue()
        self.hilo = None
        self.parar = None
        self.vaciando = None
        self.por_lotes = None

        self.carpeta = tk.StringVar(value=str(pathlib.Path.home()))
        self.estado = tk.StringVar(value="Sin empezar")
        self.detalle = tk.StringVar()
        self.avance = tk.DoubleVar(value=0)

        cuerpo = ttk.Frame(self, padding=MARGEN)
        cuerpo.pack(fill="both", expand=True)

        fila = ttk.Frame(cuerpo)
        fila.pack(fill="x")
        ttk.Label(fila, text="Carpeta:").pack(side="left")
        ttk.Entry(fila, textvariable=self.carpeta).pack(side="left", fill="x",
                                                        expand=True, padx=AIRE)

        marco = ttk.Labelframe(cuerpo, text="En un hilo, con cola",
                               padding=MARGEN)
        marco.pack(fill="x", pady=(MARGEN, 0))
        # Barra indeterminada: no se sabe cuántos ficheros hay hasta el final.
        self.barra = ttk.Progressbar(marco, mode="indeterminate", length=300)
        self.barra.pack(fill="x")
        botones = ttk.Frame(marco)
        botones.pack(fill="x", pady=(AIRE, 0))
        self.boton_contar = ttk.Button(botones, text="Contar ficheros",
                                       command=self.empezar)
        self.boton_contar.pack(side="left")
        self.boton_cancelar = ttk.Button(botones, text="Cancelar",
                                        command=self.cancelar,
                                        state="disabled")
        self.boton_cancelar.pack(side="left", padx=AIRE)
        self.cronometro = Cronometro(botones)
        self.cronometro.pack(side="right")

        lotes = ttk.Labelframe(cuerpo, text="Con after, por lotes",
                               padding=MARGEN)
        lotes.pack(fill="x", pady=(MARGEN, 0))
        self.barra_lotes = ttk.Progressbar(lotes, mode="determinate",
                                           variable=self.avance, maximum=100,
                                           length=300)
        self.barra_lotes.pack(fill="x")
        self.boton_lotes = ttk.Button(lotes, text="Sumar diez mil números",
                                      command=self.empezar_lotes)
        self.boton_lotes.pack(anchor="w", pady=(AIRE, 0))

        ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
        self.pie = ttk.Label(cuerpo, textvariable=self.estado)
        self.pie.pack(anchor="w")
        ttk.Label(cuerpo, textvariable=self.detalle, foreground=GRIS,
                  wraplength=500).pack(anchor="w", pady=(AIRE, 0))

        self.protocol("WM_DELETE_WINDOW", self.al_cerrar)
        self.centrar()

    # --- el hilo con cola

    def empezar(self):
                if self.hilo is not None and self.hilo.is_alive():
                        return
        carpeta = pathlib.Path(self.carpeta.get()).expanduser()
        if not carpeta.is_dir():
                        self.estado.set("Eso no es una carpeta")
            self.pie.configure(foreground=ROSA)
            return
        self.parar = threading.Event()
        # daemon=True: sin esto, el proceso sigue vivo tras cerrar la ventana.
        self.hilo = threading.Thread(target=contar_ficheros,
                                     args=(self.cola, carpeta, self.parar),
                                     daemon=True)
        self.hilo.start()
        self.boton_contar.configure(state="disabled")
        self.boton_cancelar.configure(state="normal")
        self.estado.set("Contando…")
        self.pie.configure(foreground=GRIS)
        self.barra.start(20)
        self.cronometro.arrancar()
        self.vaciando = self.after(RITMO_COLA, self.vaciar_cola)

    def vaciar_cola(self):
                """En el hilo principal: el único sitio donde se toca la interfaz."""
        try:
                        while True:                     # el while, no un solo get
                                self.atender(self.cola.get_nowait())
        except queue.Empty:
                        pass
        # is_alive para dejar de programarse: si no, la cadena vive para siempre.
        if self.hilo is not None and self.hilo.is_alive():
                        self.vaciando = self.after(RITMO_COLA, self.vaciar_cola)
        else:
                        self.vaciando = None

    def atender(self, mensaje):
                clase = mensaje[0]
        if clase == "progreso":
                        _, cuantos, tamano, nombre = mensaje
            self.estado.set(f"{self.miles(cuantos)} ficheros…")
            self.detalle.set(f"{self.mega(tamano)} — {nombre}")
        elif clase == "hecho":
                        _, cuantos, tamano = mensaje
            self.terminar(f"{self.miles(cuantos)} ficheros, {self.mega(tamano)}",
                          VERDE)
        elif clase == "cancelado":
                        self.terminar(f"Cancelado tras {self.miles(mensaje[1])} ficheros", GRIS)
        elif clase == "error":
            # La lógica avisa, la interfaz decide cómo. Lección 41.
                        self.terminar(f"No se ha podido leer: {mensaje[1]}", ROSA)

    def terminar(self, mensaje, color):
                self.estado.set(mensaje)
        self.pie.configure(foreground=color)
        self.barra.stop()
        self.cronometro.parar()
        self.boton_contar.configure(state="normal")
        self.boton_cancelar.configure(state="disabled")

    def cancelar(self):
                """Cooperativa: no se mata el hilo, se le pide que pare."""
        if self.parar is not None:
                        self.parar.set()
        self.boton_cancelar.configure(state="disabled")
        self.estado.set("Cancelando…")

    # --- los lotes con after

    def empezar_lotes(self):
                self.boton_lotes.configure(state="disabled")
        self.avance.set(0)
        self.procesar_lote(list(range(10_000)), 0, 0)

    def procesar_lote(self, pendientes, hechos, suma):
                """Un trozo por vuelta; entre trozo y trozo la ventana respira."""
        lote, resto = pendientes[:TAMANO_LOTE], pendientes[TAMANO_LOTE:]
        suma += sum(n * n for n in lote)
        hechos += len(lote)
        self.avance.set(hechos / 10_000 * 100)
        self.detalle.set(f"{self.miles(hechos)} de 10.000 — "
                         f"suma {self.miles(suma)}")
        if resto:
                        self.por_lotes = self.after(1, self.procesar_lote, resto, hechos,
                                        suma)
        else:
                        self.por_lotes = None
            self.estado.set(f"Suma de cuadrados: {self.miles(suma)}")
            self.pie.configure(foreground=VERDE)
            self.boton_lotes.configure(state="normal")

    # --- lo demás

    # --- el formateo es de la interfaz, lección 41

    @staticmethod
    def miles(entero):
                """1.234.567 a la española. translate intercambia los dos de una vez;
        con dos replace seguidos el segundo deshace el primero."""
        return f"{entero:,}".translate(ESPANOL)

    @staticmethod
    def mega(bytes_totales):
                return f"{bytes_totales / 1024 / 1024:,.1f} MB".translate(ESPANOL)

    def al_cerrar(self):
                """Parar las cadenas antes de irse, para no dejar cadáveres."""
        if self.parar is not None:
                        self.parar.set()
        for pendiente in (self.vaciando, self.por_lotes):
                        if pendiente is not None:
                                self.after_cancel(pendiente)
        self.destroy()

    def centrar(self):
                self.update_idletasks()
        x = (self.winfo_screenwidth() - self.winfo_width()) // 2
        y = (self.winfo_screenheight() - self.winfo_height()) // 2
        self.geometry(f"+{x}+{y}")


if __name__ == "__main__":
        Aplicacion().mainloop()