R

ReflexKR

Documentación de Reflex

Concurrencia en la cocina de medianoche

·

Resumen del curso

1

La cola de la primera noche: concurrencia y paralelismo

2

Contratar más cocineros: lo básico de threading

3

La pizarra no cuadra: condiciones de carrera y Lock

4

Solo tres fogones: herramientas de sincronización

5

Cuchillos y tablas: interbloqueo

6

El riel de comandas: queue

7

Cocineros de agencia: concurrent.futures

8

Por qué la masa no va más rápido: el GIL y el paralelismo

9

Una sola encargada de sala: primeros pasos con asyncio

10

Se cae la reserva de grupo: concurrencia estructurada

11

La sala se congela: llamadas bloqueantes y combinar herramientas

12

La noche de la crítica: todo junto

Concurrencia en la cocina de medianoche
Lección 8

Tiempo 5:50–6:40

Por qué la masa no va más rápido: el GIL y el paralelismo

La comanda de esta noche

#08 · Octava noche

Por qué la masa no va más rápido

Problema

Cuatro bolas de masa de fideos para mañana. Les pusimos cuatro cocineros, y se tarda exactamente lo mismo que amasando una sola persona.

Objetivo

Terminar las cuatro bolas de masa al menos el doble de rápido que una persona sola.

Restaurante nocturno Dongsi

La una de la madrugada; el servicio ha terminado. Sobre la mesa de trabajo esperan cuatro bolas de masa de fideos para mañana. Minho, Sua, el señor Tak y tú. Cuatro personas, una bola cada una: debería ir cuatro veces más rápido.

Pero el temporizador de la chef se detiene casi en el mismo número que cuando amasaba una sola persona. Minho ladea la cabeza. «Pero si todos teníamos las manos ocupadas...»

La chef Yoon Seoyeon señala el taco de cuchillos de la mesa. Solo hay un cuchillo clavado. «En esta cocina hay una regla muy antigua: cortar y amasar, de uno en uno. Esta noche hablamos de esa regla.»

8-1. Cuatro cocineros y el mismo tiempo (5 min)

Los pedidos de la lección 1 eran trabajo de espera: aguardar a que hirviera la sopa. La masa de hoy es distinta. Es un bucle que suma números dos millones de veces; es decir, trabajo en el que las manos no paran (trabajo de CPU).

import threading
import time

def knead(n=2_000_000):
    s = 0
    for i in range(n):   # calcular sin parar: amasar
        s += i
    return s

start = time.perf_counter()
for _ in range(4):
    knead()
