Ejercicio 1 [personal_repo]

Cree un repositorio privado de control de versiones en un servicio de alojamiento (ej.: git.ucr.ac.cr) para trabajo invididual que se utilizará para entregar:

  1. Ejemplos provistos en este material y durante las lecciones en una carpeta ejemplos o examples.

  2. Ejercicios dispuestos en este material en una carpeta ejercicios o exercises.

  3. Tareas asignadas en el curso en una carpeta tareas o homeworks.

Use como nombre del repositorio el patrón concurrenteAAs-nombre_apellido, donde AA corresponde a los dos últimos dígitos del año (ej.: 22 de 2022), s corresponde al ciclo lectivo (a para primer ciclo, b para el segundo ciclo, y c para verano). Importante: Su repositorio tiene que ser privado.

Agregue con el rol que cuente más privilegios (ej.: Maintainer) a su docente y –si conoce– al asistente del curso. Clone su repositorio en una máquina local.

En la raíz de su repositorio agregue un archivo .gitignore con el siguiente contenido:

.vscode
bin
build
doc

Cree una carpeta ejemplos/pthreads/ (o examples/pthreads) para los siguientes ejemplos en su repositorio personal de control de versiones para el curso. Para cada ejemplo cree una subcarpeta que utilice el nombre dado entre corchetes en el ejemplo.

j18-ago,v19-ago Video

Crear repositorio personal. IDE. Plugin para pseudocódigo

📹

1. Paradigmas de programación concurrente y distribuido

Ejercicio 2 [glossary]

Si no lo tiene ya, cree un archivo readme.md o preferiblemente readme.adoc en la raíz de su repositorio. En este documento cree sección llamada Glosario. Conforme vaya avanzando por los vídeos, agregue definiciones, explicaciones o ejemplos, de los siguientes conceptos importantes:

  1. Programación serial.

  2. Programación concurrente.

  3. Programación paralela.

  4. Concurrencia de tareas.

  5. Paralelismo de datos.

  6. Hilo de ejecución.

  7. Indeterminismo.

  8. Memoria privada y compartida.

  9. Espera activa.

  10. Condición de carrera.

  11. Control de concurrencia.

  12. Seguridad condicional.

  13. Exclusión mutua.

  14. Semáforo.

  15. Barrera.

  16. Variable de condición.

  17. Candado de lectura y escritura.

  18. Descomposición.

  19. Mapeo.

  20. Incremento de velocidad (speedup).

  21. Eficiencia.

  22. Comunicación punto a punto entre procesos.

  23. Comunicación colectiva entre procesos.

  24. Reducción.

Construya este glosario con definiciones, explicaciones y ejemplos cortos pero significativos, de tal forma que le sirva para repasar rápidamente un concepto sin tener que visualizar de nuevo grabaciones.

problem solving process
Figura 1. Proceso de resolución de problemas
fig concurrency v6
Figura 2. Paradigmas de programación concurrente y distribuido
j18-ago,v19-ago Video

Proceso de resolución de problemas

📹

Paradigmas de programación

📹

Paradigmas de programación (cont.)

📹

Serial vs concurrente vs paralelo

📹

Concurrencia basada en eventos

📹

Concurrencia multihilo

📹

Distribución simétrica. Clústers

📹

Distribución asimétrica. Mallas. Comparación con simétrica.

📹

Concurrencia heterogénea. Aceleración por hardware

📹

Incremento del desempeño (paralelismo de datos). Separación de asuntos (concurrencia de tareas)

📹

2. Concurrencia compartida imperativa (Phtreads)

La concurrencia de estado compartido, concurrencia de recursos compartidos, o simplemente concurrencia compartida ocurre cuando varios trabajadores (hilos de ejecución) realizan una tarea concurrentemente y pueden acceder a regiones compartidas de memoria porque están dentro del mismo espacio de direcciones de memoria del proceso.

Los hilos de ejecución siguen este esquema, dado que comparten todos los segmentos del proceso (código, datos, y memoria dinámica), excepto el segmento de pila. Por ejemplo, un dato que un hilo escriba en la memoria compartida, otro hilo puede leerlo inmediatamente, sin tener que depender de la intervención del sistema operativo. De esta forma, la memoria compartida se convierte en un mecanismo directo de comunicación entre los hilos de ejecución.

Se estudiarán en el curso dos tecnologías para implementar concurrencia compartida: Pthreads y OpenMP. Los POSIX Threads es un estándar que permite a los desarrolladores crear y controlar hilos de ejecución en los sistemas operativos basados en Unix.

Ejercicio 3 [commented_examples]

Para cada uno de los ejemplos en el material:

  1. Cree una carpeta y la estructura de subcarpetas presentada en el Ejercicio 5 [general_makefile], que incluye el diseño (design/), código fuente (src/), y el Makefile que incluye al genérico.

  2. Transcriba el enunciado del ejemplo a un archivo readme.md o readme.adoc en la carpeta del ejemplo (la misma donde se encuentra el Makefile pequeño).

  3. Transcriba pseudocódigo, código, y diagramas, mientras visualiza los videos (preferible) o descargue los archivos de ejemplo provistos a las carpetas anteriores.

  4. Mientras visualiza los vídeos, documente el código o pseudocódigo con lo que entendió. Es decir, agregue comentarios de implementaciones (en los cuerpos de subrutinas) y declaraciones (Doxygen) con oraciones cortas que explican a alto nivel lo que el código realiza. Piense que estas explicaciones en el código le servirán para comprender o estudiar el código fuente sin tener que recurrir a los vídeos.

Importante. Este ejercicio es el que tendrá el mayor peso entre todos a realizar. Sus comentarios deben ser propios. Realizados de forma individual exclusivamente por usted. Se revisará la calidad de una muestra de ejercicios seleccionados al azar de su repositorio.

2.1. Hilo de ejecución (execution thread)

2.1.1. Hola mundo con hilos

Ejemplo 1. Hola mundo en Pthreads [pthr_hello]

Escriba un programa que al correr cree dos hilos, el principal y uno secundario. Ambos hilos deben saludar en la salida estándar con el mensaje "Hello from main thread" y "Hello from secondary thread" respectivamente. Utilice un analizador dinámico de código como las herramientas memcheck y helgrind de Valgrind, o los Google Sanitizers, para asegurarse de que su programa no provoca condiciones de carrera.

L22-ago,k23-ago Video

Ejemplo Hello: Diseño en pseudocódigo

📹

Notación de pseudocódigo (ocho tipos de instrucciones)

📹

Creación de threads en pseudocódigo

📹

Ptheads = POSIX threads

📹

Convertir pseudocódigo en comentarios usando expresiones regulares

📹

Implementación del hola Pthread. pthread_create(). Error estándar

📹

Compilar el código. Compilador, preprocesador, enlazador

📹

En la siguiente solución se incluye además un archivo de reglas Makefile que permite a un desarrollador compilar el código fuente sin tener que recordar los argumentos con los que se debe invocar al compilador. Bastará que el programador emita el comando make en la carpeta, y éste invocará al compilador siguiendo la primera regla estipulada en el Makefile.

Tabla 1. Hola mundo en Pthreads [hello]
Archivo Descripción Cambios

hello.pseudo

Diseño en pseudocódigo de la solución.

hello.c

Implementación en Pthreads. Dice "hola mundo" desde el hilo principal y desde un hilo secundario.

Makefile

Un archivo para compilar el código fuente sin tener que recordar los argumentos de invocación al compilador. También permite correr pruebas durante el desarrollo como quitar pelusas (linting), revisar accesos inválidos y fugas de memoria (memcheck, asan, msan, ubsan), y revisar condiciones de carrera (helgrind, tsan).

Ejercicio 4 [recursive_hello]

Copie su ejemplos/pthreads/hello a ejercicios/pthreads/recursive_hello. Puede renombrar ejercicios/pthreads/recursive_hello/src/hello.c a recursive_hello.c o si lo prefiere, main.c.

Modifique a greet() para recibir un número de tipo size_t. Si este número es 0, greet() imprime una despedida y el número recibido. Luego termina su ejecución.

Si el número que greet() recibe es mayor que cero, imprime un saludo y el número recibido. Luego crea un hilo para que invoque a greet() pero con un número menor que el recibido por parámetro.

Modifique el procedimiento main() para crear una variable local de tipo size_t inicializada en 2. Envíe este número como parámetro de la subrutina greet() que correrá en el hilo secundario.

Recuerde copiar este enunciado en una sección de su documento de análisis ejercicios/pthreads/recursive_hello/readme.md y darle el formato apropiado. En otra sección haga una predicción de la salida de su programa antes de corerlo. Compílelo y córralo con el Makefile. Copie la salida de su programa y péguela en un bloque de código debajo de su predicción. Indique si acertó o no la predicción.

2.1.2. Herramientas

Para instalar el compilador (GCC), el analizador estático de código (cpplint), el analizador dinámico de código (valgrind), y el compilador Clang que instrumenta código con sanitizers, puede instalar los siguientes paquetes en un Linux basado en Debian:

sudo apt update
sudo apt install build-essential python3-pip valgrind clang
sudo pip3 install cpplint

Para compilar con GCC –o un compilador compatible– código fuente que use Pthreads, se necesita el parámetro -pthread:

cc -g -Wall -Wextra hello.c -o hello -pthread

echo hello > .gitignore
git add hello.c .gitignore

Recuerde siempre ignorar los ejecutables en su repositorio de control de versiones. Para ello, cree o agregue a su archivo .gitignore el nombre del ejecutable. Luego agregue el código fuente y el .gitignore a control de versiones. Los dos últimos comandos de arriba hacen este trabajo. La Figura 3 muestra el resultado final de rastrear la memoria durante la ejecución del código.

L22-ago,k23-ago Video

Tool1: El compilador. Habilitar warnings

📹

Tool2: Linter (cpplint). Análisis estático de código.

📹

Tool3: Memcheck de Valgrind: errores de memoria (fugas, accesos inválidos…​)

📹

Makefiles

📹

Tool4: Helgrind de Valgrind: errores de concurrencia (condiciones de carrera…​)

📹

Tool5: Address Sanitizer (ASan) de Google para fugas y accesos inválidos

📹

Tool6: Memory Sanitizer (MSan) de Google para memoria no inicializada

📹

Tool7: Thread Sanitizer (TSan) de Google para errores de concurrencia

📹

Tool8: Undefined Behavior Sanitizer (UBSan) de Google para comportamientos inesperados

📹

Corregir el thread leak. pthread_join()

📹

Ejercicio 5 [general_makefile]

Cambie el Makefile por el Makefile general siguiendo estos pasos para poder reutilizarlo en todos los ejemplos:

  1. Cree una carpeta common/ en su repositorio individual.

  2. Descargue el Makefile general a la carpeta common/ y agréguelo a control de versiones.

  3. En la carpeta de su ejemplo (hello/), cree o modifique el Makefile para que contenga el texto siguiente:

include ../../../common/Makefile

FLAG += -pthread

La primera línea indica al comando make que debe incluir todo el contenido del Makefile genérico que está en /common/Makefile. La tercera línea sobrescribe la variable para indicarle a los compiladores de C y C++ que agregue soporte para Pthreads. Al final su repositorio tendrá la siguiente estructura:

├── .gitignore
├── README.md
├── common
│   └── Makefile                # Makefile genérico
└── ejemplos
    └── pthreads
        └── hello
            ├── Makefile        # Makefile con el include
            ├── design
               └── hello.pseudo
            └── src
                └── hello.c

Pruebe que el nuevo Makefile funcione para correr las herramientas cubiertas en los videos.

Ejercicio 6 [grandma_lottery]

Una adorable abuelita juega todos los domingos lotería. Como es peligroso que ella salga de casa, envía a sus dos nietos a comprar un par de pedacitos a vendedores diferentes para incrementar la suerte. Ella siempre juega a "gallo tapado", es decir, sin saber el número que le venden. Sin embargo, cuando sus nietos llegan a la casa, por su cansado estado de la vista ella les tiene que preguntar el número que los vendedores le dieron a cada uno.

Modele la situación anterior, con la abuela como el hilo principal, y los dos nietos como dos hilos secundarios. Los nietos generan un número pseudoaleatorio para representar la compra de la fracción de lotería. La espera de la abuela del regreso a casa de los dos nietos es representado por su join. Los nietos comunican a la abuela los números obtenidos a través del valor de retorno de la rutina secundaria. Indague en la documentación de Pthreads cómo este valor es recuperado por pthread_join(). Implemente dos variantes en que los nietos comunican este valor a la abuela:

  1. Retornan la dirección de memoria de la variable en la que el hilo secundario tiene el número comprado.

  2. Retornan el número comprado como una falsa dirección de memoria.

Utilice las herramientas de análisis dinámico de código para determinar cuál de las dos variantes anteriores produce el resultado correcto.

Recuerde revisar que su solución no haga mal uso de la memoria ni la concurrencia, y de apegarse a la covención de estilos. Si usa rand(), el linter advertirá de que este procedimiento no es reentrante. Puede usar el procedimiento rand_r() cuya documentación puede obtener en la línea de comandos con man rand_r.

2.1.3. Rastreo de memoria

trace
Figura 3. Resultado de rastrear la memoria del programa hello
j25-ago,v26-ago Video

Indeterminismo. sleep()

📹

Rastreo de memoria. Cargado del proceso. Segmento de código. Segmento de datos

📹

Creación del hilo principal. El ambiente de ejecución del lenguaje (crt0)

📹

Invocación de main(). Stack frame

📹

Crear un nuevo hilo. Start routine. Stack pointer

📹

Estado ready y estado running de un hilo de ejecución

📹

Colas de espera. Reanudar la ejecución

📹

Estado terminated. pthread_join(). Diagrama de tiempo

📹

Definición de hilo de ejecución. Metáfora de hilo

📹

Ejercicio 7 [recursive_hello_trace]

Dibuje un rastreo de memoria del programa que relizó en el Ejercicio 4 [recursive_hello]. Puede hacerlo en su cuaderno o en un programa de ilustración. Use direcciones ficticias de memoria. Detenga el rastreo cuando el último hilo imprima la despedida en la salida estándar.

En caso de dibujar en el cuaderno, tome una fotografía. Edítela para que ocupe menos de 100kB. En caso de dibujar en un programa de ilustración, exporte la imagen a formato vectorial (SVG).

Agregue la imagen a la carpeta recursive_hello/trace/ e incrústela dentro de su documento recursive_hello/readme.ext.

2.2. Indeterminismo (indeterminism)

2.2.1. Hola mundo múltiple

Ejemplo 2. Hola mundo múltiple en Pthreads [hello_w]

Modifique el programa de "hola mundo" para crear una cantidad arbitraria de hilos de ejecución. Cada uno de los cuales imprime "Hello from secondary thread i" en la salida estándar y termina su ejecución. Permita al usuario indicar la cantidad de hilos de ejecución como argumento de la línea de comandos. Si este argumento se omite, suponga la cantidad de CPUs disponibles en el sistema.

L29-ago, k30-ago Video

Actividad hello_w. Copiar hello/ a hello_w/. Archivo .gitignore.

📹

Variables y funciones en Makefile

📹

Solución en pseudocódigo de hello_w. create_thread(func, params)

📹

Argumentos en línea de comandos. Espacios e interpolación en argumentos.

📹

Implementación de pseudocódigo. Conversión texto a número con sscanf(). Conversión entre números y punteros

📹

Corregir vulnerabilidad índice fuera de rango. Programación defensiva. assert. Validar la entrada.

📹

Desborde de pila por arreglo variable automático (vulnerabilidad). Memoria dinámica. malloc()

📹

Corregir fuga de memoria. free()

📹

Serialización por join

📹

Concepto de indeterminismo

📹

Correr herramientas de validación. Falsos positivos de helgrind

📹

Linter. Enteros de tamaño fijo. Filtrar reglas del linter

📹

Obtener la cantidad de CPUs disponibles con sysconf()

📹

Tabla 2. Hola mundo múltiple en Pthreads [hello_w]
Archivo Descripción Cambios

hello_w.pseudo

Diseño en pseudocódigo de la solución.

file diff

hello_w.c

Varios threads dicen "hola mundo" junto con el hilo principal.

file diff

Makefile

El Makefile usa unas pocas variables nombradas, variables automáticas, y funciones. Por ejemplo para especificar banderas del compilador, el nombre del ejecutable, y archivos de compilación.

file diff

Ejercicio 8 [team_shot_put]

Suponga que dos equipos se enfrentan en el deporte de lanzamiento de bala (shot put). Cada equipo tiene la misma cantidad de atletas. Los atletas de cada equipo se ordenan y se enfrentan en parejas. Para simplificar, la notación T.N indica el atleta número N del equipo T. Por ejemplo, el atleta 1 del equipo 1 se enfrenta con el atleta 1 del equipo 2 (1.1 vs 2.1), y el atleta N del equipo 1 se enfrenta con el atleta N del equipo 2 (1.N vs 2.N).

Cada atleta dispone de tres lanzamientos y se toma la distancia más larga que consiguió llegar la bala (shot) en esos tres tiros. Si el atleta N de un equipo consigue llegar la bala más largo que su par N del otro equipo, consigue un punto para su equipo. Gana el encuentro el equipo que consigue más puntos. Los equipos siempre tienen una cantidad impar de atletas.

Simule la situación anterior con programación concurrente. El hilo principal realiza el rol del árbitro. El hilo principal obtiene la cantidad de atletas por argumento de línea de comandos, que siempre debe proveerse y ser impar, de lo contrario, finaliza con un mensaje de error. Seguidamente crea dos equipos de la misma cantidad de atletas (hilos secundarios).

Cada atleta lanza la bala tres veces, lo cual se puede simular generando tres números pseudoaleatorios, tomar el mayor de ellos, imprimirlo en la salida estándar, y reportarlo al árbitro como su valor de retorno. Un lanzamiento se mide como una distancia real entre 0.0 y 25.0 metros. Para comunicar este resultado al árbitro, podría implementar una estrategia como las siguientes.

  1. Dado que un flotante de doble precisión tiene el mismo tamaño que un puntero en una arquitectura de 64 bits, puede retornar su valor como una falsa dirección de memoria.

  2. Usar memoria dinámica para alojar el resultado del mejor lanzamiento y retornar la dirección al árbitro.

  3. El árbitro crea una pizarra (matriz) donde los atletas anotan su mejor resultado. Los atletas reciben la dirección de la celda donde deben hacer la anotación.

El árbitro se encargará de comparar los resultados de cada pareja de atletas, contar los puntos, y reportar en la salida estándar el equipo ganador. La siguiente podría ser una interación con la simulación.

$ bin/team_shot_put 2
error: athlete count must be odd

$ bin/team_shot_put 3
2.1: best shot put 18.407m
1.1: best shot put 11.913m
1.3: best shot put 22.105m
2.2: best shot put 7.890m
2.3: best shot put 24.681m
1.2: best shot put 14.009m
result 1:2, team 2 wins

2.3. Memoria privada (private memory)

2.3.1. Hola mundo numerado

Ejemplo 3. Hola mundo numerado con memoria privada [hello_iw_pri]

Cree una nueva versión de su programa "hola mundo" donde cada thread imprime "Hello from secondary thread i of w", donde i es el número de thread y w la cantidad total de hilos creada. Está prohibido usar variables en el segmento de datos (globales, estáticas, externas). Cree un registro de memoria (estructura) privado para cada thread.

01-set, 02-set Video

Modularización del ejemplo hello_w. Códigos de error en la línea de comandos

📹

Makefile para uso de los ejemplos, tareas, y proyectos del curso

📹

Diferencia entre la concurrencia de tareas y el paralelismo de datos en pseudocódigo

📹

Implementación de memoria privada (parte 1): Thread team. Registros de memoria.

📹

Implementación de memoria privada (parte 2): calloc()

📹

Validar código. Ejercicio para la casa: hello_count

📹

Tabla 3. Hola mundo numerado [hello_iw_pri]
Archivo Descripción Cambios

hello_iw_pri.pseudo

Diseño en pseudocódigo de la solución.

file diff

hello_iw_pri.c

Varios threads saludan indicando su identificador y tamaño del equipo en la salida estándar.

file diff

Makefile

A partir de este ejemplo se usa un Makefile genérico con funciones para compilar, probar, y otras tareas comunes. El código fuente se recomienda esté en una subcarpeta src/.

Ejercicio 9 [hello_count]

Utilizando los argumentos en línea de comandos, ubique la cantidad máxima de threads que su sistema operativo le permite crear. Escriba el número obtenido en un archivo readme.ext en su carpeta ejemplos/pthreads/hello_iw_pri, junto con el nombre de su sistema operativo (por ejemplo, Debian 10 64bits o Fedora 30 64bits).

Ejercicio 10 [create_thread_team]

Se introducen dos procedimientos al pseudocódigo para crear y esperar por equipos de hilos:

procedure main():
  shared thread_count := 5
  declare team := create_threads(thread_count, routine)
  ...
  join_threads(team)
end procedure

procedure routine(thread_number)
  print("I am thread ", thread_number, " of ", thread_count)
end procedure

El procedimiento create_threads() crea un equipo de hilos, todos ejecutando el procedimiento routine() concurrentemente, y retorna un arreglo de valores que representan hilos de ejecución. De forma análoga, el procedimiento join_threads() recibe el arreglo de hilos y espera a que todos ellos finalicen su ejecución.

Contar con estos dos procedimientos reutilizables es sumamente útil para la implementación de equipos de hilos en el paralelismo de datos. Las siguientes son implementaciones iniciales usando Pthreads:

pthread_t* create_threads(size_t count, void*(*routine)(void*), void* data) {
  pthread_t* threads = (pthread_t*) calloc(count, sizeof(pthread_t));
  if (threads) {
    for (size_t index = 0; index < count; ++index) {
      if (pthread_create(&threads[index], NULL, routine, data) != 0) {
        fprintf(stderr, "error: could not create thread %zu\n", index);
        join_threads(index, threads);
        return NULL;
      }
    }
  }
  return threads;
}

int join_threads(size_t count, pthread_t* threads) {
  int error_count = 0;
  for (size_t index = 0; index < count; ++index) {
    const int error = pthread_join(threads[index], NULL);
    if (error) {
      fprintf(stderr, "error: could not join thread %zu\n", index);
      ++error_count;
    }
  }
  free(threads);
  return error_count;
}

Las implementaciones anteriores no proveen a los hilos secundarios su número de hilo en el equipo. Modifique la subrutina create_threads() para que cree en la memoria dinámica e inicialice un arreglo de registros de datos privados. Cada registro tiene cuatro valores: el número de hilo (obtenido del índice de un ciclo), la cantidad de hilos en el equipo de hilos, el puntero a los datos compartidos (data, recibido por parámetro), y el identificador del hilo (pthread_t) inicializado con pthread_create(). Envíe la dirección del registro privado a cada hilo al crearlo con pthread_create(). Finalmente create_threads() retorna la dirección de este arreglo de registros privados en lugar del arreglo de identificadores de hilos (pthread_t).

Modifique a join_threads() para que reciba la cantidad de hilos y la dirección al arreglo de registros privados (en lugar del arreglo de identificadores de hilos). Luego esperará por cada hilo con pthread_join() usando el identificador del hilo en los datos privados. Finalmente libera el arreglo de datos privados.

Con estos cambios, los hilos podrán saber su número de hilo (thread_number) y la cantidad de hilos que hay en el equipo (thread_count) accediendo a los campos del registro privado.

Aplique estas subrutinas a un ejemplo o ejercicio que utilice equipos de hilos, como Ejemplo 2 o Ejercicio 8 [team_shot_put].

2.4. Memoria compartida (shared memory)

2.4.1. Hola mundo numerado (memoria compartida)

Ejemplo 4. Hola mundo numerado con memoria compartida [hello_iw_shr]

Cree una nueva versión de su programa "hola mundo numerado" donde cada hilo de ejecución imprime "Hello from secondary thread i of w", donde i es el número de hilo y w la cantidad total de hilos que componen el equipo, pero con los siguientes cambios:

  1. Haga que los datos comunes para todos los hilos de ejecución (el número w) estén en un registro (estructura) compartido para cada hilo. Mantenga los datos exclusivos para cada hilo (i) se mantengan en el registro privado.

  2. Reporte la duración en segundos que los hilos toman en saludar. Puede usar la función POSIX clock_gettime() de <time.h>.

01-set, 02-set Video

Ejemplo hello_iw_shr: memoria compartida

📹

Memoria compartida en pseudocódigo. La declaración shared

📹

Implementación de la memoria compartida en Pthreads

📹

Tabla 4. Hola mundo numerado [hello_iw_shr]
Archivo Descripción Cambios

hello_iw_shr.pseudo

Diseño en pseudocódigo de la solución.

file diff

hello_iw_shr.c

Varios threads saludan indicando su identificador en la salida estándar.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

El código en este ejemplo es de suma importancia, porque es el código base para crear programas concurrentes con Pthreads. Este código permite a los hilos tanto compartir memoria como tener su propia memoria privada de trabajo sin incurrir en malas prácticas como el uso de variables globales.

Ejercicio 11 [pthreads_wrapper]

Cree una pequeña biblioteca que facilite el trabajo con hilos de ejecución, acercando el código Pthreads al pseudocódigo del curso. Inicie su solución a partir del código de Ejercicio 10 [create_thread_team] siguiendo estos pasos:

  1. Mueva las declaraciones de create_threads() y join_threads() a un encabezado en su carpeta /common, por ejemplo /common/ethreads/ethreads.h (donde la e indica extended o educative).

  2. Mueva las implementaciones de create_threads() y join_threads() a un archivo fuente correspondiente, por ejemplo /common/ethreads/ethreads.c.

  3. Convierta el registro de memoria privada (struct) en un registro opaco. Su declaración debe estar en el .h y su definición en el .c. Con esto los campos del registro serán privados guardando la debida encapsulación.

Ejercicio 12 [pthreads_wrapper2]
  1. Dado que sus clientes ya no tendrán acceso a los campos del registro, modifique la interfaz de la rutina para recibir por parámetro el número de hilo (rank), el tamaño del equipo, y un puntero a los datos privados: void* team_subroutine(size_t thread_number, size_t thread_count, void* data).

  2. Agregue el puntero a la subrutina team_subroutine en el registro privado.

  3. Implemente una rutina privada que se apega a la interfaz impuesta por Pthreads: void* team_routine(void* data). Esta rutina recibirá el puntero al registro privado opaco, extraerá el número de hilo, la cantidad de hilos en el equipo, e invocará a la subrutina cuya dirección de memoria está en el registro privado.

  4. Modifique la implementación de create_threads() para invocar la rutina del punto anterior en hilos secundarios.

  5. Copie el ejemplo Ejemplo 4 en una carpeta hello_iw_shr_reusable. Simplifique la solución para reusar las subrutinas de /common/ethreads.

  6. Documente con Doxygen la interfaz reutilizable en el .h. Provea como ejemplo trozos de código de hello_iw_shr_reusable.

Ejercicio 13 [std_thread_c]

Una desventaja de Pthreads es que no está disponible en sistemas operativos que no sean POSIX, como ocurre en Windows. Por eso, en la versión de 2011 de C se agregó a la biblioteca estándar el encabezado <threads.h> que permite programar hilos de ejecución agnósticos al sistema operativo. El código que use hilos de ejecución estándar será naturalmente más portable a nuevas plataformas.

Implemente la misma funcionalidad del ejemplo hello_iw_shr usando hilos estándar de C. Cambie el encabezado #include <pthread> por #include <threads.h>. Indague en la documentación y cree hilos estándar en lugar de hilos POSIX y espere por ellos (join). Modifique la rutina concurrente de saludo para cumplir con la nueva interfaz. Asegúrese de que su solución tenga indeterminismo y no haga mal uso de memoria o concurrencia. Recuerde apegarse a la convención de estilo (linter).

Note
Los hilos estándar de C aún no proveen soporte para semáforos ni barreras. Esta funcionalidad sigue siendo dependiente del sistema operativo en C, y normalmente de POSIX por la prevalencia de Linux en el ámbito de la supercomputación. Por esta razón se estudia en el curso la tecnología Pthreads que permite un acceso semi-estándar más completo a los servicios de concurrencia de los sistemas operativos basados en Unix. Se sabe que en estos sistemas operativos, la implementación de GCC de los hilos estándar de C usan a Pthreads internamente, por lo que requieren la bandera -pthread para funcionar. Incluso, es evidente que el diseño de los hilos estándar es casi una réplica incompleta de los POSIX Threads.
Ejercicio 14 [std_thread_cpp]

C++ también agregó soporte para programación de hilos de ejecución en el 2011 y, a diferencia de C, las funcionalidades concurentes han ido creciendo en versiones recientes. Por ejemplo, el soporte de semáforos y barreras se agregó en el estándar 2020. La funcionalidad concurrente está disponible en encabezado estándar <thread>.

