Skip to content

📙 Clase 33 — Threading: no congelar la ventana

Fase 6 · Especialización: Apps de escritorio 🎯 ⬅️ Volver al índice de clases

🎯 Qué aprendí

  • Por qué una tarea larga en un callback congela la GUI (el mainloop, bloqueado).
  • threading.Thread: trabajar en segundo plano. queue + after(): volver a la GUI seguro.
  • La regla de oro: los widgets solo se tocan desde el hilo principal.

📖 PARTE TEÓRICA

🥶 1. El problema: la ventana congelada

El mainloop es un bucle que procesa eventos (clics, teclas, redibujado). Si tu callback tarda 5 segundos, durante esos 5 segundos no se procesa nada: la ventana "no responde".

sin hilo:
  mainloop ──▶ callback (5 s de descarga) ─────────▶ mainloop
              💀 ventana congelada aquí 💀

con hilo:
  mainloop ──▶ callback lanza HILO ──▶ mainloop sigue vivo (ventana fluida)
                   └──▶ hilo trabaja aparte ──▶ deja el resultado en una COLA
  mainloop ──▶ after(100) revisa la cola ──▶ actualiza el label ✅

Los sospechosos habituales: requests (Clase 23), importar un CSV gigante, un SELECT pesado, generar un PDF.

🧵 2. threading.Thread: el trabajador

python
import threading

def tarea_lenta():
    resultado = requests.get(url, timeout=30)     # 5 segundos… en OTRO hilo
    q.put(resultado.json())                       # deja el resultado en la cola

hilo = threading.Thread(target=tarea_lenta, daemon=True)
hilo.start()          # arranca y NO espera: el callback termina al instante
PiezaPor qué
target=funcionla función que correrá en paralelo (sin paréntesis: se pasa, no se llama)
daemon=Trueel hilo muere al cerrar la app (sin él, la app puede "no cerrarse")
.start()lanza; no usar .join() en la GUI (esperaría = congelar de nuevo)

📬 3. queue + after(): el regreso seguro

Regla de oro (memorízala): los widgets solo se tocan desde el hilo principal. Tocar un label desde otro hilo causa crashes intermitentes e indescifrables.

La solución estándar: el hilo deja el resultado en una queue.Queue (cola segura entre hilos) y el hilo principal la sondea con after():

python
import queue

q = queue.Queue()

def _revisar_cola(self):
    try:
        resultado = q.get_nowait()              # ¿ya llegó algo?
    except queue.Empty:                          # todavía no…
        self.after(100, self._revisar_cola)      # re-pregunta en 100 ms (NO bloquea)
        return
    self.label.configure(text=resultado)         # ✅ hilo principal: tocar widgets OK

📌 after(ms, funcion) = "mainloop, ejecuta esto dentro de ms milisegundos". Es la forma GUI de esperar sin congelar (verificado: CTk.after existe; el patrón cola/get_nowait/ queue.Empty corrió en tu venv).

🧪 Tip de entrevista: "¿Por qué no actualizar la GUI desde un hilo secundario?" → Tkinter (y casi todo toolkit GUI) no es thread-safe: sus estructuras internas asumen un solo hilo. El patrón correcto: hilo trabajador → cola → el hilo principal consume con after().


🖥️ EN TU APP — el patrón completo

Descarga con botón deshabilitado, barra de progreso indeterminada y regreso seguro:

python
import threading, queue