print(f"sequential {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
cooks = [threading.Thread(target=knead) for _ in range(4)]
for th in cooks:
    th.start()
for th in cooks:
    th.join()
print(f"4 threads  {time.perf_counter() - start:.2f}s")
# sequential 0.24s
# 4 threads  0.23s   (medido en un PC de 12 núcleos, Python 3.14)
  • Aunque los cuatro hilos de verdad se ejecutaron, se tardó casi lo mismo que haciéndolo en orden.
  • El PC tiene 12 núcleos. No es que falten núcleos.
  • Los números varían un poco según el PC y la ejecución. Lo importante es que los dos son casi iguales.

8-2. El GIL: un solo cuchillo (8 min)

CPython (el Python normal que usamos) tiene el GIL (Global Interpreter Lock, el cerrojo global del intérprete). La regla es una sola:

Dentro de un proceso, solo un hilo a la vez ejecuta bytecode de Python.

En la cocina, es la regla de «un solo cuchillo». Aunque haya cuatro cocineros, solo corta quien tiene el cuchillo, y los demás esperan a que vuelva. El cuchillo pasa al siguiente más o menos cada 0,005 segundos (sys.getswitchinterval(), lección 3).

InstanteMinhoSuaEl señor TakTú
0–5 msAmasa con el cuchilloEsperaEsperaEspera
5–10 msEsperaAmasa con el cuchilloEsperaEspera
10–15 msEsperaEsperaAmasa con el cuchilloEspera
15–20 msEsperaEsperaEsperaAmasa con el cuchillo

Como los cuatro se turnan, las cuatro bolas avanzan un poquito cada una. Hay concurrencia, pero no paralelismo (lección 1). La cantidad total de trabajo es la misma, así que el tiempo también.

¿Por qué existe esta regla? Python lleva la cuenta, para cada objeto, de «desde cuántos sitios me están usando» (el contador de referencias). Si varios hilos modifican ese número a la vez, se descuadra como la pizarra de la lección 3. Hace mucho, CPython eligió el camino fácil de proteger todo el intérprete con un solo cerrojo, y eso es el GIL.

Mientras se espera, se suelta el cuchillo

El GIL tiene una excepción importante. Mientras un hilo espera (time.sleep, leer un archivo, una respuesta de red), suelta el GIL. Quien espera a que hierva la sopa no necesita cuchillo.

def simmer():
    time.sleep(0.3)   # esperar a que hierva la sopa: mientras tanto se suelta el GIL

start = time.perf_counter()
cooks = [threading.Thread(target=simmer) for _ in range(4)]
for th in cooks:
    th.start()
for th in cooks:
    th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# 4 threads 0.30s   (en orden habrían sido 1,2 s)

Las funciones escritas en C también pueden soltar el GIL mientras hacen un cálculo pesado. Pasa, por ejemplo, cuando hashlib calcula el hash de muchos datos. No es trabajo de manos, sino de la amasadora (código C), así que queda fuera de la regla del cuchillo.

import hashlib

dough = b"x" * 50_000_000   # 50 MB

def press():
    hashlib.sha256(dough).hexdigest()   # mientras calcula el código C, se suelta el GIL

# midiéndolo igual que arriba (aprox., en este PC)
# sequential 0.11s
# 4 threads  0.04s
Tipo de trabajoCon hilos y GILPor qué
Trabajo de espera (sleep, archivos, red)Se aceleraMientras espera, suelta el GIL
Cálculo pesado en una extensión de C (hashlib, zlib, etc.)Normalmente se aceleraEl código C suelta el GIL para calcular
Cálculo en Python puro (bucles, armar cadenas)No se aceleraEl bytecode lo ejecuta un hilo a la vez

Y el GIL no protege tu código. Solo protege el interior del intérprete; algo de varios pasos como count += 1 puede cortarse a medias. Por eso la pizarra de la lección 3 falló aunque hubiera GIL.

El banco de pruebas de abajo es una cocina en la que hilos reales hacen time.sleep en el servidor de producción. Aunque el servidor es un Python con GIL, como es trabajo de espera, las barras se solapan.

Línea de tiempo de la cocina

Pedidos

6

Cocineros

3

Cocinando

Esperando

Cancelado

Falló

4 trabajos de espera de 0,3 s cada uno. Incluso en un servidor con GIL, con varios cocineros las barras se solapan y todo termina en unos 0,3 s. Compáralo con «Solo, uno por uno».

El consejo de la chef

Si añades hilos y no va más rápido, pregúntate primero si ese trabajo es sopa o masa. Si es sopa, con hilos basta. Si es masa, no hace falta más gente, hacen falta más cocinas.

8-3. La cocina sucursal: multiprocessing (7 min)

Si solo hay un cuchillo, montas otra cocina con su propio cuchillo. Eso es un proceso. Cada proceso ejecuta su propio intérprete de Python, y tiene su propio GIL.

import multiprocessing as mp
import os

def branch(name):
    print(name, "pid", os.getpid(), "parent", os.getppid())

if __name__ == "__main__":
    print("main pid", os.getpid())
    p = mp.Process(target=branch, args=("branch-1",))
    p.start()
    p.join()
    print("exitcode", p.exitcode)
# por ejemplo:
# main pid 39516
# branch-1 pid 40268 parent 39516
# exitcode 0

Process se usa igual que threading.Thread (target, args, start, join). La diferencia está en os.getpid(), el número de proceso. La sucursal es un programa aparte, con un número distinto del de la casa central.

Hasta la nevera es otra

La sucursal no comparte memoria. Metas lo que metas en una lista desde la sucursal, la lista de la casa central sigue igual.

orders = []

def add(n):
    orders.append(n)
    print("in branch:", orders)

if __name__ == "__main__":
    p = mp.Process(target=add, args=("#1",))
    p.start()
    p.join()
    print("in main:", orders)
# in branch: ['#1']
# in main: []

El problema de la pizarra de la lección 3 ni siquiera aparece, pero a cambio, para recibir los resultados hay que pedir que te los devuelvan. Para repartir varios trabajos, lo cómodo es Pool.

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return s

if __name__ == "__main__":
    with mp.Pool(processes=4) as pool:
        print(pool.map(knead, [10, 100, 1000, 10_000]))
# [45, 4950, 499500, 49995000]

Por qué hace falta if __name__ == "__main__":

Windows y macOS abren las sucursales con el método spawn. Se lanza un Python nuevo, y ese Python vuelve a importar tu archivo desde el principio. Tiene que leer otra vez las definiciones de funciones para saber qué es knead.

Sin esta comprobación, la sucursal, al leer el archivo, se encuentra otra vez con la línea mp.Process(...).start() e intenta abrir otra sucursal. Python lo impide y lanza un error.

# si se arranca directamente al nivel superior, sin la comprobación (Windows)
p = mp.Process(target=knead, args=(1,))
p.start()
# en la sucursal:
# RuntimeError:
#         An attempt has been made to start a new process before the
#         current process has finished its bootstrapping phase.
#         ...

Por eso los ejemplos de multiprocessing se guardan en un archivo y se ejecutan con python archivo.py. En la consola interactiva o en un notebook, es fácil que falle porque la sucursal no encuentra la función.

8-4. Cocineros de agencia en la sucursal: ProcessPoolExecutor (6 min)

¿Te acuerdas del ThreadPoolExecutor de la lección 7? ProcessPoolExecutor reparte el trabajo entre procesos con la misma interfaz. submit, Future, result(), map y as_completed funcionan igual.

import os
from concurrent.futures import ProcessPoolExecutor

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return os.getpid(), s

if __name__ == "__main__":
    print("main", os.getpid())
    with ProcessPoolExecutor(max_workers=2) as ex:
        future = ex.submit(knead, 1000)
        print(future.result())
        for pid, s in ex.map(knead, [10, 100, 1000]):
            print(pid, s)
# por ejemplo:
# main 43452
# (42784, 499500)
# 42784 45
# 42784 4950
# 42784 499500

El número de proceso es distinto del de main. Qué sucursal se encarga de cada masa cambia en cada ejecución.

Lo que va y viene tiene que poder serializarse con pickle

Como la sucursal y la casa central no comparten memoria, la función, los argumentos y los resultados se empaquetan en bytes (pickle) para enviarlos y recibirlos. Lo que no se puede empaquetar no se puede enviar. El ejemplo típico es una lambda.

def double(x):
    return x * 2

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=2) as ex:
        print(ex.submit(double, 21).result())
        try:
            ex.submit(lambda x: x * 2, 21).result()
        except Exception as e:
            print(type(e).__name__, e)
