Soy ingeniero de software desde hace unos 15 años. A lo largo de mi carrera aprendí mucho y apliqué estos aprendizajes para diseñar e implementar (y ocasionalmente eliminar gradualmente o dejar como están) muchos sistemas distribuidos. En el camino cometí numerosos errores y sigo cometiéndolos. Pero como mi enfoque principal era la confiabilidad, he estado analizando mi experiencia y la comunidad para encontrar formas de minimizar la frecuencia de errores. Mi lema es: debemos intentar absolutamente cometer nuevos errores (menos obvios, más sofisticados). Cometer un error está bien, así se aprende, repetir es triste y descorazonador.
Probablemente eso es lo que siempre me ha fascinado de las matemáticas. No sólo porque es elegante y conciso, sino porque su rigor lógico evita errores. Te obliga a pensar en tu contexto actual, en qué postulados y teoremas puedes confiar. Seguir estas reglas resulta fructífero y se obtiene el resultado correcto. Es cierto que la informática es una rama de las matemáticas. Pero lo que habitualmente practicamos es la ingeniería de software, algo muy distinto. Aplicamos los logros y descubrimientos de la informática a la práctica, teniendo en cuenta las limitaciones de tiempo y las necesidades comerciales. Este blog es un intento de aplicar razonamiento semimatemático al diseño e implementación de programas informáticos. Presentaremos un modelo de diferentes regímenes de ejecución que proporciona un marco para evitar muchos errores de programación.
Cuando aprendemos a programar y damos nuestros primeros pasos tentativos (o atrevidos) solemos empezar con algo sencillo:
Adquirimos memoria muscular, aprendemos la sintaxis del lenguaje y, lo más importante, cambiamos nuestra forma de pensar y razonar. Aprendemos a leer el código, a hacer suposiciones sobre cómo se ejecuta. Casi nunca comenzamos leyendo un estándar de lenguaje y leemos atentamente su sección "Modelo de memoria", porque todavía no estamos equipados para apreciarlos y utilizarlos por completo. Practicamos prueba y error: introducimos errores lógicos y aritméticos en nuestros primeros programas. Estos errores nos enseñan a verificar nuestras suposiciones: ¿es correcta esta invariante de bucle? ¿Podemos comparar el índice y la longitud del elemento de la matriz de esta manera (dónde pones este -1)? Pero si no vemos algún tipo de error, muchas veces interiorizamos implícitamente algunos.
Es decir, este:
Las líneas de código siempre se evalúan en el mismo orden (serializadas).
Este postulado nos permite asumir que las siguientes proposiciones son verdaderas (no las vamos a demostrar):
Los axiomas matemáticos permiten derivar y construir estructuras más grandes sobre una base sólida. En matemáticas, tenemos geometría euclidiana con 4+1 postulados. El último dice:
rectas paralelas permanecen paralelas, no se cruzan ni divergen
Durante milenios, los matemáticos intentaron demostrarlo y derivarlo de los cuatro primeros. Resulta que eso no es posible. Podemos reemplazar este postulado de las “líneas paralelas” con alternativas y obtener diferentes tipos de geometrías (a saber, hiperbólica y elíptica), que abren nuevas perspectivas y resultan aplicables y útiles. Después de todo, la superficie de nuestro propio planeta no es plana y esto tenemos que tenerlo en cuenta, por ejemplo, en el software GPS y en las rutas de los aviones.
Pero antes de eso, detengámonos y hagamos las preguntas más ingenieriles: ¿para qué molestarse? Si el programa hace su trabajo, es fácil de soportar, mantener y evolucionar, ¿por qué deberíamos renunciar a esta acogedora invariante de ejecución secuencial predecible en primer lugar?
Veo dos respuestas. El primero es el rendimiento . Si podemos hacer que nuestro programa se ejecute el doble de rápido o de manera similar (requiere la mitad del hardware), esto es un logro de ingeniería. Si utilizamos la misma cantidad de recursos computacionales, podemos procesar 2x (o 3, 4, 5, 10x) de datos; puede abrir aplicaciones completamente nuevas del mismo programa. Puede ejecutarse en un teléfono móvil en su bolsillo en lugar de en un servidor. A veces podemos lograr velocidades aplicando algoritmos inteligentes o reescribiendo en un lenguaje más eficaz. Estas son nuestras primeras opciones a explorar, sí. Pero tienen un límite. La arquitectura casi siempre supera a la implementación. La ley de Moor no ha funcionado tan bien últimamente, el rendimiento de una sola CPU está creciendo lentamente, el rendimiento de la RAM (latencia, principalmente) se está quedando atrás. Entonces, naturalmente, los ingenieros comenzaron a buscar otras opciones.
La segunda consideración es la confiabilidad . La naturaleza es caótica, la segunda ley de la termodinámica va constantemente en contra de cualquier cosa precisa, secuencial y repetible. Los bits se voltean, los materiales se degradan, la energía se corta, los cables se cortan impidiendo la ejecución de nuestros programas. Mantener una abstracción secuencial y repetible se convierte en un trabajo difícil. Si nuestros programas pudieran sobrevivir a las fallas de software y hardware, podríamos brindar servicios que tuvieran una ventaja comercial competitiva; esa es otra tarea de ingeniería que podemos comenzar a abordar.
Equipados con el objetivo, podemos iniciar experimentos con enfoques no serializados.
Veamos este fragmento de pseudocódigo:
```
def fetch_coordinates(poi: str) -> Point:
…
def find_pois(center: Point, distance: int) -> List[str]:
…
def get_my_location() -> Point:
…
def fetch_coordinates(p) - Point:
…
def main():
me = get_my_location()
for point in find_pois(me, 500):
loc = fetch_coordinates(point)
sys.stdout.write(f“Name: {point} is at x={loc.x} y={loc.y}”)
Podemos leer el código de arriba a abajo y asumir razonablemente que se llamará a la función `find_pois` después de `get_my_location`. Y buscaremos y devolveremos las coordenadas del primer PDI después de buscar el siguiente. Dichos supuestos son correctos y permiten construir un modelo mental razonado sobre el programa.
Imaginemos que podemos hacer que nuestro código se ejecute de forma no secuencial. Hay muchas maneras en que podemos hacerlo sintácticamente. Nos saltaremos los experimentos con reordenamiento de declaraciones (eso es lo que hacen los compiladores y CPU modernos) y ampliaremos nuestro lenguaje para que podamos expresar un nuevo régimen de ejecución de funciones :
Los subprocesos vienen en diferentes sabores: subproceso POSIX, subproceso verde, corrutina, gorutina. Los detalles difieren mucho, pero todo se reduce a algo que se puede ejecutar. Si se pueden ejecutar varias funciones al mismo tiempo, cada una necesita su propia unidad de programación. De ahí proviene el subproceso múltiple, en lugar de uno, tenemos varios subprocesos de ejecución. Algunos entornos (MPI) y lenguajes pueden crear subprocesos implícitamente, pero generalmente tenemos que hacerlo explícitamente usando `pthread_create` en C, clases de módulo `threading` en Python o una simple declaración `go` en Go. Con algunas precauciones podemos hacer que el mismo código se ejecute principalmente en paralelo:
def fetch_coordinates(poi, results, idx) -> None: … results[idx] = poi def main(): me = get_my_location() points = find_pois(me, 500) results = [None] * len(points) # Reserve space for each result threads = [] for i, point in enumerate(find_pois(me, 500)): # i - index for result thr = threading.Thread(target=fetch_coordinates, args=(poi, results, i)) thr.start() threads.append(thr) for thr in threads: thr.wait() for point, result in zip(points, results): sys.stdout.write(f“Name: {poi} is at x={loc.x} y={loc.y}”)
Logramos nuestro objetivo de rendimiento: nuestro programa puede ejecutarse en múltiples CPU y escalar a medida que la cantidad de núcleos crece y finaliza más rápido. La siguiente pregunta de ingeniería que debemos hacernos es: ¿a qué coste?
Intencionalmente renunciamos a la ejecución serializada y predecible. Hay
La siguiente consecuencia es que esta vez una función puede finalizar antes que otra, la próxima vez puede ser al revés. Este nuevo régimen de ejecución conduce a carreras de datos: cuando funciones concurrentes trabajan con datos, significa que el orden de las operaciones aplicadas a los datos no está definido. Comenzamos a encontrar carreras de datos y aprendemos a lidiar con ellas usando:
En este punto, descubrimos al menos dos cosas. Primero, existen múltiples formas de acceder a los datos. Algunos datos son
Cuando continuamos con esta línea de razonamiento, otras técnicas como el almacenamiento local de subprocesos surgen de forma natural. Acabamos de adquirir un nuevo dispositivo en nuestro conjunto de herramientas de programación, ampliando lo que podemos lograr mediante la creación de software.
Sin embargo, hay una invariante en la que todavía podemos confiar. Cuando buscamos datos compartidos (remotos) de un hilo, siempre los obtenemos. No existe ninguna situación en la que algún fragmento de memoria no esté disponible. El sistema operativo finalizará a todos los participantes (subprocesos) al finalizar el proceso si la región de la memoria física de respaldo no funciona correctamente. Lo mismo se aplica a "nuestro" hilo, si bloqueamos un mutex, no hay forma de que perdamos el bloqueo y debemos detener lo que estamos haciendo de inmediato. Podemos confiar en esta invariante (impuesta por el sistema operativo y el hardware moderno) de que todos los participantes están vivos o muertos. Todos comparten el destino : si el proceso (OOM), el sistema operativo (error del kernel) o el hardware encuentran un problema, todos nuestros subprocesos dejarán de existir juntos sin efectos secundarios externos.
Una cosa importante a tener en cuenta. ¿Cómo dimos este primer paso introduciendo hilos? Nos separamos, nos bifurcamos. En lugar de tener una unidad de programación, introdujimos varias. Sigamos aplicando este enfoque de no compartir y veamos cómo funciona. Esta vez copiamos la memoria virtual del proceso. A eso se le llama generar un proceso . Podemos ejecutar otra instancia de nuestro programa o iniciar otra utilidad existente. Este es un gran enfoque para:
Casi todos ==
Este es otro régimen de ejecución que descubrimos al renunciar a la invariante de destino compartido y dejar de compartir la memoria virtual y hacer una copia. Las copias no son gratuitas:
¿Por qué detenerse aquí? Exploremos entre qué más podemos copiar y distribuir nuestro programa. Pero, ¿por qué distribuirse en primer lugar? En muchos casos, las tareas actuales se pueden resolver con una sola máquina.
Necesitamos ir distribuidos
Para nombrar unos pocos:
Actualizaciones del sistema operativo: de vez en cuando necesitamos reiniciar nuestras máquinas
Fallos de hardware: ocurren con más frecuencia de la que nos gustaría
Fallos externos: los cortes de energía y de red son una cosa.
Si copiamos un sistema operativo, lo llamamos máquina virtual y podemos ejecutar los programas de los clientes en una máquina física y construir un enorme negocio en la nube sobre ella. Si tomamos dos o más computadoras y ejecutamos nuestros programas en cada una, nuestro programa puede sobrevivir incluso a una falla de hardware, brindando servicio 24 horas al día, 7 días a la semana y obteniendo una ventaja competitiva. Hace mucho tiempo que las grandes corporaciones fueron aún más lejos y ahora los gigantes de Internet ejecutan copias en diferentes centros de datos e incluso en continentes, lo que hace que un programa sea resistente a un tifón o un simple corte de energía.
Pero esta independencia tiene un precio: las viejas invariantes no se aplican, estamos solos. No te preocupes, no somos los primeros. Hay muchas técnicas, herramientas y servicios para ayudarnos.
Acabamos de adquirir la capacidad de razonar sobre los sistemas y sus respectivos regímenes de ejecución. Dentro de cada sistema de gran escala, la mayoría de las partes son secuenciales y sin estado, muchos componentes tienen múltiples subprocesos con tipos de memoria y jerarquías, todas mantenidas juntas por una combinación de algunas partes verdaderamente distribuidas:
El objetivo es poder distinguir dónde nos encontramos actualmente, qué invariantes contienen y actuar (modificar/diseñar) en consecuencia. Destacamos el razonamiento básico, transformando “incógnitas desconocidas” en “incógnitas conocidas”. No lo tome a la ligera, este es un progreso significativo.