Implemente la misma funcionalidad del ejemplo hello_iw_shr usando hilos estándar de C++, de tal manera que el resultado se convierta en su código base para futuras implementaciones con hilos de ejecución en este lenguaje. Siga estos pasos, y compile y corra su solución tras completar cada uno de ellos:

  1. Su archivo solución debe tener extensión .cpp.

  2. Obtenga la cantidad de CPUs disponibles en el sistema usando funcionalidad de la clase std::thread en lugar de POSIX.

  3. Use un arreglo dinámico para almacenar los objetos que controlan los hilos de ejecución. Dado que estos objetos deben recibir en su constructor la rutina que se va a ejecutar en un hilo aparte, agréguelos al vector con el método emplace_back() en lugar de push_back(). Aunque se puede crear arreglos de punteros a objetos hilo de ejecución en memoria dinámica, mejor evite este diseño.

  4. Mida la duración de ejecución con funcionalidad estándar como std::chrono::high_resolution_clock::now() en lugar de POSIX (clock_gettime).

  5. Use manejo de memoria dinámica de C++ (new) para los datos compartidos de los hilos. Es recomendable usar un puntero inteligente (std::shared_ptr<>) para este fin.

  6. Use un arreglo dinámico para alojar los datos privados de los hilos de ejecución. Aproveche que conoce el tamaño del arreglo de antemano para crearlo de la capacidad justa.

  7. Cambie la entrada y salida de C por la genérica de C/C++. Esto implica reemplazar <stdio.h> por <iostream>, printf() por std::cout <<, stderr por std::cerr, y así por el estilo.

  8. Use std::istringstream en lugar de sscanf().

  9. Cambie el manejo de errores de C (códigos de error) por manejo de excepciones de C++. Lance objetos excepción que puedan transportar mensajes textuales. Para cada error busque la clase que mejor se aproxima en la biblioteca estándar. También puede heredar su propia clase para errores concurrentes, algo como std::concurrency_error si lo necesita (y ésta en su propio archivo .hpp). Atrape y maneje las excepciones en el procedimiento main().

  10. Elimine la inclusión de todos los encabezados dependientes del sistema operativo: <pthread.h>, <unistd.h>. Asegúrese de que las inclusiones queden en orden alfabético.

  11. En su Makefile, elimine el soporte de extensiones del compilador para apegarse sólo a código estándar C++. Asigne la variable XSTD al estándar más reciente que soporta su compilador, por ejemplo: XSTD=-std=c17` o `XSTD=-std=c20. Para esto indague la versión de su compilador (ej.: g++ --version) y aproveche para revisar si soporta semáforos contadores.

Si siguió los pasos anteriores, al final su solución no necesitará usar el operador delete ni delete[] en ninguna parte del código. Recuerde asegurarse de que su solución no realiza mal uso de la memoria, ni la concurrencia, y de apegarse a la convención de estilos.

2.5. Espera activa (busy waiting)

2.5.1. Hola mundo ordenado

Ejemplo 5. Hola mundo ordenado con espera activa [hello_order_busywait]

Haga que los threads saluden siempre en orden. Es decir, si se crean w threads, la salida sea siempre en orden

Hello from thread 0 of w
Hello from thread 1 of w
Hello from thread 2 of w
...
Hello from thread w of w

Utilice espera activa como mecanismo de sincronización (control de concurrencia).

Ejecute varias veces su solución de hello_order_busywait con la cantidad máxima de threads que el su sistema operativo le permite. ¿Es consistente la duración de su solución con espera activa?

Describa el comportamiento del programa y explique la razón de este comportamiento en el archivo readme.md dentro de su carpeta hello_order_busywait. Indique en qué condiciones sería apremiante utilizar espera activa, o en caso de no haberlas, sugiera soluciones alternativas.

La espera activa (busy waiting) es un ciclo que hace a un hilo de ejecución esperar repetitivamente hasta que una condición se haga falsa. Por ejemplo, si la variable next_thread indica el número del próximo thread que puede realizar una tarea que debe ser serializada en orden, el código

// Wait until it is my turn
while (next_thread < my_thread_id) {
  // busy-wait
}

// Do the ordered-task here
task();

// Allow subsequent thread to do the task
++next_thread;
05-set, 06-set Video

Medir tiempo de pared. CPU time. SO de tiempo real. División entera

📹

Espera activa (busy waiting). Pseudocódigo de hello_order_busywait

📹

Implementación y ejecución de espera activa con Pthreads

📹

Explicación del efecto en tiempo de ejecución de la espera activa

📹

Tabla 5. Hola mundo ordenado con espera activa [hello_order_busywait]
Archivo Descripción Cambios

hello_order_busywait.pseudo

Diseño en pseudocódigo de la solución.

file diff

hello_order_busywait.c

Varios threads saludan en orden gracias a la espera activa, pero a costa de un consumo injustificado de recursos.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejercicio 15 [delayed_busy_wait]

Espera activa con retraso. ¿Se corrige el problema de la espera activa si en lugar de tener un ciclo vacío, se espera un cierto tiempo? Copie su carpeta ejemplos/pthreads/hello_order_busywait a ejercicios/pthreads/delayed_busy_wait. Permita que el usuario pueda invocar su programa dos argumentos de línea de comandos: la cantidad de hilos a crear, y la cantidad de microsegundos a esperar cuando no es el turno del hilo de ejecución.

Espera activa con retraso constante. Si no es el turno de un hilo, éste espera una cantidad constante de microsegundos, algo como:

// Constant delayed busy waiting: wait until it is my turn
while (next_thread < my_thread_id) {
  usleep(delay);
}

Recuerde probar la calidad de su código (sanitizers, linter). Luego ejecute al menos tres veces su solución (sin sanitizers) con la cantidad máxima de hilos de ejecución que su sistema operativo le permite crear y una espera de 50µs. Anote la mayor de las duraciones que obtuvo de sus corridas, . ¿Hubo una mejora de la espera constante respecto a la espera activa pura?

Espera activa con retraso pseudoaleatorio. Altere su solución al ejercicio para que en lugar de esperar exactamente la cantidad de microsegundos indicada por el usuario, espere por una cantidad pseudoaleatoria de microsegundos cuyo límite es el número indicado por el usuario en el segundo argumento de línea de comandos. Sugerencia: puede usar compilación condicional para implementar esta variación. La espera varía en cada iteración del ciclo de espera activa, algo como:

// Random delayed busy waiting: wait until it is my turn
while (next_thread < my_thread_id) {
	const unsigned my_delay = rand_r(&my_seed) % max_delay;
  usleep(my_delay);
}

Ejecute al menos tres veces su solución pseudoaleatoria con la cantidad máxima de hilos y un máximo de espera de 50µs. Tome la mayor de las duraciones. ¿Hubo una mejora de la espera pseudoaleatoria respecto a la espera constante?

Comparación de las esperas. ¿Mejora el tiempo de ejecución de los dos tipos de esperas (constante y pseudoaleatoria) si disminuye o incrementa la espera máxima de los hilos? Haga al menos un par de ejecuciones con al menos los siguientes tiempos de espera:

  • 1µs

  • 5µs

  • 25µs

  • 50µs

  • 75µs

  • 100µs

Sugerencia: si hay varios compañeros(as) trabajando el mismo ejercicio en el laboratorio, escojan tiempos diferentes y compartan los resultados. Pueden crear una gráfica en un documento compartido. Agreguen más tiempos de ejecución si hay más de 6 integrantes.

Cree una gráfica donde el eje-x son las duraciones dadas por argumento al programa. El eje-y son los tiempos de ejecución de los programas. El gráfico tendrá dos series, una para la espera constante y otra para la espera pseudoaleatoria.

Agregue la gráfica al readme.md del ejercicio y una discusión de a lo sumo dos párrafos. Indique cuál fue el mejor tiempo máximo de espera obtenido y los efectos de disminuir o incrementarlo. Conjeture y trate de explicar por qué ocurre este comportamiento. Finalmente indique si la espera activa con retraso es una solución óptima, y en caso negativo, provea una idea que pueda lograr este ideal.

2.6. Condición de carrera y exclusión mutua (race condition, mutex)

2.6.1. Posición en la carrera

Ejemplo 6. Posición en la carrera (mutex) [race_position]

Modifique su solución a Ejemplo 5 para implementar una carrera de hilos de ejecución. Cada vez que un hilo llega a la meta, lo avisa en la salida estándar, como ocurre en la siguiente corrida hipotética:

Thread 0/4: I arrived at position 1
Thread 2/4: I arrived at position 2
Thread 1/4: I arrived at position 3
Thread 3/4: I arrived at position 4

Note que las posiciones tienen que aparecer en orden, mientras que el número de cada hilo es indeterminístico. Utilice algún mecanismo de control de concurrencia para que el reclamo de la posición sea el correcto.

05-set, 06-set Video

Ejemplo position_race: pseudocódigo con condición de carrera

📹

Ejemplo position_race: implementación y ejecución

📹

Definición de condición de carrera (data race o race condition)

📹

Exclusión mutua (mutex o candado). Región crítica. Metáfora del puente angosto

📹

Repaso de la lección previa: exclusión mutua

📹

Implementación de mutex en Pthreads. Imprimir en la región crítica

📹

Liberar el mutex (pthread_mutex_destroy)

📹

Mutex debe proteger todos los accesos a la variable, no sólo escritura

📹

Múltiples regiones críticas

📹

Olvidar liberar un mutex ("el proceso bello durmiente")

📹

Tabla 6. Posición en la carrera [position_race]
Archivo Descripción Cambios

position_race.pseudo

Diseño en pseudocódigo de la solución.

file diff

position_race.c

Varios threads compiten en una carrera, y reportan el orden en que llegan a la meta. Utilizan exclusión mutua cuando llegan a la meta para incrementar la variable contadora correctamente y para evitar la condición de carrera en la impresión en la salida estándar.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejercicio 16 [birthday_pinata]

Implemente la siguiente simulación. Los hilos de ejecución están celebrando el cumpleaños de uno de ellos y van a romper la piñata. Todos los hilos son muy algorítmicos y todos le pegan con la misma fuerza a la piñata. El usuario confeccionó la piñata y sabe cuántos de estos golpes puede soportar el material, y lo indica como segundo argumento de línea de comandos. El primer argumento es la cantidad de hilos que invitó a la fiesta.

Por más que ha intentado, el usuario no ha logrado educar a los hilos en etiqueta de piñatas. Cuando la piñata hace su aparición, todos los hilos le entran a golpes simultáneamente y se podría pensar que en un instante la rompen y se lanzan a recoger los confites, pero no siempre pasa eso. Raras veces se quedan casi infinitamente dándole palo a la piñata rota. Su simulación debe evitar este extraño y vergonzoso fenómeno.

En su simulación, para mantenerles la emoción, permita que los hilos golpeen la piñata como a ellos le gusta: en cualquier orden. Pero sí imponga la restricción de que sólo un hilo a la vez puede golpearla. La piñata revienta cuando su contador de golpes llega a cero. Cada hilo lleva el conteo de cuántos golpes pudo darle a la piñata. Los hilos reportan en la salida estándar cuántos golpes pudo darle a la piñata y quién logró romperla. El siguiente podría ser un ejemplo hipotético de ejecución.

bin/birthday_pinata 3 10
Thread 2/10: 5 hits, I broke the pinata
Thread 0/10: 1 hits
Thread 1/10: 4 hits

Recuerde como siempre verificar la calidad del código fuente y ejecutable. Su solución no debe producir conduciones de carrera. Es probable que necesite correr el programa con una piñata muy fuerte para ver que varios hilos logren golpearla.

2.7. Semáforos

La definición original de semáforo por Dijkstra puede variar ligeramente de algunas implementaciones. Para Dijkstra un semáforo es un entero con signo, con tres características:

  1. Cuando se crea un semáforo, éste se inicializa con un entero cualquiera (negativo, cero, o positivo). Pero después de inicializado las únicas dos operaciones que están permitidas es incrementar en uno (signal) y decrementar en uno (wait) al semáforo. No se puede leer el valor actual del semáforo.

  2. Cuando un hilo decrementa un semáforo, si el resultado es negativo, el hilo es bloqueado y no puede continuar hasta que otro hilo incremente el semáforo.

  3. Cuando un hilo incrementa un semáforo, si hay otros threads esperando, uno de ellos será desbloqueado. Tanto el hilo que incrementa el semáforo como el que fue desbloqueado siguen ejecutándose concurrentemente. Si hay varios hilos esperando, no hay forma de saber cuál de ellos será el desbloqueado por el scheduler del sistema operativo. El programador no tiene forma de saber si al incrementar un semáforo, se desbloqueará o no un hilo en espera, dado que no se puede leer el valor actual del semáforo por la regla 1.

El valor actual de un semáforo indica lo siguiente. Si el valor es positivo indica la cantidad de hilos que pueden decrementar el semáforo sin bloquearse. Si es negativo indica la cantidad de hilos que están bloqueados esperando actualmente por el semáforo. Si el valor es cero, indica que no hay hilos esperando por el semáforo, pero si un thread trata de decrementarlo, será bloqueado.

Algunas implementaciones, por ejemplo POSIX, permiten probar la espera (sem_trywait). Esta función nunca bloquea al hilo que la invoca. Si se trata de esperar un semáforo que tiene un valor positivo, se decrementará el semáforo y el hilo continuará ejecutándose como una invocación normal a wait(). Pero si se trata de esperar por un semáforo que quedaría en un valor negativo, la función sem_trywait() no decrementa al semáforo ni bloquea al hilo, sino que retorna de inmediato indicando un código de error (por ejemplo, -1). Dado que el hilo mantiene su ejecución, quien programa puede decidir condicionando (if) el valor de retorno del sem_trywait() y tomar distintas acciones que realice el hilo cuando se pudo o no esperar por el semáforo.

En los siguientes ejemplos se seguirá la definición original de Dijkstra, que no permite probar la espera. Se usará pseudocódigo con la siguiente sintaxis para los hilos y semáforos, con el fin de centrar la atención en estos temas y no en detalles de implementación de una tecnología particular (ej.: Pthreads):

sem := create_semaphore(3) // Create a semaphore with initial value 3
signal(sem)                // Increment and wake a waiting thread if any
wait(sem)                  // Decrement and block if the result is negative

2.7.1. Hola mundo ordenado (semáforos)

Ejemplo 7. Hola mundo ordenado con semáforos [hello_order_semaphor]

Al igual que Ejemplo 5, haga que los threads saluden siempre en orden. Es decir, si se crean w threads, la salida sea siempre en orden

Hello from thread 0 of w
Hello from thread 1 of w
Hello from thread 2 of w
...
Hello from thread w of w

Utilice una colección de semáforos como mecanismo de sincronización (control de concurrencia).

08-set, 09-set Video

Definición original de semáforo (Dijkstra)

📹

Metáfora de semáforo

📹

Usar un arreglo de semáforos para imponer orden

📹

Repaso: mutex, semáforo. Futex (fast userspace mutex)

📹

Ejemplo hello_order_semaphor: solucion en pseudocódigo

📹

Semáforos en POSIX. Semáforos de memoria. Semáforos nombrados

📹

hello_order_semaphor: implementación de la solución

📹

Corrección de errores en hello_order_semaphor y position_race [27-set]

📹

Tabla 7. Hola mundo ordenado (semáforos) [hello_order_semaphor]
Archivo Descripción Cambios

hello_order_semaphor.pseudo

Diseño en pseudocódigo de la solución.

file diff

hello_order_semaphor.c

Utiliza una colección de semáforos, uno para cada hilo de ejecución. Sólo el semáforo para el hilo 0 está inicialmente en verde. Cuando un hilo pasa su semáforo, saluda en la salida estándar, y luego enciende el semáforo del siguiente. De esta forma, las impresiones se hacen en orden. Esta solución tiene una limitación de la cantidad de hilos que se pueden crear.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

2.7.2. Ejercicios

Ejercicio 17 [hello_order_semaphor2]

Modifique su solución de Ejemplo 7 para inicializar todos los semáforos en 0. Luego haga que el hilo principal incremente el semáforo del hilo 0. ¿Produce el mismo resultado que su solución anterior?

¿Qué pasa si el hilo principal incrementa el semáforo del hilo 0 después de hacer su saludo Hello from main thread?

Ejercicio 18 [hello_order_semaphor_named]

La solución a Ejemplo 7 utiliza semáforos no nombrados o también llamados semáforos de memoria. Son regiones de la memoria alojadas con sem_init() y liberadas con sem_destroy(). En macOS, estos semáforos están desaprobados (deprecated).

Modifique su solución para usar semáforos nombrados que tienen la ventaja de ser más universales entre sistemas operativos. Son archivos, abiertos con sem_open(), cerrados con sem_close() y eliminados del sistema de archivos con sem_unlink(). Si no se eliminan, quedan en el sistema de archivos y próximas ejecuciones del programa podrían continuar usándolos o dependiendo del modo de apertura, fallarán.

¿Puede su programa con semáforos nombrados crear más hilos que con semáforos de memoria?

Ejercicio 19 [building_tasks]

Suponga usted va a construir el edificio para su organización. Para que quede lo mejor posible, usted sólo va a contratar una persona especializada para cada tarea del proceso de construcción. Estas tareas están expresadas en el siguiente grafo de tareas.

building tasks
Figura 4. Grafo de tareas de la construcción de un edificio

En un grafo de tareas (task graph) los nodos son tareas de un proceso a realizar, y los vértices son dependencias. Un vértice dirigido de A hacia B indica que la tarea A debe completarse para poder iniciar la tarea B, dado que la salida o productos generados por la tarea A son requeridos por la tarea B. Una tarea puede iniciarse sólo si todas sus dependencias han sido completadas. Dos o más tareas en esta condición pueden realizarse en paralelo.

Se modeló esta situación en un programa concurrente. El maestro de obras (foreman) es el hilo principal que contrató a cada albañil experto. Cada albañil (builder) reporta en la salida estándar cuando inicia su labor. Luego representa el tiempo dedicado con una espera aleatoria. Una vez que finalizada su labor, lo reporta en la salida estándar.

Sin embargo hay un problema con el programa. Una vez que es ejecutado, todos los albañiles inician sus tareas de inmediato, sin respetar las dependencias del grafo de tareas. Por ejemplo, el experto en pintura exterior comienza a pintar las paredes que aún no se han construido. Por favor, corrija esta irrealista situación para que la simulación logre construir el edificio acorde a las dependencias. El programa será correcto cuando no haya forma de que un albañil inicie su actividad antes de que todas sus dependencias hayan sido completadas.

2.8. Seguridad condicional (conditionally safe)

2.8.1. Hola mundo ordenado con seguridad condicional

Ejemplo 8. Hola mundo ordenado con seguridad condicional [hello_order_cond_safe]

Al igual que Ejemplo 7, haga que los threads saluden siempre en orden. Es decir, si se crean w threads, la salida sea siempre en orden

Hello from thread 0 of w
Hello from thread 1 of w
Hello from thread 2 of w
...
Hello from thread w of w

Evite (si es posible) el control de concurrencia, es decir, trate de usar seguridad condicional (conditionally safe).

08-set, 09-set Video

Seguridad condicional (conditionally safe)

📹

hello_order_cond_safe: solución en pseudocódigo

📹

hello_order_cond_safe: implementación en C

📹

Tabla 8. Hola mundo ordenado (seguridad condicional) [hello_order_cond_safe]
Archivo Descripción Cambios

hello_order_cond_safe.pseudo

Diseño en pseudocódigo de la solución.

file diff

hello_order_cond_safe.c

Imprime en orden sin usar mecanismos de control de concurrencia. Usa una estructura de datos (un arreglo) donde cada hilo de ejecución escribe sus resultados sin afectar a los demás. El hilo principal se encarga de la tarea secuencial de imprimir.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejercicio 20 [sem_vs_condsafe]

Ejecute al menos tres veces los códigos de Ejemplo 7 (imprimir en orden con semáforos) y Ejemplo 8 (imprimir en orden con seguridad condicional) con tantos hilos como su sistema operativo permite.

Anote las tres duraciones de cada solución. Tome la mayor duración de las tres corridas de cada versión. Agregue los resultados a sus cuadros y gráficos del Ejercicio 15 [delayed_busy_wait].

Si en un proyecto con sus clientes usted tuviere que implementar una de las cuatro soluciones para hacer un trabajo en orden ¿Cuál solución escogería? Agregue además un párrafo a la discusión de Ejercicio 15 [delayed_busy_wait] explicando la potencial causa de este comportamiento.

2.9. Problema del productor-consumidor

El problema del productor-consumidor en computación es un problema clásico de control de concurrencia, propuesto por Edsger Dijkstra. Se suele presentar de forma semi-concreta, lo que ayudar a identificar otros contextos donde su solución se puede aplicar. Hay dos variantes, una de buffer acotado y otra de buffer no acotado. De ambas se puede generalizar un patrón de software que puede enriquecerse con otros paradigmas de programación como el genérico y orientado a objetos.

2.9.1. Problema de buffer acotado

El problema del productor-consumidor lo tienen dos entes concurrentes, uno que produce algo que el segundo consume. Ambos tienen acceso a un espacio finito (acotado) donde se alojan los productos, comúnmente llamado buffer y de naturaleza circular. Dado que los entes son concurrentes, uno está agregando productos al buffer mientras el otro está al mismo tiempo consumiéndolos (o quitándolos) del buffer.

El problema surge cuando ambos entes actúan a diferente velocidad. Si el consumidor es más rápido que el productor, podría consumir productos que aún no se han colocado en el buffer. Si el productor es más rápido que el consumidor, lo alcanza y sobrescribe productos que el consumidor aún no ha procesado.

El problema se resuelve cuando el consumidor procesa todos los productos y en el mismo orden en que el productor los genera. Al igual que ocurre con seres humanos, la solución implica imponer espera. Si el consumidor es más veloz, vaciará el buffer y deberá esperar a que el productor genere el próximo producto. Si el productor es más veloz y satura el buffer, deberá esperar a que el consumidor libere al menos un espacio para poder continuar produciendo.

Ejemplo 9. Productor-consumidor de buffer acotado [prod_cons_bound]

Cree una simulación del productor-consumidor. Su programa recibirá seis argumentos en la línea de comandos:

  1. El tamaño del buffer.

  2. La cantidad de rondas, vueltas, o veces que se debe llenar y vaciar el buffer.

  3. La duración mínima en milisegundos que el productor tarda en generar un producto.

  4. La duración máxima en milisegundos que el productor tarda en generar un producto.

  5. La duración mínima en milisegundos que el consumidor tarda en consumir un producto.

  6. La duración máxima en milisegundos que el consumidor tarda en consumir un producto.

El programa debe crear un buffer para almacenar productos identificados por números secuenciales iniciando en 1. Por ejemplo, si se piden 2 rondas con un buffer de tamaño 3, el 4 identificará el primer producto de la segunda ronda.

Generar un producto no es inmediato. El tiempo que el productor tarda depende de la naturaleza del contexto en que se aplique el problema del consumidor-productor. Para efectos de la simulación, los argumentos 3 y 4 sirven de rango para generar números pseudoaleatorios. Cada vez que el productor tiene que construir un producto, se generará una duración pseudoaletoria y el productor esperará esta cantidad de milisegundos, simulando hasta que el producto esté acabado. Luego el productor agrega el producto al buffer e imprime en la salida estándar el texto i generated, donde i es el número de producto.

Una vez que el consumidor extrae un producto del buffer imprime en la salida estándar el texto Consuming i y se dispone a consumir. Consumir un producto no es inmediato. El tiempo que el consumidor tarda se genera también en forma pseudoaletoria con los argumentos 5 y 6. Sugerencia: indente la salida del consumidor para distinguirla de la salida del productor.

En el siguiente ejemplo de ejecución, los entes hacen dos rondas llenando y consumiendo un buffer de 3 elementos. El productor es rápido y tarda máximo 100ms creando un producto. El consumidor es más lento y podría tardar máximo 750ms consumiendo un producto. Como se puede ver en la salida, el productor rápidamente llena el buffer y debe esperar a que el consumidor libere espacio en el buffer para continuar produciendo. En la salida se comprueba que el consumidor procesa todos los datos generados por el productor y en el mismo orden.

$ ./producer_consumer 3 2 0 100 0 750
Produced 1
Produced 2
Produced 3
		Consuming 1
Produced 4
		Consuming 2
Produced 5
		Consuming 3
		Consuming 4
		Consuming 5
Produced 6
		Consuming 6

Puede usar el siguiente código no modularizado como punto de partida para su solución:

Archivo Descripción

prod_cons_bound.pseudo

Diseño inicial en pseudocódigo

prod_cons_bound.c

Implementación inicial en C sin modularización

Tabla 9. Productor-consumidor de buffer acotado [prod_cons_bound]
Archivo Descripción Cambios

prod_cons_bound.pseudo

Diseño en pseudocódigo de la solución.

file diff

prod_cons_bound.c

Simula el problema con un productor y un consumidor que comparten un buffer acotado.

file diff

Makefile

Facilita la construcción y prueba de la la solución. Usa la variable APPARGS para

file diff

L12-set, k13-set Video

Análisis del problema del productor-consumidor de buffer acotado

📹

Ejecutar el código dado

📹

Comprensión del pseudcódigo dado

📹

Idear una solución con dos semáforos

📹

Solución en pseudocódigo

📹

Comprensión del código en C dado

📹

Implementación y ejecución de la solución con Pthreads

📹

Ejercicio 21 [prod_cons_bound_mod]

Modularice su solución de Ejemplo 9. Sugerencia: puede implementar archivos para:

  1. La función principal (main.c)

  2. La simulación (simulation.h, simulation.c)

  3. El productor (producer.h, producer.c)

  4. El consumidor (consumer.h, consumer.c)

2.9.2. Problema de buffer no acotado

Dijkstra publicó una segunda versión del problema del productor-consumidor que elimina las limitaciones del buffer acotado. En esta versión, el buffer tiene memoria infinita, hay una cantidad arbitraria de productores, y una cantidad arbitraria de consumidores. Al igual que el anterior, el problema se resuelve si todos los consumidores procesan todos los productos en el mismo orden en que los productores los generan.

Ejemplo 10. Productor-consumidor de buffer no acotado [prod_cons_unbound]

Modifique la simulación del problema productor-consumidor generalizando las siguientes restricciones.

  1. El buffer no es acotado, sino de tamaño arbitrario o dinámico, definido por los límites de memoria del sistema.

  2. No existe el concepto de ronda. El primer argumento de línea de comandos indica la cantidad de productos a simular. Identifique los productos en orden secuencial.

  3. Varios productores, indicados por el usuario en el segundo argumento de línea de comandos.

  4. Varios consumidores, indicados por el usuario en el tercer argumento de línea de comandos.

  5. Cada productor y consumidor indica su número en la salida estándar.

El siguiente ejemplo de ejecución simula la construcción de 5 unidades de trabajo por 2 productores que tardan entre 0 y 100 milisegundos en producir, y 3 consumidores que tardan entre 0 y 750 milisegundos en consumir.

$ ./producer_consumer 5 2 3 0 100 0 750
0 produced 1
1 produced 3
		1 consuming 1
0 produced 2
		2 consuming 3
0 produced 5
		0 consuming 5
		1 consuming 2
1 produced 4
		2 consuming 4

Note que no necesariamente los productos se generan en orden secuencial, debido al indeterminismo de los productores. Recuerde, el problema está resuelto si todos los consumidores procesan todos los productos en el mismo orden en que los productores los generan. Puede usar el siguiente código modularizado como punto de partida para su solución:

Tabla 10. Código inicial para el productor-consumidor de buffer no acotado [prod_cons_unbound]
Archivos Descripción

prod_cons_unbound.7z

Contiene a todos los demás

prod_cons_unbound.pseudo

Diseño preliminar en pseudocódigo de la solución.

main.c

Instancia e inicia la simulación

simulation.h / simulation.c

Implementa el controlador de la simulación

common.h / common.c

Código común usado entre todos los componentes de la simulación

queue.h / queue.c

Implementa una cola de enteros

producer.h / producer.c

Rutina de inicio de un hilo productor

consumer.h / consumer.c

Rutina de inicio de un hilo consumidor

j15-set, v16-set Diseño Video

Poblema del Productor-consumidor de buffer acotado. Archivos dados. Makefile v2.2.2

📹

Comprender el pseudocódigo dado

📹

Rastreo de procesamiento. Encontrar las condiciones de carrera

📹

Hacer la cola thread-safe usando exclusión mutua

📹

Corregir productores usando variables contadoras (variante 0)

📹

Corregir productores usando ciclo infinito (variante 1)

📹

Corregir consumidores usando variables contadoras y ciclo infinito (variante 1)

📹

Evitar que los consumidores extraigan de la cola si está vacía

📹

Rastreo rápido de procesamiento de los consumidores con variables contadoras (variante 1)

📹

Consumidores con variables contadoras y espera activa (variante -2)

📹

Detener consumidores cuando la cola está vacía y los productores terminaron (variante 2)

📹

Repaso. Numerar las variantes de productor y consumidor

📹

Detener consumidores usando un valor centinela (variante 3)

📹

15-set, 16-set Implementación Video

Comprender el código C dado

📹

Implementación de la cola thread-safe: init(), is_empty()

📹

Cola thread-safe: enqueue()

📹

Cola thread-safe: dequeue(), is_empty_unsafe()

📹

Cola thread-safe: remove_first_unsafe()

📹

Cola thread-safe: clear()

📹

Cola thread-safe: ¿Debería init y destroy ser thread-safe?

📹

Implementación de la variante 1 de los productores en Pthreads

📹

Implementación de la variante 1 de los consumidores en Pthreads

📹

Tabla 11. Productor-consumidor de buffer no acotado [prod_cons_unbound]
Archivo Descripción Cambios

prod_cons_unbound0.pseudo

Variante 0: Detiene a los productores con variables contadoras y redundancia de código. Incluye también una variante de consumidor con espera activa a modo de ejemplo de una solución que no es aceptable.

file diff

prod_cons_unbound1.pseudo

Variante 1: Detiene a los productores y consumidores con variables contadoras

file diff

prod_cons_unbound2.pseudo

Variante 2: Detiene a los consumidores con cuando la cola está vacía y los productores han terminado de producir.

file diff

prod_cons_unbound3.pseudo

Variante 3: Detiene a los consumidores con un valor especial en la cola que indica la condición de parada

file diff

main.c

Inicia la ejecución de la simulación.

simulation.h, simulation.c

Realiza la simulación del productor-consumidor de buffer no acotado

file diff

common.h, common.c

Código común usado entre todos los componentes de la simulación

file diff

queue.h, queue.c

Implementa una cola thread-safe de productos (números enteros)

file diff file diff

producer.h, producer.c

Realiza la simulación del productor-consumidor de buffer no acotado

file diff

consumer.h, consumer.c

Realiza la simulación del productor-consumidor de buffer no acotado

file diff

Makefile

Facilita la construcción y prueba de la la solución. Actualizado a la versión 2.2.2

file diff

Ejercicio 22 [prod_cons_unbound_trace]