# 42
# PicklingError Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>
  • Las funciones se empaquetan por su nombre. La sucursal busca «el double del archivo» y lo usa. Una lambda, que no tiene nombre, la sucursal no la puede encontrar.
  • Por eso, las funciones que mandas a la sucursal se definen con def al nivel superior del archivo. Ni funciones dentro de funciones, ni lambdas.
  • Tampoco puedes pasar como argumento archivos abiertos, cerrojos, sockets y cosas así. Intercambia valores sencillos: números, cadenas, listas, dicts.

8-5. Lo que cuesta abrir una sucursal (4 min)

Una sucursal no sale gratis. Hay que lanzar un Python nuevo, volver a importar el archivo y empaquetar los valores de ida y vuelta. Si la masa es pequeña, esa preparación cuesta más que el trabajo.

# las 4 masas pequeñas de 8-1 (dos millones de vueltas) con 4 procesos
with ProcessPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(knead, [2_000_000] * 4))
# 4 processes 0.31s   (medido en un PC de 12 núcleos; en orden, 0,24 s)
Método (4 masas pequeñas)Tiempo (medido en un PC de 12 núcleos)
En orden0,24 s
4 hilos0,23 s
4 procesos0,31 s

Hasta va más lento. Si haces la masa diez veces más grande (veinte millones de vueltas), la cosa cambia. En este PC salió aproximadamente de 2,4 a 2,7 s en orden y de 0,9 a 1,1 s con 4 procesos. El coste de preparación casi no cambia, pero el trabajo es mucho mayor.

  • Los procesos se usan para cálculos en los que cada trozo es suficientemente grande. Si mandas miles de trabajos pequeños, agrúpalos y envíalos juntos (chunksize= de map).
  • Crea el pool una vez y úsalo muchas. Abrir un with ProcessPoolExecutor() nuevo para cada trabajo es como montar una sucursal nueva cada vez.

