asyncio: Cómo Funcionan los Procesos, Hilos y el Event Loop

asyncio: Cómo Funcionan los Procesos, Hilos y el Event Loop

Tabla de Contenido

En el artículo anterior construimos un modelo mental sobre concurrencia, paralelismo y los tipos de multitarea. Ahora vamos a bajar un nivel: entender qué son los procesos y los hilos, por qué el GIL de Python cambia las reglas del juego, y cómo asyncio logra concurrencia con un solo hilo gracias al event loop.

Procesos e Hilos

¿Qué es un proceso?

Un proceso es una instancia de una aplicación en ejecución. Cada proceso tiene su propio espacio de memoria aislado — ningún otro proceso puede acceder a él directamente — y se ejecuta en un CPU o núcleo del sistema.

¿Qué es un hilo (thread)?

Un hilo es una unidad de ejecución más ligera que vive dentro de un proceso. A diferencia de los procesos, los hilos no tienen su propia memoria: comparten el espacio de memoria del proceso que los creó.

Todo proceso tiene al menos un hilo asociado, conocido como el hilo principal (main thread). Además, un proceso puede crear hilos adicionales, comúnmente llamados hilos de trabajo (worker threads) o hilos en segundo plano (background threads).

flowchart TB subgraph P1["Proceso (PID: 1234)"] direction TB M1["Memoria del proceso"] MT["Hilo principal\n(main thread)"] W1["Worker thread 1"] W2["Worker thread 2"] M1 <--> MT M1 <--> W1 M1 <--> W2 end style P1 fill:transparent,stroke:#64748b style M1 fill:#3b82f6,stroke:#1e40af,color:#fff style MT fill:#10b981,stroke:#065f46,color:#fff style W1 fill:#f59e0b,stroke:#b45309,color:#fff style W2 fill:#f59e0b,stroke:#b45309,color:#fff

Info

Todos los hilos dentro de un proceso comparten la misma memoria. Esto hace la comunicación entre hilos muy rápida, pero introduce el riesgo de condiciones de carrera si no se maneja con cuidado.

Verificándolo en Python

Puedes inspeccionar el proceso e hilo actual con los módulos os y threading:

import os
import threading

print(f'Proceso de Python con PID: {os.getpid()}')
total_threads = threading.active_count()
thread_name = threading.current_thread().name

print(f'Python está ejecutando {total_threads} hilo(s)')
print(f'El hilo actual es: {thread_name}')
# Output esperado
Proceso de Python con PID: 48672
Python está ejecutando 1 hilo(s)
El hilo actual es: MainThread

Por defecto, tu programa Python corre en un solo hilo (MainThread) dentro de un solo proceso.

El Global Interpreter Lock (GIL)

El GIL (Global Interpreter Lock) es uno de los temas más debatidos en la comunidad Python. En términos simples: el GIL impide que un proceso de Python ejecute más de una instrucción de bytecode a la vez, incluso si tienes múltiples hilos.

Esto significa que aunque crees varios hilos con threading, solo uno ejecuta código Python en cualquier instante.

sequenceDiagram participant GIL participant T1 as Hilo 1 participant T2 as Hilo 2 GIL->>T1: 🔓 GIL adquirido activate T1 T1->>T1: Ejecuta bytecode T1->>GIL: 🔒 Libera GIL deactivate T1 GIL->>T2: 🔓 GIL adquirido activate T2 T2->>T2: Ejecuta bytecode T2->>GIL: 🔒 Libera GIL deactivate T2 GIL->>T1: 🔓 GIL adquirido activate T1 T1->>T1: Ejecuta bytecode deactivate T1

¿El GIL se libera alguna vez?

Sí. El GIL se libera durante las operaciones de I/O. Cuando un hilo hace una petición de red o una lectura de disco, libera el GIL, permitiendo que otro hilo tome el control y ejecute código Python mientras tanto.

Esto es lo que hace que threading sea útil para trabajo I/O-bound, pero ineficaz para trabajo CPU-bound.

flowchart LR A["Operación\nCPU-bound"] -->|"GIL retenido 🔒"| B["Solo 1 hilo\nejecuta a la vez"] C["Operación\nI/O-bound"] -->|"GIL liberado 🔓"| D["Otros hilos pueden\nejecutar código"] style A fill:#ef4444,stroke:#991b1b,color:#fff style B fill:#ef4444,stroke:#991b1b,color:#fff style C fill:#10b981,stroke:#065f46,color:#fff style D fill:#10b981,stroke:#065f46,color:#fff

asyncio y el GIL

