Apariencia
📙 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| Pieza | Por qué |
|---|---|
target=funcion | la función que correrá en paralelo (sin paréntesis: se pasa, no se llama) |
daemon=True | el 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 demsmilisegundos". Es la forma GUI de esperar sin congelar (verificado:CTk.afterexiste; el patrón cola/get_nowait/queue.Emptycorrió 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
fen el mainloop dentro de 100 ms sin bloquear;sleepbloquearía el mainloop (ventana congelada).
📎 Apuntes relacionados
- Las peticiones que motivan esto → Clase 23 · requests
- "No bloquees el mainloop" (la semilla) → Clase 03 · while
➡️ Siguiente
Clase 34 · Empaquetar con PyInstaller — tu app como .app/.exe instalable.