8-6. Tres formas de abrir una sucursal: spawn, fork, forkserver (3 min)

La manera de montar la sucursal se llama método de inicio (start method).

MétodoQué haceEn la cocina
spawnLanza un Python nuevo y vuelve a importar el archivoMonta una cocina desde cero en un local vacío
forkClona el proceso actual enteroCopia la casa central tal cual (hasta los cerrojos de quien estaba trabajando)
forkserverMantiene un proceso servidor limpio y clona a partir de élTiene una cocina modelo limpia y copia esa
  • Desde 3.14, el valor por defecto en Linux es forkserver. En Windows y macOS es spawn. El anterior por defecto en Linux, fork, se cambió porque al clonar un programa con hilos en marcha los cerrojos podían quedar enredados.
  • En Windows solo existe spawn.
import multiprocessing as mp

if __name__ == "__main__":
    print(mp.get_start_method())        # spawn   (Windows)
    print(mp.get_all_start_methods())   # ['spawn']

    ctx = mp.get_context("spawn")       # spawn solo para este pool
    with ctx.Pool(processes=2) as pool:
        print(pool.map(knead, [10, 100]))   # [45, 4950]
  • mp.get_context("spawn") solo se aplica a los Pool y Process creados con ese contexto. Dentro de una biblioteca, esta es la opción segura.
  • Para cambiarlo en todo el programa, llama a mp.set_start_method("spawn") una sola vez, al principio del if __name__ == "__main__":. Si lo llamas dos veces, salta RuntimeError: context has already been set.
  • El valor por defecto cambia según el sistema operativo, así que si necesitas que funcione igual en todas partes, indica spawn explícitamente y pruébalo.

8-7. Reforma: Python 3.14t sin GIL (5 min)

Desde 3.14, la versión free-threaded tiene soporte oficial. Es un Python compilado sin GIL, y el ejecutable se llama python3.14t (con una t al final). Es como reformar la cocina para que haya tantos cuchillos como cocineros.

  • Instala el binario free-threaded junto con el instalador de python.org o, si usas uv, ejecútalo con uv run --python 3.14t archivo.py.
  • Para saber si el GIL está activado en este momento, usa sys._is_gil_enabled().
# knead_threads.py: el código de 8-1 con solo dos líneas más arriba del todo
import sys
print(sys._is_gil_enabled())
...

# python knead_threads.py
# True
# sequential 0.24s
# 4 threads  0.23s

# python3.14t knead_threads.py
# False
# ...
# 4 threads  0.09s   (medido en un PC de 12 núcleos, unas 2,7 veces más rápido)

No hemos cambiado ni una letra del código. El mismo código con hilos fue unas 2,7 veces más rápido solo por cambiar de intérprete. No llega a cuatro veces por el coste de lanzar y gestionar los hilos, y por el de los mecanismos internos que permiten funcionar de forma segura sin GIL. A cambio, el código que usa un solo hilo puede ir algo más lento que en la versión normal.

