Concurrencia

La simultaneidad implica que se están llevando a cabo múltiples cálculos al mismo tiempo. La concurrencia es una característica esencial en la programación actual, presente en casi todos los sistemas modernos, independientemente de si nos agrada o no.

Algunos ejemplos donde se manifiesta la concurrencia incluyen:

  • Diversos dispositivos en una red

  • Varios programas que se realizan en un equipo

  • Diversos procesadores en un ordenador (actualmente varios núcleos de procesador en un único chip)

La concurrencia es clave para:

  • Sitios web que deben gestionar múltiples usuarios al mismo tiempo.

  • Aplicaciones móviles que delegan parte del procesamiento en servidores remotos.

  • Interfaces gráficas que necesitan realizar tareas en segundo plano sin interrumpir la experiencia del usuario (por ejemplo, IntelliJ IDEA compila código mientras lo editás).

La capacidad de programar simultáneamente continuará siendo relevante en el futuro. Dado que el aumento de la velocidad de reloj de los procesadores ha llegado a un límite físico, las mejoras en rendimiento provienen del uso de múltiples núcleos. Esto hace que dividir un cálculo en partes concurrentes sea una necesidad para aprovechar ese paralelismo.

Dos paradigmas para la programación concurrente

En general, existen dos modelos habituales de programación concurrente: el uso de memoria compartida y la transmisión de mensajes. Cada uno ofrece un enfoque distinto para coordinar tareas simultáneas.

Memoria compartida

En el modelo de memoria compartida, los módulos concurrentes interactúan accediendo y modificando estructuras de datos comunes ubicadas en un espacio de memoria accesible para todos ellos.

Ejemplos del modelo de memoria compartida:

  • A y B pueden ser dos procesadores (o núcleos) en un solo dispositivo, utilizando la misma memoria física.

  • A y B pueden ser dos programas que operan en el mismo dispositivo, utilizando un sistema de archivos compartido con archivos capaces de leer y escribir.

  • A y B pueden funcionar como dos hilos en el mismo software.

Transmisión de mensajes

En el modelo de intercambio de mensajes, los componentes concurrentes se comunican enviando y recibiendo mensajes a través de un canal. Cada mensaje recibido se coloca en una cola o estructura de gestión para su posterior procesamiento. Algunos casos representativos son:

  • A y B pueden representar dos dispositivos en una red, que interactúan a través de conexiones de red.

  • A y B pueden representar tanto un navegador web como un servidor web: A establece una conexión con B y pide una página web, mientras que B transmite a A los datos de dicha página.

  • A y B pueden representar un cliente y un servidor de mensajería inmediata.

  • A y B pueden ser dos programas que operan en el mismo ordenador, cuya entrada y salida están vinculadas a través de una tubería,(pipe), como en ls | grep en una terminal Unix

Procesos, subprocesos, asignación de tiempos segmentados

Los modelos de paso de mensajes y memoria compartida abordan la forma en que los módulos simultáneos interactúan entre sí. Los módulos concurrentes se presentan en dos categorías distintas: procesos y subprocesos.

Proceso: Un proceso es una versión de un programa en funcionamiento que se encuentra separada de otros procesos en la misma máquina. Específicamente, posee su propia parte privada de la memoria del equipo.

La abstracción del proceso puede verse como una computadora virtual. Se percibe que el programa posee toda la máquina por sí mismo, como si se hubiese diseñado una nueva computadora, con memoria nueva, únicamente para ejecutar ese programa.

Similar a las computadoras interconectadas mediante una red, usualmente los procesos no comparten memoria entre sí. Un proceso no tiene la posibilidad de entrar a la memoria ni a los objetos de otro proceso. La mayoría de los sistemas operativos permiten compartir memoria entre procesos, aunque demandan un esfuerzo particular.
En cambio, un proceso nuevo está automáticamente preparado para recibir mensajes, dado que se genera con flujos de entrada y salida estándar, los cuales son System.out y System.in que se emplean en Java.

Subproceso (o hilo): Representa un espacio de control dentro de un programa en funcionamiento. Considere esto como un punto en el programa en ejecución, junto con la pila de llamadas a métodos que lo llevaron a ese punto (para que el hilo pueda volver a la pila cuando alcance las instrucciones return).

