paint-brush
Rompiendo axiomas en la ejecución del programapor@nekto0n
20,961 lecturas
20,961 lecturas

Rompiendo axiomas en la ejecución del programa

por Nikita Vetoshkin9m2023/10/24
Read on Terminal Reader

Demasiado Largo; Para Leer

El autor, un ingeniero de software experimentado, comparte información sobre su viaje desde el código secuencial hasta los sistemas distribuidos. Enfatizan que adoptar la ejecución no serializada, los subprocesos múltiples y la computación distribuida puede conducir a un mejor rendimiento y resiliencia. Si bien introduce complejidad, es un viaje de descubrimiento y capacidades mejoradas en el desarrollo de software.
featured image - Rompiendo axiomas en la ejecución del programa
Nikita Vetoshkin HackerNoon profile picture


cometiendo nuevos errores

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.


Desde comienzos humildes

Cuando aprendemos a programar y damos nuestros primeros pasos tentativos (o atrevidos) solemos empezar con algo sencillo:


  • programar bucles, hacer aritmética básica e imprimir los resultados en una terminal
  • Resolver problemas matemáticos, probablemente en algún entorno especializado como MathCAD o Mathematica.


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. invariantes el sistema nos impone y nos proporciona.


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):


  • El orden de evaluación no cambia entre ejecuciones.
  • las llamadas a funciones siempre regresan


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.

La necesidad de cambio

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.


Hilos de ejecución

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 : al mismo tiempo o incluso en paralelo con respecto a otras funciones. Parafraseando, necesitamos introducir múltiples subprocesos de ejecución. Las funciones de nuestro programa se ejecutan en un entorno específico (diseñado y mantenido por el sistema operativo), en este momento estamos interesados en la memoria virtual direccionable y un subproceso: una unidad de programación, algo que puede ser ejecutado por una CPU.


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 sin biyección entre una función + punto en el tiempo y los datos. En cada momento siempre hay una única asignación entre una función en ejecución y sus datos:


Varias funciones ahora funcionan con datos simultáneamente:


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:

  • secciones críticas: mutexes (y spinlocks)
  • algoritmos sin bloqueo (la forma más simple se encuentra en el fragmento de arriba)
  • herramientas de detección de carreras
  • etc.


En este punto, descubrimos al menos dos cosas. Primero, existen múltiples formas de acceder a los datos. Algunos datos son local (por ejemplo, variables de ámbito de función) y solo nosotros podemos verlo (y acceder a él) y, por lo tanto, siempre está en el estado en el que lo dejamos. Sin embargo, algunos datos se comparten o remoto . Todavía reside en nuestra memoria de proceso, pero utilizamos formas especiales para acceder a él y es posible que no esté sincronizado. En algunos casos, para trabajar con él, lo copiamos en nuestra memoria local para evitar carreras de datos; por eso == .clon() ==es popular en Rust.


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.


Inventar un proceso

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:

  • reutilizar otro código con límites estrictos
  • ejecutar código que no es de confianza, aislándolo de nuestra propia memoria


Casi todos == navegadores modernos == funcionan de esta manera, por lo que pueden ejecutar código ejecutable Javascript no confiable descargado de Internet y finalizarlo de manera confiable cuando cierra una pestaña sin cerrar toda la aplicación.

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:

  • El sistema operativo necesita administrar estructuras de datos relacionadas con la memoria (para mantener el mapeo virtual -> físico)
  • Algunos bits podrían haberse compartido y, por tanto, los procesos consumen memoria adicional.



Liberándose

¿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 escapar del destino compartido principios para que nuestro software deje de depender de los problemas inevitables que encuentran las capas subyacentes.


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.


Comidas para llevar

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.