Artículos de Tecnología

Concurrencia o Paralelismo: Threads, Processes, Fibers y Actors

Guilherme Silveira
Guilherme Silveira
imagen 1

A medida que se requiere un mayor procesamiento para resolver un problema, nos encontramos con proyectos que incluyen cuestiones sobre paralelismo, concurrencia y distribución de tareas. ¿Cuáles serían las opciones disponibles y cuáles son las características distintivas de cada una de ellas?

Como es de conocimiento, el desafío de escribir códigos que se ejecuten en paralelo se vuelve relevante cuando tenemos acceso a datos compartidos (shared memory) y ocurre una o más operaciones de escritura en los mismos datos.

El enfoque de los monitores en Java (utilizando wait/notify) y las pthreads de C está relacionado con objetos que indican cuándo podemos y cuándo no debemos escribir en esta memoria compartida. A pesar de su gran potencial, resulta ser una tarea difícil de manejar y mantener para los desarrolladores. Todo esto está relacionado con el tema de la concurrencia preventiva, en la cual un planificador programa y ejecuta las threads/procesos y se presentan una o más operaciones de escritura en los mismos datos.

Otras dos alternativas que están ganando fuerza hoy en día son: los actores y procesos presentes en lenguajes como Erlang y Scala, y las fibras de Ruby 1.9.

En lenguajes como Erlang, donde las "variables" son inmutables, no se presentan efectos colaterales al ejecutar una función. Por lo tanto, podemos decir que la "memoria compartida" no sufre los males de otros lenguajes, ya que no hay estado que pueda ser alterado.

¿Cuál es la gran ventaja de la inmutabilidad al formar parte de un lenguaje? El intérprete/compilador puede detectar dos problemas, como muestra el ejemplo del pseudocódigo de Java de Java a continuación muestra:

String generarCuerpo(Movimentacion[] movimentaciones){
    String contenido = "";
    for(Movimentacion m : movimentaciones){
        contenido += m.generaContenido();  
    }
    return contenido
}

String generarRodapie(Informaciones[] informaciones){
    String rodapie = "";
    for(Informacion info : informaciones){
        rodapie += info.generarContenido();
    }
    return rodapie;
}
String procesaRelatorio(Movimentacion[] movimentaciones, Informacion[] informaciones){
    return generarCuerpo(movimentaciones) + generarRodapie(informaciones);
}

Tenga en consideración que al invocar procesaRelatorio, las otras dos funciones se ejecutarían secuencialmente; no obstante, un compilador/intérprete inteligente podría ejecutar ambas invocaciones en paralelo y concatenar los resultados una vez que ambos estén disponibles. Podría incluso ir más allá, invocando secciones de cada bucle de manera simultánea en distintos procesadores. Esto sería posible si pudiéramos asegurar la ausencia de efectos colaterales al invocar cada una de estas funciones, una garantía que no se cumple al realizarlo en Java.

Funciones que no generan efectos colaterales permiten impresionantes optimizaciones por parte del intérprete/compilador y se presentan como una alternativa excelente para aprovechar todos los cores que tenemos disponibles (un número que solo crece), evitando el desperdicio.

Por otro lado, existen partes del sistema que pueden ser programadas manualmente para operar de manera concurrente y, en ocasiones, incluso en paralelo (ya que las dos cosas son diferentes).

En sitios web de verificación de rutas de vuelo, múltiples solicitudes para diferentes sitios web pueden ejecutarse simultáneamente, con una tarea adicional que concatena todos los resultados. Para que esta tarea final reciba las respuestas, es necesario el intercambio de mensajes entre aquellos encargados de procesar las diferentes solicitudes y la tarea de consolidar la información. Quien esté a cargo de obtener información de un sitio web sería aquel que recibe el identificador del sitio web que se consultará (como LATAM, Air Europa, etc.), los criterios de búsqueda y quien se haga responsable de la tarea final de unir los resultados.

Con estas tres informaciones, dicha persona ejecuta una solicitud que es bloqueante, es decir, mantiene su thread seguro hasta obtener la respuesta, realiza transformaciones similares y finaliza notificando a los encargados de la conclusión del proceso, tal como se muestra en el pseudocódigo a continuación:

class Agente{
    def recibe(sitio,pesquisa,callback){
        respuesta = http.request(sitio + "?search_for="  + pesquisa )
        resultado = parseia(respuesta)
            callback.recibe(resultado)
    }
}

Y luego alguien es responsable de concatenar los resultados:

class TareaFinal{
    final = []

    def recibe(resultado_parcial){
        final.add(resultado_parcial)
    }

    def aguarda_hasta_el_final{
        //Espera hasta que todos los resultados parciales estén disponibles
        //y luego retorna el resultado total
    }
}

La tarea principal asumiría la responsabilidad de activar múltiples agentes que realizarán búsquedas, mientras que el agente final se encargaría de recopilar los resultados. A continuación, te presento el seudocódigo correspondiente:

class Pesquisadpr {
    def buscar (opciones){
        concatenador = new TareaFinal
        sitios = {'www.latamairlines.com','www.aireuropa.com','www.aerolinhascaelum.com'}

        for sitios in sitios{
            new Agente().envia(sitios,opciones,concatenador)
        }
        return concatenador.aguarda_hasta_el_final()
    }
}

En Java, por ejemplo, emplearíamos un thread para cada agente, de forma que cada solicitud bloqueante no impida la ejecución simultánea de las demás. Con esto, podríamos aprovechar todos los procesadores de la máquina, aunque el costo de gestionar múltiples threads en Java es algo que deseamos evitar.