Como un proceso simboliza un equipo virtual, la abstracción de subprocesos simboliza un procesador virtual. La generación de un subproceso nuevo imita la generación de un procesador nuevo en la computadora virtual que representa el proceso. Este procesador virtual recién incorporado lleva a cabo el mismo software y utiliza la misma memoria que otros subprocesos durante el proceso.

Los subprocesos están preparados de forma automática para la memoria compartida, dado que en el proceso, los subprocesos comparten toda la memoria. Es necesario un esfuerzo particular para conseguir memoria "local de subproceso" que sea exclusiva para un único subproceso. Además, es imprescindible establecer el flujo de mensajes de manera explícita, a través de la generación y utilización de estructuras de datos de cola.

¿Cómo puedo gestionar múltiples subprocesos al mismo tiempo con únicamente uno o dos procesadores en mi ordenador? Cuando existen más procesadores que subprocesos, se simula la simultaneidad a través de la segmentación temporal (time slicing), lo que implica que el procesador interfiere entre los subprocesos. La figura de arriba ilustra la manera en que tres subprocesos t1, t2 y t3 pueden ocupar un intervalo temporal en una máquina que solo cuenta con dos procesadores reales. En la representación gráfica, el tiempo progresa descendiendo, de modo que inicialmente un procesador lleva a cabo el subproceso t1 y otro el subproceso t2, y posteriormente, el segundo procesador se reubica para llevar a cabo el subproceso t3. El subproceso t2 solo se interrumpe, hasta el próximo lapso de tiempo en el mismo procesador o en otro.

En la mayoría de los sistemas, la segmentación del tiempo ocurre de manera inesperada y no determinista, lo que implica que un subproceso puede ser interrumpido o reanudado en cualquier instante.

En este ejemplo se declara una clase que implementa el siguiente nombre Runnable:

Un modismo frecuente es comenzar un hilo con un Runnable anónimo, quien suprime la clase mencionada:

Caso de memoria compartida

Consideremos un caso de un sistema de memoria compartida. La finalidad de este ejemplo es evidenciar que la programación concurrente es complicada, ya que puede presentar fallos mínimos.

Imagina que un banco cuenta con cajeros automáticos que emplean un modelo de memoria compartida, de modo que todos los cajeros automáticos tienen la capacidad de leer y escribir los mismos datos de cuenta en la memoria compartida.

Para demostrar lo que podría ocurrir incorrectamente, imaginemos el banco con una única cuenta, con un saldo en dólares guardado en la variable balance, y dos transacciones deposit y withdraw, que solo agregan o reducen un dólar:

Los clientes recurren a los cajeros automáticos para efectuar operaciones tales como:

En este ejemplo básico, cada operación consiste únicamente en un depósito de un dólar y un retiro de un dólar, por lo que debería mantener el balance de la cuenta inalterable. Durante el día, cada punto de depósito automático de nuestra red realiza una serie de operaciones de depósito/extracción.

Por lo tanto, al concluir el día, sin importar el número de cajeros automáticos en funcionamiento o la cantidad de transacciones que realizamos, deberíamos anticipar que el balance de la cuenta continúe siendo cero.

Sin embargo, si aplicamos este código, a menudo notamos que el balance al concluir el día no es cero. Si al mismo tiempo se están realizando más de una llamada cashMachine(), por ejemplo, en procesadores distintos en la misma computadora, es posible que balance no sea cero al concluir el día. ¿Qué razón tiene?

Entrelazamiento

Aquí está una situación que puede ocurrir. Imaginemos que dos cajeros automáticos, A y B, operan simultáneamente con un deposit(). Por lo general, el paso se segmenta en instrucciones de procesador de nivel inferior:

Obtener saldo (saldo=0)

Añade 1

Reescribe el resultado (saldo=1)

Cuando A y B se realizan simultáneamente, estas instrucciones de nivel inferior se entrecruzan (algunas incluso podrían ser simultáneas en ciertos aspectos, pero todavía no debemos inquietarnos por el entrelazamiento):

Este entrelazamiento es correcto: concluimos con el saldo 2, lo que significa que tanto A como B hicieron una inversión exitosa de un dólar. Pero, ¿qué sucedería si el entrelazado se percibiera de esta manera?