Rastree el procesamiento concurrente completo del diseño realizado en Ejemplo 10 con produce1() y consume3() al ser invocado con los argumentos de línea de comandos 3 2 2 0 0 0 0. Utilice una hoja de cálculo o una hoja de papel.

Ejercicio 23 [prod_cons_unbound_produce0]

En los diseños de Ejemplo 10 se proveyó una versión produce0() que sufre de redundancia de código. Modifique el diseño para eliminar dicha redundancia. Implemente esta mejora en el código C junto con la implementación de consume1() y compruebe que resuelve el problema correctamente.

Ejercicio 24 [prod_cons_unbound_consume0]

En los diseños de Ejemplo 10 se proveyeron las versiones consume1(), consume2(), y consume3(). Provea la versión consume0() análoga al produce0() que realizó en Ejercicio 23 [prod_cons_unbound_produce0]. Su diseño no debe tener redundancia de código. Implemente esta versión en C y compruebe que resuelve el problema correctamente.

Ejercicio 25 [prod_cons_unbound_produce2]

En los diseños de Ejemplo 10 se proveyó una versión produce1() que usa variables contadoras y exclusión mutua para deneter a los productores. No se proveyeron versiones produce2() y produce3().

Cree la versión produce2() que utilice control de concurrencia en lugar de variables contadoras con exclusión mutua para detener a los productores. Use la función try_wait(semaphore) que retorna false si el semáforo está en 0 o negativo en lugar de bloquear al hilo. Si el semáforo está en positivo, funciona igual que wait(semaphore).

Su código puede mantener las variables contadoras para agregar los datos en la cola, pero no para detener los productores. También si lo quiere, puede suponer que existe una función get_count(queue) que retorna la cantidad de elementos almacenada en la cola.

Implemente la variante produce2() en Pthreads junto con consume1(). Compruebe que resuelve el problema correctamente. Recuerde verificar la calidad de código (sanitizing, linting).

Ejercicio 26 [prod_cons_unbound_cpp]

Traslade su solución de Ejemplo 10 del lenguaje de programación C a C++ para que aproveche los paradigmas de programación genérica y orientada a objetos. Tome en consideración las siguientes sugerencias:

  1. Cambie la tecnología procedimental Pthreads, por los hilos provistos en la biblioteca estándar de C++. Aproveche el código que realizó en Ejercicio 14 [std_thread_cpp].

  2. Cree una cola genérica que sea thread-safe, y por lo tanto, protegida por mecanismos de control de concurrencia de C++. Use contenedores de la biblioteca estándar de plantillas.

  3. Cree una clase base abstracta para los hilos de ejecución.

  4. Cree una plantilla base abstracta para los productores. Haga que herede de la clase abstracta de hilo de ejecución.

  5. Cree una plantilla base abstracta para los consumidores, de forma similar a los productores.

  6. Herede de las abstracciones anteriores, su productor y consumidor para la simulación.

  7. Cambie el registro y funciones de la simulación por una clase. Use el productor y consumidor que adaptó en el punto anterior en la implementación de su simulación.

2.9.3. Patrón productor-consumidor

La solución al problema del productor-consumidor es aplicable a una cantidad significativa de contextos. El código siguiente trata de generalizar la solución para que pueda ser reutilizada. Para esto se aprovechan mecanismos de reutilización de código provistos por paradigmas de programación como el orientado objetos (herencia y polimórfismo) y programación genérica (plantillas en C++). Se aplican otros patrones como el modelo-vista-controlador y singleton.

Tabla 12. Patrón productor-consumidor [prod_cons_pattern]
Archivo Descripción Cambios

prod_cons_pattern/

Carpeta con los archivos fuente que simulan una red de un productor, un repartidor, y una cantidad de consumidores dada por argumentos de línea de comandos. Incluye el código para crear un servidor web, base para el proyecto01.

prod_cons_pattern.7z

Comprimido que incluye los archivos de la carpeta anterior.

La subcarpeta prodcons/ contiene las clases base genéricas reutilizables en otros contextos en particular:

  1. Thread: Clase abstracta que facilita crear hilos de ejecución. Se debe crear una clase hija que sobrescriba el método run(), el cual se invocará en un hilo aparte cuando se le invoque startThread().

  2. Semaphore: Encapsula un semáforo POSIX, dado que std::counting_semaphore requiere compiladores modernos con C++20.

  3. Producer: Clase base abstracta genérica para crear hilos productores. Se debe heredar y sobrescribir el método run() heredado de Thread.

  4. Consumer: Clase base abstracta genérica para crear hilos consumidores. Se debe heredar y sobrescribir el método run() heredado de Thread y el método consume() que se invoca cada vez que el consumidor logra extraer un dato de la cola de consumo.

  5. Assembler: Es un hilo que consume de una cola que produce en otra cola.

  6. Dispatcher: Es un hilo que consume de una cola y reparte a varias colas usando valores llave.

El código provisto ofrece un ejemplo en el que se usa las clases genéricas anteriores. Se trata de una simulación de una red, donde un productor genera paquetes dirigidos a una cantidad arbitraria de consumidores. Un repartidor intermediario se encarga de redirigir los paquetes de red a su correspondiente destinatario.

network simulation given
Figura 5. Diseño de la simulación de red

El código provisto contiene un servidor web serial que puede ser paralelizado mediante una cadena de producción, y por lo tanto, la intervención de varios hilos productores y consumidores. Este es el objetivo del proyecto01 del curso.

L19-set, k20-set Video

Patrón productor-consumidor. Ejecución de la simulación de red

📹

Principio del patrón productor-consumidor. Flow-based programming paradigm

📹

Repaso del patrón modelo-vista-controlador (MVC)

📹

Controlador de la simulación de red

📹

Controlador de la simulación de red (repaso)

📹

Clase abstracta genérica Producer

📹

Clase abstracta Thread. Registro NetworkMessage

📹

Plantilla Queue. Clase envoltura Semaphore

📹

Clase abstracta genérica Consumer. Condición de parada

📹

Clase abstracta genérica Dispatcher (repartidor)

📹

Clase abstracta genérica Assembler. Problema del diamante

📹

Ejercicio 27 [network_simul_packet_loss]

Modifique el código de la simulación de red para introducir pérdida de paquetes. Reciba un parámetro más en línea de comandos que corresponde a la probabilidad de perder paquetes, como un porcentaje en precisión flotante. Implemente un hilo ensamblador que es uno de los destinos del repartidor, como se ve en el siguiente diseño de flujo de datos.

network simul packet loss

Por cada paquete que este hilo ensamblador recibe, se genera un número flotante pseudoaleatorio entre 0 y 100. Si este número generado es menor que la probabilidad de pérdida de paquetes, el hilo descarta el paquete, de lo contrario, modifica al azar el destino del paquete, y lo pone de regreso en la cola entre el productor y el repartidor.

Tome en cuenta que el hilo ensamblador que extravía paquetes no es parte de la cantidad de consumidores. El siguiente podría ser un ejemplo de ejecución con dos consumidores, y un ensamlador que extravía aproximadamente la mitad de los paquetes que recibe. La otra mitad son redirigidos a los dos consumidores.

bin/network_simul_packet_loss 1000 2 1 -2 0 50
Info	Producer	1000 messages sent
Info	Consumer	416 messages consumed
Info	Consumer	404 messages consumed
Info	Assembler	180 messages lost
Ejercicio 28 [network_simul_packet_loss_fix]

Es probable que su solución de Ejercicio 27 [network_simul_packet_loss] pierda más paquetes de lo esperado. Corra su solución con un porcentaje de pérdida de 0 mensajes. En teoría la cantidad de mensajes recibidos por los consumidores debería ser igual a la cantidad de mensajes generados por el productor. En caso contrario:

  1. Explique por qué ocurre una pérdida adicional de mensajes.

  2. Idee una solución, impleméntela en el código fuente, y corra varias veces su simulación para verificar que la cantidad de mensajes consumidos y perdidos coincida con los producidos.

Ejercicio 29 [network_simul_packet_loss2]

Modifique su solución al Ejercicio 27 [network_simul_packet_loss] para que el hilo que extravía paquetes esté ubicado entre el productor y el repartidor, de tal forma, que el porcentaje de pérdida de paquetes afecte a todos los paquetes y no una fracción de los mensajes de red.

¿Cuántas líneas de código debió modificar para reflejar este cambio? ¿Cuántas de esas líneas fueron en la implementación de su (a) productor, (b) repartidor, (c) consumidor, (d) ensamblador, y (e) controlador de la simulación?

Ejercicio 30 [network_simul_producers]

Modifique la simulación de red para recibir la cantidad de productores en el segundo argumento de línea de comandos. Cree tantos productores como indica el usuario y todos producen en la misma cola. Utilice mecanismos de control de concurrencia para que los productores se repartan la carga de creación de mensajes de red.

Ejercicio 31 [thread_team]

Modifique el código reutilizable del patrón productor-consumidor para crear equipos de hilos. Agregue una plantilla de clases ThreadTeam<ThreadType, SharedData>, que crea un equipo de hilos todos de tipo ThreadType, que comparten datos de tipo SharedData. El siguiente ejemplo crea un equipo de 10 hilos contadores que comparten un contador protegido por mutex:

struct SharedCounter {
  std::mutex canAccess;
  long count = 0;
};

class CounterThread : public Thread {
 public:
  int run() override {
    SharedCounter* counter = this->getSharedData<SharedCounter>();
    counter->canAccess.lock();
    const long myIteration = counter->count++;
    counter->canAccess.unlock();
    std::cout << this->getThreadNumber() << '/' << this->getThreadCount() <<
      " got iteration " << myIteration << std::endl;
  }
};

int main() {
  SharedCounter counter;
  ThreadTeam<CounterThread, SharedCounter> team(10, &counter);
  team.startThreads();
  team.waitToFinish();
  std::cout << counter.count << std::endl;
}

Modifique la clase Thread para que tenga como atributos un puntero void* hacia los datos compartidos, el número de hilo en el equipo, y la cantidad de hilos en el equipo. Provea métodos get y set para estos atributos. Los métodos set serán usados por su clase ThreadTeam. Los métodos get serán usados por las clases que hereden de Thread. El método getSharedDadata<T>() recibirá por parámetro el tipo de datos al que se deberá convertir el puntero void*.

Ejercicio 32 [network_simul_bounded]

Modifique la simulación de red para recibir en un argumento de línea de comandos la capacidad máxima de paquetes que cabe en cada cola de la simulación. Modifique la plantilla Queue para que represente un buffer acotado. Sugerencia, agregue un semárofo canProduce que se inicialice en esta capacidad indicada por el usuario en el constructor de la cola. Si este número no se provee, suponga el número más grande que permite un semáforo en POSIX, indicado por la constante SEM_VALUE_MAX declarada en <climits>.

Corra su simulación de red con diferentes límites en las colas ¿Se afecta el tiempo de ejecución?

Ejercicio 33 [network_simul_merger]

Provea un componente genérico llamado Merger que realice el trabajo opuesto al repartidor (dispatcher). Es decir, reciba paquetes de una cantidad arbitraria de colas y redirija todos ellos hacia una única cola. Considere que este componente no puede determinar de antemano por cuáles colas de entrada recibirá o no paquetes de red ni en qué orden.

Sugerencia: agregue funcionalidad a la cola thread-safe para que se pueda establecer el semáforo canConsume. Haga que el Merger sea el dueño de un semáforo y lo comparta con todas las colas de las que consume.

Para probar su Merger puede alterar la solución a Ejercicio 27 [network_simul_packet_loss] para unir los paquetes que van dirigidos al último consumidor y los que produce el ensamblador extraviador de paquetes. De esta manera el último consumidor recibirá tanto los mensajes que iban originalmente dirigidos a él como los que el extraviador de paquetes dejó pasar. Necesitará crear una cola intermedia desde el controlador de la simulación. ¿Se puede lograr este mismo efecto sin un Merger?

2.9.4. Servidor web concurrente (proyecto 1)

L19-set, k20-set Video

Objetivo del proyecto. Estructura del código dado. Correr el servidor web

📹

Servidor web vs aplicación web. Interfaz de un servidor

📹

El servidor web (clase HttpServer). Puertos de red

📹

Iniciar el servidor (método start). Bitácoras. Aceptar todas las conexiones

📹

Establecimiento de la conexión

📹

Mensaje de solicitud y de respuesta. Ciclo de atención de solicitudes. Lista de to-do

📹

Metáfora del food court. Tomador de órdenes (HttpConnectionHandler). Socket de red

📹

Avances del proyecto 1 explicados en la cadena de producción

📹

Enrutamiento web. HTML. Solicitud HTTP. Respuesta HTTP

📹

La aplicación web de factorización de números

📹

3. Paralelismo de datos

3.1. Optimización

La optimización es una etapa normalmente opcional del proceso de resolución de problemas (Figura 6), que surge cuando las implementaciones no logran satisfacer los requerimientos de eficiencia de los usuarios. Consiste regresar a la fase de diseño, y encontrar un modelo solución más eficiente. Este modelo típicamente es más elaborado, complejo, con un efecto de hacer más difícil de comprender al código o hacerlo más dependiente de un contexto, por lo que debe tenerse el cuidado de que exista un retorno de la inversión. Resulta muy apremiante realizar esta fase del proceso de resolución de problemas de forma sistemática, por lo que se propone un Método sugerido para optimizar.

problem solving process
Figura 6. Fase de optimización en el proceso de resolución de problemas
Método sugerido para optimizar
  1. Medir el rendimiento del código antes de realizar las modificaciones.

  2. Analizar el código para detectar las regiones críticas a optimizar (profiling).

  3. Hacer las modificaciones que se cree incrementarán el rendimiento en las regiones críticas.

  4. Asegurarse de que las modificaciones sean correctas (correr el programa o casos de prueba).

  5. Medir el rendimiento del código después de realizar las modificaciones y comparar para determinar si hubo una ganancia.

  6. Indiferentemente de si se incrementó o no el rendimiento, documentar las lecciones aprendidas, ya que servirán para otros desarrolladores que intenten optimizar la misma sección de código.

  7. Si es realmente requerido incrementar aún más el rendimiento, repetir desde el paso 2.

L19-set, k20-set Video

Motivación al paralelismo de datos

📹

Optimización. Método sugerido para optimizar

📹

Repaso de optimización

📹

3.2. Descomposición

En un problema, la descomposición es identificar las tareas o unidades de trabajo que se pueden realizar de forma independiente, y por lo tanto, de forma paralela. Una vez definidas, las tareas se consideran unidades indivisibles de computación. Es seguido por el mapeo, que consiste en asignar esas unidades de trabajo a los ejecutantes (procesos o hilos de ejecución). Esta sección resume conceptos teóricos de la descomposición de acuerdo a (Grama, Gupta, Karypis, y Kumar. Introduction to Parallel Computing 2ed, 2003) y el mapeo se estudia en la sección siguiente.

La granularidad (granularity) de la descomposición es la cantidad y tamaño de las tareas en la que un problema es descompuesto, y puede ser:

  1. Granularidad fina (fine-grained) cuando se definen muchas tareas pequeñas. Tiende a incrementar el grado de concurrencia, pero también, la interacción entre tareas (ejecutantes), es decir, compoartir datos, trabajo, o información de sincronización.

  2. Granularidad gruesa (coarse-grained) cuando se definen pocas tareas grandes. Disminuye la interacción entre ejecutantes, pero reduce el grado de concurrencia.

Existen algunas técnicas que pueden emplearse para definir las unidades de descomposición, entre las que se pueden citar:

  1. Descomposición recursiva (recursive decomposition) es una forma de divide y vencerás. Un problema o tarea se divide en subproblemas o subtareas que se pueden realizar de forma concurrente. Cada una de estas se subdividen en otros subproblemas o subtareas y así sucesivamente. Por ejemplo, quicksort es un algoritmo que se puede paralelizar de esta manera. Cada vez que se subdivide el vector en dos, el ejecutante se encarga de un trozo y crea otro ejecutante que se encarga del otro trozo del arreglo.

  2. Descomposición de datos (data-decomposition) se usa con grandes estructuras de datos. Se particiona el conjunto de datos en trozos de aproximadamente igual carga de trabajo. Todos los ejecutantes contribuyen al progreso de la solución, y típicamente obtiene un incremento del desempeño sistemático.

  3. Descomposición exploratoria (exploratory decomposition) se usa con problemas de búsqueda en un espacio de soluciones, el cual se puede particionar para buscar concurrentemente hasta encontrar las soluciones esperadas. Apenas un ejecutante encuentra una o la última solución, avisa a los demás para que todos se detengan. Son ejemplos, el ajedrez o buscar el camino en un laberinto. A diferencia de la descomposición de datos, los ejecutantes podrían no contribuir al progreso de la solución (su partición no contiene solución alguna) y un incremento de desempeño no se comporta de forma sistemática, pues depende de dónde se encuentre la solución en el espacio de búsqueda y cómo se particione éste.

  4. Descomposición especulativa (speculative decomposition) se usa cuando se puede predecir con alguna probabilidad que un evento futuro ocurrirá, y la tarea asociada puede ser ejecutada en forma anticipada y concurrente a la actual. De esta forma si el evento ocurre, su tiempo de ejecución será menor. Por ejemplo, tras una consulta en un buscador web, mientras el navegador carga los resultados, concurrentemene carga la página del primer resultado retornado por el buscador. Si el usuario hace click en el primer resultado, verá casi de forma instantánea la página cargada en el navegador.

Las técnicas anteriores no son las únicas ni son excluyentes. Varias pueden emplearse para resolver problemas complejos, a lo que corresponde una descomposición híbrida (hybrid decomposition).

L19-set, k20-set Video

Descomposición: concepto y técnicas: recursiva, de datos, exploratoria, especulativa

📹

3.3. Mapeo

La Figura 7 muestra el resultado de la actividad de mapear tres conjuntos de datos de distinta naturaleza, representados como arreglos en fondo celeste. El valor en cada celda celeste significa una unidad de trabajo medida como una potencial duración. Los números sobre el arreglo de fondo celeste indican el índice basado en cero.

mapping
Figura 7. Actividad de mapear tres conjuntos de datos a cuatro trabajadores

Debajo de los arreglos celestes en la Figura 7 se anota el número de trabajador que realiza esa unidad de trabajo. El número varía de acuerdo al mapeo. Los mapeos pueden clasificarse en estáticos o dinámicos. En el mapeo estático la asignación de las unidades de trabajo se conoce de antemano, es decir, antes de iniciar el trabajo, sólo depende de conocer el número total de unidades de trabajo y trabajadores. El mapeo por bloque y cíclico son dos formas de mapeo estático.

En el mapeo dinámico la asignación ocurre durante la realización del trabajo, es decir, los trabajadores se asignan las unidades de trabajo conforme ellos terminan las previas. Aunque normalmente consigue mejores distribuciones, es más costoso en recursos que los mapeos estáticos, dado que requiere control de concurrencia y manipulación de estructuras de datos dinámicas.

L19-set, k20-set Video

Mapeo: concepto. Mapeo estático. Mapeo dinámico

📹

Actividad de mapeo manual en una hoja de cálculo

📹

3.3.1. Mapeo por bloque