asyncio aprovecha exactamente este comportamiento: como las operaciones de I/O liberan el GIL, puede lograr concurrencia con un solo hilo. En lugar de crear múltiples hilos, asyncio crea objetos llamados coroutines — que puedes pensar como hilos ultra-ligeros.

Note

Mientras una coroutine espera por una operación de I/O, el event loop ejecuta otras coroutines que estén listas. El resultado: concurrencia sin los problemas de sincronización del multithreading.

Concurrencia de Un Solo Hilo

La concurrencia de un solo hilo no se basa en hacer dos cosas simultáneamente (eso sería paralelismo), sino en aprovechar los tiempos de espera de las operaciones de I/O para ejecutar otras tareas. El mecanismo se apoya en tres pilares:

  • Sockets no bloqueantes
  • Delegación al SO
  • Event Loop

1. Sockets no bloqueantes

A diferencia de un socket estándar que detiene el programa hasta recibir una respuesta, un socket no bloqueante permite enviar una petición y devolver el control al programa inmediatamente, sin esperar el resultado.

2. Delegación al sistema operativo

Cuando el código llega a una operación de red o lectura de disco, Python le delega la espera al sistema operativo. El hilo de ejecución queda libre de inmediato.

3. El Event Loop como coordinador

Mientras el SO vigila los sockets, el hilo de Python sigue ejecutando otras tareas. Cuando el SO detecta que los datos llegaron, envía una notificación. El event loop recibe este aviso y “despierta” la tarea pausada para que procese la respuesta.

El siguiente diagrama de secuencia muestra cómo interactúan estos tres pilares:

sequenceDiagram participant MT as Main Thread participant EL as Event Loop participant OS as Sistema Operativo participant S1 as Socket 1 participant S2 as Socket 2 MT->>EL: Envía tareas EL->>EL: Ejecuta tarea 1 hasta I/O EL->>OS: Registra Socket 1 para monitoreo OS->>S1: Vigila socket EL->>EL: Ejecuta tarea 2 hasta I/O EL->>OS: Registra Socket 2 para monitoreo OS->>S2: Vigila socket S1-->>OS: Datos listos ✅ OS-->>EL: Notifica Socket 1 EL->>EL: Reanuda tarea 1 S2-->>OS: Datos listos ✅ OS-->>EL: Notifica Socket 2 EL->>EL: Reanuda tarea 2

Cómo Funciona el Event Loop

En su forma más básica, un event loop es simplemente un bucle infinito que procesa mensajes de una cola, uno a la vez:

from collections import deque

messages = deque()

while True:
    if messages:
        message = messages.pop()
        process_message(message)

En asyncio, el event loop mantiene una cola de tasks (tareas) en lugar de mensajes simples. Cada task es un wrapper alrededor de una coroutine. Cuando una coroutine alcanza una operación de I/O, se pausa y le devuelve el control al event loop, que puede ejecutar otras tareas que no estén esperando I/O.

flowchart TD A["Event Loop inicia"] --> B["¿Hay tareas en la cola?"] B -->|Sí| C["Tomar siguiente tarea"] C --> D{"¿La tarea tiene\nI/O pendiente?"} D -->|No| E["Ejecutar hasta\nel próximo await"] E --> F{"¿La tarea alcanzó\nuna operación I/O?"} F -->|Sí| G["Pausar tarea y registrar\nsocket en el SO"] F -->|No| H["Tarea completada ✅\nRemover de la cola"] G --> B H --> B D -->|Sí, datos listos| I["Reanudar tarea"] I --> F D -->|Sí, esperando aún| B B -->|No| J["Esperar notificaciones\ndel SO"] J --> B style A fill:#3b82f6,stroke:#1e40af,color:#fff style H fill:#10b981,stroke:#065f46,color:#fff

Note

Este ciclo se repite indefinidamente hasta que todas las tareas se completan o el programa se cierra explícitamente. Si una coroutine ejecuta trabajo CPU-bound sin ceder control con await, bloqueará todo el event loop y ninguna otra tarea podrá avanzar.

En conclusión

  • Un proceso tiene memoria aislada; un hilo comparte memoria dentro de su proceso.
  • El GIL impide paralelismo real con hilos en Python, pero se libera durante operaciones de I/O.
  • asyncio aprovecha esta liberación del GIL para lograr concurrencia en un solo hilo mediante coroutines.
  • El event loop coordina las tareas: ejecuta coroutines hasta que alcanzan un await, las pausa, y reanuda otras que ya tengan datos listos.
  • Los sockets no bloqueantes y la delegación al SO son los mecanismos que permiten esta concurrencia sin crear múltiples hilos.

Referencias