Hay dos cosas que conviene saber.

  • Una extensión de C puede volver a activar el GIL. Si importas un módulo de extensión que no declara soporte para free-threading, Python avisa y vuelve a activar el GIL. Con la variable de entorno PYTHON_GIL=0 (o -X gil=0) puedes forzar que siga desactivado, y con PYTHON_GIL=1 el GIL se activa incluso en 3.14t.
  • Las condiciones de carrera aparecen más. El experimento de la pizarra de la lección 3 dio en 3.14t 2410 de 4000 (medido). Como todo corre a la vez de verdad, los cerrojos importan todavía más.

En el banco de pruebas de abajo, las cuatro barras de arriba son valores medidos en un PC de 12 núcleos al preparar el curso. Abajo, «Amasar en el servidor» ejecuta la masa (600 000 vueltas × 4) en orden y con 4 hilos, directamente en el servidor de producción de este sitio. El servidor usa una versión con GIL y no se sabe de antemano cuántos núcleos tiene, así que los números pueden ser distintos de las barras medidas. Fíjate en si las dos barras tienen una longitud parecida.

Comparación de masa

Medido en local (PC de 12 núcleos, Python 3.14)

Uno por uno

0.24s

4 hilos

0.23s

4 procesos

0.31s

4 hilos (3.14t, sin GIL)

0.09s

Barras medidas de 4 trabajos de CPU: en orden, con hilos, con procesos y con hilos en 3.14t sin GIL. Ejecútalo también en el servidor para ver su estado del GIL y su número de núcleos.

El consejo de la chef

Sin GIL desaparece la regla del cuchillo, no la de la pizarra. Si varios escriben en la misma pizarra, ahora sí que lo hacen a la vez de verdad. Antes de pasarte a 3.14t, revisa primero los cerrojos.

8-8. Cocinas con mampara: subintérpretes (2 min)

Las sucursales salen caras, y a la reforma todavía no se han sumado todas las bibliotecas. Entre medias hay una opción más: cocinas separadas por mamparas dentro de un mismo local, es decir, subintérpretes. Dentro de un solo proceso hay varios intérpretes, y cada compartimento tiene su propio GIL (su propio cuchillo). En 3.14 llegaron a la biblioteca estándar con el módulo concurrent.interpreters.

from concurrent import interpreters

booth = interpreters.create()
booth.exec("menu = ['#1', '#2']; print('booth menu:', menu)")
booth.exec("menu.append('#3'); print('booth menu:', menu)")
print("main has menu?", "menu" in globals())

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return s

print(booth.call(knead, 1000))
booth.close()
# booth menu: ['#1', '#2']
# booth menu: ['#1', '#2', '#3']
# main has menu? False
# 499500
  • Con create() se crea el compartimento, con exec(cadena de código) se ejecuta código en él y con call(función, argumentos) se llama a una función y se recibe el resultado.
  • Las variables del compartimento (menu) solo existen dentro de él. Igual que en una sucursal, no se comparte memoria.

En la práctica, lo cómodo es InterpreterPoolExecutor (3.14), con la misma forma que en la lección 7.

from concurrent.futures import InterpreterPoolExecutor

if __name__ == "__main__":
    with InterpreterPoolExecutor(max_workers=4) as ex:
        results = list(ex.map(knead, [2_000_000] * 4))
# en este PC, unos 0.18 s (3.14 con GIL; en orden, 0.24 s)

Como cada compartimento tiene su propio cuchillo, incluso con el Python normal las masas avanzan en paralelo. Ten en cuenta que muchas extensiones de C todavía no son compatibles con los subintérpretes.

Cierre (10 min)

El objetivo de esta noche era terminar las cuatro bolas de masa al menos el doble de rápido que una persona sola. Es cálculo en Python puro, así que con hilos no sirve; y como cada bola es grande, compensa el coste de abrir sucursales.

import time
from concurrent.futures import ProcessPoolExecutor

BIG = 20_000_000   # una bola de masa grande

def knead(n):
    s = 0
    for i in range(n):
        s += i
    return s

