Por qué hilos
En resumen: Un hilo es una línea de ejecución separada dentro del programa; los hilos aceleran el trabajo que espera (red, disco), mientras que el trabajo que calcula necesita procesos en Python por el GIL.
Problema
El cliente espera tres veces
El cliente pulsa «Pedir». Antes de ver «Gracias», el servidor tiene que consultar tres servicios por la red: el pago (si el banco acepta la tarjeta), el almacén (si el artículo está disponible) y el correo (si salió la confirmación). Cada uno responde en unos 200 ms.
Aquí la red es Network. call espera 200 ms y devuelve la respuesta del servicio. El programa consulta los tres uno tras otro:
net = Network()payment = net.call("pago", "aceptado")stock = net.call("almacén", "disponible")email = net.call("correo", "enviado")print(payment)print(stock)print(email)print(f"Rondas de espera: {net.rounds}")pago: aceptadoalmacén: disponiblecorreo: enviadoRondas de espera: 3Cada pregunta empieza cuando termina la anterior. El cliente espera la suma: unos 600 ms. Y todo el tiempo el procesador no hace nada: solo espera una respuesta.
Network también cuenta las rondas de espera: una ronda nueva empieza cuando el programa pregunta y no se espera ninguna otra respuesta. Aquí hay tres, una por pregunta. En esta lección la velocidad se mide con este número y no en milisegundos: el tiempo depende de la máquina, mientras que el número de rondas es el mismo en cada ejecución.
Más precisamente, para curiosos
Así funciona Network. with self.lock viene de la lección «Hilos»: los dos contadores los tocan varios hilos a la vez. Por qué sin él se pierden números lo cuenta la lección siguiente, «Condición de carrera».
class Network: def __init__(self): self.lock = threading.Lock() self.in_flight = 0 self.rounds = 0
def call(self, service, answer): """Consulta un servicio por la red: la respuesta llega después de ~200 ms.""" with self.lock: if self.in_flight == 0: self.rounds += 1 # nadie espera: una ronda nueva self.in_flight += 1 time.sleep(0.2) with self.lock: self.in_flight -= 1 return f"{service}: {answer}"La idea
Trabajo que espera y trabajo que calcula
El trabajo de un programa es de dos tipos.
- Trabajo que espera (I/O-bound): una petición al banco, una consulta a la base de datos, leer un archivo, enviar un correo. El programa pregunta y se queda quieto hasta que llega la respuesta. Mientras tanto el procesador está libre.
- Trabajo que calcula (CPU-bound): 1000 facturas en PDF, miniaturas de productos, un informe sobre un millón de pedidos. Aquí el procesador está ocupado todo el tiempo.
En Python esto es aún más marcado por el GIL: en cada momento solo un hilo ejecuta código Python. Esperar libera el GIL (time.sleep, la red, los archivos), así que los hilos ayudan al trabajo que espera. Calcular lo retiene, así que los hilos no lo aceleran. Para eso hacen falta procesos.
Así que la primera pregunta ante un programa lento es: «¿qué está esperando?». La respuesta decide qué ayuda.
Míralo
Los tres a la vez
ThreadPoolExecutor de la lección «Hilos»). submit devuelve un Future enseguida, y result() espera la respuesta:net = Network()with ThreadPoolExecutor(max_workers=3) as pool: payment = pool.submit(net.call, "pago", "aceptado") stock = pool.submit(net.call, "almacén", "disponible") email = pool.submit(net.call, "correo", "enviado") print(payment.result()) print(stock.result()) print(email.result())print(f"Rondas de espera: {net.rounds}")pago: aceptadoalmacén: disponiblecorreo: enviadoRondas de espera: 1net = Network()with ThreadPoolExecutor(max_workers=3) as pool: payment = pool.submit(net.call, "pago", "aceptado") stock = pool.submit(net.call, "almacén", "disponible") email = pool.submit(net.call, "correo", "enviado") print(payment.result()) print(stock.result()) print(email.result())print(f"Rondas de espera: {net.rounds}")net = Network()payment = net.call("pago", "aceptado")stock = net.call("almacén", "disponible")email = net.call("correo", "enviado")print(payment)print(stock)print(email)print(f"Rondas de espera: {net.rounds}")Paso a paso, en palabras
- Un pedido necesita tres respuestas por la red: el pago, la comprobación del almacén y el correo de confirmación. Cada una espera unos 200 ms.
- Una tras otra: el hilo pregunta por el pago y no hace nada hasta que llega la respuesta.
- Solo entonces pregunta por el almacén. Otros 200 ms de espera.
- Solo entonces pregunta por el correo. Otros 200 ms de espera.
- 3 rondas de espera: el cliente espera unos 600 ms, y el procesador está parado todo el tiempo.
- A la vez: las tres preguntas salen juntas, cada una en su propio hilo, y las tres esperas se superponen.
- El programa espera las respuestas. Cuando llega la primera, las otras dos también están listas.
- 1 ronda de espera: unos 200 ms, lo que tarda la respuesta más lenta. Las mismas respuestas, en el mismo orden, un tercio de la espera.
Las respuestas son las mismas y en el mismo orden. Hay una ronda de espera: las tres preguntas salen antes de que haya llegado ninguna respuesta. El cliente espera unos 200 ms, lo que tarda el servicio más lento, en vez de 600.
Los hilos apenas usan aquí el procesador: tres ejecuciones cortas decall y tres pausas. Por eso esto funciona también en un servidor con un solo núcleo.Más precisamente, para curiosos
asyncio: un solo hilo que hace malabares con miles de esperas sin un hilo para cada una. Esa es la lección «async no es un hilo».Pruébalo
El correo espera el pago
Un compañero cambia el pedido: el correo tiene que decir si está pagado y si hay existencias, así que sale solo después de que hayan respondido el pago y el almacén.
net = Network()with ThreadPoolExecutor(max_workers=3) as pool: payment = pool.submit(net.call, "pago", "aceptado") stock = pool.submit(net.call, "almacén", "disponible") # el correo dice si está pagado y si hay existencias print(payment.result()) print(stock.result()) email = pool.submit(net.call, "correo", "enviado") print(email.result())print(f"Rondas de espera: {net.rounds}")El pago y la comprobación del almacén salen juntos: esa es la primera ronda. El correo sale solo después de las dos respuestas, cuando no se espera nada más: una segunda ronda. Unos 400 ms. El trabajo que depende de otro trabajo no puede superponerse con él.
Pruébalo
Esperar la respuesta
¿Qué espera la respuesta del pago?
payment = pool.submit(net.call, "pago", "aceptado")
print(payment.)result() espera a que la tarea termine y devuelve su resultado.Problema
Mil facturas a fin de mes
A fin de mes la tienda genera una factura en PDF para cada uno de 1000 pedidos. Aquí nada espera la red: el procesador calcula todo el tiempo. render representa el dibujo del PDF con un cálculo que solo carga el procesador y devuelve una suma de control:
def render(invoice): """Genera la factura (aquí: un cálculo que solo carga el procesador).""" h = invoice for i in range(2000): h = (h * 31 + i) % 1_000_003 return h
def render_range(bounds): """Las facturas desde start hasta end, inclusive.""" start, end = bounds return sum(render(n) for n in range(start, end + 1))total = render_range((1, 1000))print("Facturas: 1000")print(f"Suma de control: {total}")Facturas: 1000Suma de control: 499409728Un núcleo calcula todo mientras los demás están parados. Aquí no hay rondas de espera: nada espera.
Míralo
Cuatro procesos, cuatro núcleos
Primero con hilos. El resultado es correcto, pero por el GIL los cuatro hilos calculan uno a uno y no van más rápido:
bounds = [(p * 250 + 1, p * 250 + 250) for p in range(4)]with ThreadPoolExecutor(max_workers=4) as pool: total = sum(pool.map(render_range, bounds))print("Facturas: 1000")print(f"Suma de control: {total}")Facturas: 1000Suma de control: 499409728Con ProcessPoolExecutor cada parte va a un proceso aparte: una copia separada de Python con su propio GIL, en su propio núcleo. pool.map devuelve los resultados en el orden de las partes. if __name__ == "__main__": evita que el código principal se ejecute otra vez en cada proceso:
if __name__ == "__main__": bounds = [(p * 250 + 1, p * 250 + 250) for p in range(4)] with ProcessPoolExecutor(max_workers=4) as pool: total = sum(pool.map(render_range, bounds)) print("Facturas: 1000") print(f"Suma de control: {total}")Facturas: 1000Suma de control: 499409728La suma de control es la misma: dividir no cambia el resultado, solo quién lo calcula.
Cuánto más rápido es depende de la máquina. En mi portátil, con un cálculo 25 veces más pesado por factura (50 000 pasos en vez de 2000), un hilo tardó unos 1,2 s, cuatro hilos todavía unos 1,2 s y cuatro procesos unos 0,4 s.Son tiempos de ejemplo, no una promesa. Más partes que núcleos no ayudan: solo se turnan en los mismos núcleos.
Más precisamente, para curiosos
- Cuántos hilos. Para el trabajo que calcula, tantos como núcleos:
os.cpu_count()te dice cuántos. Para el trabajo que espera, muchos más está bien: solo duermen. - No todo se divide. Si una décima parte del trabajo no se puede dividir (por ejemplo sumar las partes o escribir un archivo), ningún número de núcleos lo hace más de 10 veces más rápido. Es la ley de Amdahl.
- Python 3.13 tiene además una versión sin GIL (free-threaded). En ella los hilos calculan en paralelo, pero todavía es experimental.
- Los procesos no comparten memoria: sus partes se pasan dentro y el resultado vuelve por copia. Por eso a un proceso le das un trozo grande de trabajo, no una factura cada vez.
La idea
Qué ayuda
| Trabajo que espera | Trabajo que calcula | |
|---|---|---|
| Qué lo frena | la respuesta de la red, del disco, de la base de datos | el propio cálculo |
| Qué ayuda | esperar varias cosas a la vez | más núcleos |
| En Python | hilos, asyncio | procesos: ProcessPoolExecutor |
| Con un núcleo | ayuda | no ayuda |
Los hilos tienen un precio. Mientras cada uno trabaje con sus propios datos (su propio servicio, sus propias 250 facturas), todo va bien. En cuanto dos hilos tocan lo mismo (un contador compartido, las existencias del almacén), empiezan errores que no aparecen en cada ejecución. Esa es la lección siguiente: «Condición de carrera».
Ejercicio
La página del producto
Resuelve la tarea en tu editor.
La página del producto. Antes de mostrar la página, el servidor consulta tres servicios: el precio, las reseñas y la entrega. Cada uno responde en unos 200 ms y no depende de los demás. Usa Network de la primera tarjeta y consúltalos todos a la vez, de modo que haya una ronda de espera:
precio: 89 €reseñas: 4,6 de 5entrega: mañanaRondas de espera: 1Más tareas
- Cinco servicios. Añade el almacén y un descuento. ¿Cuántas rondas hay si el descuento depende del precio?
- Cuántos núcleos. Ejecuta las facturas con 1, 2, 4 y 8 partes y mide el tiempo en tu máquina. ¿Cuándo deja de ir más rápido? Compáralo con el número de núcleos de tu máquina.
- La herramienta equivocada. Pon en
renderunapause/sleepde 200 ms en cada décima factura, como si esperara al disco. ¿Qué ayuda más ahora: más hilos o más núcleos?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Listas: append, remove y sort · Lambdas · Hilos
- Continúa en: Condición de carrera · Bloqueo · Operaciones atómicas y objetos inmutables · Productor y consumidor
- Ejercicios sobre el tema: Concurrencia y condiciones de carrera