class App(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.cola = queue.Queue()
        # …widgets: self.boton, self.progreso (CTkProgressBar), self.resultado…

    def _descargar(self):                        # ① el callback: rápido y no bloquea
        self.boton.configure(state="disabled")   # evitar doble clic
        self.progreso.start()                    # barra en movimiento
        threading.Thread(target=self._trabajo, daemon=True).start()
        self.after(100, self._revisar_cola)      # empezar a sondear

    def _trabajo(self):                          # ② EN OTRO HILO: cero widgets aquí
        try:
            r = requests.get("https://api.github.com/users/octocat", timeout=30)
            self.cola.put(("ok", r.json()))
        except requests.RequestException as e:
            self.cola.put(("error", str(e)))     # los errores TAMBIÉN van por la cola

    def _revisar_cola(self):                     # ③ HILO PRINCIPAL: aquí sí widgets
        try:
            estado, datos = self.cola.get_nowait()
        except queue.Empty:
            self.after(100, self._revisar_cola)  # aún nada: seguir sondeando
            return
        self.progreso.stop()
        self.boton.configure(state="normal")
        if estado == "ok":
            self.resultado.configure(text=f"{datos['login']}: {datos['public_repos']} repos")
        else:
            self.resultado.configure(text=f"⚠️ {datos}")

Checklist del patrón (vale para descarga, importación, reporte…):

  • [ ] El callback lanza el hilo y retorna al instante
  • [ ] El hilo no toca ningún widget (solo cola.put)
  • [ ] Los errores del hilo también viajan por la cola (nunca se pierden)
  • [ ] after() sondea y actualiza la GUI en el hilo principal
  • [ ] Botón deshabilitado mientras trabaja (nada de lanzar 5 descargas)

🗄️ ¿Y SQLite? Una conexión sqlite3 no se comparte entre hilos (por defecto lanza ProgrammingError). Lo simple y robusto: el hilo trabajador abre su propia conexión, hace su consulta pesada, y pasa los datos (no la conexión) por la cola.


🏋️ EJERCICIOS CON SOLUCIÓN

Ejercicio 1 — El diagnóstico

Un usuario reporta: "cuando aprieto Importar, la app se cuelga 10 segundos y luego revive". ¿Qué está pasando y cuál es el plan?

Ver solución
python
# La importación corre EN el callback → bloquea el mainloop 10 s (no procesa
# eventos: "colgada"). Plan: mover la importación a un threading.Thread(daemon=True),
# reportar por queue.Queue, sondear con after(100), deshabilitar el botón mientras.

Ejercicio 2 — Sin GUI, el esqueleto

Escribe (en terminal) un hilo que duerma 0.3 s, ponga "listo" en una cola, y el principal lo lea (verificado en tu venv).

Ver solución
python
import threading, queue, time

q = queue.Queue()
def trabajo():
    time.sleep(0.3)
    q.put("listo")

threading.Thread(target=trabajo, daemon=True).start()
print(q.get())      # bloquea hasta que llegue: "listo"
# (en GUI usarías get_nowait + after, para NO bloquear)

Ejercicio 3 — Encuentra los 2 bugs

python
def _descargar(self):
    hilo = threading.Thread(target=self._trabajo)
    hilo.start()
    hilo.join()

def _trabajo(self):
    r = requests.get(url, timeout=30)
    self.label.configure(text=r.json()["login"])
Ver solución
python
# Bug 1: hilo.join() en el callback ESPERA al hilo → congela igual que sin hilo.
#        (además falta daemon=True)
# Bug 2: self.label.configure(...) DESDE el hilo secundario → toca widgets fuera
#        del hilo principal (crashes intermitentes). Debe ir por cola + after().

❓ Preguntas y respuestas (autoevaluación)

1. ¿Por qué una petición de 5 s congela la ventana si va en el callback?

El callback corre dentro del mainloop: mientras no termine, no se procesan eventos ni redibujados.

2. ¿Qué hace daemon=True al crear el Thread?

El hilo muere automáticamente al cerrar la app (no la deja "colgada" abierta).

3. ¿Desde qué hilo se pueden tocar los widgets?

Solo el principal. Los hilos trabajadores comunican resultados por una cola.

4. ¿Qué hace self.after(100, f) y por qué no time.sleep?

Programa f en el mainloop dentro de 100 ms sin bloquear; sleep bloquearía el mainloop (ventana congelada).


📎 Apuntes relacionados

➡️ Siguiente

Clase 34 · Empaquetar con PyInstaller — tu app como .app/.exe instalable.