El mapeo estático por bloque asigna rangos continuos de trabajo a cada trabajador (ejecutante, hilo, o proceso). Es el mapeo que potencialmente puede disminuir más fallos de caché o false sharing (Section 3.8) si se trabaja con memoria continua. El bloque de un trabajador \$i\$ está dado por el rango de índices enteros \$[\text{start}, \text{finish}[\$, donde \$\text{start}\$ y \$\text{finish}\$ son funciones dadas por

\$\text{start(i,D,w)} = i\lfloor \frac{D}{w} \rfloor + \min(i, \mod(D, w))\$
\$\text{finish(i,D,w)} = \text{start(i + 1,D,w)\$

donde \$i\$ es el número del trabajador (iniciando en 0), \$D\$ es la cantidad de datos, y \$w\$ es la cantidad total de trabajadores.

L19-set, k20-set Video

Mapeo estático por bloque no balanceado

📹

Modelo matemático del mapeo estático por bloque balanceado

📹

Mapeo por bloque en la actividad de la hoja de cálculo

📹

3.3.2. Mapeo cíclico

El mapeo estático cíclico asigna al trabajador \$i\$ todas las unidades de trabajo con índice \${i,i+w, i+2w, ...}\$. Puede tener ventajas cuando las unidades de datos presentan algún patrón predecible y que no es apto para el mapeo por bloque. Se puede hacer un híbrido del mapeo cíclico por bloque que reparte no una unidad, sino bloques de unidades de trabajo de igual tamaño de forma cíclica a cada trabajador.

L19-set, k20-set Video

Mapeo estático cíclico. Metáfora de la baraja de naipes

📹

Mapeo estático cíclico con bloques

📹

3.3.3. Mapeo dinámico

Los algoritmos de mapeo dinámico reparten las unidades de descomposición entre los ejecutantes en tiempo de ejecución. Un principio de diseño que puede seguirse es "cuando un ejecutante se desocupa toma la siguiente unidad de trabajo disponible". Este principio puede implementarse de formas como las siguientes:

  1. Variable de control. Una variable compartida y protegida por exclusión mutua que indica cuál es la próxima unidad de trabajo disponible. Cuando un ejecutante se desocupa, compite por el mutex y cuando lo adquiere, toma la siguiente unidad de trabajo disponible y deja en la variable compartida la unidad que le sigue. Esto se repite hasta que los ejecutantes hayan completado todo el trabajo.

  2. Productor-consumidor. El trabajo se agrega, normalmente por el hilo principal a un buffer (por ejemplo, una cola thread-safe) llamado work pool. Los ejecutantes consumen del buffer y procesan cada unidad de trabajo que extraen. Se debe decidir la forma de detener los consumidores, como usar variables contadoras, cuando el buffer está vacío, o cuando se extrae un valor centinela.

  3. Mapeo dinámico aleatorio. En caso de paso de mensajes o de uso del patrón productor-consumidor, el productor de trabajo lo reparte de forma aleatoria entre los ejecutantes (consumidores).

L19-set, k20-set Video

Mapeo dinámico

📹

3.4. Métricas

3.4.1. Incremento de velocidad (speedup)

Para determinar en cada iteración de optimización si hubo realmente una ganancia de rendimiento (pasos 1 y 5 del método sugerido), se puede utilizar varias métricas. La medida relativa speedup \$S\$ (incremento de velocidad), se calcula como la relación entre el tiempo que tarda una computación previa a la optimización (\$T_\text{before}\$), contra el tiempo que tarda la misma computación posterior la optimización (\$T_\text{after}\$):

\$S = \frac{T_\text{before}}{T_\text{after}} = \frac{T_\text{serial}}{T_\text{parallel}}\$

La paralización de código serial es una forma de optimización. Una comparación de incremento de velocidad común es el tiempo de ejecución serial (antes) respecto al tiempo de ejecución posterior a la paralización (después). En este caso, el speedup indica la cantidad de veces que la computación paralela es más rápida que la computación serial. Un valor mayor a 1 indica un incremento de velocidad, 1 que la velocidad se mantiene igual, y un valor menor a 1 un decremento en la velocidad.

L19-set, k20-set Video

Métrica del incremento de velocidad (speedup)

📹

3.4.2. Eficiencia

El speedup sólo considera los tiempos de ejecución pero no los recursos que se invirtieron en conseguir el incremento de desempeño. La métrica de eficiencia (\$E\$, efficiency) es una relación entre el incremento de velocidad y la cantidad de trabajadores (\$w\$) que tuvieron que involucrarse para conseguirlo:

\$E = \frac{\text{speedup}}{\text{workers}} = \frac{T_\text{serial}}{T_\text{parallel} \cdot \text{w}}\$

La eficiencia es un valor entre 0 y 1, donde 0 indica un sistema no eficiente, y 1.0 es la eficiencia ideal donde todo el trabajo es realizado en paralelo por los trabajadores, en forma equitativa y sin necesidad de control de concurrencia. Sin embargo, es poco probable que un programa logre una eficiencia de 1.0. Normalmente los programas tienen porciones de código secuencial (por ejemplo, antes de crear los hilos de ejecución, o al usar control de concurrencia como exclusión mutua) y porciones de código paralelo, como ocurre en la línea de tiempo de la Figura 8.

typical time graph
Figura 8. Gráfico de tiempo de un programa con tres fases: serial inicial, paralela, y serial final
L19-set, k20-set Video

Métrica de eficiencia (efficiency). Gráficos combinados

📹

3.4.3. Medición de los tipos de mapeo

L19-set, k20-set Video

Mapeos estáticos para los casos 2 y 3 en la hoja de cálculo

📹

Mapeo dinámico para los casos 2 y 3 en la hoja de cálculo

📹

Comparación de los tipos de mapeo

📹

Ejercicio de la simulación de mapeo. Algoritmos de posibles implementaciones de los mapeos

📹

Ejercicio 34 [mapping_simulation]

Escriba un programa que recibe de la entrada estándar un conjunto de duraciones de trabajo, y como argumento de línea de comandos la cantidad de trabajadores. El programa debe crear tantos hilos como trabajadores se indicaron en los argumentos (o la cantidad de CPUs disponibles si se omite), y repartir el trabajo de la entrada estándar siguiendo los tres tipos principales de mapeo vistos en clase: estático por bloque, estático cíclico, y dinámico.

Los hilos de ejecución reciben el trabajo asignado por el mapeo y simulan que lo realizan mediante una espera (sleep). Suponga que las duraciones están dadas en milisegundos. Cada hilo debe llevar la estadística de la cantidad de trabajo asignado.

Tras realizar las tres simulaciones (una por cada tipo de mapeo), el programa reporta estadísticas en la salida estándar que le ayudarían a un desarrollador a escoger el tipo mapeo más adecuado a un conjunto de datos de interés. La salida del programa puede tener formato de tabla como el sugerido a continuación, el cual sigue la estructura del ejemplo de la sección Section 3.3.

$ echo 1 2 1 3 2 1 1 2 1 3 1 2 2 3 2 2 1 1 2 2 2 2 | ./mapping_simulation 4
39         0    1    2    3 Duration Speedup Efficiency
Block     10   10   10    9    10.00    3.90      0.975
Cyclic     9   12    7   11    12.00    3.25      0.813
Dynamic   10   10   10    9    10.00    3.90      0.975

En la línea de encabezado de la salida, 39 es la duración serial (suma de todas las duraciones en la entrada estándar), y 0 a 3 son los números de trabajador (hilo de ejecución). Los números bajo ellos son las sumas de las duraciones asignadas (trabajo) de cada hilo. La columna duración indica el tiempo tomado por cada tipo de mapeo (tiempo de pared del mapeo y no el mayor de los hilos). Con esta duración de tiempo de pared se calcula el incremento de velocidad y la eficiencia, respecto a la hipotética versión serial.

Puede aprovechar el siguiente código inicial para implementar su simulación. Primero idee una solución que complete el pseudocódigo provisto. Se requiere implementar los tres tipos de mapeos cuyos procedimientos se encuentran vacíos. Puede agregar código en la subrutina main que puede utilizar en la implementación de los mapeos. Si lo desea puede implementar más de una versión del mapeo dinámico, como: repartir con una variable contadora, usar un buffer thread-safe (cola), y un mapeo dinámico aleatorio.

3.5. Ley de Amdahl

La Ley de Amdahl establece que el máximo speedup que un programa puede alcanzar por paralelización está acotado por la porción del programa que se mantiene o sólo puede ser serial. Por ejemplo, para la Figura 8 la duración total del trabajo si se realizara en forma serial sería \$T_s = 1 + 24 + 1 = 26\$ horas. Si se tuviera una paralelización ideal, la fase paralelizable de 24h se podría repartir entre \$w\$ trabajadores y reducir a \$\frac{24}{w}\$ horas, por ejemplo en la Figura 8 se ilustran \$w = 3\$ trabajadores que reducirían la fase central a \$\frac{24}{3} = 8\$ horas. Por consiguiente la duración total de la versión paralela del programa sería \$2 + \frac{24}{w}\$ horas, y por lo tanto, el incremento de velocidad se obtendría por la relación:

\$S = \frac{T_s}{T_p} = \frac{26}{2 + \frac{24}{w}}\$

Por ejemplo, con \$w = 3\$ trabajadores, el speedup sería \$S = \frac{26}{2 + \frac{24}{3}} = 2.6\$. Con ocho trabajadores se tendría el doble del speedup anterior \$S = 5.2\$. Para maximizar el speedup es natural pensar en incrementar la cantidad de trabajadores \$w\$, procurando que el denominador se acerque a cero. Sin embargo, en caso extremo, cuando \$w\$ tiende a infinito número de trabajadores se obtiene que

\$\lim_{w \to \infty} S = \lim_{w \to \infty} \frac{26}{2 + \frac{24}{w}} = \frac{26}{2 + 0} = 13\$

Es decir, para la situación del programa de la Figura 8, el speedup está acotado a 13. Aunque la Ley de Amdahl puede verse como una limitación negativa al paralelismo, también puede usarse a favor. Si se sabe que para un programa concurrente el speedup máximo es 13, con unos pocos hilos o alquilar unas pocas máquinas en la nube, se podría alcanzar un speedup satisfactorio, en lugar de invertir en recursos costosos que poco incrementarían la velocidad, dado a que está acotada como muestra la Figura 9 para 100 trabajadores.

amdahl
Figura 9. El incremento de velocidad está acotado por la porción serial de un programa
j22-set, v23-set Video

Mapeo dinámico aleatorio

📹

Eficiencia ideal (1.0)

📹

Ley de Amdahl. Máximo speedup teórico. Usarla para estimar recursos

📹

3.6. Profiling

Para encontrar regiones de código que podrían ser optimizadas por su alto consumo de recursos se puede usar una herramienta de profiling. Estas herramientas proveen análisis dinámico de código ejecutable con fines de optimización. Algunos ejemplos son Callgrind y perf.

3.6.1. Callgrind

Callgrind es una herramienta que forma parte de Valgrind. Conviene incrustar el código fuente al compilar el ejecutable para obtener información más contextualiazada, como muestran los siguientes comandos.

# Install tools
sudo apt install build-essential valgrind kcachegrind

# Compile executable including its source code
cc -g -Wall -Wextra source.c -o executable -pthread

# Run the program under callgrind
valgrind --tool=callgrind --separate-threads=yes ./executable args_for_executable

# Visualize the data collected by callgrind in the current directory
kcachegrind

Callgrind realiza contabilidad de invocaciones de subrutinas y cantidad de instrucciones ejecutadas. Se requiere incluir el código fuente dentro del ejecutable al compilar el programa. Luego se corre el programa con callgrind, el cual crea archivos en la carpeta donde se invoca con nombres como callgrind.PID-TH donde PID es el número de proceso y TH el número de hilo, si se pide separar las estadísticas de cada hilo ejecutado por el programa. Finalmente se requiere un programa de visualización que tome las estadísticas almacenadas en los archivos y los presente de forma que ayude a las personas a comprender el comportamiento del programa, como KCachegrind (en algunas distribuciones de Linux podría llamarse qcachegrind).

fig callgrind v4 isprime
Figura 10. Visualización de KCachegrind de las líneas que consumen más procesamiento

KCachegrind permite encontrar rápida y visualmente las líneas de código fuente que consumen más instrucciones de procesador, y por tanto, las líneas críticas que convienen optimizar, incluso en una base de código extensa. En la esquina superior izquierda de la Figura 10 muestra un gráfico de consumo por cada hilo de ejecución. En la esquina inferior izquierda muestra las subrutinas que han ejecutado más instrucciones de procesador en porcentajes, isPrime() está seleccionada. En la esquina superior derecha se muestra las líneas de código fuente de isPrime() que consumieron más instrucciones. Se puede ver la invocación a qSqrt() y el operador módulo (%) son los que causan casi la totalidad del procesamiento. Estos son los puntos críticos a optimizar para esta aplicación.

Ejemplo 11. Marcar la cerca [fence_profiling]

Suponga que se le contrata para optimizar una solución a un problema que debe encontrar el perímetro mayor para marcar una cerca en mapas. Ya existe una solución serial disponible en la carpeta fence/.

Encuentre las regiones de código que consumen mayor procesamiento y podrían ser las prioritarias de paralelizar. Use una herramienta de profiling para este fin.

j22-set, v23-set Video

Análisis estático y dinámico de código

📹

Problema de ejemplo: marcar la cerca

📹

Callgrind: herramienta de profiling para invocaciones y procesamiento

📹

Concepto de visualización. Procesos en segundo plano en el shell

📹

Kcachegrind: herramienta de visualización. Encontrar regiones de código que más consumen CPU

📹

Ejercicio 35 [fence_parallelization]

Diseñe una solución paralela al problema de marcar la cerca de la Ejemplo 11. Implemente una solución en C o C++ paralelizada. Compruebe que realmente obtuvo un incremento del desempeño. Puede usar la tecnología que guste, por ejemplo, Pthreads, Threads estándar, u OpenMP.

3.6.2. Gprof

Gprof es una herramienta que reporta el tiempo de pared que toma cada invocación de subrutina. Sin embargo, parece ir quedando en desuso posiblemente por prevalencia de otras herramientas como Perf. Los siguientes comandos resumen el uso de gprof:

# Compile a modified executable that measures function call durations
gcc -g -Wall -Wextra -pg -no-pie source.c -o executable

# Run the executable as usual, it will generate binary file gmon.out
./executable args

# Generate a report from the gmon.out binary log
gprof ./executable gmon.out > report.txt

3.6.3. Perf

La herramienta perf permite realizar variedad de análisis de rendimiento de los programas, sin afectar su tiempo de ejecución. Tampoco requiere modificar o recompilar el ejecutable en estudio. Es capaz de reportar las funciones que consumen más CPU, generar gráficos de uso de CPU, entre otras operaciones. Para obtener la duración total que tarda un programa se usa la acción stat de la forma:

perf stat ./executable args

Para que perf pueda monitorear otros programas, puede requerir darle permisos o configurar el sistema operativo para disminuir el nivel de seguridad del sistema. Este paso varía mucho de un sistema operativo a otro. En distribuciones basadas en Debian o Ubuntu, puede ejecutar los siguientes comandos con permisos de administración para instalar perf.

# Debian
apt install linux-perf
echo 1 > /proc/sys/kernel/perf_event_paranoid
echo kernel.perf_event_paranoid=1 > /etc/sysctl.d/local.conf

# Ubuntu
apt install linux-tools-common linux-tools-`uname -r`
echo 1 > /proc/sys/kernel/perf_event_paranoid
echo 'kernel.perf_event_paranoid = 1' >> /etc/sysctl.conf

3.7. Clústers de computadoras

Un clúster es una red de computadoras lo más homogéneas posibles: misma marca y modelo de procesador, igual cantidad de RAM, disco duro, mismo sistema operativo, misma versión del sistema operativo, compiladores, herramientas…​ de tal forma que un programa pueda correr de forma distribuida en paralelo sin hacerle modificaciones.

j22-set, v23-set Video

Clúster de Arenal. Nodo máster y esclavos

📹

Determinar cantidad de CPUs (lscpu). Núcleos físicos y virtuales. Hyperthreading

📹

Correr procesos en el clúster. Monitorear procesos (top). Terminar procesos (kill)

📹

3.8. Falso compartido (false sharing)

Ejemplo 12. Arreglo falso compartido [false_sharing_array]

Estudie y ejecute el programa dado en sus cuatro modos, usando los argumentos de línea de comandos 0 a 3. Todos los modos realizan la misma tarea de actualizar dos elementos de un arreglo cien millones de veces. En los modos 0 y 1 los dos elementos se actualizan de forma serial. En los modos 2 y 3 dos hilos concurrentemente actualizan los elementos, como se muestra en la siguiente tabla.

Modo Tipo Elemento 1 Elemento 2

0

Secuencial

El primero del arreglo

El segundo del arreglo

1

Secuencial

El primero del arreglo

El último del arreglo

2

Concurrente

El primero del arreglo

El segundo del arreglo

3

Concurrente

El primero del arreglo

El último del arreglo

Se espera que las versiones seriales tarden aproximadamente lo mismo y que las versiones concurrentes tarden aproximadamente la mitad de las versiones seriales. Sin embargo, esto no ocurre. Trate de explicar por qué.

Archivo Descripción

false_sharing_array.c

Actualiza cien millones de veces dos elementos de un arreglo.

Makefile

Permite compilar y ejecutar la solución. Puede usar make perf para estudiar el comportamiento del programa.

j10-nov, v11-nov Video

false_sharing_array: Análisis del problema

📹

false_sharing_array: Código inicial. Tiempo de ejecución de los 4 modos

📹

Arquitectura de la jerarquía de caché

📹

Rastreo de caché. Fallo de caché. Principio de localidad. Dirty bit. Coherencia de caché

📹

Falso compartido (false sharing). Modificación de memoria cercana y distante

📹

Análisis dinámico de fallos de caché (profiling). perf stat

📹

Relación entre los tipos de mapeo y el falso compartido

📹

Ejemplo 13. Registro falso compartido [false_sharing_record]

Estudie y ejecute el programa dado en sus cuatro modos, usando los argumentos de línea de comandos 0 a 3. Todos los modos realizan las mismas tareas: sumar usando un incremento en una variable compartida, e incrementar un contador compartido. En los modos 0 y 1 las dos tareas se realizan de forma serial. En los modos 2 y 3 la suma y el incremento se realizan de forma concurrente, como se muestra en la siguiente tabla.

Modo Tipo Tarea 1 Tarea 2

0

Secuencial

Suma leyendo el incremento compartido

Incrementa el contador compartido

1

Secuencial

Suma con una copia privada del incremento

Incrementa el contador compartido

2

Concurrente

Suma leyendo el incremento compartido

Incrementa el contador compartido

3

Concurrente

Suma con una copia privada del incremento

Incrementa el contador compartido

Se espera que las versiones seriales tarden aproximadamente lo mismo, y que las versiones concurrentes tarden aproximadamente la mitad de las versiones seriales. Sin embargo, esto no ocurre. Trate de explicar por qué.

Archivo Descripción

false_sharing_record.c

Suma leyendo e incrementa escribiendo cien millones de veces los campos de un registro compartido.

Makefile

Permite compilar y ejecutar la solución. Puede usar make perf para estudiar el comportamiento del programa.

j10-nov, v11-nov Video

false_sharing_record: Evitar el falso compartido con memoria privadas

📹

4. Concurrencia de tareas

Los dos tipos de problemas que resuelve el paradigma de programación concurrente son los que requieren incremento de desempeño y separación de asuntos.

  1. El incremento del desempeño se busca principalmente al optimizar algorimos que requieren mucho poder de cómputo o procesar grandes volúmenes de datos. Son de especial interés para otras disciplinas que requieren apoyo de la computación. Se busca principalmente maximizar el paralelismo de datos a través de entes de ejecución (hilos o procesos) y disminuir la cantidad de comunicación entre los entes.

  2. La separación de asuntos o concurrencia de tareas no busca tanto la optimización, sino que los entes realicen tareas distintas de forma correcta. Los problemas en esta categoría son de especial interés para la computación misma, dado que son aplicables a variedad de herramientas como sistemas operativos, motores de bases de datos, servidores web, entre otros. Típicamente los problemas en esta categoría buscan que la concurrencia de tareas genere resultados correctos a través de la aplicación de mecanismos de sincronización, por lo que se les suele también llamar problemas de sincronización.

Esta sección se concentra en problemas del segundo tipo de la lista anterior, muchos de los cuales tienen aspecto de acertijos y resultan muy interesantes para la persona profesional de la computación. Están basados en el libro libre The Little Book of Semaphores escrito por Allen B. Downey.

L26-set, k27-set Video

Concurrencia de tareas. El pequeño libro de los semáforos

📹

4.1. Primitivas

En la solución de problemas de sincronización se suele trabajar a nivel de diseño, lo que permite concentrarse en el problema de sincronización y abstraer los detalles del contexto en el que se aplica (ej.: persistencia en una base de datos) y los detalles de implementación (como lenguajes de programación y ambientes de concurrencia).

Existen varias notaciones empleadas para el diseño concurrente, como diagramas de flujo, UML, TLA+, cálculo-π, pseudocódigo, redes de Petri, entre otros. En el curso se usarán los últimos dos.

4.1.1. Algoritmos (pseudocódigo)

No existe una convención estándar para especificar algoritmos en pseudocódigo. Por el contrario, es común que los autores escojan sus propias convenciones. Existen estilos similares a un lenguaje de programación, a la notación matemática, o al idioma natural. En el curso se ha usado una notación que ha surgido de la creación de los docentes y estudiantes del mismo. En el siguiente listado se resume esta notación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// Declares a constant shared by all threads, operator := is for variables
const CONST_NAME = const_value

// Declares a new data type as an enumeration of constants
define Month as enumeration of
	jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
end enumeration

// Declares a new type of record, note the use of Uppercase
define Date as record of
	day as integer,
	month as Month,
	year as integer
end record

// Use {} for records. Use [] for arrays and associative-arrays
const CHRISTMAS = Date{25, dec, 0000}

// Main subroutine that will be executed by the main thread
procedure main()
	// Declares a local, private variable, data type is optional if it can be
	// inferred from initial_value
	declare local_var as data_type := initial_value
	// Declares a variable shared by all threads, stored in shared_data record
	// Its initial value is read as an integer from standard input
	shared thread_count := read_integer()
	// Create an array of boolean, char, integer, float, text, semaphore, record
	shared values as array of thread_count of integer initialized in 0
	// Concurrency control mechanisms are always shared
	shared a_mutex := create_mutex()
	// Semaphores receive an initial integer value
	shared a_semaphore := create_semaphore(5)
	// Create a secondary thread, returns a thread identifier
	// args is a list of comma-separated values passed as private variables
	declare thread1 := create_thread(routine1, args)
	// Create a team of threads, all of them will execute the same routine
	// concurrently. The routine will be called with the thread_number as first
	// parameter starting at 0. Extra arguments can be provided.
	// Returns an array of thread identifiers
	declare team := create_threads(thread_count, routine2, 5)
	// Secondary threads are implicitly joined before main() finishes, however
	// they can be joined/waited manually using the thread identifiers
	join_thread(thread1)
	join_threads(team)
end procedure

// Functions always return a value and has no side effects in process state
function routine1(params)
	// Arithmetic operators are similar to mathematics: div for integer division,
	// mod for integer modulus, and / for floating point division
	return 3 * mod(10, 4)
end function

// Procedure may not return a value and has side effects in process state
procedure routine2(my_rank, increment)
	// Note the flow control structures
	if condition then
		// Condition-loop
		while condition do
			statements
		end while
	else
		// Counter-loops, "for" implies declaration of new variables
		// "in steps of 1" is by default
		for index := 0 to thread_count in steps of 1 do
			values[my_rank] := index * my_rank + increment
		end for
	end if
end procedure

Cuando la notación de pseudocódigo no define un constructo específico, se toman características del lenguaje de programación usado en el curso, en este caso, C/C++.

L26-set, k27-set Video

Notaciones de pseudocódigo concurrente

📹

Ejemplo 14. Rutas de ejecución [execution_paths]

Dos threads ejecutan las siguientes tareas. Liste todas las rutas de ejecución indicando el valor final de la variable x y la salida estándar. Una ruta de ejecución (execution path) es una combinación de instrucciones que depende del orden en que sean ejecutadas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
procedure main()
  shared x := 0
  create_thread(thread_a)
  create_thread(thread_b)
end procedure

procedure thread_a()
  x := 5
  print(x)
end procedure

procedure thread_b()
  x := 7
end procedure
L26-set, k27-set Video

Rutas de ejecución concurrente

📹

Tabla 13. Rutas de ejecución [execution_paths]
Archivo Descripción Cambios

execution_paths.md

Lista todas las posibles rutas de ejecución.

4.1.2. Redes de Petri

Las redes de Petri (Petri net) fueron ideadas por el alemán Carl Adam Petri (1926-2010) a sus 13 años para describir procesos químicos, aunque su formalización ocurriría 23 años después, en su tesis doctoral de matemática en 1962. La aplicación de este concepto beneficia la representación de fenómenos que tienen componentes que actúan de forma concurrente o distribuida.

Una red de Petri es una representación matemática (un modelo) de la estructura y comportamiento dinámico de un sistema distribuido. Un sistema es un conjunto de componentes que interactúan de forma coordinada para realizar una actividad. El sistema es un concepto recursivo porque un sistema, con todos sus componentes, forma una unidad que puede ser parte de un sistema más grande, lo que crea una jerarquía o red de sistemas y subsistemas. Un sistema tiene estado (memoria), realiza actividades (procesamiento), e interacciona con otros sistemas (comunicación).

Cada componente en sí es un sistema que tiene un estado independiente al de otros componentes, lo que crea distribución del estado. El comportamiento futuro de un componente depende de este estado, y puede o no depender del pasado gracias a que el estado puede guardar historia. La actividad que un componente realiza podría ser simultánea a las actividades que realizan los otros componentes del sistema, lo que crea concurrencia o paralelismo de las actividades.

Es común que los componentes tengan que interactuar para lograr la actividad global del sistema, y esto requiere sincronización entre ellos para que los resultados sean correctos. Esta sincronización es normalmente una forma de espera (waiting), donde un componente detiene su actividad hasta la ocurrencia de un evento que asegure el orden correcto de actividades o la transferencia correcta de estado entre los componentes.

L26-set, k27-set Video

Redes de Petri. Modelado de sistemas concurrentes y distribuidos.

📹

execution paths
Figura 11. Red de Petri que representa las rutas de ejecución

Ejemplo 15. Agregar declaración de variable [execution_paths]

Al modelo de Petri de las rutas de ejecución le hizo falta la declaración de la variable commpartida shared x := 0. Modifique la red de Petri para agregar esta instrucción. Use la herramienta Workflow Petrinet Designer (WoPeD) que requiere del ambiente de ejecución de Java.

Tabla 14. Agregar declaración de variable [execution_paths]
Archivo Descripción Cambios

execution_paths.pnml

Red de petri que representa el mismo programa que el pseudocódigo dado.

Ejemplo 16. Rutas de ejecución extremas [for_count]

Suponga que el hilo de ejecución principal crea w=100 hilos secundarios con los siguientes códigos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
procedure main()
  shared count := 0
  create_threads(100, secondary)
end procedure

procedure secondary()
  for i := 0 to 100 do
    const temp := count
    count := temp + 1
  end for
end procedure
  1. ¿Cuál es el valor más grande que la variable compartida count podría llegar a obtener? ¿En qué rutas de ejecución se alcanza este valor?

  2. ¿Cuál es el menor valor que la variable compartida count podría llegar a obtener? ¿En qué rutas de ejecución se alcanza este valor?

L26-set, k27-set Video

Ejemplo de las rutas de ejecución en red de Petri. Lugares, transiciones y arcos

📹

Ejemplo for_count: encontrar rutas de ejecución que cumplen una condición

📹

Tabla 15. Rutas de ejecución extremas [for_count]
Archivo Descripción Cambios

for_count.md

Conjetura los potenciales resultados.

4.2. Patrones básicos

4.2.1. Avisar/notificar (signaling)

Ejemplo 17. Avisar/notificar [signaling]

Modifique los códigos de los hilos para que la instrucción a1 se ejecute siempre antes que la instrucción b1. Esto es, que un hilo envíe una señal (aviso) a otro (signaling).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
procedure main()
  create_thread(thread_a)
  create_thread(thread_b)
end procedure

procedure thread_a()
  statement a1
end procedure

procedure thread_b()
  statement b1
end procedure

Modifique la red de Petri para que el hilo a haga el aviso al hilo b:

signaling given
j29-set, v30-set Video

Ejemplo signaling (avisar/notificar) en pseudocódigo

📹

Ejemplo signaling (avisar/notificar) en red de Petri

📹

Tabla 16. Avisar/notificar [signaling]
Archivo Descripción Cambios

signaling.pseudo

Usa un semáforo que indica cuando la instrucción a1 ha sido ejecutada.

file diff

signaling.pnml

Red de petri que usa una un estado intermedio para representar el aviso.

signaling

Ejercicio 36 [signaling_3]

Modifique los códigos de los hilos para que la instrucción a1 se ejecute siempre antes que la instrucción b1 y ésta siempre se ejecute antes que la instrucción c1. Este orden de ejecución puede abreviarse como a1 < b1 < c1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
procedure main()
  create_thread(thread_a)
  create_thread(thread_b)
  create_thread(thread_c)
end procedure

procedure thread_a()
  statement a1
end procedure

procedure thread_b()
  statement b1
end procedure

procedure thread_c()
  statement c1
end procedure

Actualice la red de Petri para que el aviso se dé entre los tres hilos.

Ejercicio 37 [signaling_w]

Generalice el patrón de aviso (signaling) para dada una cantidad arbitraria w de hilos que ejecutan la instrucción a, lo hagan en el orden del número de hilo. Por ejemplo, si ai es la ejecución de la instrucción statement a por parte del hilo con número i, entonces se asegure que siempre se ejecute la secuencia a0 < a1 < a2 < a3 < …​ < aw.

1
2
3
4
5
6
7
8
procedure main()
  input shared thread_count
  create_threads(thread_count, secondary)
end procedure

procedure secondary(thread_number)
  statement a
end procedure

Nota: una solución a este problema se había hecho antes en el curso ¿recuerda en cuál ejemplo?

4.2.2. Encuentro (rendezvous)

Ejemplo 18. Encuentro [rendezvous]

Modifique los códigos de los hilos para que la instrucción a1 y b1 se ejecuten siempre antes que las instrucciones a2 y b2. Este problema tiene el nombre francés rendezvous que significa encuentro en un punto de ejecución, y ninguno de los dos hilos pueden continuar su ejecución hasta que el otro haya llegado.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
procedure main()
  create_thread(thread_a)
  create_thread(thread_b)
end procedure

procedure thread_a()
  statement a1
  statement a2
end procedure

procedure thread_b()
  statement b1
  statement b2
end procedure

Modifique la red de Petri para que ocurra el encuentro entre los hilos de ejecución:

rendezvous given
j29-set, v30-set Video

Encuentro (rendezvous): solución 1 en pseudocódigo [err]

📹

Encuentro (rendezvous): solución 2 en pseudocódigo [err]

📹

Encuentro (rendezvous): Bloqueo mutuo (deadlock)

📹

Encuentro (rendezvous): red de Petri

📹

Tabla 17. Encuentro [rendezvous]
Archivo Descripción Cambios

rendezvous1.pseudo

Usa dos semáforos para asegurar que dos threads han llegado a cierto punto. En esta solución, los dos threads avisan apenas hayan terminado de ejecutar las instrucciones a1 o b1. En esta solución el nombre del semáforo indica el evento ocurrido, como a1_ready, y es lo que se prefiere. Otro nombre válido es indicar el manejador del evento o acción que debe realizarse cuando la señal ocurre, como can_run_b2.

file diff

rendezvous2a.pseudo
rendezvous2b.pseudo

En esta versión un thread primero espera y el otro avisa. Es una solución correcta aunque ligeramente menos eficiente que la versión 1. Hay dos variantes equivalentes: a) El hilo a primero avisa y luego espera y el hilo b primero espera y luego avisa. b) El hilo a primero espera y luego avisa y el hilo b primero avisa y luego espera.

file diff file diff

rendezvous3.pseudo

En esta versión ambos threads esperan a que el otro haya terminado de ejecutar su instrucción. No es una solución al problema porque genera un bloqueo mutuo (deadlock).

file diff

rendezvous1.pnml

Un encuentro es una doble señal cruzada entre los hilos

rendezvous1

Ejercicio 38 [chess_vous]

En un torneo de ajedrez los participantes efectúan el siguiente itinerario tradicional. Los jugadores y árbitros se identifican para ingresar al centro deportivo (enter_room). Cada jugador ubica la mesa con su tablero y se sienta en ella. Una vez que ambos jugadores están presentes, avisan al árbitro. El árbitro establece el tiempo en el reloj (set_clock) del tablero y con ello ambos jugadores inician la partida (play_chess).

El problema se considera resuelto si el árbitro establece el reloj sólo hasta que los dos jugadores hayan ingresado al centro deportivo (enter_room) y los jugadores juegan (play_chess) hasta que el árbitro haya establecido el reloj (set_clock). Considere el siguiente código inicial que trata de representar el escenario descrito.

procedure main()
  shared player1_ready := semaphore(0)
  shared player2_ready := semaphore(0)
  shared players_ready := semaphore(0)
  shared clock_ready := semaphore(0)

  create_thread(player1)
  create_thread(player2)
  create_thread(referee)
end procedure

procedure player1()
  enter_room()
  play_chess()
end procedure

procedure player2()
  enter_room()
  play_chess()
end procedure

procedure referee()
  enter_room()
  set_clock()
end procedure

Agregue control de concurrencia al código inicial para considerando todas las posibles rutas de ejecución, el problema siempre esté resuelto.

4.2.3. Exclusión mutua con semáforos (mutex)

Ejemplo 19. Exclusión mutua con semáforos [sem_mutex]

Agregue semáforos al pseudocódigo siguiente para forzar a que los incrementos en los hilos se hagan con exclusión mutua.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
procedure main()
  shared count := 0
  create_thread(thread_a)
  create_thread(thread_b)
end procedure

procedure thread_a()
  count := count + 1
end procedure

procedure thread_b()
  count := count + 1
end procedure

Corrija la red de Petri para que los incrementos se realicen con exclusión mutua.

sem mutex given
j29-set, v30-set Video

Exclusión mutua con semáforos (sem_mutex): pseudocódigo

📹

Exclusión mutua con semáforos (sem_mutex): red de Petri

📹

Tabla 18. Exclusión mutua [sem_mutex]
Archivo Descripción Cambios

sem_mutex.pseudo

Un semáforo inicializado en 1 y que nunca supera este valor, es un semáforo binario. Es equivalente a un mutex excepto que no tiene dueño (ownership). Cuando el semáforo tiene el valor 1 indica que el mutex está disponible, y el valor 0 que está bloqueado. Se diferencia en que un mutex nunca supera el valor 1, mientras que un semáforo técnicamente puede hacerlo, y de que un mutex sólo puede ser incrementado por el mismo thread que lo decrementó.

file diff

sem_mutex.pnml

Un lugar (place) con un token del cual salen dos arcos sirve para crear exclusión mutua en una red de Petri, lo que corresponde a "bloquear" el mutex. Luego el token debe regresar a este lugar, lo que semánticamente equivale a "desbloquear" (unlock) el mutex.

sem mutex

Ejemplo 20. Exclusión mutua simétrica [sem_mutex_sym]

Note que en la actividad anterior ambos threads ejecutaban el código en subrutinas distintas, a lo que se le llama una solución asimétrica (separación de asuntos o concurrencia de tareas). Sin embargo, el código de las subrutinas era el mismo y por tanto podría convertirse en una solución simétrica. En una solución simétrica los hilos ejecutan el mismo código, es decir, la misma subrutina.

Las soluciones simétricas son a menudo más fáciles de generalizar. Agregue semáforos al pseudocódigo siguiente para forzar a que los incrementos hechos por una cantidad arbitraria de hilos sean con exclusión mutua.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
procedure main()
  shared count := 0
  input const thread_count
  create_threads(thread_count, secondary)
end procedure

procedure secondary()
  // Critical section
  count := count + 1
end procedure

Corrija la red de Petri para que una cantidad arbitraria de hilos (cinco en la red dada) hagan el incremento con exclusión mutua.

sem mutex sym given
L03-oct, k04-oct Video

Exclusión mutua simétrica (sem_mutex_sym): pseudocódigo

📹

Repaso de patrones básicos en redes de Petri

📹

Exclusión mutua simétrica (sem_mutex_sym): red de Petri

📹

Tabla 19. Exclusión mutua simétrica [sem_mutex_sym]
Archivo Descripción Cambios

sem_mutex_sym.pseudo

El semáforo usado adecuadamente obliga a una serialización de los hilos.

file diff

sem_mutex_sym.pnml

Se usa un place con un token que permite avanzar sólo a un hilo a la vez

sem mutex sym

4.2.4. Exclusión mutua acotada (multiplex)

Ejemplo 21. Exclusión mutua acotada [multiplex]

Modifique el pseudocódigo dado para que imponga una cota o límite superior de n hilos ejecutando una sección de concurrencia acotada. A este patrón se le llama multiplex y es útil para problemas donde se tienen distintos entes trabajando al mismo tiempo pero con máximo de capacidad.

Por ejemplo, la cantidad de cajeros atendiendo en las ventanillas de un banco o de clientes patinando en la pista del salón de patines. En este último caso, si la capacidad de la pista se agota, algunos clientes tendrán que esperar afuera, y apenas una persona sale de la pista, otra podrá ingresar de inmediato. Use el código siguiente para reflejar este comportamiento.

1
2
3
4
5
6
7
8
9
procedure main()
  input const skater_count, const room_capacity
  create_threads(skater_count, skater)
end procedure

procedure skater()
  // Concurrency-bounded region
  skate()
end procedure

Modifique la red de Petri para reflejar la llegada de 10 patinadores a un salón de patines que tiene una capacidad para 3 patinadores concurrentes.

multiplex given
L03-oct, k04-oct Video

Concurrencia acotada (multiplex): pseudocódigo

📹

Concurrencia acotada (multiplex): red de Petri

📹

Tabla 20. Exclusión mutua acotada [multiplex]
Archivo Descripción Cambios

multiplex.pseudo

Simplemente se inicializa el semáforo con el límite superior de threads permitidos concurrentemente. Si el semáforo llega a alcanzar el valor 0, indica que se agotó la capacidad concurrente y los hilos subsecuentes tendrán que esperar. Cuando un hilo incrementa el semáforo es porque "sale del salón" y deja espacio para que otro ingrese. ¿Cuál será el valor final del semáforo cuando todos los hilos hayan pasado por la sección crítica?

file diff

multiplex.pnml

Es la misma solución a sem_mutex_sym (Ejemplo 20) con la diferencia de que el semáforo en lugar de inicializarse en 1, lo hace en la capacidad de la región de concurrencia acotada.

multiplex

4.2.5. Barrera (barrier)

Ejemplo 22. Barrera de una pasada con semáforos [sem_barrier]
  1. Generalice su solución a la actividad rendezvous (Ejemplo 18) para asegurar que una cantidad arbitraria de hilos no continúen su ejecución hasta que todos hayan alcanzado (ejecutado) el punto de encuentro. Es decir, si se crean w hilos, los primeros w - 1 hilos que lleguen al punto de encuentro deberían esperar hasta que el hilo en la posición w llegue al punto de encuentro. En tal momento todos los w threads podrán continuar ejecutándose concurrentemente. A este patrón se le conoce como barrera (barrier). Implemente una barrera con semáforos en el siguiente pseudocódigo para que la instrucción Statement B se ejecute sólo hasta que todos los hilos hayan ejecutado la instrucción Statement A.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    procedure main()
      // How many threads have arrived to the barrier
      shared count := 0
      // Protects the increment of the count
      shared can_access_count := create_semaphore(1)
      // Locked (0) until all threads arrive, then it is unlocked (1)
      shared barrier := create_semaphore(0)
      // Read thread count from standard input
      input shared const thread_count
      // Create a thread team running secondary
      create_threads(thread_count, secondary)
    end procedure
    
    procedure secondary()
      Statement A
      // Adapt rendezvous solution here
      // Statement B can be only executed until all threads have run Statement A
      Statement B
    end procedure
    
  2. Agregue una barrera a la red de Petri para que los tres hilos ejecuten las instrucciones Statement B sólo hasta que los tres hayan ejecutado las instrucciones Statement A.

    sem barrier 3 given
  3. Generalice la red de Petri para que w hilos ejecuten las instrucciones Statement B sólo hasta que todos hayan ejecutado las instrucciones Statement A.

    sem barrier w given
L03-oct, k04-oct Video

Barrera con semáforos (sem_barrier): pseudocódigo

📹

Barrera con semáforos (sem_barrier): red de Petri 1 con join/fork

📹

Barrera con semáforos (sem_barrier): red de Petri 2 con arcos ponderados

📹

Metáfora de barrera: aguja de paso vehicular

📹

Tabla 21. Barrera de una pasada con semáforos [sem_barrier]
Archivo Descripción Cambios

sem_barrier.pseudo

Implementa una barrera de una pasada. Es decir, después de usada, la barrera no se debe reutilizar.

file diff

sem_barrier_3.pnml

Implementa una barrera para 3 hilos usando un esquema de fork/join

sem barrier 3

sem_barrier_w.pnml

Implementa una barrera para una cantidad arbitraria de hilos usando arcos ponderados (weighted arcs)

sem barrier w

Ejemplo 23. Barrera reusable con semáforos [sem_barrier_reusable]

Haga que su solución a la actividad Ejemplo 22 sea reusable. Es decir, que la barrera quede lista para volver a usarse, por ejemplo, dentro de un ciclo. Debe asegurarse de que el contador quede en 0, y los threads que salen de la barrera no se combinen con otros que aún están en ella.

Sugerencia: Puede reemplazar la barrera por dos torniquetes. Un torniquete (turnstile) o "trompo" es un dispositivo de control de acceso que impide o da paso a una secuencia de personas de acuerdo a ciertas condiciones. Se usa típicamente en instalaciones deportivas, transporte público, acceso a edificios, parques de diversiones, y otros.

Un torniquete es un semáforo que detiene a todos los hilos que lleguen a él. Pero apenas un hilo logre pasarlo, inmediatamente habilita el paso al siguiente. Puede pensarse como una manera más elegante de que el último hilo que llega a la barrera haga un ciclo de incrementos por todos los demás.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
procedure main()
  ...
  // Stops all threads, but the first one that passess it passess the rest
  shared turnstile := create_semaphore(0)
  ...
end procedure

procedure secondary()
  while true do
    Statement A
    ...
    // Use turnstile
    wait(turnstile)
    signal(turnstile)
    ...
    // Statement B can be only executed until all threads have run Statement A
    Statement B
  end while
end procedure
L03-oct, k04-oct Video

Problema de la barrera reusable (sem_barrier_reusable). Patrón torniquete o trompo (turnstile)

📹

Barrera diseñada con un torniquete (turnstile)

📹

Barrera reusable en un ciclo con dos torniquetes (turnstiles)

📹

Tabla 22. Barrera reusable con semáforos [sem_barrier_reusable]
Archivo Descripción Cambios

sem_barrier_reusable.pseudo

Usa dos torniquetes (trompos) para evitar que hilos de ejecución ávidos salgan de la barrera y vuelvan a ingresar a ella antes que otros hayan salido.

file diff

La solución a este problema permite reutilizar barreras. Por ejemplo, se puede crear una interfaz como la siguiente:

Tabla 23. Interfaz barrera reusable con semáforos [sem_barrier_reusable]
Archivo Descripción

sem_barrier_include.pseudo

Pseudocódigo que convierte la barrera en código reusable.

Para la solución de nuevos problemas se puede suponer que se tiene disponible barreras con la interfaz del siguiente listado, en el que se crea una barrera que se levanta cuando llegan 3 hilos de ejecución a esperar por ella.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
procedure main:
	shared my_barrier := create_barrier(3)
	...
end procedure

procedure secondary:
	...
	wait(my_barrier)
	...
end procedure
L03-oct, k04-oct Video

Interfaz en pseudocódigo concurrente para crear barreras

📹

4.2.6. Carrera de relevos (barrera)

Ejemplo 24. Carrera con relevos [relay_race]

Simule una carrera de relevos, donde los competidores son equipos de threads. Cada equipo consta de dos corredores. Un corredor tomará la estafeta (en inglés, baton) y se posicionará en la línea de salida. El otro corredor esperará en la mitad de la pista a que el primer corredor llegue y le transfiera la estafeta. En tal momento, el segundo corredor seguirá a toda velocidad hasta alcanzar la meta. Ganan únicamente los tres primeros equipos en llegar con la estafeta a la meta, quienes son premiados con oro, plata, y bronce.

Su simulación recibirá tres parámetros: la cantidad de equipos, la duración en milisegundos que tarda el primer corredor atravesando la etapa 1 de la carrera (entre la salida y la mitdad de la pista), y la duración en milisegundos del segundo corredor atravesando la etapa 2 de la carrera (de la mitad de la pista hasta la meta). Suponga que los corredores de una etapa tardan exactamente esta duración y no un valor generado al azar. Ejemplo de ejecución:

./relay_race 10 300 700
Place 1: team 2
Place 2: team 4
Place 3: team 1
Simulation time: 1.006079000s

En la salida anterior se reportan sólo los tres primeros equipos en llegar. Recuerde verificar la corrección de su solución (ej.: fugas de memoria, condiciones de carrera). Puede usar el siguiente código como punto de partida.

Archivo Descripción

relay_race.pseudo

Pseudocódigo inicial para la carrera de relevos.

relay_race.c

Código en C con Pthreads inicial para la carrera de relevos.

L03-oct, k04-oct Video

Carrera de relevos (relay_race): análisis del problema

📹

Carrera de relevos (relay_race): solución gráfica con metáforas

📹

Carrera de relevos (relay_race): solución en pseudocódigo

📹

Barreras en Pthreads

📹

Carrera de relevos (relay_race): implementación en Pthreads. _DEFAULT_SOURCE. Git Stash

📹

Tabla 24. Carrera con relevos [relay_race]
Archivo Descripción Cambios

relay_race.svg

Diseño de la simulación de carrera de relevos usando metáforas visuales.

relay race

relay_race.pseudo

Diseño en pseudocódigo de la simulación de carrera de relevos con equipos de hilos de ejecución.

file diff

relay_race.c

Implementación con Pthreads de la simulación de la carrera de relevos.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejercicio 39 [exc_]

¿Afecta el orden de creación de los equipos el resultado de la carrera de relevos? Modifique su solución de la Section 4.2.6 para crear los equipos en orden inverso y compare los resultados de las ejecuciones. Sugerencia: no duplique el código, sino que utilice metaprogramación (#ifdef…​) para escoger en tiempo de compilación el orden de creación de los equipos.

4.3. Variable de condición

Ejemplo 25. Misterio (variable de condición) [mist_cond_var]

Rastree la memoria y el procesamiento del siguiente programa en una copia de esta hoja de cálculo, la cual tiene precargado el programa. Exporte el rastreo a un PDF y agréguelo a su repositorio de ejemplos. ¿Qué trabajo realiza la función mistery()?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

typedef struct {
  unsigned int counter;
  unsigned int max;
  pthread_mutex_t mutex;
  pthread_cond_t cond_var;
} mist_t;

void mist_init(mist_t* mist, unsigned max) {
  mist->counter = 0;
  mist->max = max;
  pthread_mutex_init(&mist->mutex, NULL);
  pthread_cond_init(&mist->cond_var, NULL);
}

void mist_destroy(mist_t* mist) {
  mist->counter = 0;
  pthread_mutex_destroy(&mist->mutex);
  pthread_cond_destroy(&mist->cond_var);
}

void mistery(mist_t* mist) {
  pthread_mutex_lock(&mist->mutex);
  ++mist->counter;
  if (mist->counter < mist->max) {
    // Preferred: while ( pthread_cond_wait(...) != 0 ) /* empty */;
    pthread_cond_wait(&mist->cond_var, &mist->mutex);
  } else {
    mist->counter = 0;
    pthread_cond_broadcast(&mist->cond_var);
  }
  pthread_mutex_unlock(&mist->mutex);
}

static mist_t mist;

void* run(void* data) {
  fprintf(stderr, "%zu: before mist()\n", (size_t)data);
  sleep((unsigned)(size_t)data);
  mistery(&mist);
  fprintf(stderr, "%zu: after mist()\n", (size_t)data);
  return NULL;
}

int main() {
  mist_init(&mist, 3);

  pthread_t* workers = malloc(3 * sizeof(pthread_t));
  for (size_t index = 0; index < 3; ++index) {
    pthread_create(&workers[index], NULL, run, (void*)index);
  }

  for (size_t index = 0; index < 3; ++index) {
    pthread_join(workers[index], NULL);
  }

  mist_destroy(&mist);
  return 0;
}
j03-nov, v04-nov Video

mist_cond_var: enunciado. Rastreo de procesamiento. Supuestos

📹

mist_cond_var: rastreo de procesamiento parte 1

📹

Concepto de variable de condición. Metáfora del oficial de tránsito y el intercomunicador. Variables de condición en Pthreads

📹

mist_cond_var: rastreo de procesamiento parte 2

📹

Tabla 25. Misterio (variable de condición) [mist_cond_var]
Archivo Descripción Cambios

mist_cond_var.pdf

Rastreo de procesamiento en una hoja de cálculo del código misterio.

4.4. Filósofos comensales

En 1965 Edsger Dijkstra planteó el problema de los filósofos comensales como un ejercicio de un examen para estudiantes. Posteriormente Tony Hoare lo reformuló y se convirtió en un problema clásico de sincronización de procesos en los cursos de sistemas operativos. Existen varias adaptaciones como la que se muestra a continuación considerando algunos aspectos de higiene.

Cinco filósofos, que representan procesos o hilos de ejecución, están sentados a una mesa circular (Figura 12). Los filósofos en silencio piensan y luego comen, y repiten esta rutina eternamente. Ante cada filósofo se encuentra un plato, y al centro de la mesa un tazón con infinito abastecimiento de espagueti. Este abastecimiento representa un recurso compartido, como una cinta magnética en el problema original.

dining philosophers
Figura 12. Filósofos comensales

Para poder comer espagueti, en la mesa están dispuestos cinco tenedores (forks) o palillos chinos en el problema original (chopstiks). Tanto los filósofos como los palillos están enumerados de 0 a 4 como se ve en la figura Figura 12. Para poder comer, un filósofo i necesita los dos palillos (get chopstick), uno a su mano izquierda (i + 1) y el otro a su mano derecha (i), y no los comparte mientras está comiendo. Una vez que haya terminado de comer, el filósofo lava los palillos y los coloca de regreso en sus posiciones en la mesa (put chopstick). Un filósofo nunca sabe cuándo otro va a comer o pensar.

El reto es construir un algoritmo que permita a los filósofos eternamente pensar y comer que cumpla los siguientes tres requerimientos:

  1. Los filósofos no se detengan de pensar y comer (bloqueo mutuo o deadlock).

  2. Ningún filósofo muera de hambre esperando por un palillo (inanición o starvation).

  3. Dos o más filósofos puedan comer al mismo tiempo (concurrency).

Los filósofos ya saben cómo realizar las operaciones de pensar think() y comer eat(), ninguna de las dos dura eternamente, y no se debe detener a un filósofo mientras las realiza.

Ejemplo 26. Filósofos comensales [dining_philosophers]

Corrija el siguiente pseudocódigo de concurrencia de recursos compartidos para que resuelva el problema de los filósofos comensales, y por lo tanto, logre cumplir con los tres requerimientos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
procedure main(argc, argv[]):
  shared chopsticks[] := create_semaphores(5, 1)

  for id := 0 to 5 do
    create_thread(philosopher, id)
  end for
end procedure

procedure philosopher(id):
  while true do
    think()
    get_left_chopstick(id)
    get_right_chopstick(id)
    eat()
    put_left_chopstick(id)
    put_right_chopstick(id)
  end while
end procedure

procedure get_left_chopstick(id):
  wait(chopsticks[(id + 1) mod 5])
end procedure

procedure get_right_chopstick(id):
  wait(chopsticks[id])
end procedure

procedure put_left_chopstick(id):
  signal(chopsticks[(id + 1) mod 5])
end procedure

procedure put_right_chopstick(id):
  signal(chopsticks[id])
end procedure

Considere las condiciones que son necesarias para que el bloqueo mutuo o la inanición ocurran, y cambie una de ellas a la vez para generar distintas soluciones:

  1. Considere la lateralidad de los filósofos.

  2. Considere la cantidad de filósofos que pueden comer concurrentemente.

  3. Haga que los filósofos decidan con variables protegidas por exclusión mutua.

j03-nov, v04-nov Video

Filósofos comensales: Enunciado del problema (dining_philosophers)

📹

Filósofos comensales: análisis del psedocódigo inicial

📹

Filósofos comensales: solución 1: lateralidad (zurdos y diestros)

📹

Filósofos comensales: solución 2: cuota (capacidad de comensales concurrentes en la mesa)

📹

Filósofos comensales: Otras soluciones: con contadores, distribuida centralizada (mesero), y distribuida descentralizada.

📹

Filósofos comensales: solución en red de Petri (con inanición)

📹

Tabla 26. Filósofos comensales [dining_philosophers]
Archivo Descripción Cambios

dining_philosophers_laterality.pseudo

Solución 1: No todos los filósofos tienen la misma lateralidad

file diff

dining_philosophers_quota.pseudo

Solución 2: Hay una cuota o límite (de 4) en la cantidad de filósofos que pueden intentar comer al mismo tiempo, implementada por un multiplex

file diff

dining_philosophers.pnml

Red de petri que impide el bloqueo mutuo de los filósofos comensales, pero no la inanición.

dining philosophers

Ejercicio 40 [dining_philosophers_dist]

Idee una solución distribuida, y por lo tanto, mediante paso de mensajes, al problema de los filósofos comensales. Su solución puede ser:

  1. Centralizada, por ejemplo, con un proceso que toma el rol de mesero (árbitro).

  2. Descentralizada, donde los filósofos se ponen de acuerdo entre ellos para determinar quién come y quien espera en caso de conflicto por los palillos.

Ejercicio 41 [dining_philosophers_n]

El problema de los filósofos comensales original considera 5 filósofos. Generalice una solución concurrente (no distribuida) para que permita que una cantidad arbitraria de n ≥ 1 filósofos pensar y comer con max(n, 2) palillos. Su solución debe cumplir los requerimientos del problema.

Ejercicio 42 [dining_philosophers_impl]

Implemente su solución generalizada al problema de los filósofos comensales (Ejercicio 41 [dining_philosophers_n]) en alguna tecnología concurrente. Permita que el usuario especifique el rango mínimo y máximo de tiempo en milisegundos para pensar y comer. Haga impresiones cuando los filósofos piensan y comen tras una espera de duración aleatoria en el rango dado. Ejecute su solución para determinar que resuelve el problema.

4.5. Lectores y escritores

4.5.1. Problema de lectores y escritores

El problema de los lectores y escritores es de mucha importancia en la disciplina de la computación porque ocurre en varios escenarios, como sistemas operativos y bases de datos.

Ejemplo 27. Lectores y escritores [readers_writers]

Permita que varios lectores puedan leer de un medio común, pero sólo un escritor puede modificarlo a la vez. Por cada letra R que se lea de la entrada estándar se crea un hilo lector (reader), y por cada W que se lea de la entrada estándar se crea un hilo escritor (writer). Varios hilos pueden invocar la función read() al mismo tiempo, pero sólo un único hilo puede invocar write() a la vez. Esta puede considerarse una exlusión mutua categórica (categorical mutual exclusion) porque permite paralelismo de una categoría de hilos, pero excluye los de otra.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
procedure main()
  while true do
    case read_char() of
      'R': create_thread(reader)
      'W': create_thread(writer)
      EOF: return
    end case
  end while
end procedure

procedure reader()
  read()
end procedure

procedure writer()
  write()
end procedure
L07-nov, k08-nov Video

Lectores y escritores: Enunciado del problema

📹

Lectores y escritores: Solución en pseudocódigo

📹

readers_writers_rwlock: Código inicial

📹

Tabla 27. Lectores y escritores [readers_writers]
Archivo Descripción Cambios

readers_writers.pseudo

Diseño en pseudocódigo de la solución.

file diff

Ejercicio 43 [readers_writers]

Implemente su diseño al problema Ejemplo 27 usando semáforos en Pthreads u otra tecnología. Asegúrese de que su implementación genera los resultados deseados y no provoca anomalías de memoria ni de concurrencia.

Ejercicio 44 [readers_writers_no_starvation]

La solución al ejemplo Ejemplo 27 permite innanición (starvation) de los hilos escritores si en la entrada aparecen muchos lectores. Idee una solución en pseudocódigo que impida la innanición.

4.5.2. Candados de lectura-escritura

Ejemplo 28. Candados de lectura y escritura [readers_writers_rwlock]

Implemente una solución al problema de los lectores y escritores usando candados de lectura y escritura de Pthreads. Los escritores escriben en un contador compartido, y los lectores reportan el valor leído. Recuerde que varios lectores pueden trabajar al mismo tiempo mientras que en escritor necesita acceso exclusivo al recurso compartido. Su código no debe producir anomalías de memoria ni concurrencia. Puede usar el siguiente código como punto de partida.

Archivos Descripción

readers_writers_rwlock.7z

Contiene a los demás archivos

readers_writers_rwlock.c

Código inicial para la solución a los lectores y escritores con candados de lectura y escritura

Makefile

Makefile para facilitar la construcción de la solución con Pthreads

input001.txt
input002.txt

Casos de prueba de ejemplo

L07-nov, k08-nov Video

Candado de lectura y escritura. Metáfora del puente levadizo. rwlock de Pthreads

📹

Ejercicios de candados de lectura y escritura. Comparación de rendimiento entre rwlock vs mutex.

📹

Tabla 28. Candados de lectura y escritura [readers_writers_rwlock]
Archivo Descripción Cambios

readers_writers_rwlock.c

Los candados de lectura y escritura de Pthreads (pthread_rwlock_t) tienen una interfaz similiar a un mutex con la diferencia de que tienen dos operaciones de decremento: pthread_rwlock_rdlock() para los hilos que pretenden leer del medio y pthread_rwlock_wrlock() para quienes pretenden modificarlo.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

input001.txt
input002.txt

Casos de prueba de ejemplo. Son parte del código inicial dado.

Ejercicio 45 [rwlock_starvation]

Determine si los candados de lectura y escritura POSIX permiten innanición. Modifique el código de Ejemplo 28 para imponer una espera en el código protegido por el candado tanto para leer como para escribir. Permita indicar esta espera en milisegundos en argumentos de línea de comandos. Haga corridas con tantos hilos como le permite su sistema operativo. Si las escrituras ocurren sólo hasta el final de la ejecución de su programa, podría ser indicador de inanición.

Ejercicio 46 [readwr_vs_rwlock]

Implemente su solución a Ejercicio 44 [readers_writers_no_starvation] con alguna tecnología como Pthreads. Compare el rendimiento de su solución sin inanición al problema de los lectores-escritores contra los candados de lectura-escritura de Pthreads. ¿Cuál consigue el mejor rendimiento?

4.6. Reentrante vs thread-safe

Ejemplo 29. Azar reentrante [reentrant_rand]

Haga el mínimo de modificaciones a la siguiente implementación del generador lineal congruencial de números pseudoaleatorios para que sea:

  1. Thread-safe

  2. Reentrante y thread-safe

Archivos Descripción

reentrant_rand.c

Generación de números aleatorios que no es ni reentrante ni thread-safe

Makefile

Makefile para facilitar la construcción de la solución con Pthreads

j10-nov, v11-nov Video

Subrutinas reentrantes vs thread-safe

📹

Ejercicio 47 [reentrant_array]

Haga que la siguiente implementación de un arreglo dinámico en C sea reentrante. Asegúrese de que produzca la salida esperada y no genere fugas de memoria ni accesos inválidos (con valgrind o sanitizers):

Archivos Descripción

main.c

Agrega, imprime, y elimina elementos en el arreglo dinámico

array.h

Interfaz del arreglo dinámico

array.c

Implementación del arreglo dinámico (con errores)

Makefile

Makefile para facilitar la construcción de la solución con Pthreads

Ejercicio 48 [array_thrsafe_mutex]

Modifique su arreglo dinámico en C para que sea thread-safe. Proteja la implementación de cada subrutina pública con un mutex. Note que las subrutinas para incrementar y reducir la capacidad son privadas (no están declaradas en el encabezado .h). El mutex debe ser único para cada instancia de un arreglo, es decir, dos instancias del arreglo no comparten el mismo mutex.

Su implementación debe permitir al siguiente código de prueba correr correctamente sin generar fugas de memoria, accesos inválidos ni condiciones de carrera:

Ejercicio 49 [array_thrsafe_rwlock]

Provea una segunda implementación thread-safe de su arreglo dinámico en C. Provea un candado de lectura y escritura (pthread_rwlock_t) para cada instancia del arreglo dinámico. Proteja el código de cada subrutina que no modifica el arreglo bloqueando el candado para lectura. De la misma forma proteja el código de cada subrutina que modifica bloqueando el candado para escritura.

Su implementación debe permitir al código de prueba del ejercicio anterior correr correctamente sin generar fugas de memoria, accesos inválidos ni condiciones de carrera:

Ejercicio 50 [array_thrsafe_perf]

Copie sus dos implementaciones del arreglo dinámico thread-safe de los dos ejercicios anteriores a la carpeta array_thrsafe_perf. Renombre los símbolos y archivos de la implementación con mutex del prefijo array_ por array_mutex_ y la implementación con candados de lectura y escritura para iniciar con el prefijo array_rwlock_. De esta forma, las dos implementaciones pueden coexistir en un mismo ejecutable. Para probarlas puede usar el siguiente programa:

El programa de pruebas anterior recibe siete argumentos en la línea de comandos:

  1. La cantidad inicial de elementos en el arreglo (size_t).

  2. La cantidad de operaciones a realizar en el arreglo (size_t).

  3. El porcentaje de operaciones que son inserciones en el arreglo (double).

  4. El porcentaje de operaciones que son eliminaciones en el arreglo (double).

  5. El porcentaje de operaciones que son búsquedas en el arreglo (double).

  6. La cantidad de trabajadores (threads) a probar (size_t).

  7. El arreglo a probar ("mutex" o "rwlock").

De acuerdo al último argumento, el hilo principal crea un arreglo thread-safe protegido por un mutex o por un candado de lectura-escritura (rwlock). Luego el hilo principal inserta la cantidad inicial (primer argumento) de elementos aleatorios en el arreglo.

Luego el programa lanza la cantidad de hilos indicada en el sexto argumento. Los hilos se reparten la cantidad de operaciones indicada en el segundo argumento. Cada hilo procura distribuir sus operaciones sobre el arreglo de acuerdo a los porcentajes dados en los argumentos tres a seis. Los hilos escogen estas operaciones al azar, sin ningún orden definido.

Verifique que el programarealice las operaciones sin fugas de memoria o accesos inválidos. Haga varias corridas para replicar los experimentos de (Pacheco 2011 pp.188-9). Con la herramienta gprof (si lo prefiere puede usar perf) mida la duración de cada corrida.

Para ambos experimentos cree arreglos con 1000 elementos iniciales y realice un millón de operaciones con 1 hilo, 1xCPU, 2xCPU, y 4xCPU, donde CPU es la cantidad de CPU disponibles en la máquina donde realiza las pruebas. En el primer experimento realice 10% de inserciones, 10% de eliminaciones, y 80% de búsquedas en el arreglo. Para el segundo experimento realice 0.1% de inserciones, 0.1% de eliminaciones, y 99.8% de búsquedas. Anote las duraciones de cada corrida en una hoja de cálculo con la siguiente estructura:

Tabla 29. Duraciones

threads

distribution

array

1 thread

1xCPU

2xCPU

4xCPU

10-10-80

mutex

rwlock

.1-.1-99.8

mutex

rwlock

Elabore en su hoja de cálculo un gráfico con cuatro series (una por cada fila) que permita comparar el rendimiento de los mutex contra los candados de lectura-escritura (rwlock). ¿En qué circustancia recomendaría usted usar candados de lectura y escritura? Responda con un comentario en la celda "rwlock" correspondiente.

4.7. Otros problemas de sincronización

4.7.1. Formar agua

Este problema aparentemente está basado en un ejercicio de libro de programación concurrente de Gregory Andrews, y popularizado en el curso de sistemas operativos de la Universidad de California en Berkeley.

Ejemplo 30. Formar agua [build_h2o]

Hay dos tipos de hilos: los de hidrógeno y los de oxígeno. Su objetivo es formar agua con ellos. Usted dispone de un dispositivo, que cuando se coloca en él dos átomos de hidrógeno y uno de oxígeno, y se invoca bond() en cada uno de ellos, se forma agua. El dispositivo no funciona en cualquier otra combinación de átomos.

El dispositivo sólo puede trabajar en una molécula a la vez. Es decir, usted debe garantizar que los tres átomos de una molécula invoquen bond() antes de los hilos de la siguiente molécula. Escriba el código para cada átomo que garantice este comportamiento. Puede tomar el siguiente pseudocódigo como punto de partida.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
procedure main()
  while read atom do
    case atom of
      'H': create_thread(hydrogen)
      'O': create_thread(oxygen)
    end case
  end while
end procedure

procedure hydrogen()
  bond()
end procedure

procedure oxygen()
  bond()
end procedure
L07-nov, k08-nov Video

build_h2o: Enunciado. Solución con metáforas visuales

📹

build_h2o: Solución en pseudocódigo

📹

Tabla 30. Formar agua [build_h2o]
Archivo Descripción Cambios

build_h2o.pseudo 🖼

Solución propuesta por Jeisson Hidalgo. Utiliza multiplex y una barrera.

file diff

4.7.2. Parejas de baile (colas con semáforos)

Ejemplo 31. Parejas de baile (parte 1) [dancing_pairs_1]

En una academia de artes las personas aprendices están entrenando bailes criollos, los cuales se bailan en parejas de un hombre con una mujer. Como es natural, las personas bailarinas no llegan todas al mismo tiempo al salón. Cuando se aproximan a este salón hacen dos grupos o filas, una de hombres y otra de mujeres. Cuando en ambas filas hay personas, un hombre y una mujer se toman de la mano e ingresan al salón de baile.

Naturalmente si una o varias mujeres llegan a la fila y la fila de hombres está vacía, tendrán que esperar. Lo mismo ocurre si los hombres llegan y encuentran la fila de mujeres vacía.

Simule con hilos de ejecución a las personas bailarinas, su espera en las colas, y el ingreso al salón de baile. Inmediatamente que hayan ingresado, bailarán, lo cual es indicado por la subrutina dance(). Suponga que las personas bailarinas se leen de la entrada estándar donde W indica una mujer y M un hombre como muestra el siguiente código. Suponga además que el salón tiene capacidad ilimitada para el número de parejas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
procedure main()
  while read genre do
    case genre of
      'M': create_thread(male)
      'W': create_thread(female)
    end case
  end while
end procedure

procedure male()
  dance()
end procedure

procedure female()
  dance()
end procedure

Sugerencia. Un semáforo puede representar una cola de espera. Un valor inicial de 0 indica que la cola está vacía. Recuerde que un valor negativo indica la cantidad de hilos esperando en ella.

Ejemplo 32. Parejas de baile (parte 2) [dancing_pairs_2]

Si es necesario, modifique su solución al problema Ejemplo 31 para asegurarse de que realmente los bailarines entren en pareja al salón de baile. Es decir, que un hombre invoque dance() concurrentemente con una única mujer y viceversa. Suponga que se trata de un examen o audición, y por tanto, sólo una pareja puede estar en el salón de baile a la vez con el fin de que el jurado pueda calificarlos.

Ejercicio 51 [dancing_pairs_3]

Modifique su solución al problema Ejemplo 32 para asegurarse que los bailarines entren en pareja al salón de baile, el cual tiene capacidad ilimitada para la cantidad de parejas.

Ejercicio 52 [dancing_pairs_4]

Modifique su solución al problema Ejercicio 51 [dancing_pairs_3] para asegurarse que los bailarines entren en pareja al salón de baile, el cual tiene una capacidad limitada para la cantidad de parejas dada en argumentos de línea de comandos (o si lo prefiere, la entrada estándar).

Si el salón está lleno, las nuevas parejas deberán esperar a que se libere espacio. Las parejas en el salón bailan hasta cansarse. Para cada bailarín genere un número pseudoaleatorio que indica la cantidad de minutos que baila. Cuando se conforma una pareja, bailarán por el mínimo de minutos de sus integrantes.

Ejercicio 53 [dancing_pairs_5]

Modifique su solución al problema Ejercicio 52 [dancing_pairs_4] para que las parejas, después de bailar en el salón, retornen a la sala de espera. Una vez en la sala de espera, la pareja se disuelve y los integrantes pueden hacer pareja con otros, como si llegaran al salón por primera vez.

Reciba en la entrada estándar la cantidad mínima de minutos que un bailarín deberá estar en la pista de baile. En el momento en que todos los bailarines hayan cumplido este tiempo, la simulación termina. Puede suponer que la subrutina dance() retorna la cantidad de minutos bailados.

Ejercicio 54 [dancing_pairs_6]

Modifique su solución al problema Ejercicio 53 [dancing_pairs_5] para un baile criollo donde se forman, no parejas, sino equipos de 4 bailarines compuestos de dos hombres y dos mujeres.

4.7.3. Fumadores de cigarrillos

Originalmente presentado por Suhas Patil. Participan cuatro hilos:

  • Un agente que representa un sistema operativo que asigna recursos.

  • Tres fumadores que representan aplicaciones que necesitan los recursos para realizar su labor.

Los cuatro hilos están en un ciclo infinito. Los fumadores esperan por tres ingredientes que representan los recursos: papel, tabaco, y fósforos. Una vez que obtienen los tres ingredientes pueden fabricar un cigarrillo y fumarlo.

El agente tiene un suminitro infinito de todos los tres ingredientes, y cada fumador tiene un suministro infinito de uno de los tres ingredientes. Es decir, un fumador tiene sólo papel, otro tiene sólo tabaco, y el tercero tiene sólo fósforos. Sin embargo, los fumadores no se comunican entre ellos.

El agente repetidamente escoge dos ingredientes al azar y los hace disponible a los fumadores. El fumador con el tercer ingrediente debería recoger los dos disponibles y fabricar su cigarrillo. Por ejemplo, si el agente provee papel y tabaco, el fumador que tiene fósforos debería recogerlos y avisarle al agente que ya puede sacar otros dos ingredientes.

El reto de la solución es permitir a las aplicaciones, que cuentan con los recursos que necesitan, correr concurrentemente, y evitar despertar una aplicación que no puede proceder. Usted tiene el rol de un desarrollador de aplicaciones, quien no puede modificar el código del agente (sistema operativo). Esta es una restricción realista, dado que no se modifica el sistema operativo cada vez que alguien desarrolla una aplicación para el usuario.

El siguiente pseudocódigo provee la implementación del agente, el cual delega trabajo en sub-agentes que proveen los materiales. Se provee una implementación intuitiva para cada fumador. ¿Qué problema presenta esta solución?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
main:
	shared agent_sem := semaphore(1)
	shared match := semaphore(0)
	shared paper := semaphore(0)
	shared tobacco := semaphore(0)

	create_thread(agent)
	create_thread(smoker_with_matches)
	create_thread(smoker_with_paper)
	create_thread(smoker_with_tobacco)


agent:
	create_thread(agent_without_matches)
	create_thread(agent_without_paper)
	create_thread(agent_without_tobacco)

agent_without_matches:
	while true do
		wait(agent_sem)
		signal(paper)
		signal(tobacco)

agent_without_paper:
	while true do
		wait(agent_sem)
		signal(match)
		signal(tobacco)

agent_without_tobacco:
	while true do
		wait(agent_sem)
		signal(match)
		signal(paper)


smoker_with_matches:
	while true do
		wait(paper)
		wait(tobacco)
		make_cigarette()
		signal(agent_sem)
		smoke()

smoker_with_paper:
	while true do
		wait(match)
		wait(tobacco)
		make_cigarette()
		signal(agent_sem)
		smoke()

smoker_with_tobacco:
	while true do
		wait(match)
		wait(paper)
		make_cigarette()
		signal(agent_sem)
		smoke()

Ejemplo 33. Fumadores de cigarrillos [cigarette_smokers]

Corrija el pseudocódigo dado para que los agentes y fumadores logren su objetivo.

Sugerencia. La solución propuesta por Parnas usa tres hilos asistentes llamados pushers que responden a los avisos (signal) del agente, llevan cuenta de los ingredientes disponibles, y avisaan al fumador apropiado. Esta solución agrega las siguientes variables al código provisto:

1
2
3
4
5
6
7
8
9
main:
	shared mutex := semaphore(1)
	shared is_match := false
	shared is_paper := false
	shared is_tobacco := false

	shared match_sem := semaphore(0)
	shared paper_sem := semaphore(0)
	shared tobacco_sem := semaphore(0)

Las variables booleanas indican si un ingrediente está o no disponible en la mesa. Los pushers usan los semáforos con nombre de ingrediente para avisar al fumador con tal ingrediente que los ingredientes complementarios están disponibles.

5. Concurrencia compartida declarativa (OpenMP)

OpenMP (Open Multi-Processing) es una tecnología desarrollada por varias empresas para implementar paralelismo de datos de manera más declarativa, o menos imperativa que Pthreads. No está diseñada para concurrencia de tareas, por ejemplo, no dispone de semáforos. Es una especificación para C/C++ y Fortran que varios compiladores implementan. Conviene tener a mano la guía de referencia al iniciar con esta tecnología. Para los ejemplos de esta sección cree una carpeta openmp/ en su repositorio personal de control de versiones para el curso.

5.1. Hola mundo (región paralela, directivas)

El siguiente es un "Hola mundo" en OpenMP. En adelante se usará C++, aunque OpenMP puede usarse perfectamente con C. Para trabajar con esta tecnología se requiere comprender conceptos como los siguientes:

  1. Instrumentalización de código: el compilador modifica o inyecta código que no estaba en los programas de los desarrolladores.

  2. Directivas del preprocesador (pragma): son ignoradas por compiladores en los que no se ha habilitado la instrumentalización.

  3. Región paralela (omp parallel): siempre implica la creación de un equipo de hilos.

  4. Barrera o join: está implícita en muchas directivas de OpenMP, como es el caso tras el bloque de instrucciones que conforman la región paralela (omp parallel).

  5. En los Linux OpenMP usa Pthreads internamente. La instrumentalización traslada el código de la región paralela a una rutina, crea hilos con pthread_create(), y espera por ellos con pthread_join().

  6. El hilo principal no puede ejecutar trabajo durante la región paralela, a diferencia de Pthreads.

j06-oct, v07-oct Video

Concurrencia de tareas declarativa: OpenMP

📹

Hola mundo en OpenMP. Directivas del preprocesador. Instrumentalización de código con OpenMP

📹

Región paralela. Bloque estructurado

📹

Invocación de subrutinas en la región paralela

📹

Indeterminismo en la salida por los operadores de flujo de C++. Región crítica (mutex)

📹

OpenMP con Google Sanitizers y Valgrind

📹

Tabla 31. Hola mundo en OpenMP [hello]
Archivo Descripción Cambios

hello.cpp

Dice "hola mundo" desde hilos secundarios con OpenMP. En esta tecnología el hilo principal sólo crea y espera por los hilos secundarios por lo tanto, no puede realizar otra acción mientras los hilos secundarios están en ejecución.

Makefile

Agrega la bandera -fopenmp para habilitar la tecnología OpenMP. Se compara contra el ejemplo de Ejemplo 24.

file diff

Para compilar con GCC o un compilador compatible debe habilitarse instrumentalización de OpenMP con la opción -fopenmp. El Makefile del ejemplo incluye esta bandera:

gcc -g -Wall -Wextra -fopenmp source.c   -o executable
g++ -g -Wall -Wextra -fopenmp source.cpp -o executable

Para poder usar OpenMP con Valgrind (y en algunas circuntancias con Google Sanitizers) es necesario recompilar la biblioteca de OpenMP que viene con el compilador. Es decir, descargar el código fuente del compilador, compilarlo con banderas que indiquen que se quiere habilitar el soporte para Valgrind (o Sanitizers), y posiblemente instalar el compilador en el sistema o hacerlo funcionar en como una alternativa al oficial de la distribución de Linux.

5.2. Hola mundo múltiple (funciones de biblioteca)

Ejemplo 34. Hola mundo múltiple en OpenMP [hello_w]

Modifique el programa de "hola mundo" para que cada hilo de ejecución salude indicando su número y la cantidad de hilos en el equipo al que pertenece. Permita al usuario indicar la cantidad de hilos como argumento de la línea de comandos. Si el usuario omite este argumento, suponga la cantidad de CPUs disponibles en el sistema. La siguiente podría ser una salida hipotética.

Hello from secondary thread 1 of 4
Hello from secondary thread 2 of 4
Hello from secondary thread 0 of 4
Hello from secondary thread 3 of 4

Indague en la especificación cómo obtener el número de CPUs disponibles con OpenMP o bien, la cantidad sugerida de hilos a crear en una región paralela.

j06-oct, v07-oct Video

Ejemplo hello_w. Mutex nombrados. Cláusula num_threads. Biblioteca de subrutinas de OpenMP

📹

Ejemplo de un bloque no estructurado

📹

Tabla 32. Hola mundo múltiple en OpenMP
Archivo Descripción Cambios

hello_w.cpp

Cada hilo secundario saluda diciendo su número en el equipo (team) y la cantidad de miembros en el equipo.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Aunque OpenMP no está diseñado para la concurrencia de tareas (por ejemplo, no provee semáforos), se puede conseguir separación de asuntos asignando a los hilos secundarios tareas distintas de acuerdo a su número de hilo (rank), por ejemplo:

1
2
3
4
5
6
7
#pragma omp parallel num_threads(2)
{
  switch (omp_get_thread_num()) {
    case 0: produce(); break;
    case 1: consume(); break;
  }
}

5.3. Repartir las iteraciones (omp parallel for)

La directiva omp parallel crea siempre una región paralela, que implica la creación y destrucción (join) de hilos de ejecución (a menos de que esté anidada). La instrucción o bloque paralelizado es ejecutado por todos los hilos del equipo. La directiva omp parallel for siempre debe estar seguida por un ciclo por contador (for) estructurado (sin terminaciones abruptas como break, continue, y return). Por ser una región paralela (por la palabra parallel) también crea y destruye hilos, pero a diferencia de omp parallel, omp parallel for reparte las iteraciones del ciclo entre los hilos creados.

Ejemplo 35. Repartir las iteraciones [parallel_for]

Modifique el programa de Ejemplo 34 para repartir iteraciones entre hilos de ejecución. El programa debe permitir al usuario indicar en el segundo argumento opcional de la línea de comandos la cantidad de iteraciones. Si el usuario omite este argumento, tome la cantidad de hilos a crear, de tal forma que cada hilo tenga al menos una iteración con que trabajar.

El programa debe imprimir en la salida estándar el número de hilo y las iteraciones que realizó. Por ejemplo, para repartir 10 iteraciones entre 3 hilos, una salida podría ser:

$ bin/parallel_for 3 10
0/3: iteration 0/10
0/3: iteration 1/10
1/3: iteration 4/10
1/3: iteration 5/10
1/3: iteration 6/10
0/3: iteration 2/10
0/3: iteration 3/10
2/3: iteration 7/10
2/3: iteration 8/10
2/3: iteration 9/10

¿Qué tipo de mapeo aplicó por defecto OpenMP? Como buena práctica de programación, impida que OpenMP escoja automáticamente el tipo de memoria entre datos privados o compartidos (default(none)).

L10-oct, k11-oct Video

Repaso. Potencial concurrencia de tareas en OpenMP

📹

Ejemplo parallel_for: repartir iteraciones

📹

Restricciones: ciclo por contador y bloque estructurado

📹

Pasar valores a los hilos: default(none), datos privados, datos compartidos

📹

Tabla 33. Parallel-for en OpenMP [parallel_for]
Archivo Descripción Cambios

parallel_for.cpp

La directiva parallel for siempre define una nueva región paralelizada, y por tanto, que siempre crea una nueva cantidad dada de threads, y entre ellos se reparten las iteraciones de un ciclo por contador. Como cualquier región paralela, al finalizar su ejecución, todos los threads se unen al principal o invocador.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

5.4. Reutilizar hilos (omp for)

Si se tienen varias regiones parallel for una tras otra que utilizan la misma cantidad de hilos, es ineficiente crearlos y destruirlos cada vez que se ingresa y sale de estas secciones, en especial si se encuentran dentro de un ciclo, de ahí la utilidad de la directiva for.

Ejemplo 36. Reutilizar hilos [several_for]

Modifique el programa de Ejemplo 35 para realizar tres etapas o fases de iteración. Es decir, tres ciclos for independientes uno tras otro. En cada ciclo se realiza la misma cantidad de iteraciones.

Optimice el programa para reutilizar los hilos. Es decir, en cada ciclo no cree ni destruya hilos, sino que créelos antes del primer ciclo y destrúyalos después del tercer ciclo. En cada ciclo los hilos son reutilizados.

El programa debe imprimir en la salida estándar la etapa en la que se encuentra, el número de hilo y las iteraciones que realizó. Las etapas siempre deben aparecer en orden y separadas entre ellas por un único cambio de línea. Por ejemplo, para repartir 5 iteraciones entre 2 hilos en las tres etapas, una salida podría ser:

$ ./several_for 2 5
stage 1: 0/2: iteration 0/5
stage 1: 1/2: iteration 3/5
stage 1: 1/2: iteration 4/5
stage 1: 0/2: iteration 1/5
stage 1: 0/2: iteration 2/5

stage 2: 1/2: iteration 3/5
stage 2: 1/2: iteration 4/5
stage 2: 0/2: iteration 0/5
stage 2: 0/2: iteration 1/5
stage 2: 0/2: iteration 2/5

stage 3: 1/2: iteration 3/5
stage 3: 1/2: iteration 4/5
stage 3: 0/2: iteration 0/5
stage 3: 0/2: iteration 1/5
stage 3: 0/2: iteration 2/5
L10-oct, k11-oct Video

Enunciado de reutilizar hilos (several_for)

📹

Reutilizar hilos. Diferencia entre omp parallel for y omp for

📹

Barreras (omp barrier) y ejecución por un hilo al azar (omp single)

📹

Anuncio: Ejercicio de ordenamiento par-impar. Comandos gprof y perf

📹

Tabla 34. Parallel-for en OpenMP [several_for]
Archivo Descripción Cambios

several_for.cpp

La directiva for no define una nueva región paralela, y por tanto no crea una nueva cantidad de hilos ni los destruye. La directiva for debe estar dentro de una región paralelizada (parallel o parallel for) y puede repetirse tantas veces como guste. La directiva reparte las iteraciones del ciclo que le sigue entre los hilos que se crearon en la región paralela. Es decir, la directiva for reutiliza los hilos de la región paralela. La utilidad de esta directiva es evitar el overhead de crear y destruir hilos entre ciclos for, uno tras otro, en un código fuente. Implícitamente cada directiva omp for incluye una barrera tras la instrucción o bloque que afecta.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejercicio 55 [several_for_stages]

En el ejemplo several_for hay tres etapas (stages) de secciones omp for. Mofique este código para que el usuario pueda indicar la cantidad de etapas como tercer argumento de línea de comandos. Elimine el código redundante de la segunda y tercera etapa. Haga que su solución repita el código de una etapa, tantas veces como lo haya indicado el usuario. Asegúrese en cada etapa de repartir las iteraciones indicadas por el usuario en el segundo argumento de línea de comandos.

No debe crear ni destruir hilos de ejecución entre una etapa y otra. Todos los hilos deben ser creados antes de iniciar la primera etapa, y destruidos una vez que la última etapa ha sido ejecutada. Las salidas de una etapa no deben mezclarse con las salidas de la siguiente.

Ejercicio 56 [odd_even_sort_serial]

El siguiente código implementa el ordenamiento par-impar (odd-even transposition sort) de un arreglo a con n elementos. Rastree el algoritmo con el arreglo a = [7 4 6 8 3] (puede usar una tabla o dibujos de los arreglos, no es necesario rastrear segmentos de memoria).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
for (int phase = 0; phase < n; ++phase) {
  if (phase % 2 == 0) {
    for (int i = 1; i < n; i += 2) {
      if (a[i - 1] > a[i]) {
        swap(&a[i - 1], &a[i]);
      }
    }
  } else {
    for (int i = 1; i < n - 1; i += 2) {
      if (a[i] > a[i + 1]) {
        swap(&a[i], &a[i + 1]);
      }
    }
  }
}

Implemente una versión serial del ordenamiento par-impar (odd-even transposition sort) en una subrutina serial_odd_even_sort(size_t n, double arr[n]) que ordena un arreglo de números flotantes. Escriba un programa de pruebas main() que reciba por argumentos de línea de comandos el tamaño n del arreglo, crea un arreglo de números flotantes de doble precisión en la memoria dinámica, y los inicializa con valores aleatorios. El programa de pruebas ordena el arreglo con su implementación de serial_odd_even_sort().

Puede imprimir el arreglo en la salida estándar para comprobar que realmente se haya ordenado, pero luego deshabilite la impresión para realizar las pruebas de rendimiento. Veririque que su programa no provoque accesos inválidos ni fugas de memoria.

Ejercicio 57 [odd_even_sort_parallel_for]

Provea una solución concurrente con OpenMP del ordenamiento par-impar. Conjeture cuál o cuáles ciclos pueden ser paralelizados en este algoritmo de ordenamiento. Utilice la directiva parallel for para paralelizar el o los ciclos. Modifique el programa de pruebas para recibir como segundo argumento de línea de comandos la cantidad de hilos que deben emplearse. Asegúrese que su implementación produce resultados correctos y no genere fugas de memoria.

Ejercicio 58 [odd_even_sort_two_for]

Cree una segunda solución concurrente con OpenMP del ordenamiento par-impar. Cree un equipo de hilos y reutilícelo en él o los for que haya paralelizado. Utilice la directiva for para reutilizar el equipo de hilos. Asegúrese que su implementación produce resultados correctos y no genere fugas de memoria.

Ejercicio 59 [odd_even_sort_perf]

Con la herramienta perf mida el tiempo de ejecución de su implementación serial, parallel-for, y two-for del algoritmo de ordenamiento par-impar con un millón de elementos. Anote las duraciones obtenidas en una hoja de cálculo, con 1 hilo (1), tantos hilos como la mitad de CPUs disponibles (h) en el sistema, igual a la cantidad de CPUs (C), y dos veces la cantidad de CPUs disponibles (2C) en la máquina donde se realizaron las pruebas, la cual debe tener al menos 8 núcleos.

5.5. Descomposición y mapeo en OpenMP (schedule)

Ejemplo 37. Descomposición y mapeo en OpenMP [schedule]

Modifique el programa de Ejemplo 35 para repartir iteraciones entre hilos usando los tipos de mapeo (scheduling) provistos por OpenMP:

static
static,N
dynamic
dynamic,N
guided
guided,N

Recuerde que el programa debe permitir al usuario indicar la cantidad de hilos y de iteraciones como argumentos opcionales en la línea de comandos. Agregue un tercer parámetro que equivale al número N de la lista de arriba, conocido como el "tamaño de bloque" (chunk en terminología de OpenMP). Si se omite, use el tipo de mapeo sin este parámetro.

Cree un arreglo dinámico de enteros, uno para cada iteración. Haga que los hilos marquen en el arreglo las iteraciones que les fueron asignadas, con su número identificador. Por ejemplo si se reparten 7 iteraciones entre 3 hilos, y si al hilo 2 le correspondieron las iteraciones 5 y 6 establecería

arr[5] := 2
arr[6] := 2

Finalmente el programa debe imprimir en la salida estándar una tabla, con el tipo de mapeo en las filas, las iteraciones en las columnas, y los threads en las celdas. Por ejemplo, para repartir 10 iteraciones entre 3 threads, una salida sin N podría ser:

$ ./schedule 3 10
          0 1 2 3 4 5 6 7 8 9
static    0 0 0 0 1 1 1 2 2 2
dynamic   0 1 0 2 1 0 2 1 0 2
guided    0 0 0 0 1 1 2 2 0 1

La siguiente es una salida hipotética con N=2. Haga más corridas y trate de inferir el algoritmo de mapeo de cada planificación provista por OpenMP.

$ ./schedule 3 10 2
          0 1 2 3 4 5 6 7 8 9
static,2  0 0 1 1 2 2 0 0 1 1
dynamic,2 0 0 1 1 0 0 2 2 1 1
guided,2  0 0 0 0 1 1 2 2 0 0
L10-oct, k11-oct Video

Descomposición (iteraciones) y mapeo (scheduling) en OpenMP. Mapeo estático por bloque

📹

Mapeo dinámico y mapeo guiado en OpenMP

📹

Mapeo guiado (guided scheduling). Diferencias entre GCC y Clang

📹

Parámetro tamaño de bloque en los mapeos de OpenMP. Mapeo cíclico

📹

Tabla 35. Mapeo (scheduling) en OpenMP [schedule]
Archivo Descripción Cambios

schedule.cpp

Reparte iteraciones entre hilos y guarda el rastro de qué hilo las ejecutó.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejemplo 38. Mapeo en tiempo de ejecución en OpenMP [runtime_schedule]

Cree una versión reducida de Ejemplo 37 que escoja el tipo de mapeo (scheduling) en tiempo de ejecución con la variable ambiente OMP_SCHEDULE. Por ejemplo:

$ OMP_SCHEDULE=static bin/runtime_schedule 3 10
runtime    0 0 0 0 1 1 1 2 2 2

$ OMP_SCHEDULE=static,1 bin/runtime_schedule 3 10
runtime    0 1 2 0 1 2 0 1 2 0
j13-oct, v14-oct Video

Mapeo en tiempo de ejecución (runtime). Variables de ambiente (entorno)

📹

Tabla 36. Mapeo en tiempo de ejecución en OpenMP [runtime_schedule]
Archivo Descripción Cambios

runtime_schedule.cpp

Escoje el tipo de mapeo en tiempo de ejecución de acuerdo al valor de una variable ambiente

file diff

Makefile

Facilita la construcción y prueba de la la solución.

5.6. Reducciones (reduction)

El paralelismo contribuye en incrementar la velocidad en que la máquina procesa colecciones de datos. Dos operaciones comunes derivadas del paradigma de programación funcional con la transformación de los valores de una colección en otros valores (map), y la reducción de los valores de una colección a uno sólo (reduce).

Ejemplo 39. Promedio concurrente con control de concurrencia [average_mutex]

Haga un programa que lea un conjunto arbitrario de números flotantes de la entrada estándar, calcule concurrentemente el promedio y lo imprima en la salida estándar. La cantidad de números no se conoce de antemano. Para evitar la condición de carrera, use control de concurrencia (exclusión mutua). Sugerencia: cree primero una versión serial. Mida el rendimiento de una ejecución serial y la concurrente. El siguiente ejemplo de ejecución calcula el promedio de los primeros 10 millones de números.

seq 1 10000000 | perf stat bin/average_mutex
j13-oct, v14-oct Video

Ejemplo de calcular el promedio (average_atomic): versión serial

📹

Tabla 37. Promedio concurrente con control de concurrencia [average_mutex]
Archivo Descripción Cambios

average_serial.cpp

Calcula el promedio de números leídos de la entrada estándar de forma serial.

average_mutex.cpp

Calcula el promedio de números leídos de la entrada estándar con varios hilos serializados con exclusión mutua. Esta implementación es menos eficiente que la versión serial.

file diff

Makefile include.mk

Reutiliza el archivo de Makefile con un enlace simbólico y un archivo de inclusión.

Ejemplo 40. Promedio concurrente con variables privadas [average_private]

La solución al ejemplo anterior tiene una serialización innecesaria provocada por el control de concurrencia para evitar una condición de carrera. Una alternativa es usar seguridad condicional (conditionally safety) ¿Qué diseño propondría usted para aplicar los beneficios de la seguridad condicional a la solución de este problema?

Una forma de crear seguridad condicional es con variables privadas declaradas en la pila de cada hilo de ejecución. Utilice este esquema para calcular sumas parciales. Aplique control de concurrencia para calcular la suma total.

j13-oct, v14-oct Video

average_atomic: solución con mutex: serialización innecesaria

📹

average_atomic: seguridad condicional con variables privadas

📹

Tabla 38. Promedio concurrente con variables privadas [average_private]
Archivo Descripción Cambios

average_private.cpp

Cada hilo calcula su suma parcial en una variable local privada con los elementos del arreglo que le correspondieron. Usa exclusión mutua para agregar su suma parcial a la total.

file diff

Makefile

Reutiliza el archivo de Makefile mediante una inclusión y sobrescribir una variable.

Ejemplo 41. Promedio concurrente con operaciones atómicas [average_atomic]

En el ejemplo anterior, la región crítica consta de una operación muy elemental, que los procesadores pueden ejecutar de forma excluyente entre sus núcleos, gracias a las operaciones atómicas en su conjunto de instrucciones. Lenguajes de programación como C11/C++11 disponen de decoraciones para instruir al compilador a usar operaciones atómicas. La tecnología OpenMP también dispone de este constructo. Use operaciones atómicas de OpenMP para reemplazar el control de concurrencia en el cálculo de la suma total.

j13-oct, v14-oct Video

average_atomic: operaciones atómicas

📹

Tabla 39. Promedio concurrente con operaciones atómicas [average_atomic]
Archivo Descripción Cambios

average_atomic.cpp

Usa una operación atómica del procesador que es más eficiente que un mecanismo de control de concurrencia provisto por el sistema operativo.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejemplo 42. Reducciones en OpenMP [average_reduction]

Modifique su solución a Ejemplo 41 para calcular el promedio de forma paralela usando reducciones de OpenMP. Compare su código resultante con la versión serial original.

j13-oct, v14-oct Video

average_reduction: Reducciones en OpenMP

📹

Tabla 40. Reducciones en OpenMP [average_reduction]
Archivo Descripción Cambios

average_reduction.cpp

Utiliza la notación compacta de las reducciones, que es semejante a la versión serial más una decoración con directivas.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

Ejercicio 60 [desc_stats]

Haga un programa que lea un conjunto arbitrario de números flotantes de la entrada estándar, calcule concurrentemente y en una pasada por el arreglo los siguientes estadísticos: valor mínimo, promedio, desviación estándar, y máximo. Imprima cada estadístico en la salida estándar. La cantidad de números no se conoce de antemano. Use reducciones de OpenMP.

Ejercicio 61 [desc_stats_median]

Modifique su solución al problema anterior para agregar la mediana. Imprímala antes del promedio. Indague en su lenguaje de programación cómo ordenar concurrentemente el arreglo.

5.7. Tareas (task)

OpenMP permite fácilmente repartir las iteraciones de ciclos por contador (for estructurados). Sin embargo, los ciclos por contador no son la única forma de repetición que podría necesitarse paralelizar, también existen ciclos por condición (while y for no estructurados) y la recursión (ej.: el recorrido por una lista o árbol). Para poder paralelizar este tipo de repeticiones, OpenMP v3 introdujo el concepto de tareas y OpenMP v4 grupos de tareas.

Dentro de una región paralela, algunos o todos los hilos pueden crear tareas siempre y cuando sean independientes entre sí. Las tareas se ejecutarán luego en cualquier orden. Cada vez que un hilo encuentre una directiva #pragma omp task, el hilo creará una tarea y la pondrá en una cola para su ejecución posterior. Cuando un hilo llega al final de la región paralela, sacará repetitivamente tareas de la cola y las ejecutará. Las tareas pueden crear otras tareas que serán también colocadas al final de la cola. Una vez que la cola esté vacía, ya no quedará trabajo pendiente en la región paralela y el hilo se destruirá (join).

Ejemplo 43. Promedio concurrente con control de concurrencia [shuffle_sentence]

Escriba un programa que lea palabras de la entrada estándar hasta que estas se acaben. Piense que cada palabra es una tarea y las tareas deben hacerse de forma concurrente. El objetivo con cada tarea es simplemente imprimir la palabra en la salida estándar, separada de otras por un espacio en blanco, de manera que se forme una oración con indeterminismo (con sentido o no). Su programa debe crear tareas de OpenMP que respeten estas reglas:

  1. La primera palabra leída de la entrada estándar debe siempre imprimirse de primero.

  2. Las demás palabras se imprimen indeterminísticamente en cualquier orden separadas por un espacio en blanco.

  3. La oración siempre debe terminar en punto.

Todas las tres reglas anteriores deben ser realizadas por tareas de OpenMP. Ejemplos de ejecución:

$ echo El niño pobre | bin/shuffle_sentence
El pobre niño.

$ echo El del niño mordió al papá perro | bin/shuffle_sentence
El papá mordió al perro del niño.

$ echo El del niño mordió al papá perro | bin/shuffle_sentence
El mordió perro al papá niño del.

Puede tomar el siguiente código como punto de partida.

Tabla 41. Código inicial para barajar la oración.
Archivos Descripción

shuffle_sentence.cpp

Código C++ inicial para barajar la oración.

Ejercicio 62 [omp_mergesort]

Utilice tareas de OpenMP para paralelizar el algoritmo mergesort provisto:

Tabla 42. Código inicial para ordenamiento paralelo
Archivos Descripción

mergesort.7z

Código C++ inicial que implementa mergesort serial.

Registre el incremento del desempeño tomando como línea base una ejecución serial sin salida que tarde más de 10 segundos, algo como:

$ perf stat bin/mergesort 50000000 8 0

6. Distribución simétrica (MPI)

El paradigma de programación distribuido permite crear programas escalables que aprovechan máquinas que pueden correr procesos y pueden comunicarse a través de redes de computadoras. En la concurrencia de recursos compartidos, los hilos pueden acceder a la misma memoria y el mismo reloj sin intervención del sistema operativo. En la concurrencia de recursos distribuidos, los procesos tienen cada uno su propia memoria exclusiva y los relojes pueden ser distintos, por lo que tienen que comunicarse a través de los mecanismos provistos por el sistema operativo para intercomunicación de procesos, normalmente el paso de mensajes.

Existen dos variantes de la distribución. En la distribución heterogénea, los ambientes en el que corre el programa son distintos: diferente hardware, sistema operativo, huso horario, etc., lo que forma una malla de computadoras. En la distribución homogénea, el ambiente (tanto el hardware como el software) debe ser idéntico para todos los procesos del programa, lo que se llama un clúster de computadoras. La distribución homogénea logra conseguir los mayores niveles de paralelismo.

L17-oct, k18-oct Video

Paradigma de programación distribuida

📹

Repaso de distribución

📹

Especificación MPI. Implementaciones: MPICH, Open MPI. Instalación de Open MPI

📹

Correr comandos con mpiexec en la máquina local, la máster, y nodos esclavos

📹

6.1. Interfaz de paso de mensajes (MPI)

Message Passing Interface (MPI) es una tecnología de distribución homogénea creado por un grupo de académicos con el fin de facilitar el parelismo de aplicaciones científicas, que se convirtió en un estándar de facto. Existen otras tecnologías como Charm++ de más alto nivel.

Cree una carpeta mpi/ en su repositorio de control de versiones. Para el pseudocódigo se utilizará la siguiente convención de paso de mensajes:

Pseudocódigo de paso de mensajes
// Global constants
process_number    // Number of this process (integer)
process_count     // Number of processes in the team this process belongs to
process_hostname  // Name of the computer where this process is running (text)
any_process       // For accepting messages from any process
any_tag           // For accepting messages classified with any tag

// Send a variable or array (a set of bytes) to the destination process
send(message, count, to_process, tag = 0)
// Receive a set of bytes into a variable from a source process
receive(&message, count, from_process = any_process, tag = any_tag)
// Send a variable or array from the source process to the rest
broadcast(&message, count, from_process)
// Reduce the data value from all processes to a single result value in
// destination process applying the given operation
reduce(data, &result, count, operation, to_process)
// Reduce the data value from all processes to a single result value that will
// be available in all processes
all_reduce(data, &result, count, operation)
// Distributes an array into subsets for each process using block mapping
scatter(entire_array, entire_count, &subset_array, &subset_count, from_process)
// Updates the entire array from the subsets that where assigned to each process
gather(&entire_array, entire_count, subset_array, subset_count, to_process)
// Get the wall time for the calling process
wall_time()
// Make all processes of a team wait until all of them have reached the barrier
wait_barrier()

6.2. Concepto de proceso

Un proceso es un programa en ejecución. De un mismo ejecutable se pueden crear múltiples procesos, por ejemplo, correr varias instancias de un procesador de palabras para editar archivos distintos. Cada instancia sería un proceso independiente. Si uno de ellos produce una excepción, los demás continuarán ejecutándose. Los procesos permiten resolver problemas que requieren separación de asuntos o incremento de desempeño. La tecnología MPI está diseñada para facilitar la creación de procesos para incrementar el desempeño (paralelismo de datos).

6.2.1. Hola mundo en MPI

Ejemplo 44. Hola mundo en MPI [mpi_hello]

Haga que cada proceso del equipo salude, indicando su número, la cantidad de procesos miembros del equipo, y la máquina desde la que se ejecuta.

Hello from main thread of process 0 of 3 on hostname1
Hello from main thread of process 2 of 3 on hostname2
Hello from main thread of process 1 of 3 on hostname2

Dado que existe indeterminismo por la salida estándar, la salida que su programa obtenga sea distinta a la anterior.

L17-oct, k18-oct Video

Ejemplo hello (saludo distribuido). Solución en pseudocódigo

📹

Documentación de MPI. MPI_Init() y MPI_Finalize(). El comunicador (mpiexec). El mundo (equipo de procesos).

📹

Process rank (número de proceso). Tamaño del mundo (cantidad de procesos). Nombre del procesador (nombre de la máquina)

📹

Reusar Makefile. Directiva include. Guiones mpicc y mpic++

📹

Correr localmente el ejemplo de hola mundo. Errores de Sanitizers y Valgrind.

📹

Pregunta: Depuración y análisis de código distribuido. Bitácoras. Monitoreo. Visualización

📹

Correr el ejemplo de hola mundo en el clúster con MPICH. Mapeo cíclico con bloques (slots) de procesos a nodos

📹

Correr el ejemplo de hola mundo en el clúster con Open MPI. Mapeo por bloque (slots) de procesos a nodos

📹

Editar archivos remotamente: nano, vi, extensiones de IDE. sshfs

📹

Tabla 43. Hola MPI [hello]
Archivo Descripción Cambios

hello.pseudo

Diseño en pseudocódigo de la solución.

hello.cpp

Dice "hola mundo" desde uno o varios procesos en una o varias máquinas.

Makefile

Reutiliza el archivo de Makefile usando la directiva include y sobrescribe las variables del compilador para usar los scripts mpicc y mpic++.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/hello

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/hello

6.2.2. Compilación y ejecución de programas con MPI

Para compilar programas con MPI se requiere instalar una implementación de MPI como MPICH (preferible) u Open MPI. Conviene instalarlo también en su computadora personal. Si su extensión es basada en Debian, puede instalar MPICH con los comandos:

sudo apt update
sudo apt install mpich

Para compilar se debe invocar al compiler wrapper mpicc o mpic++ el cual pasa parámetros al compilador de GCC para que ubique los encabezados y las bibliotecas del MPI instalado, por ejemplo:

mpicc -g -Wall -Wextra file.c -o app

Si usa el Makefile genérico, puede indicarle que invoque a los wrappers en lugar del compilador de GCC, sobrescribiendo las variables CC (C-Compiler) y XC (C++ Compiler):

include ../../common/Makefile

CC=mpicc
XC=mpic++

Si se quiere que el programa corra en varios nodos de un clúster, se requiere un archivo que los liste, uno por línea, además el tamaño de bloque, que indica la antidad de la cantidad de procesos a asignar por nodo (slots). La sintaxis varía dependiendo de la implementación. Para MPICH se indica la cantidad de procesos en la misma línea separado por dos puntos (:) por ejemplo:

node1:4
node2:4
node3:2

El archivo anterior indica que se quieren correr 4 procesos en la computadora cuyo nombre es node1, cuatro procesos en node2, y 2 procesos en node3. La implementación Open MPI usa el texto slots= para indicar la cantidad de procesos en cada nodo. El ejemplo anterior se escribiría:

node1 slots=4
node2 slots=4
node3 slots=2

Es común que en los clústers de computadoras se ofrezcan archivos preconfigurados como los anteriores o preferiblemente se usen sistemas de colas. Por ejemplo, para el clúster de Arenal de la ECCI, se ofrecen copias en la carpeta /etc/skel/, la cual se copia cuando se crean las cuentas de usuario, por lo que probablemente disponga de ellos en su carpeta de usuario.

Ambas tecnologías reparten los procesos usando un mapeos estáticos distintos. Los números en el archivo de hosts corresponden a tamaños de bloque. Por ejemplo, en MPICH si se crean 13 procesos se asignarán los primeros 4 al node1, los siguientes 4 al node2, los siguientes 2 al node3, y los restantes 3 al node1 como se ve a continuación:

node1: 0 1 2 3 10 11 12
node2: 4 5 6 7
node3: 8 9

Para correr un programa en un clúster con MPICH, se usa el comando

mpiexec -np P -f hosts.txt bin/my_program args

donde P es la cantidad de procesos que se quieren correr en el clúster. En Arenal MPICH está cargado por defecto. Los siguientes comandos cambian el ambiente a Open MPI (comando module) y nótese cómo los argumentos para mpiexec difieren para esta tecnología:

module clear
module load compilers/gcc-9.4.0
module load mpi/openmpi-4.1.1
mpiexec --oversubscribe --bind-to none -np P --hostfile hosts.txt bin/my_program args

El parámetro --oversubscribe indica a Open MPI que permita crear más procesos que la cantidad de procesos indicados en el archivo de hosts. El parámetro --bind-to indica cuál es el valor de referencia para establecer el máximo de hilos a permitir por proceso. Por defecto se usa la cantidad de procesadores disponibles, mientras que el valor none deshabilita este tope y permite a los programas o usuarios escoger la cantidad de hilos sin restricciones.

Ejercicio 63 [mpi_wrapper]

Escriba una clase reutilizable Mpi en C++ que encapsule funcionalidad repetitiva del estándar MPI y provea un buen manejo de errores. Dado que usará plantillas, su clase estará en un archivo Mpi.hpp. En ejercicios posteriores usted incrementará y reutilizará esta clase.

La clase debe tener al menos los atributos indicados en el pseudocódigo propuesto para ejecución distribuida (Pseudocódigo de paso de mensajes), esto es: número de proceso (int), cantidad de procesos en el equipo (int), y nombre de la máquina donde corre el proceso (std::string).

Provea un constructor que reciba referencias a los argumentos de línea de comandos, inicialice el ambiente de ejecución de MPI, y llame funciones de MPI para inicializar los atributos de la clase. Lance una instancia de std::runtime_error en el constructor si no puede inicializar el ambiente de MPI u obtener el valor para un atributo. Provea un mensaje descriptivo en cada excepción. Provea un destructor que termine la ejecución del ambiente MPI, pero no lance excepciones en caso de fallo.

Para cada atributo de la clase provea un método get en línea (inline), pero no provea métodos set. Provea además un método rank() para obtener el el número de proceso, y un método size() para obtener la cantidad de procesos en el equipo.

Documente la clase, atributos, y métodos con Doxygen. Agregue a la carpeta un archivo main.cpp con el siguiente código que debería producir el mismo resultado que el Ejemplo 44. Pruebe que al ejecutarlo con varios procesos, estos saluden de forma indeterminística.

int main(int argc, char* argv[]) {
  try {
    Mpi mpi(argc, argv);
    std::cout << "Hello from main thread of process " << mpi.getProcessNumber()
      << " of " << mpi.getProcessCount() << " on " << mpi.getHostname()
      << std::endl;
  } catch (const std::exception& error) {
    std::cerr << "error: " << error.what() << std::endl;
  }
}

6.3. Distribución híbrida (proceso-hilo)

Los procesos (distribución) no ejecutan código, sino los hilos (concurrencia) dentro de sí, y los hilos no pueden existir si no es dentro de un proceso. Dado que se puede crear una cantidad arbitraria de procesos y en cada uno de ellos, una cantidad arbitraria de hilos, se tiene un modelo híbrido de ejecución distribuida-concurrente que aproveche las ventajas de cada paradigma.

6.3.1. Procesos e hilos de ejecución

Ejemplo 45. Distribución híbrida [hello_hybrid]

El ejemplo de "Hola mundo" lanza un único hilo principal (main thread) en cada proceso ejecutado. Modifique el programa para que cada proceso lance tantos hilos secundarios (secundary thread) como CPUs hay disponibles en la máquina donde se ejecuta. Cada hilo secundario debe imprimir en la salida estándar una línea antecedida por un tabulador en la que salude al usuario. Por ejemplo si se lanzan dos procesos y cada uno crea tres hilos secundarios, se podría obtener una salida como la siguiente:

Hello from main thread of process 0 of 2 on hostname1
	Hello from thread 0 of 3 of process 0 on hostname1
	Hello from thread 2 of 3 of process 0 on hostname1
	Hello from thread 1 of 3 of process 0 on hostname1
Hello from main thread of process 1 of 2 on hostname2
	Hello from thread 1 of 3 of process 1 on hostname2
	Hello from thread 0 of 3 of process 1 on hostname2
	Hello from thread 2 of 3 of process 1 on hostname2

Dado que existe indeterminismo por la salida estándar, la salida que obtenga puede ser muy distinta a la anterior. Sugerencia: Puede usar OpenMP junto con MPI para lograr el modelo de concurrencia híbrida solicitada en esta actividad.

j20-oct, v21-oct Video

Ejemplo hello_hybrid. Solución MPI+OpenMP. Ejemplo de ejecución en el clúster.

📹

Modelo de ejecución de MPI (ssh, nfs)

📹

Rastreo de procesos e hilos. Mecanismos de intercomunicación de procesos (IPC)

📹

hello_hybrid: diseño en pseudocódigo para Pthreads y OpenMP

📹

Tabla 44. Hola MPI híbrido [hello_hybrid]
Archivo Descripción Cambios

hello_hybrid_pthread.pseudo

Diseño en pseudocódigo de la solución híbrida entre distribución y concurrencia imperativa (Pthreads).

hello_hybrid_omp.pseudo

Diseño en pseudocódigo de la solución híbrida entre distribución y concurrencia declarativa (OpenMP).

hello_hybrid.cpp

Dice "hola mundo" desde el hilo principal e hilos secundarios (OpenMP) en varios procesos (MPI).

file diff

Makefile

Agrega la bandera -fopenmp del compilador para instrumentalizar el código objeto con OpenMP

file diff

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/hello_hybrid

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe --bind-to none -np 5 --hostfile hosts_openmpi bin/hello_hybrid

6.3.2. Mapeo híbrido (proceso-hilo)

En capítulos previos se ha usado la concurrencia también para el paralelismo de datos, es decir, repartir trabajo entre hilos mediante descomposición y mapeo. El modelo de ejecución híbrido permite repartir trabajo entre procesos y luego entre hilos. En tecnologías declarativas como OpenMP, la unidad de descomposición (iteraciones) y los tipos de mapeos están implementados de antemano. En tecnologías imperativas, la descomposición y el mapeo es implementado manualmente por las personas desarrolladoras. Este es el caso de MPI, aunque se proveen subrutinas muy elementales para distribuir datos que se verán luego.

Ejemplo 46. Distribución del rango con argumentos [hybrid_distr_arg]

Modifique el programa del Ejemplo 45 (hello_hybrid) para que reciba un rango indicado como dos números enteros [a,b[ en los argumentos de línea de comandos. Su programa debe distribuir (particionar) el rango entre los procesos de forma lo más equitativa posible, y dentro de los procesos debe distribuir los subrangos entre los hilos de ejecución.

El hilo principal de cada proceso debe reportar en la salida estándar el rango asignado al proceso. Use la notación hostname:process.thread: message como prefijo para simplificar la salida. Cada hilo secundario debe reportar su rango asignado antecedido por un tabulador. La siguiente podría ser una interacción con su programa en dos nodos de un clúster, cada uno con un proceso por nodo, y cada nodo con tres CPU:

$ mpiexec -n 2 -f hosts.txt bin/hybrid_distr_arg 3 20
hostname1:0: range [3, 12[ size 9
	hostname1:0.0: range [3,6[ size 3
	hostname1:0.1: range [6,9[ size 3
	hostname1:0.2: range [9,12[ size 3
hostname2:1: range [12, 20[ size 8
	hostname2:1.0: range [12,15[ size 3
	hostname2:1.1: range [15,18[ size 3
	hostname2:1.2: range [18,20[ size 2

Reporte además el tamaño del rango asignado a cada proceso e hilo de ejecución. Puede tomar el siguiente código como punto de partida.

Tabla 45. Código inicial para distribuir el rango en argumentos
Archivos Descripción

hybrid_distr_arg.pseudo

Diseño preliminar en pseudocódigo de la solución.

hybrid_distr_arg.cpp

Código C++ inicial para la distribución del rango en argumentos

j20-oct, v21-oct Video

hybrid_distr_arg: problema y pseudocódigo inicial

📹

hybrid_distr_arg: calcular el inicio y fin en mapeo estático por bloque

📹

hybrid_distr_arg: Distribuir y llevar el rastro de las iteraciones en pseudocódigo

📹

hybrid_distr_arg: Implementación con MPI y OpenMP. Ejemplo de ejecución

📹

Tabla 46. Distribución del rango con argumentos [hybrid_distr_arg]
Archivo Descripción Cambios

hybrid_distr_arg.pseudo

Diseño en pseudocódigo de la solución.

file diff

hybrid_distr_arg.cpp

Distribuye el rango en los argumentos de línea de comandos entre procesos y entre hilos usando mapeo por bloque.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/hybrid_distr_arg

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe --bind-to none -np 5 --hostfile hosts_openmpi bin/hybrid_distr_arg

Ejercicio 64 [mpi_error]

Implemente una clase para transportar potenciales errores que puedan surgir al usar la tecnología MPI y que necesitará en ejercicios siguientes.

Cree un archivo MpiError.hpp dentro de la carpeta mpi_wrapper/. Cree una clase MpiError que herede de std::runtime_error. Declare los tres constructores que están en el siguiente fragmento de código. Necesitará hacer un forward declaration de la clase Mpi.

explicit MpiError(const std::string& message);
MpiError(const std::string& message, const Mpi& mpi);
MpiError(const std::string& message, const Mpi& mpi, const int threadNumber);

En un archivo MpiError.cpp implemente los tres constructores. Cada uno debe invocar al constructor de std::runtime_error y proveerle un texto cuyo formato es el siguiente para cada constructor (las líneas corresponden corresponden a los constructores del código previo):

message
hostname:process_number: mesasge
hostname:process_number.thread_number: mesasge

Modifique la implementación de la clase Mpi para que cuando una función de la biblioteca MPI retorne un código de error, su clase lance una instancia de MpiError. Pruebe su código con varios procesos. Modifique temporalmente su código para provocar o lanzar una excepción y verificar que el texto con formato de la excepción se imprima en el error estándar.

6.4. Comunicación punto a punto (send-receive)

La comunicación punto a punto envía mensajes de un proceso a otro que forman parte del mismo equipo. MPI provee una familia de funciones para enviar y recibir mensajes. Las dos más básicas son: MPI_Send y MPI_Recv.

6.4.1. Envío y recepción de escalares

Ejemplo 47. Control de concurrencia con comunicación punto a punto [send_recv_ord_sem]

Modifique el ejemplo Ejemplo 44 (hello) para que los procesos saluden en orden por su identificador (rank). Dado que la función de comunicación punto a punto MPI_Recv implica bloqueo, y la espera es el mecanismo fundamental del control de concurrencia, puede usarse para sincronizar procesos, junto con MPI_Send. La salida será siempre determinística, por ejemplo:

Hello from main thread of process 0 of 3 on a_host
Hello from main thread of process 1 of 3 on other_host
Hello from main thread of process 2 of 3 on a_host

Cada proceso debe escribir en la salida estándar su saludo. Controle la concurrencia de esta impresión con comunicación punto-a-punto simulando semáforos.

L24-oct, k25-oct Video

Comunicación punto a punto. Primitivas send y receive. Entrada y salida blocking o sincrónica

📹

send_recv_ord_sem: Solución en pseudocódigo. Negativos en módulo y residuo

📹

send_recv_ord_sem: Implementación con MPI. MPI_Send(). MPI_Recv(). Operaciones orientadas a vectores
Nota: MPI v4 permite a un proceso enviarse mensajes a sí mismo

📹

Repaso ejemplo send_recv_ord_sem

📹

send_recv_ord_sem: Visualización manual de una ejecución en el clúster

📹

send_recv_ord_sem: Manejo de errores y modularización del código fuente

📹

Tabla 47. Control de concurrencia con comunicación punto a punto [send_recv_ord_sem]
Archivo Descripción Cambios

send_recv_ord_sem.pseudo

Diseño en pseudocódigo de la solución.

file diff

send_recv_ord_sem.cpp

Siempre los procesos saludan en orden. El proceso 0 imprime su saludo y avisa al siguiente. Los demás procesos esperan a recibir la señal del proceso previo.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/send_recv_ord_sem

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/send_recv_ord_sem

Ejercicio 65 [mpi_wrapper_send_recv]

Modifique su clase Mpi en la carpeta mpi_wrapper/ (no cree una nueva) para que pueda enviar y recibir mensajes punto a punto con métodos send() y receive(). La interfaz de estos y futuros métodos de esta clase serán similares al pseudocódigo propuesto para distribución (Pseudocódigo de paso de mensajes).

Naturalmente es deseable que su clase Mpi sea capaz de enviar y recibir valores de todos los tipos de datos primitivos soportados por MPI. Sin embargo no debe introducir redundancia de código, es decir, evitar algo como:

class Mpi {
  ...
 public:
  // MPI_CHAR, MPI_SIGNED_CHAR
  void send(const char& ch, int toProcess, int tag = 0);
  // MPI_UNSIGNED_CHAR, MPI_BYTE
  void send(const unsigned char& ch, int toProcess, int tag = 0);
  // MPI_SHORT...

 public:
  // MPI_CHAR, MPI_SIGNED_CHAR
  void receive(char& ch, int fromProcess, int tag = MPI_ANY_SOURCE);
  // MPI_UNSIGNED_CHAR, MPI_BYTE
  void receive(unsigned char& ch, int fromProcess, int tag = MPI_ANY_SOURCE);
  ...
};

Para evitar esta redundancia de código, utilice el paradigma de programación genérica. Cree plantillas de subrutinas. La plantilla recibe por parámetro el tipo de datos genérico a enviar o recibir por MPI. Sin embargo, la implementación de estos métodos tendrá que invocar funciones de la biblioteca de MPI que no son genéricas, sino que requieren constantes enteras acordes al tipo de datos. Puede usar las siguientes subrutinas que mapean los tipos de datos primitivos de C++ a constantes enteras que MPI requiere:

 public:
  static inline MPI_Datatype map(bool) { return MPI_C_BOOL; }
  static inline MPI_Datatype map(char) { return MPI_CHAR; }
  static inline MPI_Datatype map(unsigned char) { return MPI_UNSIGNED_CHAR; }
  static inline MPI_Datatype map(short) { return MPI_SHORT; }
  static inline MPI_Datatype map(unsigned short) { return MPI_UNSIGNED_SHORT; }
  static inline MPI_Datatype map(int) { return MPI_INT; }
  static inline MPI_Datatype map(unsigned) { return MPI_UNSIGNED; }
  static inline MPI_Datatype map(long) { return MPI_LONG; }
  static inline MPI_Datatype map(unsigned long) { return MPI_UNSIGNED_LONG; }
  static inline MPI_Datatype map(long long) { return MPI_LONG_LONG; }
  static inline MPI_Datatype map(unsigned long long) { return MPI_UNSIGNED_LONG_LONG; }
  static inline MPI_Datatype map(float) { return MPI_FLOAT; }
  static inline MPI_Datatype map(double) { return MPI_DOUBLE; }
  static inline MPI_Datatype map(long double) { return MPI_LONG_DOUBLE; }

A modo de ejemplo, con los métodos anteriores, la expresión map(0.0) se evaluará por MPI_DOUBLE, y la expresión map(double()) también se evaluará por MPI_DOUBLE. Es decir, con un valor value cualquiera del tipo de datos typename DataType se puede obtener su constante de MPI correspondiente con la expresión map(value) ó map(DataType()). Provea entonces dos plantillas en su clase de acuerdo al siguiente interfaz en pseudocódigo para envío y recepción de valores escalares:

send(value, toProcess, tag=0)
receive(&value, fromProcess, tag=any_tag)

La primera plantilla servirá para generar métodos que reciben un valor (referencia constante) del tipo de datos genérico, y lo envía hacia el proceso destino usando MPI_Send(). La segunda plantilla recibe una referencia no constante de una variable y en ella escribe un valor recibido por la red proveniente del proceso fromProcess con MPI_Recv(). Con estos métodos el ejemplo Ejemplo 47 podrá reducirse al siguiente código que es mucho más claro y fácil de mantener:

#include "../mpi_wrapper/Mpi.hpp"

int main(int argc, char* argv[]) {
  try {
    Mpi mpi(argc, argv);
    const int rank = mpi.getProcessNumber();
    const int count = mpi.getProcessCount();
    const int previous = (count + rank - 1) % count;
    const int next = (rank + 1) % count;

    bool can_print = true;
    if (rank > 0) {
      mpi.receive(can_print, previous);
    }
    std::cout << "Hello from main thread of process " << rank << " of "
      << count << " on " << mpi.getHostname() << std::endl;
    mpi.send(can_print, next);
  } catch (const std::exception& error) {
    std::cerr << "error: " << error.what() << std::endl;
  }
}

Copie el código anterior a un archivo main.cpp en una carpeta mpi_wrapper_send_recv/, asegúrese de que compila y produce los resultados esperados.

6.4.2. Envío y recepción de arreglos

El paso de mensajes en MPI es orientado a arreglos. Es decir, las operaciones pueden enviar no sólo un elemento (escalar), sino varios de ellos (vectorial), siempre y cuando estén serializados en la memoria. También permite separar asuntos de acuerdo al número de proceso (rank).

Ejemplo 48. Comunicación punto a punto ordenada [send_recv_ord_itm]

Modifique la solución a la Ejemplo 47 (send_recv_ord_sem) para que los procesos saluden en orden por su identificador (rank) a través de un intermediario. Es decir, los procesos envían su saludo a uno del equipo, quien recibe los saludos y los imprime en la salida estándar en orden.

Al usar un itermediario con comunicación punto a punto, se simula "seguridad condicional". Note que los saludos son información vectorial y no escalar. Tenga presente que no es posible enviar datos discontinuos de la memoria a través de la red en una operación de send/receive.

L24-oct, k25-oct Video

send_recv_ord_itm: Programación orientada a arreglos. Simular seguridad condicional con un intermediario. Solución en pseudocódigo 1.

📹

send_recv_ord_itm: Implementación 1. Buffers en C++: stringstream, string, y vector

📹

send_recv_ord_itm: Los procesos no pueden enviarse mensajes a sí mismos. Solución 2

📹

send_recv_ord_itm: Visualización de ejecución. Message match: type, source, tag

📹

Tabla 48. Comunicación punto a punto ordenada [send_recv_ord_itm]
Archivo Descripción Cambios

send_recv_ord_itm.pseudo

Diseño en pseudocódigo de la solución.

file diff

send_recv_ord_itm.cpp

Todos los procesos envían mensajes de saludo al intermediario (proceso 0) quien los recibe en cualquier orden, pero los imprime en orden del número de proceso (rank) en la salida estándar.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/send_recv_ord_itm

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/send_recv_ord_itm

Ejercicio 66 [mpi_wrapper_send_recv_arr]

Las funciones de paso de mensajes de MPI son orientadas a arreglos. Todas ellas reciben la dirección de memoria (puntero) donde inicia un arreglo en la memoria. Si la operación es un send, se envía la cantidad de elementos a partir de esa dirección al proceso destino. Si la operación es un receive, se escribe a partir de esta dirección los elementos que se recibieron por la red hasta un máximo indicado por una cantidad de elementos. Dado que MPI está diseñado pensando en arreglos, para enviar o recibir un único elemento hay que enviar su dirección de memoria e indicar que sólo 1 elemento conforma el arreglo.

Hasta el momento su implementación de la clase wrapper de MPI sólo permite enviar valores escalares. En este ejercicio, modifique la clase Mpi en la carpeta mpi_wrapper/ para proveer variaciones de los métodos genéricos send() y receive() que puedan enviar y recibir arreglos de valores. Estas variaciones están indicadas en el siguiente pseudocódigo, donde Type es un tipo de datos genérico.

/// Send a scalar value to another process
send(const Type& value, toProcess, tag = 0)
/// Send an array of count values to another process
send(const Type* values, count, toProcess, tag = 0)
/// Send an array of values to another process
send(const std::vector<Type>& values, toProcess, tag = 0)
/// Send a text to another process
send(const std::string& text, toProcess, tag = 0)

/// Wait until it receives a scalar value from other process
receive(Type& value, fromProcess = any_process, tag = any_tag)
/// Wait until it receives at most capacity values from another process
receive(Type* values, capacity, fromProcess = any_process, tag = any_tag)
/// Wait until it receives at most capacity values from another process
receive(std::vector<Type>& values, capacity, fromProcess = any_process, tag = any_tag)
/// Wait until it receives a text of at most length chars from another process
receive(std::string& text, capacity, fromProcess = any_process, tag = any_tag)

En la carpeta mpi_wrapper_send_recv_arr/ provea un archivo main.cpp que use los métodos send() y receive() de textos para lograr la funcionalidad del ejemplo de imprimir en orden a través de un intermediario.

6.4.3. Indeterminismo del paso de mensajes

La comunicación punto a punto en MPI es indeterminística entre procesos distintos, pero no entre los mismos procesos. Por ejemplo, si varios procesos envían mensajes a un receptor, los mensajes llegarán en cualquier orden. Pero si un proceso A envía varios mensajes a otro proceso B, los mensajes llegarán en el mismo orden en que fueron enviados.

Ejemplo 49. Comunicación punto a punto no ordenada [send_recv_urd]

Modifique la solución de la Ejemplo 48 (send_recv_ord_itm) para que usando comunicación punto a punto, los procesos saluden al proceso intermediario y éste reporte los saludos en el orden en que los recibió.

L24-oct, k25-oct Video

send_recv_urd: Relajar el message match con MPI_ANY_SOURCE y MPI_ANY_TAG

📹

Tabla 49. Comunicación punto a punto no ordenada [send_recv_urd]
Archivo Descripción Cambios

send_recv_urd.pseudo

Diseño en pseudocódigo de la solución.

file diff

send_recv_urd.cpp

Todos los procesos envían mensajes de saludo al intermediario (proceso 0) quien los recibe e imprime en el orden en que los recibió, y no en el orden de número de proceso (rank).

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/send_recv_urd

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/send_recv_urd

Ejercicio 67 [mpi_wrapper_stream_op]

Permita enviar varios mensajes en secuencia a otro proceso con el operador de flujo << y recibir varios mensajes en secuencia de otro proceso con el operador >>. Para indicar el número de proceso use el operador corchetes []. Por ejemplo, en el siguiente código todos los procesos envían un saludo al proceso 0 compuesto de un texto y un número. Por su parte, el proceso 0 recibe el texto y el número de todos los demás procesos y los imprime en la salida estándar.

if (mpi.rank() > 0) {
  mpi[0] << "Hello from " << mpi.rank();
} else {
  for (int source = 1; source < mpi.size(); ++source) {
    std::string text;
    int rank = 0;
    mpi[source] >> text >> rank;
    std::cout << source << " says: " << text << rank << std::endl;
  }
}

Haga que el operator[] retorne un objeto de una clase MpiStream que implemente los operadores de flujo. Este objeto tiene por atributos el número de proceso con el que se hará la comunicación, y una referencia al objeto Mpi. Haga que el operador corchetes lance una excepción si el número de proceso interlocutor está fuera de rango.

Implemente en la clase MpiStream operadores de flujo << y >> genéricos (plantillas) que invoquen las versiones correspondientes de los métodos send() y receive de la clase Mpi. Su implementación del operador >> tendrá que hacer supuestos sobre el tamaño de los arreglos.

6.4.4. Tipos de send y receive en MPI

MPI ofrece una familia de funciones de send y receive que varían principalmente en:

  1. Si permiten a las personas desarrolladoras controlar los buffers de memoria que se usan para enviar o recibir datos, marcadas con una B en su nombre, como MPI_Bsend().

  2. Si se bloquean (por defecto) o no mientras se realiza la operación. Las que no se bloquean están marcadas con una I en su nombre, por ejemplo MPI_ISend(). Estas funciones retornan inmediatamente, pero invocan a un manejador de eventos cuando su operación finalmente haya tenido efecto.

  3. Si la función de envío espera hasta que los datos hayan sido recibidos por completo por el proceso receptor. En tal caso se marcan con una R de ready, como MPI_RSend(). No aplica para las funciones de receive.

La siguiente tabla resume las combinaciones que pueden generarse de las variantes anteriores. Las funciones están ordenadas por la duración de su efecto, de la que tardaría menos hasta la que duraría más.

Function Blocking Description

Send

I

MPI_Isend()

Nonblocking

Starts copying data to buffer

B

MPI_Bsend()

Blocking

Allows control of sending buffer

Ib

MPI_Ibsend()

Nonblocking

Starts copying to a controlled buffer

S

MPI_Send()
MPI_SSend()

Blocking

Blocks until data has started to arrive to target

R

MPI_Rsend()

Blocking

Blocks until data has completely arrived to target

Ir

MPI_Irsend()

Nonblocking

Starts sending, callbacks until data has arrived to target

Receive

MPI_Recv()

Blocking

Blocks until all data has been received and copied to process' memory

I

MPI_Irecv()

Nonblocking

Callbacks when all data has been received and copied to process' memory

M

MPI_Mrecv()

Blocking

Allow control how message matching is done?

Im

MPI_Imrecv()

Nonblocking

Allow control how message matching is done?

L24-oct, k25-oct Video

Tipos de send y receive en MPI: blocking, non-blocking (I), buffered (B) y ready (R)

📹

Ejercicio 68 [ping_pong_perfect]

Un ejercicio clásico de paso de mensajes es simular un juego de tenis de mesa (ping-pong) entre dos procesos. Uno lanza la bola al otro, quien la recibe y la regresa al primero, y así sucesivamente. Los dos jugadores son incansables y nunca pierden un servicio. Haga que su programa simule este comportamiento. Si su programa es invocado con una cantidad distinta a dos jugadores, debe reportar un mensaje de error y finalizar.

Haga que cada proceso imprima en la salida estándar un mensaje cuando hace un servicio. Puede permitir que el usuario especifique un segundo argumento de línea de comandos para establecer una espera en milisegundos que tarda un jugador en servir desde que recibe la bola. Esto puede ayudar a hacer más legible la salida disminuyendo el indeterminismo. El siguiente podría ser un ejemplo de ejecución hipotético.

bin/ping_pong_perfect 2 500
0 serves
1 serves
0 serves
1 serves
^C
Ejercicio 69 [ping_pong_realistic]

Adapte su solución al ejercicio anterior para que sea más realista. En el primer argumento de línea de comandos, el usuario indicará el marcador de victoria de la partida. Por ejemplo, si se indica 10, el primer jugador que anote 10 goles ganará la partida y el juego termina.

Los jugadores en esta versión no son perfectos. El segundo y tercer argumento de línea de comandos indica la probabilidad de acierto en cada servicio (tiro o pase) del primer y segundo jugador respectivamente. Por ejemplo, la siguiente ejecución indica que el jugador 1 (proceso 0) tiene una tasa de acierto del 85.5% de los tiros y el jugador 2 de un 88%.

mpiexec -n 2 ping_pong 3 85.5 88
1: 24 1
2: 53 0
3: 11 0
0 wins 2 to 1

En la salida estándar indique una línea por cada ronda y una línea final con el resultado. En cada ronda se indica la cantidad de servicios (pases) jugados y el proceso que la ganó. La última línea indica el marcador final o si hubo un empate.

6.5. Lectura distribuida

Ejemplo 50. Lectura distribuida de la entrada estándar [stdin_sendrecv]

Modifique la solución de la Ejemplo 49 (send_recv_urd) para que:

  1. Los procesos lean un arreglo de números en la entrada estándar con los cuales trabajar. Luego cada proceso imprime los números que leyó en la salida estándar.

  2. Haga las correcciones para que todos los procesos tengan y puedan efectivamente imprimir el arreglo.

  3. Los procesos reporten en la salida estándar la cantidad de segundos en que tarda su ejecución.

L24-oct, k25-oct Video

stdin_sendrecv: Solución ingenua: todos los procesos leen de la entrada estándar, pero mpiexec sólo la envía a proceso 0

📹

stdin_sendrecv: Solución en pseudocódigo: proceso 0 envía el arreglo a los demás. Orden de paso de mensajes

📹

stdin_sendrecv: Implementación de la solución. static_assert en C++

📹

stdin_sendrecv: Medición de tiempo de pared con MPI_Wtime. Problema del tiempo distribuido

📹

Tabla 50. Lectura distribuida de la entrada estándar [stdin_sendrecv]
Archivo Descripción Cambios

stdin_sendrecv.pseudo

Diseño en pseudocódigo de la solución.

file diff

stdin_sendrecv.cpp

MPI sólo permite al proceso 0 leer. Este debe reenviar copias de los datos o asignar fracciones de los mismos para que los otros hilos puedan realizar su trabajo. La función MPI_Wtime() retorna un valor de doble precisión flotante que indica una cantidad de segundos transcurrida a partir de un punto de referencia que sólo es válido para el proceso que invoca la subrutina.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/stdin_sendrecv

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/stdin_sendrecv

input001.txt

Un archivo con algunos números para poder probar redireccionando la entrada, ej: mpiexec -np 3 -f hosts_mpich bin/stdin_sendrecv < tests/input001.txt

MPI permite que sólo el proceso 0 lea de la entrada estándar. Más específicamente, la entrada la lee el comando mpiexec, la captura y la envía al proceso 0. Los demás procesos no reciben la entrada, por tanto si intentan leer, se quedarán bloqueados perennemente. Si un proceso debe leer de la entrada estándar, el proceso 0 tendrá que usar comunicación para enviarla a los demás procesos.

La lectura de archivos nombrados no sufre las limitaciones de la entrada estándar en MPI. Normalmente los clústers de computadoras se configuran para que un archivo esté disponible en todos los nodos esclavos, por ejemplo, haciendo que el disco duro donde se encuentran los datos de los usuarios, esté compartido por todas las máquinas. De esta manera, los procesos creados con MPI tendrán acceso al mismo archivo y podrán leerlo de forma concurrente. Como es de esperar, el archivo nombrado forma un recurso compartido entre procesos, de tal forma que si se modifica de forma concurrente sin control de concurrencia o seguridad condicional, provocará una condición de carrera y potencialmente contamine el archivo.

Para calcular la duración en segundos del tiempo en la pared use la función MPI_Wtime. La medición del tiempo en los sistemas distribuidos es un problema sumamente complejo, dado que los procesos pueden ejecutarse en máquinas distintas, y por consiguiente, con diferentes relojes no perfectamente sincronizados entre sí. La función MPI_Wtime() reporta un tiempo transcurrido en segundos que un proceso puede usar como referencia para sí mismo, pero no es válido para otros procesos.

Ejercicio 70 [hybrid_distr_stdin]

Modifique el programa del rango distribuido (Ejemplo 46) para que en caso de que el rango no se especifique en los argumentos de línea de comandos, los lea en la entrada estándar. Sugerencia, use comunicación punto a punto.

Ejercicio 71 [hybrid_distr_file]

Modifique el programa del rango distribuido (Ejercicio 70 [hybrid_distr_stdin]) para que el usuario pueda espeficar un nombre de archivo en los argumentos de línea de comandos. Cada proceso abre y lee de este archivo los valores del rango. En caso de que el nombre del archivo no se especifique en los argumentos de línea de comandos, lea el rango en la entrada estándar. Si el nombre del archivo se provee pero no se puede leer o no contiene un rango, reporte un mensaje de error y finalice el programa.

Ejercicio 72 [hybrid_distr_wtime]

Modifique el programa del rango del Ejercicio 70 [hybrid_distr_stdin] para que cada proceso reporte en la salida estándar la cantidad de segundos en que tarda su ejecución. Mida la duración desde que inicializa el ambiente de ejecución de MPI (MPI_Init) hasta inmediatamente antes de finalizarlo (MPI_Finalize). Un ejemplo de salida podría ser el siguiente.

$ mpiexec -n 2 -f hosts.txt ./hybrid_distr_wtime 3 20
hostname1:0: range [3, 12[ size 9 in 0.000123s
	hostname1:0.0: range [3,6[ size 3
	hostname1:0.1: range [6,9[ size 3
	hostname1:0.2: range [9,12[ size 3
hostname2:1: range [12, 20[ size 8 in 0.000342s
	hostname2:1.0: range [12,15[ size 3
	hostname2:1.1: range [15,18[ size 3
	hostname2:1.2: range [18,20[ size 2

¿Cuál de las dos formas de especificar el rango consigue menores tiempos de ejecución: en argumento de línea de comandos o la entrada estándar?

6.6. Barreras distribuidas

Implementar mecanismos de control de concurrencia distribuida es menos eficiente que en concurrencia de recursos compartidos, por lo que puede explicar su menor número. Las operaciones de send y receive pueden usarse para enviar avisos (signaling) como se hace con los semáforos. MPI ofrece barreras con la subrutina MPI_Barrier() que imponen espera a un equipo de procesos hasta que todos los miembros hayan alcanzado la barrera.

Ejemplo 51. Carrera con relevos distribuida [relay_race_dist]

Simule una carrera de relevos, donde los competidores son procesos en equipos. Cada equipo consta de dos corredores. Un corredor tomará la estafeta (en inglés, baton) y se posicionará en la línea de salida. El otro corredor esperará en la mitad de la pista a que el primer corredor llegue y le transfiera la estafeta. En tal momento, el segundo corredor seguirá a toda velocidad hasta alcanzar la meta.

Usted puede imponer restricciones sobre la cantidad de procesos con los que deberá ser ejecutada la simulación. Por ejemplo, un número mínimo de procesos que puede ser par o impar de acuerdo a sus decisiones de separación de asuntos. Idee un mecanismo para decidir qué procesos pertenecen al mismo equipo. Por ejemplo, los procesos 1 y 2 podrían conformar el equipo 1, los procesos 3 y 4 al equipo 2, y así sucesivamente. Si se invoca con una cantidad no adecuada de procesos, la simulación deberá detenerse y emitir un mensaje de error que ayude al usuario a escoger el número correcto.

Cada proceso recibirá dos parámetros: la duración en milisegundos que tarda el primer corredor atravesando la etapa 1 de la carrera (entre la salida y la mitad de la pista), y la duración en milisegundos del segundo corredor atravesando la etapa 2 de la carrera (de la mitad de la pista hasta la meta). Suponga que los corredores de una etapa tardan exactamente esta duración y no un valor generado al azar. Ejemplo de ejecución:

$ mpiexec -np 10 -f hosts.txt bin/relay_race_dist 1100 900
Place 1: team 3 in 2.35075s
Place 2: team 4 in 2.35085s
Place 3: team 5 in 2.35087s
Place 4: team 2 in 2.35089s
Place 5: team 1 in 2.35092s

Puede usar el siguiente código como punto de partida.

Archivo Descripción

relay_race_dist.pseudo

Pseudocódigo inicial para la carrera de relevos distribuida.

relay_race_dist.cpp

Código inicial para la carrera de relevos distribuida en C++ con MPI.

j27-oct, v28-oct Video

relay_race_dist: Enunciado del problema

📹

Solución con metáforas visuales. Modelos centralizados y descentralizados

📹

Solución en pseudocódigo: subrutina main()

📹

Pseudocódigo de los corredores de las etapas 1 y 2. Barreras en MPI (MPI_Barrier())

📹

Pseudocódigo del árbitro

📹

Implementación con MPI. Tiempo transcurrido desde el punto de vista del árbitro

📹

Tabla 51. Carrera con relevos [relay_race_dist]
Archivo Descripción Cambios

relay_race_dist.pseudo

Diseño en pseudocódigo de la solución.

file diff

relay_race_dist.cpp

Implementación con MPI de la simulación de la carrera de relevos distribuida.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/relay_race_dist

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/relay_race_dist

6.7. Comunicación colectiva

MPI permite dos formas de paso de mensajes:

  1. Punto a punto donde un proceso envía (send) un mensaje directamente a otro proceso, quien explícitamente lo recibe (receive).

  2. Comunicación colectiva donde un proceso envía un mensaje a todos los demas (broadcast), o un proceso recibe mensajes de todos los demas (reduction).

6.7.1. Difusión de mensajes (broadcast)

Ejemplo 52. Envío y recepción de valores mediante broadcast [stdin_bcast]

Modifique la solución de la Ejemplo 50 (stdin_sendrecv) para que el proceso intermediario comparta los valores leídos de la entrada estándar con los demás procesos mediante comunicación colectiva.

j27-oct, v28-oct Video

stdin_bcast: Enunciado y diseño en pseudocódigo. Concepto de comunicación colectiva. Difusión (broadcast)

📹

stdin_bcast: Implementación. Difusión de mensajes en MPI (MPI_Bcast())

📹

Tabla 52. Envío y recepción de valores mediante broadcast [stdin_bcast]
Archivo Descripción Cambios

stdin_bcast.pseudo

Diseño en pseudocódigo de la solución.

file diff

stdin_bcast.cpp

Usa las capacidades de comunicación colectiva de MPI para copiar el arreglo que el proceso 0 lee de la entrada estándar en los demás procesos del equipo.

file diff

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/stdin_bcast

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/stdin_bcast

input001.txt

Un archivo con algunos números para poder probar redireccionando la entrada, ej: mpiexec -np 3 -f hosts_mpich bin/stdin_bcast < tests/input001.txt

En una difusión (broadcast) un proceso cualquiera llamado raíz envía datos a todos los demás procesos del equipo. Los demás procesos recibirán, y por tanto, esperarán, por los datos si llegan al broadcast antes que el emisor (raíz). Internamente los procesos crean un árbol de envíos, donde el proceso emisor (raíz) envía los datos a otros dos, y éstos a su vez a otros dos, y así recursivamente hasta completar todos los procesos del equipo. Idealmente todos los procesos del equipo deben ejecutar la invocación al broadcast, de lo contrario, el equipo de procesos se quedaría bloqueado (aunque los procesos "hoja" podrían no provocar este comportamiento).

Ejercicio 73 [hybrid_distr_bcast]

Modifique el programa del rango distribuido en la entrada estándar (Ejercicio 72 [hybrid_distr_wtime]) para que en lugar de usar comunicación punto a punto para enviar el rango, use comunicación colectiva.

Ejercicio 74 [distr_bingo]

Los procesos (creados cerca de 1965) tienen más edad que los hilos de ejecución (conceptualizados cerca de 1979). Como cualquiera de edad, los procesos disfrutan jugando Bingo. Para participar cada proceso paga ₡1000 (unos $2 estadounidenses). Como juegan por gusto, el premio es todo el dinero recabado entre quienes juegan, que siempre deben ser al menos 3 procesos.

Este es un Bingo clásico americano, pero "de traje". Cada proceso trae o crea su propio cartón de Bingo, escogiendo 24 números enteros al azar entre 1 y 75 inclusive. Antes de iniciar cada proceso paga su cuota y por transparencia enseña su cartón a los demás en la salida estándar. Pero cuidadito de enredar a los adultos mayores, ellos esperan que los 75 números estén distribuidos en cinco columnas, cada una para 15 enteros, rotuladas con las letras B, I, N, G, y O. Es decir, los números entre 1 a 15 aparecen en la columna B, los números entre 15 a 30 en la columna I, y así sucesivamente. Y claro, la casilla del centro del cartón no tiene número para que el juego termine más rápido.

Por supuesto, no se puede jugar si alguien no canta los números. Uno de los procesos tendrá que tomar este rol. A quien usted designe como moderador, tomará tómbola y sólo se dedicará a cantar los números, ya que si canta y juega al mismo tiempo, por la edad se le puede enredar el asunto, y todos los procesos terminen dormidos, o peor "terminen agarrados del pelo".

A veces por su avanzada edad usted tendrá que echarle una ayudita al proceso moderador a cantar los números, dictándoselos en la entrada estándar. Si usted "pinta" que el moderador "se la puede jugar solo", pásele un argumento en la línea de comandos. Cuando el moderador vea ese numerito, se sentirá halagado del reconocimiento de sus capacidades, tomará con energía la tómbola, y cantará un número tras otro como salgan de la tómbola (al azar). Ese númerito en los argumentos es una duración en milisegundos que usted le recomendará al talentoso moderador esperar entre un número y otro, porque si no, los cantará tan rápido que no le dará chance al resto de viejillos de marcarlos en su cartón.

Los procesos siempre juegan a cartón lleno. Apenas uno de ellos completa su cartón, gritará a todo el mundo "BINGO!" seguido de todos los números de su cartón. El moderador verificará que todos los números realmente hayan salido y en tal caso, indicará que ha sido ganador y su premio (₡1000 por cada cartón en juego).

Como procesos de edad que son, el código de ética es simplemente impecable, y nunca jamás un proceso inventaría números que no tiene en su cartón. Así que siempre que alguien dice "BINGO!" es porque realmente ganó. Pero como es sabido en este juego, podría ocurrir que dos o más procesos ganen al mismo tiempo. En tal caso, el moderador tras verificar todos los cartones, repartirá el premio entre los ganadores. Por razones de espacio el siguiente es un ejemplo de ejecución con cartones de 5 números del 01 al 15.

$ mpiexec -n 3 bin/distr_bingo
Process 1: 03 06 07 10 11
Process 2: 01 04 10 11 14
07
10
06
08
04
12
11
01
09
14
Process 2: BINGO! 01 04 10 11 14
Moderator: Process 2 wins CRC 2000

6.7.2. Reducciones (reduce)

Ejemplo 53. Reducción en MPI [lucky_number_reduce]

Escriba un programa donde cada proceso escoge al azar su número de la suerte entre 00 y 99 inclusive. Haga que el proceso 0 reporte el mínimo, el promedio, y el máximo de los números de la suerte escogidos entre todos los procesos. El siguiente puede ser un ejemplo de ejecución:

$ mpiexec -np 5 -f hosts.txt bin/lucky_number_reduce
Process 0: my lucky number is 83
Process 0: all minimum: 10
Process 0: all average: 44.20
Process 0: all maximum: 83
Process 1: my lucky number is 56
Process 2: my lucky number is 10
Process 3: my lucky number is 10
Process 4: my lucky number is 62

Para su solución y generar números pseudoaleatorios, puede usar el siguiente código base en C++.

Archivo Descripción

lucky_number_reduce.pseudo

Pseudocódigo inicial para el diseño de la solución.

lucky_number_reduce.cpp

Código inicial para la implementación de la solución.

UniformRandom.hpp

Clase en C++ para generar números pseudoaleatorios en un rango con distribución uniforme.

UniformRandom.cpp

Ejemplo de uso de la clase anterior. Crea un comando que recibe tres argumentos COUNT MIN MAX y genera COUNT números pseudoaleatorios en el rango [MIN, MAX[ en la salida estándar.

L31-oct, k01-nov Video

lucky_number_reduce: enunciado. Reusar código en carpeta common en Makefile o con enlaces simbólicos

📹

lucky_number_reduce: diseño en pseudocódigo. Reducciones distribuidas. Programación funcional: map y reduce.

📹

lucky_number_reduce: código fuente inicial. Generación de números pseudoaleatorios

📹

lucky_number_reduce: implementación. MPI_Reduce()

📹

Cómo funciona la reducción en MPI. Árbol binario de reducción. Usar número de proceso en la semilla de números aleatorios.

📹

Tabla 53. Reducción en MPI [lucky_number_reduce]
Archivo Descripción Cambios

lucky_number_reduce.pseudo

Diseño en pseudocódigo de la solución.

file diff

lucky_number_reduce.cpp

Todos los procesos escogen un número de la suerte al azar y lo envían al proceso 0 quien imprime estadísticas. Note que se usa el número de proceso en la semilla para generar números pseudoaletorios para evitar que los procesos escojan el mismo número de la suerte.

file diff

UniformRandom.hpp

Clase en C++ para generar números pseudoaleatorios en un rango con distribución uniforme. Es parte del código inicial dado en el enunciado.

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/lucky_number_reduce

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/lucky_number_reduce

Ejercicio 75 [reduce_send_recv]

Implemente la reducción de la Ejemplo 53 con operaciones de send y receive. Su solución no debe hacer un envío de todos los procesos al receptor directamente, dado que no es eficiente. Su solución debe realizar un árbol binario de envíos de la misma forma que MPI_Reduce() trabaja. Es decir, los procesos envían a otros intermediarios quienes realizan la operación de reducción, y éstos a su vez la envían a otros intermediarios, hasta que finalmente el valor resultante queda en el proceso receptor.

Nota: Este ejercicio tiene una complejidad alta y se recomienda idear un modelo solución antes de implementarlo.

6.7.3. Difusión de reducciones (all-reduce)

Ejemplo 54. Reducción en MPI [lucky_number_who]

Modifique su programa de la Ejemplo 53 (lucky_number_reduce) para que cada proceso escoja al azar su número de la suerte entre 00 y 99 inclusive, y el mismo proceso reporta si su número es el menor o el mayor de los números escogidos por todos los procesos. El siguiente puede ser un ejemplo de ejecución:

$ mpiexec -np 5 -f hosts.txt bin/lucky_number_who
Process 0: my lucky number (83) is greater than the average (36.00)
Process 0: my lucky number (83) is the maximum (83)
Process 1: my lucky number (18) is less than the average (36.00)
Process 2: my lucky number (07) is less than the average (36.00)
Process 2: my lucky number (07) is the minimum (07)
Process 3: my lucky number (46) is greater than the average (36.00)
Process 4: my lucky number (26) is less than the average (36.00)

El reporte de mínimo, máximo y promedio es independiente. Por ejemplo, si sólo un proceso se ejecuta cumplirá los tres criterios:

$ mpiexec -np 1 -f hosts.txt bin/lucky_number_who
Process 0: my lucky number (31) is the minimum (31)
Process 0: my lucky number (31) is equals to the average (31.00)
Process 0: my lucky number (31) is the maximum (31)

Puede usar el siguiente código como punto de partida.

Archivo Descripción

lucky_number_who.pseudo

Pseudocódigo inicial para el diseño de la solución.

lucky_number_who.cpp

Código inicial para la implementación de la solución.

L31-oct, k01-nov Video

lucky_number_who: enunciado del problema. Solución en pseudocódigo. Operación all_reduce()

📹

lucky_number_who: implementación. MPI_Allreduce()

📹

Otros temas de MPI: scatter, gather, soporte multi-hilos, paralelización de archivos.

📹

Tabla 54. Reducción en MPI
Archivo Descripción Cambios

lucky_number_who.pseudo

Diseño en pseudocódigo de la solución.

file diff

lucky_number_who.cpp

Cada proceso escoge un número de la suerte y lo envía a todos los demás. Pero en lugar de recibir los números enviados, cada proceso recibe el mínimo, la suma y el máximo de los números escogidos. Con estas reducciones cada proceso calcula el promedio y compara su número de la suerte contra estos estadísticos.

file diff

UniformRandom.hpp

Clase en C++ para generar números pseudoaleatorios en un rango con distribución uniforme. Es parte del código inicial dado en el enunciado del problema anterior.

Makefile

Facilita la construcción y prueba de la la solución.

hosts_mpich

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con MPICH, ej.: mpiexec -np 5 -f hosts_mpich bin/lucky_number_who

hosts_openmpi

Archivo de hosts para distribuir entre los nodos del clúster Arenal de la ECCI con Open MPI, ej.: mpiexec --oversubscribe -np 5 --hostfile hosts_openmpi bin/lucky_number_who

6.7.4. Descomposición y mapeo distribuido (scatter y gather)

MPI ofrece la función MPI_Scatter() que toma un vector de valores y los reparte entre todos los procesos de un equipo, incluyendo al mismo que tiene el vector original. Usa un mapeo estático por bloque.

MPI_Scatter() tiene la limitación de que la cantidad de elementos en el vector tiene que ser múltiplo de la cantidad de procesos P. En caso contrario produce un error. La subrutina MPI_Scatterv() rompe esta limitación, pero requiere que el invocador construya de antemano una estructura de datos que indique los rangos del vector que cada proceso recibirá.

Una vez que un vector ha sido repartido entre procesos, estos realizarán algún trabajo con los valores asignados y probablemente querrán regresar los resultados al proceso que repartió los datos. Las subrutinas MPI_Gather() y MPI_Gatherv() realizan el trabajo de recolectar los datos distribuidos entre los procesos y consolidarlos en el vector original.

Las funciones MPI_Scatter() y MPI_Gather() sufren de una limitación artificial, mientras que la interfaz de las funciones MPI_Scatterv() y MPI_Gatherv es compleja. Por otra parte, las primitivas de send y receive permiten a los desarrolladores implementar los tipos de mapeo tradicionales, como se hizo en la Ejemplo 46 (hybrid_distr_arg) para el mapeo estático por bloque.

Ejercicio 76 [hybrid_distr_cyclic]

Modifique su solución al Ejercicio 73 [hybrid_distr_bcast] para repartir el rango usando un mapeo cíclico entre los procesos y los hilos.

MPI ofrece otras funcionalidades avanzadas. Por ejemplo, la subrutina MPI_Init_thread() permite escoger si los hilos secundarios dentro de un proceso pueden o no invocar subrutinas de MPI, y en caso afirmativo si éstas se ejecutan con exclusión mutua o no.

MPI ofrece su propio manejo de entrada y salida optimizado para el paralelismo. Por ejemplo, la familia de subrutinas MPI_File_ están diseñadas para que todos los procesos de un equipo puedan abrir o modificar el mismo archivo de forma paralela, en especial si el hardware facilita el acceso paralelo. Esto se contrapone a las primitivas de entrada y salida de los sistemas operativos, que están ideadas para que sólo un proceso acceda un archivo a la vez.

7. Distribución heterogénea: aceleración gráfica

La aceleración por hardware es el uso de la electrónica para optimizar tareas específicas. Por ejemplo, disminuir la cantidad de tiempo de ejecución, disminuir el consumo de energía, o incrementar el tiempo de respuesta, entre otras optimizaciones. Este hardware se conoce como de propósito específico, pues está diseñado para trabajar sólo el contexto para el que fue diseñado. En el extremo opuesto se encuentran las CPUs que son de propósito general y pueden ser programadas para una cantidad arbitraria de contextos. En un punto medio se encuentra hardware como los aceleradores gráficos y los field-programmable gate arrays (FPGA).

Los aceleradores gráficos o GPUs (Graphics Processing Units) sugieron a finales de los 1990 para mejorar la calidad y velocidad de despliegue de imágenes en video juegos, animaciones, y aplicaciones afines. Se caracterizan por un uso masivo del paralelismo conocido como many cores. Este hardware también sirve para solventar problemas científicos y de otros contextos que requieren más paralelismo que el disponible en un procesador de uso general. Sin embargo, a nivel de software, inicialmente la programación de GPUs fue a través de ambientes de programación exclusivos para contextos gráficos, como OpenGL y DirectX, lo que motivó a iniciativas de crear ambientes de programación general usando los aceleradores gráficos, como OpenCL y CUDA.

El Open Computing Language (OpenCL) es un ambiente de programación basado en C y creado hacia el 2009 por un consorcio de compañías de hardware con el ambicioso objetivo de correr en una variedad de plataformas heterogéneas, entre las que se incluyen CPUs, GPUs, FPGAs, digital signal processors (DSP), y otros dispositivos de aceleración por hardware. En el extremo opuesto se encuentra la Compute Unified Device Architecture (CUDA), una extensión para los lenguajes C/C++ y Fortran, creada hacia el 2007 por Nvidia exclusivamente para programación de aceleradores gráficos y sólo los producidos por esta compañía. Esta limitación de CUDA es quizá la explicación de su éxito, dado que está en armonía con un principio de la optimización, de que las soluciones que logran más eficiencia tienden a ser más dependientes del contexto.

Máquina vs dispositivo.

Cree una carpeta ejemplos/gpu en su repositorio personal.

7.1. Transmisión de calor

Ejemplo 55. Rendimiento de la transmisión de calor [heat_perf]

Descargue los siguientes archivos a una carpeta ejemplos/gpu/heat_perf. Agregue la hoja de cálculo a control de versiones, pero no los casos de prueba (archivos .bin). Llene las celdas en blanco de la hoja de cálculo con las duraciones de ejecución de las actividades en esta sección. Actualice la gráfica para incluir los incrementos de velocidad en un eje secundario.

Ejemplo 56. Transmisión de calor serial [heat_serial]

Descargue los archivos de la solución serial a una carpeta ejemplos/gpu/heat_serial. Agréguelos a control de versiones. Compile la solución. Realice las cuatro ejecuciones de la columna serial de la hoja de cálculo que descargó en Ejemplo 55, y anote la cantidad de generaciones y sus duraciones. Un ejemplo de ejecución podría ser:

./heat_serial ../heat_perf/test001.bin 0.1
  • Sugerencia 1: realice las ejecuciones en terminales distintas simultáneamente. Es decir, no espere a que la anterior finalice para realizar la siguiente.

  • Sugerencia 2: compare las ejecuciones de su ejecutable generado con GCC y PGI.

Ejemplo 57. Transmisión de calor OpenMP [heat_openmp]

Copie la solución de Ejemplo 56 a la carpeta heat_openmp. Modifique la solución para aprovechar los CPU disponibles en la máquina donde se ejecuta la simulación. Use OpenMP como tecnología para paralelizar la simulación.

Ejecute la solución resultante con los dos casos de prueba de Ejemplo 55 y anote las duraciones en la hoja de cálculo. Verifique que la cantidad de generaciones sea la misma que la versión serial.

Ejemplo 58. Transmisión de calor OpenAcc [heat_openacc]

Copie la solución de Ejemplo 56 a la carpeta heat_openacc_cpu. Modifique la solución para paralelizar la simulación con OpenACC. Adapte el Makefile-pgi para generar un ejecutale unificado (unified binary) que incluya código para CPU y GPU. Revise cuidadosamente la salida del compilador. Realice dos tipos de ejecuciones:

  1. Ejecute la solución resultante en CPU con los dos casos de prueba de Ejemplo 55 y anote las duraciones en la hoja de cálculo. Verifique que la cantidad de generaciones sea la misma que la versión serial. Compare las duraciones con OpenMP.

  2. Ejecute la solución resultante en GPU con los dos casos de prueba de Ejemplo 55 y anote las duraciones en la hoja de cálculo. Verifique que la cantidad de generaciones sea la misma que la versión serial.

La siguiente podría ser una interacción:

$ make
pgcc -c -g -acc -ta=multicore,tesla -Minfo=accel heat_openacc.c -o heat_openacc.o
pgcc.   -g -acc -ta=multicore,tesla -Minfo=accel heat_openacc.o -o heat_openacc

$ ACC_DEVICE_TYPE=host ./heat_openacc ../heat_perf/test001.bin 0.1
...
$ export ACC_DEVICE_TYPE=nvidia
$ ./heat_openacc ../heat_perf/test001.bin 0.1