Existen otras soluciones, incluso en el contexto de Java, que implican el uso de una API de NIO, permitiendo la ejecución de tareas de entrada y salida sin que la thread actual quede a la espera del resultado final. Por un lado, esta alternativa aumentaría la capacidad de atender un mayor número de solicitudes, sin embargo, conlleva el costo de verificar si el resultado "ya está disponible". Es un compromiso entre rendimiento y escalabilidad. Dicho rendimiento podría lograrse de forma más elegante y transparente mediante la utilización de bibliotecas de concurrencia de flujo de datos.

La solución que algunos lenguajes presentan es conocida como procesos. Estos procesos se ejecutan de manera concurrente y, dado que el código está diseñado para minimizar los efectos colaterales, en gran medida puede ser ejecutado en paralelo. Estos procesos resultan más eficientes, ya que no implican una sobrecarga de información debido al intercambio de estado de memoria y pila de ejecución, a diferencia de la implementación de threads en Java.

Aún más impresionante es la capacidad de ejecutar cada uno de estos procesos en máquinas diferentes a la actual. Si el código del agente es fácilmente serializable o permite un inicio rápido en varias máquinas remotas, resulta mucho más rentable ejecutar este proceso en diversas máquinas. Incluso es posible realizar concatenaciones parciales hasta obtener el resultado final, acercándose a las ideas del Map Reduce.

Finalmente, existe la opción de las corrutinas que se discuten nuevamente, en este caso en el mundo Ruby, a través de Fibers.

La utilización de las Fibers posibilita escribir un código donde X procesos mantienen una comunicación entre sí, indicándose mutuamente cuándo es el momento adecuado para ceder la ejecución y permitir que otro proceso tome su lugar. Muchos recordarán el método Thread.yield() de Java, que muestra un comportamiento similar al mencionado. Sin embargo, no garantiza que la thread actual se detenga y permitir que otro proceso se ejecute.

En cambio, las fibers aseguran que este proceso se detenga momentáneamente, permitiendo que quien las invocó continúe su ejecución. La principal ventaja reside en la capacidad de separar, de esta manera, el código que controla diversos procesos (fibers) del código que ejecuta cada procesamiento. La desventaja es que los procesos se ejecutan de manera concurrente, no en paralelo.

En los días actuales, otro tema que está generando muchas conversaciones es la cuestión de la memoria transaccional. En resumen, esta es una forma que permite crear transacciones sobre las variables de memoria, con el propósito de controlar automáticamente situaciones en las cuales no es posible lograr la inmutabilidad de los datos compartidos.

Pero esta aproximación presenta ciertas dificultades y detalles importantes que deben ser estudiados, tales como las complicaciones para hacer operaciones de IO durante una transacción. Lo problema que se plantea es de "¿como efectuar el rollback del lanzamiento de un cohete?. Una posible solución para esto es implementada por Haskell, a través de los monads.

Podremos comprender mejor todas las ventajas y desventajas de cada una de estas opciones aquí mencionadas cuando formen parte del día a día de muchos programadores, en un futuro no tan distante.

Podremos adquirir una comprensión más profunda de todas las ventajas y desventajas de cada una de estas opciones aquí mencionadas cuando formen parte del día a día de muchos programadores, en un futuro no tan distante.

Agradezco a Rafael Ferreira y a Renato Lucindo por las discusiones y revisiones.

foto-autor

Guilherme Silveira

Cofundador de Alura, Caelum y GUJ. Con 18 años de docencia en las áreas de programación y datos, ha creado más de 100 cursos. Tiene formación en ingeniería de software, sesgo matemático y creativo, además de ser medallista de oro en competencias nacionales de informática, habiendo representado a Brasil en los mundiales. Participante en comunidades de educación tecnológica y de código abierto, habiendo escrito 7 libros. Hace magia y habla coreano en su tiempo libre.

Artículos de Tecnología

En Alura encontrarás variados cursos sobre . ¡Comienza ahora!

Precios en:
USD
  • USD
  • BOB
  • CLP
  • COP
  • USD
  • PEN
  • MXN
  • UYU

Semestral

  • 273 cursos

    Cursos de Programación, Front End, Data Science, Innovación y Gestión.

  • Videos y actividades 100% en Español
  • Certificado de participación
  • Estudia las 24 horas, los 7 días de la semana
  • Foro y comunidad exclusiva para resolver tus dudas
  • Luri, la inteligencia artificial de Alura

    Luri es nuestra inteligencia artificial que resuelve dudas, da ejemplos prácticos y ayuda a profundizar aún más durante las clases. Puedes conversar con Luri hasta 100 mensajes por semana

  • Acceso a todo el contenido de la plataforma por 6 meses
US$ 65.90
un solo pago de US$ 65.90
¡QUIERO EMPEZAR A ESTUDIAR!

Paga en moneda local en los siguientes países

Anual

  • 273 cursos

    Cursos de Programación, Front End, Data Science, Innovación y Gestión.

  • Videos y actividades 100% en Español
  • Certificado de participación
  • Estudia las 24 horas, los 7 días de la semana
  • Foro y comunidad exclusiva para resolver tus dudas
  • Luri, la inteligencia artificial de Alura

    Luri es nuestra inteligencia artificial que resuelve dudas, da ejemplos prácticos y ayuda a profundizar aún más durante las clases. Puedes conversar con Luri hasta 100 mensajes por semana

  • Acceso a todo el contenido de la plataforma por 12 meses
US$ 99.90
un solo pago de US$ 99.90
¡QUIERO EMPEZAR A ESTUDIAR!

Paga en moneda local en los siguientes países

Acceso a todos
los cursos

Estudia las 24 horas,
dónde y cuándo quieras

Nuevos cursos
cada semana