Ahora el balance es 1: ¡se ha extraviado el dólar de A! A y B examinaron el balance simultáneamente, determinaron saldos finales distintos y después se precipitaron a guardar el nuevo saldo, ignorando el depósito del otro.

Condición de carrera

Una condición de carrera implica que la precisión del programa (la satisfacción de las condiciones subsiguientes y los invariantes) se basa en la cronología relativa de los sucesos en los cálculos concurrentes A y B. Cuando ocurre esto, afirmamos que "A participa en una carrera con B".

Algunos entrelazamientos entre sucesos pueden ser positivos, ya que son coherentes con lo que generaría un solo proceso no concurrente, pero otros entrelazados generan respuestas equivocadas, infringiendo postcondiciones o invariantes.

Modificar el código no ayudará

Todas estas versiones del código de cuenta bancaria muestran el mismo estado de carrera:

No se puede determinar simplemente observando el código Java cómo el procesador lo ejecutará. No es posible definir qué operaciones serán indivisibles, las operaciones atómicas. Solo por ser una línea de Java no es atómico. No toca el saldo únicamente una vez ya que el número de saldo se muestra únicamente una vez en la línea. El compilador de Java, y en realidad el procesador mismo, no asume responsabilidad sobre las operaciones de nivel básico que producirá a partir de su código. En realidad, un compilador Java contemporáneo común genera precisamente el mismo código para estas tres versiones.

La enseñanza fundamental es que no se puede determinar simplemente observando una expresión si estará protegida de las condiciones de la carrera.

Reordenamiento

En realidad, es incluso más grave que eso. Se puede entender la situación de carrera en el balance de la cuenta bancaria a través de diversas intercalaciones de operaciones secuenciales en distintos procesadores. Sin embargo, en realidad, cuando se emplean diversas variables y varios procesadores, incluso es poco probable garantizar que las modificaciones en dichas variables se presenten en la misma secuencia u orden.

Aquí un ejemplo. Considere que emplea un bucle que verifica de manera continua si existe una condición concurrente; a esta condición se le denomina Espera Ocupada y no es un patrón adecuado. En este escenario, también se fractura el código:

Dos métodos se llevan a cabo en distintos subprocesos. computeAnswerhace un extenso cálculo, llegando al final a la respuesta 42, que establece la variable answer. A continuación, configura la variable readyen true para señalar al método useAnswer, que se lleva a cabo en el otro subproceso que la respuesta está preparada para ser utilizada. Examino el código, answerse configura antes que ready, por lo que una vez que useAnswer identifica readycomo true, resulta lógico suponer que será 42, ¿no es así? No es el caso.

El inconveniente radica en que los compiladores y procesadores actuales realizan numerosas acciones para acelerar el código. Uno de estos procedimientos consiste en crear copias temporales de variables como answer y ready en un almacenamiento más veloz (registros o cachés en un procesador), y manejarlas de manera temporal antes de volver a almacenarlas en su lugar original en la memoria. El almacenamiento puede ocurrir en una secuencia distinta a la que se gestionaron las variables en el programa. Esto es lo que podría estar ocurriendo bajo las sábanas (expresado en lenguaje Java para que sea comprensible). El procesador está generando de manera eficaz dos variables temporales,tmpr y tmpa, para gestionar los campos ready y answer:

Ejemplo de paso de mensajes

Ahora observamos la estructura de mensajes de paso en nuestro caso de cuenta bancaria.

Actualmente, no solo los cajeros automáticos son módulos, sino que también las cuentas son módulos. Los módulos se comunican transmitiendo mensajes entre ellos. Las peticiones entrantes se agrupan en una cola para ser administradas de una en una. El destinatario no cesa su labor mientras aguarda una respuesta a su petición. Administra más peticiones provenientes de su propia cola. Finalmente, la respuesta a su petición retorna en forma de otro mensaje.

Lamentablemente, la transmisión de mensajes no descarta la posibilidad de condiciones de competencia. Imaginemos que cada cuenta acepta y realiza operaciones get-balance y withdraw con los mensajes pertinentes. En los cajeros automáticos A y B, dos usuarios buscan sacar un dólar de la misma cuenta. Primero verifican el balance para garantizar que nunca extraen más de lo que posee la cuenta, ya que los excesos provocan grandes sanciones bancarias:

El problema es nuevamente el entrelazado, aunque en esta ocasión es el entrelazado de los mensajes transmitidos a la cuenta bancaria, en vez de las directrices que A y B han ejecutado. Si la cuenta inicia con un dólar en ella, ¿qué cadena de mensajes persuadirá a A y B a creer que ambos tienen la posibilidad de retirar un dólar, superando de esta manera la cuenta?

Una enseñanza aquí es que debe seleccionar meticulosamente las operaciones atómicas como withdraw-if-sufficient-funds (retirar si hay fondos suficientes), de un modelo de transmisión de mensajes. sería una operación más eficaz que simplemente realizar una operación withdraw aislada sin verificar el saldo.

La simultaneidad es complicado de verificar y depurar

Si no te he persuadido de que la simultaneidad es difícil, aquí te presentamos lo más grave. Es muy complicado determinar las condiciones de competencia a través de ensayos. Incluso si una prueba ha detectado un fallo, puede resultar extremadamente complicado identificarlo en la sección del programa que lo origina.

Los errores de simultáneo muestran una reproducibilidad sumamente deficiente. Es complicado lograr que ocurran de la misma forma dos veces. La demora en la transmisión de instrucciones o mensajes se basa en el tiempo relativo de los sucesos que son altamente impactados por el ambiente. Las demoras pueden originarse por otros programas en funcionamiento, otros tráficos en la red, decisiones de programación del sistema operativo, fluctuaciones en la velocidad del reloj del procesador, entre otros factores. Cada vez que se lleva a cabo un programa que incluye una condición de carrera, puede que se genere una conducta distinta.

Este tipo de errores son conocidos como heisenbugs, llamados así porque no son deterministas y son complicados de reproducir consistentemente, en contraposición a un bohrbug, que surge de manera recurrente cada vez que lo observas. Gran parte de los fallos en la programación secuencial son bugs de bohr.

Un heisenbug incluso puede desaparecer al tratar de mirarlo con printlno debugger! El motivo es que la impresión y la depuración son considerablemente más lentas que otras actividades, frecuentemente 100-1000 veces más lentas, lo que altera significativamente el tiempo de las operaciones y el entrelazado. Así pues, introduciendo un sencillo estado de cuenta impreso en el cashMachine():

Y de pronto, el balance siempre es 0, tal como se espera, y el insecto parece desaparecer. Pero solo se encuentra enmascarado, no verdaderamente estable. Una variación temporal en otro punto del programa puede provocar que el error vuelva de forma inesperada.

Es complicado gestionar correctamente la simultaneidad. El propósito de esta lectura es darle un poco de asombro. Durante las próximas lecturas, examinaremos métodos fundamentados en principios para la creación de programas simultáneos, de manera que estén más preparados ante este tipo de fallos.

En resumen

  • Simultaneidad: múltiples cálculos que se realizan al mismo tiempo

  • Paradigmas de memoria conjunta y transmisión de mensajes

  • Procesos e hilos

    • El procedimiento se asemeja a una computadora virtual; el hilo se asemeja a un procesador virtual.
  • Condiciones de la carrera

    • Cuando la rectificación del resultado (postcondiciones e invariantes) se basa en la duración relativa de los sucesos.

Estas ideas están vinculadas con nuestras tres características fundamentales del software óptimo, principalmente de formas negativas. Es imprescindible la simultaneidad, pero genera graves dificultades de corrección.Trataremos de solucionar tales problemas en futuras entradas.

  • A salvo de insectos. Los errores de simultaneidad son algunos de los errores más difíciles de encontrar y corregir, y requieren un diseño cuidadoso para evitarlos.

  • Fácil de entender. Predecir cómo el código concurrente podría intercalarse con otro código concurrente es muy difícil de hacer para los programadores. Lo mejor es diseñar el código de tal manera que los programadores no tengan que pensar en intercalar en absoluto.

0
Subscribe to my newsletter

Read articles from Cristian Martin Farias directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cristian Martin Farias
Cristian Martin Farias

Desarrollador apasionado por convertir bugs en funcionalidades (y café en productividad). Especialista en sistemas distribuidos, microservicios y todo lo que hace que la tecnología funcione… o finja que lo hace. Amante del código limpio, aunque mi historial de commits podría decir otra cosa. Siempre aprendiendo, siempre depurando, y ocasionalmente negociando con mi teclado para que coopere.