if __name__ == "__main__":
    start = time.perf_counter()
    alone = [knead(BIG) for _ in range(4)]
    print(f"sequential {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as ex:
        branches = list(ex.map(knead, [BIG] * 4))
    print(f"4 processes {time.perf_counter() - start:.2f}s", branches == alone)
# por ejemplo (en este PC):
# sequential 2.43s
# 4 processes 0.93s True

Unas 2,5 veces más rápido. Y el resultado es idéntico al de una persona sola. Con 3.14t, ejecutar knead con cuatro hilos daría un efecto parecido.

HerramientaCuchillo (GIL)MemoriaCoste de arranqueAdecuada para
Hilos (3.14)Uno por procesoCompartidaBajoTrabajo de espera
Hilos (3.14t)NingunoCompartidaBajoTrabajo de espera + cálculo
ProcesosUno por sucursalSeparada (pickle)AltoCálculos grandes
SubintérpretesUno por compartimentoSeparadaMedioCálculo, código en Python puro

La chef se desata el delantal. «La masa la hemos resuelto con más cocinas. ¿Y la sala? Con diez mesas, ¿pondrías diez camareros, o una sola persona que atienda las diez?»

Diario de servicio — Octava noche. Con hilos, la masa no se aceleró: solo hay un cuchillo. Repartida entre cuatro sucursales, salió 2,5 veces más rápido. Mañana reorganizamos la sala.

Ejercicios

  1. Cambia el knead de 8-1 por time.sleep(0.06) y vuelve a medir en orden y con 4 hilos. Explica con el GIL por qué esta vez los hilos sí lo aceleran.
  2. Cambia el tamaño de la masa de 8-5 a 200 000, dos millones y veinte millones de vueltas, y apunta en una tabla los tiempos en orden y con ProcessPoolExecutor. ¿A partir de qué tamaño empiezan a ganar los procesos en tu PC?
  3. Haz submit en un ProcessPoolExecutor de una función definida dentro de otra función (una función anidada), mira qué error sale y corrígelo.

La tarjeta de recetas de la chef

  • GIL: el bytecode de Python lo ejecuta un hilo a la vez. Al esperar (I/O) y en parte del código C, se suelta el GIL.

  • El trabajo de cálculo (CPU) no se acelera con hilos → procesos, 3.14t, subintérpretes.

  • ProcessPoolExecutor se usa igual que ThreadPoolExecutor. if __name__ == "__main__": obligatorio.

  • Lo que va y viene de la sucursal se serializa con pickle: nada de lambdas ni funciones anidadas. def al nivel superior.

  • Lanzar procesos cuesta caro: solo para cálculos grandes. Método de inicio: spawn en Windows y macOS, forkserver en Linux desde 3.14.

  • sys._is_gil_enabled(): False en 3.14t (python3.14t). El mismo código con hilos corre en paralelo tal cual.

  • concurrent.interpreters: create() → exec() / call(), InterpreterPoolExecutor (3.14).

← Anterior

Lección 7 · Cocineros de agencia: concurrent.futures

Siguiente →

Lección 9 · Una sola encargada de sala: primeros pasos con asyncio

Recibe avisos de nuevas lecciones

Te avisaremos por correo cuando este curso tenga lecciones nuevas o se abra un curso de continuación.

Datos: tu correo electrónico · Finalidad: avisos de nuevas lecciones o de la publicación de este curso · Conservación: hasta que pidas que lo borremos. No lo usamos para nada más ni lo compartimos. Responde a cualquier aviso y lo borraremos enseguida.

Preguntas y respuestas

Pregunta lo que quieras sobre esta lección. Aquí se reúnen las preguntas de todas las versiones de idioma de esta página.

Se muestra como texto plano, sin enlaces ni formato.

R

ReflexKR

Aprende Python y Reflex haciendo. Todos los ejemplos se ejecutaron y verificaron en Reflex 0.10.0.

ReflexKR es gratis y sin banners publicitarios. Si te ayudó, puedes apoyarlo con un café. ☕ Invítanos un café

Aviso de afiliados
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex