1.5 Concurrencia, paralelismo y distribución¶
Hay tres formas de conseguir que un sistema haga varias cosas «a la vez». Se diferencian en cuántos procesadores hay y dónde están.
El punto de partida: ejecución secuencial¶
Un programa secuencial hace una instrucción detrás de otra y no empieza un paso hasta que acaba el anterior. Es lo más sencillo de escribir y de depurar, pero desaprovecha el equipo: con 8 núcleos usa uno y los otros siete esperan, y mientras espera al disco o a la red, la CPU tampoco hace nada.
Concurrencia¶
Hay concurrencia cuando varias tareas están en curso durante el mismo periodo, aunque no se ejecuten en el mismo instante. Es un solo cocinero que prepara una ensalada y una sopa: pica un poco, remueve la sopa, vuelve a picar... Los dos platos avanzan, pero en cada instante solo hace una cosa.
En un equipo con un solo núcleo, el sistema operativo hace lo mismo: ejecuta un trozo de un proceso, lo para y ejecuta un trozo de otro (es lo que se llama multiprogramación). Como los cambios son tan rápidos, parece que todo va a la vez. No se gana tiempo de CPU, pero no se desperdicia: mientras un proceso espera al disco, el procesador atiende a otro (los procesos limitados por E/S lo permiten).
Paralelismo¶
Hay paralelismo cuando varias tareas se ejecutan en el mismo instante, en núcleos o procesadores distintos: ahora hay dos cocineros, uno pica y otro remueve. Necesita hardware con varios núcleos (multiprocesamiento).
- Todo programa paralelo es concurrente, pero no al revés. La concurrencia es organizar varias tareas; el paralelismo es tener más manos para hacerlas.
- Multitarea no es lo mismo que multinúcleo. Multitarea es que el sistema tenga varias tareas en curso (Windows 95 ya era multitarea con un solo núcleo); multiprocesamiento es tener varios núcleos.
Tareas independientes y dependientes¶
- Independientes: cada parte se calcula sin saber nada de las demás (aplicar un filtro a 1000 fotos, contar los primos de un rango). Basta con repartir el trabajo: paralelismo «trivial».
- Dependientes: un paso necesita el resultado del anterior (la sucesión de Fibonacci, los pasos de una receta). Hay que sincronizar, y eso limita lo que se gana.
La ley de Amdahl¶
Casi todos los programas tienen una parte que se puede paralelizar y otra secuencial por naturaleza (preparar los datos, repartirlos, juntar los resultados). Por muchos núcleos que se añadan, la parte secuencial tarda lo mismo. Si una fracción p del programa es paralelizable, con n núcleos la aceleración máxima es:
S(n) = 1 / ((1 − p) + p / n)
Para un programa que tarda 100 s y es paralelizable en un 80 % (p = 0,8):
def aceleracion(p, n):
return 1 / ((1 - p) + p / n)
p = 0.8 # el 80 % del programa se puede paralelizar
for n in (1, 2, 4, 8, 1000):
s = aceleracion(p, n)
print(f"{n:>5} núcleos: aceleración {s:.2f}x, el programa tarda {100 / s:.0f} s")
print(f"Con infinitos núcleos: {1 / (1 - p):.2f}x, {100 / (1 / (1 - p)):.0f} s")
Salida:
1 núcleos: aceleración 1.00x, el programa tarda 100 s
2 núcleos: aceleración 1.67x, el programa tarda 60 s
4 núcleos: aceleración 2.50x, el programa tarda 40 s
8 núcleos: aceleración 3.33x, el programa tarda 30 s
1000 núcleos: aceleración 4.98x, el programa tarda 20 s
Con infinitos núcleos: 5.00x, 20 s
Aunque hubiera infinitos núcleos, el programa nunca baja de 20 s: es lo que tarda su parte secuencial. Un programa paralelo va tan rápido como su parte secuencial más lenta. Es lo que pasa en un supermercado: cientos de personas compran a la vez, pero al pagar se forman colas porque hay pocas cajas.
Medido en un equipo real¶
Esta tabla no es teórica: se ha medido repartiendo el cálculo de los números primos hasta 400 000 entre 1, 2 y 4 procesos (en un equipo con varios núcleos; tus tiempos serán otros).
import multiprocessing
import time
def contar_primos(desde, hasta):
cuenta = 0
for n in range(max(desde, 2), hasta):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
cuenta += 1
return cuenta
if __name__ == "__main__":
LIMITE = 400_000
tiempo_uno = None
for procesos in (1, 2, 4):
trozos = [(i * LIMITE // procesos, (i + 1) * LIMITE // procesos) for i in range(procesos)]
inicio = time.perf_counter()
with multiprocessing.Pool(procesos) as pool:
total = sum(pool.starmap(contar_primos, trozos))
tiempo = time.perf_counter() - inicio
tiempo_uno = tiempo_uno or tiempo
print(f"{procesos} proceso(s): {total} primos en {tiempo:.2f} s (aceleración {tiempo_uno / tiempo:.2f}x)")
Salida:
1 proceso(s): 33860 primos en 1.62 s (aceleración 1.00x)
2 proceso(s): 33860 primos en 0.97 s (aceleración 1.67x)
4 proceso(s): 33860 primos en 0.68 s (aceleración 2.37x)
Con 4 procesos la aceleración no es 4. Hay tres razones: parte del trabajo es secuencial (repartir y sumar), crear procesos cuesta tiempo y los trozos no están equilibrados (los números grandes tardan más en comprobarse, así que el último trozo tarda más que el primero). Es la ley de Amdahl en la práctica.
Programación distribuida¶
En la programación distribuida, el trabajo se reparte entre varios ordenadores independientes, conectados por una red, que colaboran como si fueran uno solo. Cada ordenador tiene su propia memoria y su propio reloj, y solo se comunican enviándose mensajes. Ejemplos: una aplicación web con el servidor en la nube y el cliente en el móvil, un videojuego en línea, el sistema de pagos de un banco.
| Ventajas | Inconvenientes | |
|---|---|---|
| Paralela (un equipo, varios núcleos) | Comunicación muy rápida a través de la memoria; más sencilla de programar | Limitada a los núcleos de una máquina; si el equipo falla, falla todo |
| Distribuida (varios equipos) | Se amplía añadiendo máquinas; tolera fallos (si cae un servidor, otro lo sustituye) | La red es lenta y puede fallar; hay que tratar mensajes perdidos o duplicados; es más difícil de programar y depurar |
Resumen¶
| Secuencial | Concurrente | Paralela | Distribuida | |
|---|---|---|---|---|
| Procesadores | 1 | 1 o más | Varios en un equipo | Varios equipos |
| Tareas a la vez | Una | Varias en curso, alternándose | Varias en el mismo instante | Varias en máquinas distintas |
| Comunicación | — | Memoria compartida o sistema operativo | Memoria compartida o sistema operativo | Mensajes por la red |
| Ejemplo | Un script que procesa un fichero | Un sistema con un núcleo | Renderizar un vídeo con 8 núcleos | Una aplicación en la nube |
Los sistemas reales combinan las tres: un servidor web atiende a muchos clientes de forma concurrente, usa varios núcleos en paralelo y forma parte de un sistema distribuido.
Qué recordar¶
- Concurrente: tareas en curso que se alternan (aunque haya un núcleo). Paralela: tareas en el mismo instante en varios núcleos. Distribuida: varios equipos que se envían mensajes.
- Ley de Amdahl:
S(n) = 1 / ((1 − p) + p / n); el máximo con infinitos núcleos es1 / (1 − p). - Repartir el trabajo tiene un coste: la aceleración real es menor que la teórica.