1. Servidor concurrente

Este proyecto pretende convertir un código heredado serial (legacy code) a un servidor web en concurrente (avance 1), con aplicaciones concurrentes (avance 2) y aplicaciones distribuidas (avance 3). Involucra tanto concurrencia de tareas como paralelismo de datos. Se realiza en equipos de estudiantes. El máximo número de participantes por equipo lo establecerá su docente del curso.

1.1. Repositorio y archivos

La solución se entrega en un repositorio privado que cada equipo de estudiantes debe crear para proyectos. Agregue a su docente al repositorio. Tome en cuenta los lineamientos de la carta al estudiante respecto a proyectos que no se reiterarán en este documento.

El código de la rama principal representará el servidor web en producción. Eviten hacer commits directamente a ella. En su lugar, resuelva cada requerimiento en una rama (branch) y pida que sus colegas revisen los cambios y fusionen esta (merge) con la rama principal. Es decir, tome un requerimiento, cree una rama para el requerimiento, haga tantos commits como necesite, solicite un merge request (también llamado pull request). Cuando le asignen un merge request, revise el código de sus colegas, si lo necesita, haga sugerencias de correcciones. Una vez que la solución sea satisfactoria, realice el merge y revise que el código en la rama principal corra adecuadamente. De esta manera se estará emulando el flujo de trabajo habitual en la industria, que además tiene beneficios importantes al revisar código de otras personas que es la labor de líderes técnicos.

La rama principal siempre debe compilar y correr. Un merge o commit en esta rama nunca "romper el build", por ejemplo con un error de compilación. Para las revisiones de avances se considerarán los commits en la rama principal (master). Para cada avance rotule (git tag) el último commit antes de la fecha de entrega por con los nombres avance11 (o en inglés milestone11) para el primer avance, avance12 para el segundo, y así sucesivamente. En caso que necesite corregir algo después de la fecha de entrega, hágalo sin dudar.

Si el repositorio se va a usar para un único proyecto, la raíz del repositorio será la carpeta de proyecto, y debe tener su propio documento de análisis (readme.adoc).

Si el repositorio se va a usar para varios proyectos, cree una carpeta dentro del repositorio para cada proyecto, a la cual se le llamará carpeta del proyecto. Cada proyecto debe tener su documento de análisis (readme). El repositorio también debe tener también su propio readme ubicado en la raíz del mismo. El readme del repositorio debe indicar el propósito de del repositorio (sobre proyectos del curso), tener enlaces hacia las carpetas de los proyectos que facilite su navegabilidad junto con un párrafo que resuma el propósito del proyecto, y listar los nombres de los miembros del equipo.

El código heredado es parte del ejemplo patrón productor-consumidor. Sigue la estructura de archivos y directorios característica de un proyecto de Unix, descrita en el enunciado de la tarea01, y debe ser preservada por los miembros del equipo. Los siguientes comandos obtienen una copia del código heredado:

# Clone your empty repository
git clone <repo_url>
cd <repo_clone>

# Fetch reusable Makefile into common/ subdirectory
wget -q http://jeisson.work/Makefile -P common/

# Fetch Makefile for your web server project into repo's root directory
wget -q http://jeisson.work/concurrente/2025b/material/taskc/prod_cons_pattern/Makefile

# Fetch the legacy code into src/ subdirectory
make src

# Create the design/ directory to store your diagrams
make project=design/readme.adoc_

# Make the initial commit
git add .
git status # Verify no *.html* files are staged
git commit -m 'Legacy code'

Los siguientes comandos compilan, corren el servidor web serial heredado, y hacen algunas pruebas desde la línea de comandos:

# Compile the web server (using ASan as an example)
make asan -j

# Run the web server and navigate it from a web browser http://localhost:8080/
make run

# Measure server response time using an example request (use another terminal)
make crun

# Run a stress test
make stress

# Stop the web server with Ctrl+C. Clean the generated code
make clean

Las clases, métodos, y tipos de datos, nuevos o que modifiquen deben estar documentados con Doxygen y el código no trivial en los cuerpos de las subrutinas. Su solución debe ser modular, dividiendo el código en subrutinas, clases, y archivos. El código debe tener un estilo consistente y no generar diagnósticos del linter (cpplint).

1.2. Análisis

El objetivo del proyecto es desarrollar un servidor web concurrente y distribuido que permita a sus visitantes interaccionar con una cantidad arbitraria de aplicaciones web. En el primer avance el servidor web debe ser concurrente, y las aplicaciones web son seriales orientadas a objetos. El equipo debe proveer al menos las siguientes aplicaciones web:

  1. Una aplicación que calcula la factorización prima de números enteros (ya provista en el código heredado).

  2. Una aplicación que calcula sumas de Goldbach.

  1. Una aplicación que descifra textos usando el algoritmo SHA256.

  2. Una aplicación que siempre sirve una página de error 404, indicando que el recurso no se encuentra.

La elección de las aplicaciones anteriores es un poco atípica en desarrollo web. Fueron escogidas específicamente por requerir grandes cantidades de recursos, lo que motiva a combinar paralelismo de datos junto con concurrencia de tareas para responder tan eficientemente como sea posible a sus clientes web.

En este proyecto el equipo trabaja con una base de código existente, lo cual es el escenario más probable que enfrente la persona profesional al integrarse a la fuerza laboral y para ello reciba una inducción de parte de otras personas profesionales. Una inducción a este código está disponible en el formato de video en la sección Servidor web concurrente (proyecto 1) del material del curso.

Al leer el documento de análisis (readme.adoc) debe quedar claro a una persona lectora ajena al proyecto, qué problema el proyecto resuelve. Resuman este propósito y provean un enlace hacia este enunciado. Es sumamente importante una sección manual de uso dentro de este documento, que incluya cómo compilar el servidor web, cómo correrlo, y cómo usarlo a través de un navegador. Explique los argumentos en línea de comandos que recibe el servidor, ejemplos de ejecuciones, mensajes de error que podría generar, y cómo detenerlo tanto con Ctrl+C como el comando kill. Asegúrese de que los nombres de los integrantes del equipo estén en el readme del repositorio.

Se recomienda que un miembro del equipo haga su análisis en el readme y lo suba al repositorio, luego otro integrante enriquece el documento conforme realiza su análisis y así hasta el último miembro del equipo. También pueden trabajar simultáneamente todos los miembros del equipo en un documento compartido que luego suben a control de versiones. Recuerde, no escriba para cumplir una asignación de un curso, ni para el docente, no es este el fin. Escriba para perfeccionar sus habilidades de comunicación, pensando en que clientes leerán su documento. Esta habilidades blandas le permitirán junto con sus habilidades técnicas subir en la escala salarial. Una vez completado el documento de análisis se recomienda probarlo pidiéndole a personas ajenas al proyecto leerlo y pedirles que les expliquen de vuelta lo que comprendieron.

Una vez implementadas las aplicaciones, agreguen al inicio del readme una captura de pantalla de una consulta hecha a través de un navegador. Agregar una imagen al inicio es una práctica común en los repositorios de control de versiones que provee un efecto más llamativo y profesional del proyecto. Guarde la o las imágenes en una subcarpeta img/ y no en la raíz del proyecto, y asegúrese de que no ocupe más de 100kB. No es necesario hacer capturas de la terminal, ya que el texto se puede copiar y pegar en un bloque de código dentro de su documento de análisis.

Aplicación de factorización prima

Factorizar números enteros en sus componentes primos es una tarea repetitiva y tediosa, en especial para números grandes. Sin embargo, también lo es para las computadoras tradicionales. La seguridad de sus comunicaciones privadas, datos sensibles y su dinero en el banco depende de esta dificultad, ya que están protegidos por números difíciles de factorizar.

Todo número entero positivo se clasifica en tres categorías: uno, primo, o compuesto. Un número primo es un número entero divisible solamente por 1 y por él mismo, como es el caso de 17. Los números compuestos son aquellos que tienen más de dos divisores, se pueden descomponer y representar como una multiplicación de factores primos. Por ejemplo, el número 10 se puede representar como 2 x 5, el número 100 como 2 x 2 x 5 x 5 o como 2^2 x 5^2 usando notación de potencias. El número 1 es especial y no se considera ni primo ni compuesto.

Se necesita una aplicación web que reciba una lista de números enteros y calcule la factorización prima de cada uno de ellos. La salida tendrá los números en el mismo orden en que fueron provistos. Cada línea de la salida contiene un número ingresado seguido de su factorización prima. Los exponentes se deben anteceder por el carácter ^. Note que los exponentes 1 no se deben escribir. A continuación se muestra un ejemplo:

Ejemplo de entrada:

1,7,25,87,378,1400,-40

Salida esperada:

1: NA
7: 7
25: 5^2
87: 3 29
378: 2 3^3 7
1400: 2^3 5^2 7
-40: invalid number

Su programa debe poder factorizar números positivos de 64 bits con signo, es decir, entre 0 y 2^63-1 (int64_t de la biblioteca <stdint.h>). Si en una línea se provee un número fuera de este rango o un texto que no es un número, el programa debe generar el texto "invalid number", y continuar procesando el resto de números.

Conjetura de Goldbach

En 1742 Christian Goldbach de Prusia le escribió una carta al matemático suizo Leonhard Euler con una conjetura:

Todo número entero mayor que `5`:

  1. Si es par se puede escribir como suma de dos números primos (conjetura fuerte). Por ejemplo: 6=3+3, 8=3+5, `14=3+11=7+7`.

  2. Si es impar se puede escribir como suma de tres números primos (conjetura débil). Por ejemplo.: 7=2+2+3, 9=2+2+5, `9=3+3+3`.

Euler no pudo demostrar la conjetura, ni tampoco cientos de matemáticos convirtiéndolo en el problema abierto más intrigante en la historia de la teoría de números. Algunas novelas y películas hacen alusión a la conjetura, y entre los años 2000 y 2002 se ofreció un premio de un millón de dólares a quien lograse demostrarla, premio que no fue reclamado. En el 2013, tras 271 años de su aparición, el matemático peruano Harald Helfgott propuso una demostración para la conjetura débil que está actualmente sometida a revisión de pares, y en caso de validarse convertiría la conjetura débil en teorema.

Escriba una aplicación que reciba una lista de números enteros y calcule todas las sumas de Goldbach que equivalen a cada número. Su programa producirá como resultado un resumen de cuántos números fueron ingresados y cuántas sumas en total se encontraron.

Luego listará los números en el el mismo orden en que fueron ingresados. Cada línea de la salida contiene un número ingresado seguido por la cantidad de sumas de Goldbach que ese número tiene. Si el número ingresado es negativo, se considerará como que la persona usuaria quiere que el programa además liste todas las sumas del correspondiente número positivo. A continuación se muestra un ejemplo.

Ejemplo de entrada:

1,2,6,7,-8,-9,10,11,12,13,-14,-21,

Salida esperada:

Total: 12 numbers 19 sums

1: NA
2: NA
6: 1 sums
7: 1 sums
-8: 1 sums: 3 + 5
-9: 2 sums: 2 + 2 + 5, 3 + 3 + 3
10: 2 sums
11: 2 sums
12: 1 sums
13: 2 sums
-14: 2 sums: 3 + 11, 7 + 7
-21: 5 sums: 2 + 2 + 17, 3 + 5 + 13, 3 + 7 + 11, 5 + 5 + 11, 7 + 7 + 7

Note que las sumas deben estar ordenadas por sus valores, del menor a mayor. Su programa debe poder calcular números de 64 bits con signo, es decir, entre 0 y 2^63-1 . Si se provee un número fuera de este rango, el programa debe generar el texto "NA". Si se provee un texto que no sea un número, el programa debe reportar el mensaje "invalid input" y no continuar procesando más números.

Descifrado de textos

Provea una aplicación web que trate de encontrar el texto original con que se generó un código hash usando el algoritmo SHA256 en codificación hexadecimal. La aplicación recibirá en el URI un texto de 64 caracteres hexadecimales, y tratará de encontrar el texto original que lo generó por fuerza bruta. Por ejemplo, el texto abcd genera el código SHA256 88d4266fd4e6338d13b845fcf289579d209c897823b9217da3e161936f031589. Si la aplicación recibe este código de 64 caracteres, debe tratar de encontrar el texto original abcd.

La aplicación hará su mejor esfuerzo probando todos los textos de máximo 5 caracteres usando el alfabeto inglés en minúsculas, únicamente (26 símbolos). Es decir, la aplicación generará todos los posibles textos con este alfabeto:

"" (cadena vacía)
a
b
...
z
aa
ab
...
aaaaa
...
zzzzz

Por cada texto, generará su código SHA256 y lo comparará con el código recibido en el URI. Si encuentra una coincidencia, reportará el texto original. Si tras probar con todas las posibles palabras no encuentra ninguna coincidencia, generará una página con un mensaje de error.

Para generar el código SHA256 de un texto, puede usar la biblioteca Crypto++. El siguiente programa de ejemplo calcula el código SHA256 de cada línea que reciba en la entrada estándar:

 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
// Copyright 2025 Jeisson Hidalgo ECCI-UCR CC-BY-4.0
#include <cryptopp/filters.h>
#include <cryptopp/hex.h>
#include <cryptopp/sha.h>
#include <iostream>
#include <string>

/**
 * @brief Converts the given text to its SHA256 hash in hexadecimal encoding.
 * Example:
 *
 * SHA256("aa") ==
 * "961b6dd3ede3cb8ecbaacbd68de040cd78eb2ed5889130cceb4c49268ea4d50"
 *
 * A cryptographic hash function is a mathematical function that converts an
 * arbitrary-sized input (often called a "message") into a fixed-length string,
 * but finding the original input from the hash is computationally infeasible.
 *
 * @param text Any plain text
 * @return A 64-length string with the SHA256 hash in hexadecimal encoding
 * @authors Adapted from https://stackoverflow.com/a/7045815
 */
std::string SHA256(const std::string& text) {
  // Result of converting the text to SHA256 in hexadecimal encoding
  std::string result;

  // [1] Create the Crypto++ pipeline
  // Create a dataflow pipeline that flows data to a string sink with the result
  CryptoPP::StringSink* sink = new CryptoPP::StringSink(result);
  // Convert the result to hexadecimal encoding
  CryptoPP::HexEncoder* encoder = new CryptoPP::HexEncoder(sink, false);
  // Use the SHA256 cryptographic algorithm
  CryptoPP::SHA256 hash;
  // Create a pipeline similar to the Unix command:
  // echo $text | sha256sum | hex
  CryptoPP::HashFilter* filter = new CryptoPP::HashFilter(hash, encoder);

  // [2] Execute the pipeline, it will delete the dynamic memory automatically
  // Extract data from the text string and pass it through the pipeline towards
  // the sink that fills the result string
  CryptoPP::StringSource(text, true, filter);
  // Return the hexadecimal result
  return result;
}

int main() {
  // Convert lines from standard input to SHA256 in hexadecimal encoding
  std::string line;
  while (std::getline(std::cin, line)) {
    std::cout << SHA256(line) << ' ' << line << std::endl;
  }
  return 0;
}

Para compilar el programa necesitará instalar la biblioteca junto con sus archivos de encabezados, lo cual puede lograrse su administrador de paquetes:

sudo apt install libcrypto++-dev # Debian
sudo dnf install cryptopp-devel # RedHat
brew install cryptopp # MacOS
pkg install cryptopp # BSD

Finalmente agregar la biblioteca a su ejecutable cuando lo compile. Esto lo puede lograr con LIBS += -lcryptopp en su archivo Makefile.

Importante: Su aplicación no debe almacenar los códigos SHA256 en un caché, si no, que debe calcularlos en el momento que reciba la solicitud. Su código fuente debe fácilmente permitir incrementar el alfabeto y la longitud máxima de los textos a probar.

1.3. Diseño

Esta primera entrega consiste en modificar el código heredado serial para que el servidor web se comporte de manera concurrente. Es decir, que el código sea capaz de aceptar y atender múltiples conexiones de manera concurrente usando hilos de ejecución. El hilo recibirá de su cliente una o varias solicitudes dirigidas a la misma o diferentes aplicaciones. El hilo se encargará de localizar la aplicación correspondiente y permitirle a esta que responda al cliente.

Su solución debe tener un buen diseño concurrente expresado mediante un diagrama de flujo de datos, es decir, una cadena de producción que resalte la concurrencia y la aplicación del patrón productor-consumidor. Puede tomar el siguiente diseño inicial como punto de partida (clic en la imagen para abrirla y clonarla).

proy1.1 dsgn
Figura 1. Diseño parcial/inicial del flujo de datos del servidor concurrente

El diseño de la Figura 1 es inicial, incompleto. El equipo debe adaptarlo para ser concreto, completo, e incluir al menos los siguientes detalles:

  1. Mostrar el recorrido completo de datos concretos (sockets, solicitudes, números o textos) por el flujo de la cadena de producción. Los datos inician con dos conexiones (dos sockets). Puede suponer que el socket 1 es la conexión del cliente 1 y el socket 2 es la conexión con el cliente 2.

  2. En al menos uno de esos sockets se piden al menos dos solicitudes HTTP. Puede representar una solicitud con el texto F n1 …​ nN o G n1 …​ nN o D t1 …​ tN, donde F indica que es una solicitud de factorización, G que es una solicitud de sumas de Goldbach, y D que es una solicitud de descifrado de texto. Es seguida por una cantidad arbitraria de números o textos separados por espacios en blanco o comas. Al menos una aplicación web debe recibir dos solicitudes. Con cuatro solicitudes HTTP en total es suficiente para el diagrama.

  3. Al menos una solicitud HTTP debe solicitar al menos dos números sea a factorizar o de sumas de Goldbach, y al menos una solicitud HTTP debe solicitar al menos dos textos a descifrar. Al menos uno de los números a factorizar debe tener al menos dos potencias primas. Al menos uno de los números de Goldbach debe ser par y otro impar. Al menos uno de los números genera al menos dos sumas de Goldbach. Estos datos se deben representar en las aplicaciones para el avance 1.1, y luego en nodos de colas para los avances 1.2 y 1.3.

  4. El diagrama inicial presenta sólo la aplicación de factorización y su modelo. Deben agregarse los objetos para las aplicaciones web de Goldbach y descifrado, junto con sus objetos modelo respectivos. Deben además redirigirse los datos correspondientes a cada aplicación.

  5. Use elementos gráficos consistentemente. Provea colores, formas, patrones que agreguen significado al diseño. Amplíe la Simbología o Leyenda al diagrama, explicando el significado de cada elemento gráfico. Por ejemplo, es importante distinguir las flechas que indican flujo de datos del patrón productor-consumidor (entre un hilo y otro), de flechas que sólo son invocaciones de métodos dentro del mismo hilo de ejecución.

  6. Las relaciones que no utilizan simbología estándar UML, debe indicarse su nombre. Por ejemplo, una relación de una cola hacia un hilo probablemente llevará el nombre de consume(), mientras que una relación hacia el objeto modelo será el nombre del método con que ese objeto encuentra la factorización prima o las sumas de Goldbach.

  7. En la cadena de producción intervienen instancias (objetos) de clases. Las clases nuevas que ustedes agregan al proyecto o modifican del código heredado, se deben representar en UML. Ubique estas clases en los alrededores del diagrama de flujo de datos para facilitar la lectura del diseño. Si no es fácil de inferir la clase UML a partir del nombre de un objeto, conecte el objeto en el flujo de datos con líneas punteadas a su respectiva clase UML. En las instancias (objetos) dentro del flujo de datos no es necesario indicar los nombres de los atributos, basta con indicar sólo los valores de los campos en el mismo orden que están en la clase UML.

  8. Los métodos más relevantes para la concurrencia en la cadena de producción corresponden a los métodos de correr los hilos, detenerlos, producir y consumir. El código a ejecutar en estos métodos pueden diseñarse en pseudocódigo como se ha visto en las lecciones del curso. El diseño debe centrarse en la lógica concurrente y no los detalles algorítmicos seriales.

Los diseños deben guardarse en la carpeta design/ dentro de su proyecto 1. Las imágenes deben exportarse a formato SVG (preferible) o PNG. Dentro de esta carpeta agregue el documento de diseño design/readme.adoc, donde se explique de forma resumida su diseño e incruste las imágenes (diagramas) y listados de código (algoritmos).

Al leer el documento de diseño, una persona desarrolladora tendrá una comprensión de alto nivel de la solución. Es muy útil para la fase de implementación o para futuros miembros de un equipo con el fin de poder comprender el diseño de la solución. Recuerde: no diseñe para cumplir con una asignación de un curso, ni para su docente (esta persona no lo necesita, pues no es un cliente real), ese no es el fin. Su propósito es desarrollar sus habilidades para resolver problemas con artefactos baratos y fáciles de modificar (diseño) antes de implementar, y recibir realimentación que le ayude en el futuro cuando sí esté construyendo soluciones para sus clientes reales.

En el documento de análisis para el proyecto, agregue una sección de diseño y un enlace el documento de diseño. Esto permitirá la navegabilidad desde la raíz del repositorio al proyecto 01, y de éste hacia su diseño.

Probablemente notará que en el diseño de la Figura 1 no hay una cadena de producción concurrente completa. La cadena concurrente se completará en el siguiente avance de proyecto.

1.4. Servidor web concurrente

El 'código heredado' implementa un servidor web que se comporta de manera serial. Como es sabido, los servidores (o servicios) de software son de naturaleza concurrente (ej.: servidores de bases de datos, de correo, enrutadores de red, y hasta su sistema operativo). El reto del equipo es convertir a este servidor web serial en uno concurrente para que naturalmente pueda atender varias conexiones de clientes al mismo tiempo. Nótese que esta mejora sólo altera el código del servidor (parte izquierda de la Figura 1), y no de las aplicaciones web (parte derecha de la Figura 1). Estas se ven beneficiadas por la concurrencia del servidor aunque las aplicaciones sigan siendo seriales.

Al iniciar, el servidor web recibirá tres argumentos de línea de comandos, todos opcionales, y el equipo podría agregar otros:

  1. El puerto en el que el servidor web esperará por conexiones de clientes. Si no se provee, suponga 8080.

  2. La cantidad máxima de conexiones (maxConnections) que el servidor puede atender concurrentemente. Si no se provee, suponga tantas conexiones como CPUs hay disponibles en el sistema.

  3. La capacidad máxima de la cola de conexiones. Si no se provee, suponga el número máximo en que se puede inicializar un semáforo POSIX.

Una vez que el servidor inicia, se debe crear este tantos hilos de ejecución como se indique en el segundo argumento de línea de comandos. Estos hilos se encargarán de atender conexiones, por lo tanto, serán instancias de una clase cuyo nombre se sugiere sea HttpConnectionHandler y se almacenará en la carpeta src/http/ (ver Figura 1).

Para poner los HttpConnectionHandler en funcionamiento, se deben agregar a la cadena de producción. Esta cadena no existe, puesto que el servidor es serial. Se debe modificar la clase HttpServer ubicada en la carpeta http/ del código heredado para implementar un patrón productor consumidor. El productor será el hilo principal que produce conexiones aceptadas con los clientes, llamadas sockets que corresponden a objetos de la clase Socket y se deben poner copias de estos objetos en la cola. Los consumidores de estas conexiones serán los HttpConnectionHandler.

La cola de conexiones (sockets) entre el servidor web y los HttpConnectionHandler debe ser un buffer acotado, para reducir la probabilidad de que el servidor acepte muchas conexiones que no podrá atender por saturación. El tercer argumento de línea de comandos indica la capacidad de esta cola, que debe ser un número positivo (1 o más). En el futuro agregará más colas a la cadena de producción, y puede suponer la capacidad del tercer argumento para todas ellas.

En el código heredado, el servidor serial acepta una conexión, de ella extrae una solicitud HTTP, la responde, cierra la conexión, y continúa con la próxima conexión, y así indefinidamente. Esto implica que si dos usuarios se conectan al servidor, el segundo será atendido hasta que el primero haya terminado todas solicitudes. En la versión concurrente, este diseño cambia a una pequeña cadena de producción como la siguiente:

  1. El objeto HttpServer en el hilo principal sólo debe aceptar solicitudes de conexión y poner las conexiones aceptadas en cola, nada más, no atenderlas.

  2. Los hilos/objetos HttpConnectionHandler consumen las conexiones de red con los clientes.

  3. Los hilos/objetos HttpConnectionHandler extraen todas las solicitudes HTTP que el cliente envíe a través de la conexión (socket), no sólo la primera. Por cada solicitud HTTP ensambla los objetos HttpRequest y HttpResponse. Estos dos objetos transportan una copia del Socket y deben viajar juntos por su cadena de producción.

  4. El HttpConnectionHandler es un objeto de la capa del servidor, no de aplicación. Por cada solicitud que extraiga, no debe calcular la respuesta, sino que delega en las aplicaciones web. Es decir, el HttpConnectionHandler recorrerá todas las aplicaciones web en busca de aquella encargada de atender la solicitud (si la hay). Para este avance la aplicación web es sólo un objeto, no un hilo de ejecución. Por lo tanto, la aplicación web es un objeto que recibirá la solicitud (los objetos HttpRequest y HttpResponse) y hará su trabajo del dominio (ej.: calculará la factorización prima, las sumas de Goldbach, o el texto descifrado, de cada dato solicitado) en el mismo hilo de ejecución del HttpConnectionHandler.

  5. Después de obtener el resultado del dominio (la descomposición prima, las sumas de Goldbach, o texto descifrado, de los valores de una solicitud), la aplicación web responderá directamente al cliente usando el socket que el objeto HttpResponse transporta internamente, pero sin cerrarlo (a menos de que el cliente use el protocolo HTTP/1.0).

  6. Una vez que la aplicación web responde al cliente, el HttpConnectionHandler retorna al ciclo de solicitudes para atender más peticiones que el cliente pueda tener (paso 3 de esta lista). Cuando el cliente no tenga más solicitudes, cerrará la conexión (socket) y el HttpConnectionHandler saldrá del ciclo de atender solicitudes y regresará a la cola para consumir otra conexión, si la hay (paso 2 de esta lista).

El ciclo indicado en los pasos anteriores se detendrá cuando el HttpConnectionHandler extraiga una condición de parada de la cola, representada por un Socket inválido creado con el constructor por defecto. Para ello, el servidor web tendrá que colocar en cola tantas condiciones de parada como HttpConnectionHandler se hayan creado, cuando reciba la señal para detenerse, como un Ctrl+C, explicado más adelante.

El código heredado tiene documentación y comentarios de tipo // TODO: que proporcionan una guía sobre lo que se debe implementar. Esto es una cortesía del curso, y desdichadamente una práctica poco común en la industria.

1.5. Aplicación web orientada a objetos

Las aplicaciones web en el primer avance, son seriales y deben estar implementadas en el paradigma de programación orientada a objetos y siguiendo el patrón modelo-vista-controlador (MVC), en coherencia con el resto de la base de código provisto. De acuerdo a este patrón, el código del servidor web (controlador), la aplicación web (vista), y el cálculo del dominio del problema (modelo), deben estar en objetos separados. Se recomienda una separación en al menos las dos siguientes clases de C++:

  1. La aplicación web. Tiene principalmente responsabilidades de una vista ("lo que ve el usuario"). Nombres adecuados para las clases que las presentan pueden ser FactWebApp, GoldbachWebApp, DecryptWebApp, y NotFoundWebApp. Estos objetos encargan de recibir las solicitudes HTTP que emite el cliente, extraer los datos de la solicitud HTTP, pasarlos al modelo (explicado en el siguiente punto), esperar el resultado que retorna el modelo, ensamblar y despachar las respuestas HTTP que serán las que verán los clientes. Nótese que ni el servidor web ni la aplicación web deben realizar cálculo alguno relativo al dominio, pues de esto se encarga el objeto modelo.

  2. El modelo. Tiene estrictamente responsabilidades del dominio del problema, por lo tanto, no debe saber nada de web ni redes de computadoras. Se le puede dar un nombre como PrimeFactModel, PrimeFactSolver, PrimeFactCalculator, FactorizationCalculator, etc. Se encarga de aplicar la lógica del dominio a los datos solicitados. Esta clase trabaja con estructuras de datos en memoria. Por ejemplo, recibe colecciones de números y retorna colecciones de números factorizados o sumas de Goldbach. No debe producir resultados en formato HTML ni texto formateado. Recuerde que por ser un modelo, esta clase debería poderse reutilizar en otras aplicaciones (ej.: de dispositivos móviles) sin cambio alguno. Note que la aplicación de errores 404 no necesita un objeto modelo.

Important
Puede ocurrir que entre varias aplicaciones haya código común. No genere redundancia de código. Utilice mecanismos de reutilización de código, como es debido.

Su solución debe realizar los cálculos correctamente. Es decir, la aplicación web responde con una página que tiene las factorizaciones primas, sumas de Goldbach, o textos descifrados. Para efectos de este proyecto, el servidor web produce un único mensaje de respuesta por cada solicitud HTTP del cliente, la cual contiene todos los resultados de los números solicitados. Esto implica que si el cálculo de las factorizaciones o sumas toma tiempo al servidor, el cliente deberá esperar. Si el cálculo tarda más que la duración máxima de inactividad de una conexión de red (timeout), cuando la aplicación haya concluido, no podrá responder al cliente porque éste se habrá desconectado ya. Para efectos de esta evaluación, no es necesario corregir este error.

La aplicación web debe permitir ingresar listas de valores separados por comas tanto en el URI (barra de direcciones del navegador), como el formulario web. Dado que la web, al utilizar conexiones de red, está expuesta a ataques mal intencionados. La aplicación debe validar sus entradas. Por ejemplo, debe reportar números mal formados, fuera de rango, o entradas mal intencionadas, deben reportarse con mensajes de error en la página web resultado, en lugar de caerse o producir resultados con números incorrectos. Tome en cuenta que el manejo de números positivos y negativos deben tener diferente tratamiento en la aplicación de Goldbach.

1.6. Finalización del servidor

Al finalizar el servidor web (con Ctrl+C o el comando kill), éste debe reaccionar a la señal y hacer la limpieza debida. Esto implica, entre otras tareas, detenerse de aceptar más conexiones de clientes, avisar a los hilos secundarios para que terminen su ejecución, esperar que terminen el trabajo pendiente, y finalmente liberar recursos como estructuras de datos en memoria dinámica, archivos, u otros, sin provocar fugas de memoria ni otros problemas. Para lograr este efecto se recomienda realizar lo siguiente.

  1. Convertir la clase HttpServer en un objeto singleton. Este patrón de software se aplica a aquellas clases que sólo pueden tener una única instancia (objeto) dentro de un proceso. Sus constructor es privado, no permite copias, y un método permite obtener acceso a esa única instancia. La clase Log en el código heredado ya implementa este patrón.

  2. Registrar un procedimiento libre o método estático (preferible), técnicamente conocido como signal handler, que se invoque cuando el proceso reciba la señal SIGINT (Ctrl+C) o SIGTERM (comando kill PID). Véase la función signal() de la biblioteca estándar de C.

  3. Cuando el signal handler se invoque, podrá conseguir la instancia del servidor web, ya que éste es un singleton, y le solicita que se detenga, invocando su método stop(). Sólo se debe hacer esta invocación y nada más, porque el sistema operativo podría llamar al signal handler en cualquiera de los hilos del proceso, y no necesariamente en el hilo principal. El propósito de llamar a stop() es provocar que el hilo principal se detenga de esperar por nuevas conexiones de clientes y comience el proceso de finalización del servidor.

  4. El método HttpServer::stop(), que podría ser invocado en cualquier hilo y no necesariamente en el hilo principal, invoca el método heredado stopListening(), el cual cierra de golpe el socket de aceptar conexiones. Esto provoca que el HttpServer corriendo en el hilo principal falle al tratar de conseguir el próximo cliente, lance una excepción, retorne de la invocación this→acceptAllConnections() e ingrese a la sección catch en el método run(). A partir de este punto en adelante del método run() invoque métodos para detener la cadena de producción (enviar condiciones de parada), liberar estructuras de datos, y al servidor, sabiendo con seguridad que este código se ejecutará desde el hilo principal.

Para poder probar que su servidor finaliza adecuadamente y libera los recursos, puede correr el ejecutable instrumentalizado (ej.: asan, tsan, …​) en forma interactiva y presionar Ctrl+C. Si corre el ejecutable en un ambiente simulado (ej.: memcheck, helgrind), Valgrind reporta identificador de proceso (PID, Process ID) de la forma ===PID===. Lo podrá detener con el comando kill PID donde PID se reemplaza por el número de proceso. Alternativamente puede usar pkill nombre_proceso, donde nombre_proceso es el nombre de su ejecutable. Al detener su proceso, no se deben obtener reportes de errores ni fugas de memoria.

1.7. Pruebas de concurrencia

Una vez que se haya implementado, es necesario probar que el servidor web sea concurrente. Una prueba sencilla es crear dos conexiones, por ejemplo, con dos ventanas de un navegador. Solicitar primero en una ventana uno o varios datos lentos de procesar, que tarden varios segundos. Mientras esa solicitud aún no haya terminado, en la segunda ventana del navegador solicitar uno o varios datos rápidos de procesar. Si el servidor responde la segunda solicitud antes de que la primera finalice, es un indicio de que es concurrente. Si por el contrario, la segunda solicitud es atendida hasta después que la primera finalice, indica que el servidor web está serializando las conexiones.

Para determinar más sistemáticamente que el servidor web realmente atiende varias conexiones de forma concurrente, se usará una herramienta de pruebas de estrés (stress testing), como httperf. Idealmente se correrán las pruebas en el laboratorio de computadoras del curso. Se ejecuta el servidor web en una máquina (instrumentalizado con ASan, luego con TSan). Mantenga visible un administrador de procesos para monitorear el consumo de CPU a lo largo de todas las pruebas en todas las máquinas usadas.

Obtenga la dirección IP local de esa máquina con el comando ip addr. Luego corra el servidor web. Por ejemplo, el siguiente comando corre el servidor en el puerto 8080, para atender un máximo de 30 conexiones concurrentes y un máximo de 70 conexiones en espera:

ip addr | grep -E '\b\d+(\.\d+){3}\b'
bin/webserv 8080 30 70

Desde otras computadoras del laboratorio, inicie varias pruebas de estrés. Dirija solicitudes a las diferentes aplicaciones web. Por ejemplo, el siguiente comando trata de establecer un total de 200 conexiones (--num-conns) con el servidor que está escuchando en el puerto 8080 (--port) en la máquina con dirección IP 10.137.1.117 (--server), a una tasa de 50 conexiones cada segundo (--rate). Es decir, en el primer segundo tratará de establecer las primeras 50 conexiones, en el siguiente segundo enviará las solicitudes de conexión 51 a 100, y así, hasta que en el cuarto segundo solicite las 200 conexiones.

httperf --server 10.137.1.117 --port 8080 --num-conns 200 --rate 50 --num-call 3 --uri /fact?number=123,999 --timeout 1

Sin embargo, el servidor atenderá las primeras 30 solicitudes de conexión de forma concurrente, las siguientes 70 que vayan apareciendo las aceptará pero las pondrá en cola. Las restantes 100 solicitudes de conexión quedarán pendientes hasta que se libere espacio en la cola, o transcurra el segundo indicado en el argumento --timeout, lo que ocurra primero.

Una vez que el servidor web acepte cualquiera de las 200 conexiones, httperf enviará de inmediato 3 solicitudes HTTP (--num-call) por esa conexión. Cada solicitud pedirá el recurso identificado con el URI http://10.137.1.117:8080/fact?number=123,999, el cual usted deberá ajustar a lo que su aplicación web espera. Es recomendado obtener este URI de una ejecución con el navegador. Si el servidor web no le llegara a responder una de estas solicitudes en 1 segundo (--timeout), httperf lo tomará como una no-respuesta y lo reportará en las estadísticas. En el el sitio del proyecto httperf se explica con detalle cómo interpretar su salida.

Para la versión serial (el código dado) del servidor web, se tendrán respuestas positivas sólo si se realiza una única solicitud HTTP por conexión, es decir --num-call 1, ya que esta versión atiende una solicitud y cierra la conexión con el cliente, ignorando las demás solicitudes que éste realice. Para este avance, una vez que se haya implementado concurrencia en el servidor web, se debería tener respuesta de todas las solicitudes que el cliente haga en la misma conexión con el argumento --num-call mayor a 1.

El Makefile provisto facilita la ejecución de pruebas de estrés con la regla make stress. El siguiente ejemplo replica la prueba de estrés presentada anteriormente:

make stress HOST=10.137.1.117 NUMS=123,999 TIMEOUT=1

Haga varias pruebas de estrés en el laboratorio de computadoras del curso. Ajuste los parámetros de httperf de tal manera que su servidor pueda apenas cerca del 50% de las solicitudes en el tiempo establecido. Guarde esta consulta en las variables del Makefile provisto, pues servirán para pruebas durante la revisión de éste y futuros avances.

Agregue a su documento de análisis una subsección de "Pruebas de concurrencia" en la sección de "Manual de usuario". Escriba los comandos para correr httperf, describa sus argumentos, y una explicación corta de la salida esperada.

1.8. Evaluación

La revisión de avance será mediante una sesión interactiva en el laboratorio del curso. Los miembros del equipo primero realizan una presentación del producto y corren las pruebas de validez y de rendimiento. Luego se revisan algunas regiones de código elegidas por sus docentes. Para esta etapa se debe mostrar en pantalla tanto el diseño como el código al mismo tiempo en dos máquinas distintas. Todos los miembros del equipo deben participar durante la sesión. Se dará crédito a la evidencia presentada en cada aspecto de este enunciado y se distribuye en los siguientes rubros.

  1. [5%] Buen uso y aporte equilibrado del repositorio (commits, ignores, tags) y directorios.

  2. [10%] Análisis: README.md, problema, integrantes, manual de usuario, captura, navegabilidad.

  3. [10%] Pruebas de concurrencia (httpef) con ASan y TSan.

  4. [10%] Diseño concurrente: documento de diseño, flujo de datos, clases en UML, pseudocódigo.

  5. [10%] Servidor web concurrente mediante el patrón productor consumidor.

  6. [15%] Hilos consumidores HttpConnectionHandler enrutan solicitudes entre aplicaciones.

  7. [15%] Aplicaciones web orientadas a objetos y sus modelos: Goldbach, Descifrado, y NotFound.

  8. [15%] Finalización correcta del servidor. Singleton. Buen uso de la memoria y de la concurrencia (Sanitizers y Valgrind).

  9. [10%] Modularización en subrutinas y archivos. Convención de estilos (cpplint). Documentación de interfaces (doxygen) e implementaciones (algoritmos).

Recuerde que en todos los rubros se evalúan las buenas prácticas de programación.

2. Aplicaciones concurrentes

En el avance 1.1 el servidor web pasó de ser serial a concurrente, mientras que las aplicaciones eran seriales. En este avance, sus aplicaciones web serán también concurrentes para hacer un mejor uso del hardware en que corre el servidor.

2.1. Repositorio y archivos

Nota: Para optar por la mejora en la calificación del avance 01 tras la revisión, el equipo debe crear los issues o comentarios TODO, y aplicar las correcciones siguiendo los lineamientos para este aspecto indicados en las tareas individuales. Al final de las correcciones marcar el último commit con la etiqueta avance11fix (o en inglés milestone11fix).

Los cambios en el código para este segundo avance se deben hacer in place. Es decir, se realizan sobre la misma carpeta del avance 01 del repositorio de control de versiones. No cree copias del código fuente del proyecto. Los avances no son productos diferentes, sino, ir mejorando y madurando un producto, como ocurriría con software para un cliente real.

2.2. Análisis

En el avance 1 se construyó un servidor web concurrente que podía atender una cantidad arbitraria de clientes de forma simultánea. Los clientes solicitan cálculos sobre el dominio (ej.: factorización prima, sumas de Goldbach, descifrar textos), que son calculados por una aplicación web serial. Sin embargo, este diseño no es óptimo. Por ejemplo, si en una máquina con 8 núcleos de procesador se levanta un servidor web capaz de atender 1000 conexiones concurrentes, podrían ocurrir escenarios como los siguientes:

  1. Si en efecto se conectan 1000 usuarios que solicitan una cantidad considerable de trabajo cada uno, el servidor tendrá 1000 hilos (ó "1000 calculadoras") compitiendo por los 8 núcleos, lo que crea una gran competencia y cambio de contexto entre ellos, además de mayor consumo de memoria principal.

  2. Si se conectan varios usuarios, uno de ellos hace una solicitud que requiere una cantidad considerable de trabajo, digamos media hora, y los demás hacen solicitudes livianas. Los hilos de ejecución que atienden las solicitudes livianas terminarán rápido, mientras que el hilo que se encargue de la solicitud pesada se quedará trabajando por tiempo prolongado en un núcleo de CPU, mientras las restantes siete CPUs estarán desaprovechadas. Peor aún, cuando el hilo sobrecargado finalice, es muy probable que su cliente se haya desconectado por superar el límite de espera de red, y por lo tanto, el consumo de recursos fue en vano. En el otro extremo, por más que el cliente reintente la misma consulta, el servidor será incapaz de responderle a tiempo aunque disponga de los recursos para lograrlo.

En este avance se creará una solución que haga mejor uso de los recursos de la máquina, al hacer concurrentes las aplicaciones web que realizan la lógica del dominio. Se deberá crear tantos hilos para los cálculos del dominio (calculadoras) como CPUs hay disponibles en el sistema, indiferentemente de la cantidad de aplicaciones.

2.3. Diseño

En esta segunda entrega, modifique sus aplicaciones web para que no hagan un consumo agresivo o desequilibrado de los recursos de la máquina. Esta modificación consiste en convertir a aplicaciones web concurrentes aquellas que requieran paralelismo de datos. Estas aplicaciones reutilizarán entre ellas, la cantidad adecuada de hilos de paralelismo de datos que realizarán cálculos del dominio. La cantidad de estos hilos debe coincidir con la cantidad de núcleos de procesador disponibles en el sistema. El equipo de HttpConnectionHandler no se consideran hilos de paralelismo de datos, si no de concurrencia de tareas, porque la mayoría del tiempo pasan bloqueados esperando solicitudes de sus clientes.

Actualicen su diseño para incorporar el equipo de hilos de paralelismo de datos en el flujo de datos. El diagrama tendrá una cadena de producción completa que aplique el patrón productor-consumidor. El diseño de la Figura 1 es incompleto, pero servirá de base para ejemplificar algunos de los cambios que el equipo de desarrollo debe considerar.

proy1.2 dsgn
Figura 2. Diseño parcial de la aplicación concurrente

En el avance anterior, cada hilo HttpConnectionHandler realizaba varios asuntos, entre ellos cálculos del dominio (factorización, sumas de Goldbach, o descifrado) invocando en su pila métodos de las aplicaciones. En este avance, su diseño debe crear un equipo de tantos hilos como CPUs hay disponibles en el sistema para estos cálculos del dominio. Se les va a llamar calculadoras por sencillez en este documento. El rol de este equipo de hilos debe estar claramente indicado en su diagrama de acuerdo al patrón concurrente visto en el curso (productores, consumidores, ensambladores, o repartidores).

Su diseño debe contemplar cómo se comunican los hilos calculadores con otros hilos de la cadena de producción. Antes de las calculadoras se encuentran los hilos HttpConnectionHandler. Sin embargo, intercomunicarlos directamente a través de una cola no es óptimo, dado que los HttpConnectionHandler extraen solicitudes HTTP, de las cuales no se tiene control. Unas solicitudes pueden ser muy pesadas y otras muy livianas. Recuerde que se quiere evitar que un hilo calculador se ataree con un trabajo pesado mientras los otros estén ociosos. Se sugiere entonces la siguiente cadena de producción.

  1. Al igual que el avance 1, el servidor sólo acepta solicitudes de conexión y pone en cola las conexiones (sockets), nada más.

  2. Los HttpConnectionHandler consumen las conexiones (sockets). De una conexión, extraerán todas las solicitudes HTTP del cliente (no sólo la primera). El HttpConnectionHandler buscará la aplicación a quien va dirigida la solicitud HTTP. Las aplicaciones web que continúen siendo seriales, atenderán la solicitud HTTP en la pila del HttpConnectionHandler como de costumbre. Sin embargo, las aplicaciones web que requieren concurrencia, no atenderán estas solicitudes HTTP invocando métodos de los objetos modelo en la pila del HttpConnectionHandler. En su lugar, las estas aplicaciones construirán un objeto que el HttpConnectionHandler podrá poner en otra cola. Su equipo debe decidir un diseño de software que permita distinguir aplicaciones web seriales de las concurrentes.

  3. Su equipo de desarrollo deberá idear un diseño para los objetos que se agreguen a la cola que va después de los HttpConnectionHandler, dado que son objetos dependientes de las aplicaciones web. Recuerde que en C++ no se puede almacenar objetos de distinto tamaño (tipo de datos) en una colección. Pero este lenguaje sí provee mecanismos eficientes para lograrlo. Recuerde que esta, al igual que todas las colas, debe tener un nombre en significativo en su diseño y su capacidad puede controlarse con un argumento de la línea de comandos.

  4. ¿Quién consumirá las solicitudes HTTP que los HttpConnectionHandler pusieron en cola? Como se indicó antes, no es óptimo que sean los hilos calculadores. Cree un nuevo tipo de hilo que consume las solicitudes HTTP de esta cola y realiza una tarea que ayude a optimizar el trabajo de los hilos calculadores. Este nuevo tipo de hilo hará algún trabajo sobre las solicitudes HTTP, y colocará en cola los datos que procesarán los hilos calculadores. El equipo de desarrollo debe identificar y nombrar el trabajo de este hilo, y su función de acuerdo a la teoría de paralelismo de datos. Es muy importante que el equipo de desarrollo idee mecanismos de trazabilidad entre los datos que van a las calculadoras y las solicitudes HTTP que los generó.

  5. Exprese en su diseño el rol del patrón productor-consumidor de los hilos calculadores. Cada vez que un hilo calculador extrae trabajo de la cola, invocará un método que realiza un cálculo dependiente de la aplicación que creó el trabajo (factorización prima, sumas de Goldbach, descifrado de texto, o cualquier otro de futuras aplicaciones). El hilo calculador no necesita saber cuál de estos cálculos está realizando. Use mecanismos del lenguaje de programación que permitan a los hilos calculadores poder realizar trabajos de aplicaciones arbitrarias, incluso aplicaciones web que se podrían agregar al servidor web en el futuro, sin tener que modificar el código de los hilos calculadores. El resultado del cálculo es puesto por estos hilos en otra cola.

  6. Un hilo que se encarga de determinar cuáles solicitudes HTTP están completas. Este hilo recibe datos provenientes de los hilos calculadores, y debe revisar si la solicitud a la que pertenece cada dato está o no completa. Sólo si la solicitud está completa, la pone en cola.

  7. Un hilo que recibe las solicitudes que están completas, ensambla el mensaje de respuesta HTTP y lo envía al cliente que hizo la solicitud (navegador).

El diseño debe reflejar el recorrido de los datos de ejemplo concretos escogidos en el avance 1. La cadena de producción consta de datos que fluyen por las colas, y son procesados por objetos concurrentes y de paralelismo de datos. Especifique valores concretos de estos datos en los nodos de las colas, conforme estos fluyen a lo largo de la cadena de producción y en las invocaciones de métodos relevantes (flechas en la cadena de producción). Los datos e hilos que sean registros de memoria (objetos o instancias), requieren su correspondiente clase UML. La elección de los campos de cada clase y las relaciones entre ellas, deben reflejar decisiones adecuadas al aplicar la concurrencia de tareas (herencia de clases: productoras, consumidoras, ensambladores y repartidores), el paralelismo de datos (descomposición y mapeo), y una correcta trazabilidad.

El diseño debe balancear la carga de trabajo lo más equitativamente posible entre las CPUs disponibles en el sistema. Por tanto, deberán tomar decisiones de descomposición y mapeo que afectan a la naturaleza de las solicitudes. Es decir, las solicitudes serán descompuestas en unidades más finas de trabajo. Esto provoca que los datos que hayan en una cola tengan diferente granularidad que los datos de otras colas. Por lo tanto, no habrá una asociación 1 a 1 entre datos de distintas colas, por lo que el equipo deberá decidir algún mecanismo para mantener la trazabilidad.

Para representar la trazabilidad en su diseño, provea datos en los nodos de las colas que permitan ensamblar luego las respuestas que serán enviadas a los clientes. Usted y su equipo deben estar en capacidad de explicar oralmente cómo las solicitudes se van transformando en cada paso de la cadena de producción, hasta llegarse a construir la respuesta. Esta explicación podría incluir frases como "Cuando el hilo X extrae una solicitud HTTP de la cola de consumo, invoca su método Y el cual le retorna Z y por cada elemento de Z agrega a la cola de producción…​"

Al igual que el avance 1, el diseño concurrente consta del flujo de datos, diagramas de clase UML, y pseudocódigo. El pseudocódigo sirve para detallar las acciones que se realizan en la creación, producción, y consumo a lo largo de la cadena de producción. El texto del documento de diseño debe actualizarse junto con los diagramas y el pseudocódigo anterior.

2.4. Implementación

Se debe aplicar el diseño de la aplicación web concurrente siguiendo los patrones sugeridos (productor-consumidor, modelo-vista-controlador, y singleton) al código solución en C++. La implementación de la solución debe apegarse al diseño elaborado por el equipo en la sección anterior. Su solución debe producir los mismos resultados que el avance anterior, aunque estos sean generados por una cadena de producción. Las aplicaciones deben defenderse de entradas inválidas, en lugar de caerse o producir resultados incorrectos.

Al finalizar el servidor web, interactivamente con Ctrl+C o el comando kill, éste debe reaccionar a la señal y hacer la limpieza debida de la aplicación concurrente. Esto implica al menos, detenerse de aceptar más conexiones de clientes, avisar a los hilos secundarios para que terminen su ejecución, tanto los que atienden conexiones como los hilos calculadores, esperar que terminen el trabajo pendiente (no debe quedar trabajo residual en el servidor), y finalmente liberar recursos como estructuras de datos en memoria dinámica, archivos, u otros.

2.5. Pruebas

El código solución debe demostrar calidad en el uso de memoria y concurrencia (ej.: Sanitizers y Valgrind). Para probar que la solución realiza un adecuado proceso de descomposición y mapeo, se pueden hacer varias consultas en paralelo. Por ejemplo, una consulta con varios datos que requieren mucho procesamiento debería consumir todos los CPUs disponibles y responderse en un tiempo menor que el avance 1. Dos consultas simultáneas, primero una con números pesados más que la cantidad de CPUs disponibles y segundo otra con números rápidos de procesar, provocaría que la segunda no se atienda de inmediato.

Dado que se tiene un mejor diseño concurrente, se puede esperar una mayor tasa de respuesta ante una prueba de estrés. El equipo puede comparar con las pruebas de estrés definidas en el avance 1. Si es necesario, incrementar los valores en los parámetros de httperf como la cantidad de solicitudes por conexión (--num-call) o la tasa de solicitudes por segundo (--rate), hasta obtener solicitudes no respondidas.

Para volver al avance 1 se puede usar la etiqueta, por ejemplo, git checkout avance11fix y realizar las mediciones. Para volver al último commit se puede con git checkout master. Actualice la sección de "Pruebas de concurrencia" en el documento de análisis del proyecto con los argumentos de (argumentos de httperf) como sus salidas.

2.6. Evaluación

Se usa la misma logística y lineamientos de la revisión 1. Recuerde que en todos los rubros se evalúan las buenas prácticas de programación.

  1. [10%] Repositorio y análisis: Buen uso y aporte equilibrado del repositorio y directorios. Actualizar readme con concurrencia.

  2. [10%] Pruebas de concurrencia (httperf). Buen uso de la memoria y de la concurrencia (Sanitizers/Valgrind).

  3. [20%] Diseño concurrente: documento de diseño, diagrama de flujo de datos, clases en UML, pseudocódigo.

  4. [15%] Servidor: Construcción y destrucción de los objetos hilos, y la interconexión con colas. Manejo de señales y finalización de la cadena.

  5. [20%] Cadena de producción: declaración e implementación de los nuevos hilos de concurrencia de tareas y de paralelismo de datos.

  6. [15%] Aplicaciones concurrentes: separación de las seriales (herencia). Trazabilidad de datos por la cadena de producción.

  7. [10%] Modularización en subrutinas y archivos. Convención de estilos (cpplint). Documentación de interfaces (doxygen) e implementaciones (algoritmos).

3. Distribución

En el avance 1 el servidor web atendía conexiones concurrentemente, de aplicaciones web que eran seriales, lo cual no es óptimo. En el avance 2 se paralelizaron las aplicaciones web que necesitaban concurrencia para hacer un uso más balanceado de las CPUs. Esta solución logra hacer un uso más eficiente de los recursos de una máquina, pero desaprovecha otras máquinas que podrían estar disponibles en la organización o un clúster.

En el avance 3 el objetivo es distribuir las aplicaciones web que requieren paralelismo de datos para que sean escalables, aprovechar varias máquinas que tenga disponibles una organización, y reducir sustancialmente los tiempos de respuesta. De esta forma, si el servidor web recibe una carga sustancial de trabajo, puede responder en menor tiempo y disminuir la probabilidad de que los clientes se impacienten y se desconecten dejando trabajo residual en el servidor.

3.1. Requerimientos

Para lograr la distribución, los procesos de su solución se intercomunicarán a través de paso de mensajes, y tomarán uno de tres roles: máster, esclavo, o intermediario. La diferencia entre los roles de los procesos es sencilla. Un proceso máster asigna (envía) trabajo incompleto a procesos esclavos, y recibe de estos los resultados. Un proceso esclavo recibe trabajo de su proceso máster, lo procesa (en sus "calculadoras"), y envía los resultados de regreso a su proceso máster.

Las relaciones máster-esclavo forman un árbol n-ario, con el proceso máster como nodo raíz, y los procesos esclavos como nodos hijos. El árbol no está limitado a dos niveles. Se pueden tener procesos híbridos con ambos roles. Se le llamará proceso intermediario a un proceso que es tanto máster como esclavo. Es un proceso que puede recibir trabajo de otro proceso máster, y delegarlo en otros procesos esclavos, recibir el trabajo completado de los esclavos y enviarlo de vuelta a su proceso máster. Esta versatilidad es muy poderosa, dado que permite crear árboles de tamaños arbitrarios que distribuyan trabajo a cantidades arbitrarias de computadoras en el mundo, incluso teóricamente a todas las que existan en el planeta.

Los procesos máster, esclavo, e intermediario, se pueden correr a partir del mismo ejecutable. Este rol es simplemente una condición escogida por usuarios en el momento de correr el ejecutable en los argumentos de línea de comandos (u otras entradas). Dado que es un único ejecutable que toma varios roles en tiempo de ejecución, se genera a partir del mismo código fuente. El siguiente podría ser un escenario hipotético de ejecución y se ilustra en la Figura 3.

proy1.3 proc tree
Figura 3. Ejemplo de un árbol de distribución

En la máquina 1 con IP 10.0.0.1 se corre un proceso máster que escucha por solicitudes de conexión de clientes en el puerto 8080, con capacidad de atender 1000 conexiones concurrentemente (1000 hilos HttpConnectionHandler) y un límite de 128 elementos en las colas de la cadena de producción, con el siguiente comando:

bin/webserv 8080 1000 128

Se sabe que es un proceso máster porque sólo recibe los argumentos de línea de comandos de avances anteriores. Hasta el momento, el servidor se comportará exactamente igual que en el avance 1.2, y por tanto, tendrá siempre toda su cadena de producción completa. Es decir, si un cliente (navegador) se conectara a este servidor máster en la dirección http://10.0.0.1:8080/ y realiza solicitudes HTTP, este servidor hará los cálculos necesarios usando sus hilos de ejecución y le responderá al cliente. A este proceso se le llamará Proceso 1 en este ejemplo hipotético de ejecución.

Luego, supóngase que en una máquina 2, con IP 10.0.0.2 se corre un proceso esclavo que ayudará a realizar cálculos al proceso de la máquina 1. Simplemente se corre el mismo ejecutable, pero se agrega dos argumentos de línea de comandos más: la dirección de red y el puerto donde se encuentra el proceso que será su máster:

bin/webserv 9090 500 64 10.0.0.1 8080

El proceso esclavo de la máquina 2, al cual llamaremos proceso 2, intentará conectarse con el proceso máster de la máquina 1, y le enviará mensajes para que lo acepte como uno de sus procesos esclavos. El proceso máster aceptará la oferta y agregará el proceso 2 como uno de sus esclavos. Si el proceso esclavo no logra conectarse con el proceso máster, terminará su ejecución con un mensaje de error.

Si un navegador se conecta ahora al proceso 1 y hace solicitudes HTTP, el proceso 1 no realizará ningún cálculo, si no que los delegará en el proceso 2. El proceso 2 recibirá los trabajos, hará los cálculos, y retornará los resultados al proceso 1, el cual los recibirá y ensamblará la respuesta HTTP que enviará a su cliente.

El proceso esclavo 2 también es un servidor web, por lo tanto, un navegador podría conectarse a http://10.0.0.2:9090/ y hacer solicitudes. Dado que el proceso 2 no tiene esclavos, tendrá que realizar los cálculos y responder al navegador.

Supóngase que se crea un proceso 3 en la máquina 3 con IP 10.0.0.3, y se crea un proceso 4 en la máquina 4 con IP 10.0.0.4, con los siguientes comandos:

bin/webserv 3333 333 33 10.0.0.2 9090
bin/webserv 4444 444 44 10.0.0.2 9090

Ambos, el proceso 3 y el 4, serán esclavos del proceso 2. El proceso 2 aceptará a ambos como sus dos procesos esclavos. Si un navegador se conecta directamente al proceso 2 y envía solicitudes HTTP, el proceso 2 no realizará cálculos, si no que los distribuirá entre sus procesos esclavos 3 y 4 usando algún algoritmo de mapeo tratando de balancear la carga. Los procesos 3 y 4 realizarán los cálculos, retornarán los resultados al proceso máster 2, quien ensamblará la respuesta y responderá al navegador.

Si un navegador se conecta al proceso 1 y realiza una solicitud, éste la delegará a su proceso esclavo 2. El proceso intermediario 2 no realizará ningún cálculo, sino que distribuirá los cálculos a sus procesos esclavos. Cuando éstos le respondan, el proceso intermediario 2 responderá a su proceso máster 1, quien ensamblará la respuesta para el navegador.

Si un proceso termina su ejecución al recibir una señal, actuará como lo hacía en el avance 1.2. Es decir, cerrará el socket para escuchar por más conexiones, tratará de terminar el trabajo que haya pendiente en su cadena de producción, y finalmente liberará los recursos. Adicionalmente, si es un proceso máster, enviará mensajes para finalizar la ejecución de sus procesos esclavos. Es decir, que si se finaliza el proceso raíz de un árbol, el árbol completo terminará su ejecución.

Los procesos matendrán una colección de conexiones con sus procesos esclavos. Si un esclavo se desconecta, su conección debe ser removida de la colección, de tal forma que no se le distribuya más trabajo. En caso de que un proceso máster deje de tener esclavos, volverá actuar como un sevidor en el avance 1.2, es decir, volverá a realizar cálculos en sus hilos de paralelismo de datos.

3.2. Conexiones máster-esclavo

Técnicamente, los procesos no son o máster o esclavos de forma permanente. Si no que adquieren uno de estos dos roles dependiendo de la forma en que establecen conexión con otros procesos de su servidor web. Cuando un proceso de su solución corre, inicialmente su lista de procesos esclavos estará vacía. El proceso debe administrar dinámicamente esta lista. Si esta lista llegara a tener elementos, el proceso tendrá el rol de máster para todos ellos, y les distribuirá trabajo dinámicamente. La lista podría llegar a estar vacía, y el servidor tendría que realizar los cálculos que sus clientes le pidan.

Para entender cómo ocurre este fenómeno, un escenario concreto, como el de la Figura 3 puede ayudar a explicarlo. Primero se crea el proceso 1 quien tendrá su lista de esclavos vacía y estaría esperando por conexiones de clientes.

Cuando el proceso esclavo 2 es iniciado, analizará sus argumentos de línea de comandos y notará que debe conectarse al proceso 1, como lo haría cualquier otro navegador web. El proceso 2 enviará una solicitud HTTP especial, ofreciéndose como un proceso esclavo. Esta solicitud entre otros datos, llevará una contraseña que sólo los procesos de su solución deben conocer.

El proceso 1 aceptará la conexión y pondrá el socket en cola. Cuando un HttpConnectionHandler extraiga la primera solicitud del socket verá el ofrecimiento de ser nodo esclavo. Si la contraseña provista es la esperada, el HttpConnectionHandler agregará el socket con el proceso 2 a la lista de esclavos, y si la contraseña es inválida, cerrará la conexión y volverá a la cola de sockets. Nótese que el HttpConnectionHandler no enruta el ofrecimiento de esclavo a las aplicaciones, como sí haría con cualquier otra solicitud HTTP de otra naturaleza.

Al aceptarse la conexión, el proceso 2 será confirmado como esclavo y podrá continuar la creación de la cadena de producción como lo haría normalmente. Luego el proceso 2 pondrá el socket con el proceso máster 1 en cola para ser procesado como cualquier otro por un HttpConnectionHandler.

Entre el proceso máster y el esclavo habrá una conexión de red que debe mantenerse abierta. Si el proceso esclavo terminara su ejecución, cerrará el socket, el proceso máster detectará el evento y lo eliminará de su lista de procesos esclavos.

3.3. Servicios web

Cuando un proceso esclavo se conecta con su proceso máster, será tratado como cualquier otro cliente web, es decir, como si fuese un navegador. El recíproco también es válido. El proceso máster enviará trabajo incompleto a sus procesos esclavos como si fuesen respuestas hacia un navegador. Por lo tanto, los procesos máster y esclavos hablarán a través del protocolo HTTP. Cuando este protocolo se usa para intercomunicar dos software que consumen los mensajes, se trata de un servicio web, a diferencia de una aplicación web donde los mensajes HTTP están ideados para ser consumidos por seres humanos.

Su equipo deberá diseñar un esquema de paso de mensajes HTTP para efectivamente lograr intercomunicar sus procesos a través de este protocolo. Por ejemplo, una vez que el proceso máster acepta la conexión de un proceso esclavo, el esclavo enviará un mensaje HTTP ofreciéndose como proceso esclavo y proveyendo la contraseña de que es un proceso de confianza y no un atacante. Su equipo de desarollo deberá escoger cómo este mensaje se diferencia de un mensaje nomal, enviando información adicional en alguno o varios de sus campos: primera línea, encabezado, o cuerpo del mensaje. Puede estudiarse este protocolo en el material del curso de desarrollo web, en especial es de utilidad la Figura 4.

http message
Figura 4. Mensajes de solicitud y respuesta HTTP

Su equipo deberá decidir la estructura de otros mensajes especiales, por ejemplo:

  1. El proceso máster asigna trabajo parcial a un proceso esclavo.

  2. El proceso esclavo responde con el resultado parcial al proceso máster.

  3. El proceso máster reporta al proceso esclavo que va a terminar su ejecución ante una señal (Ctrl+C).

3.4. Distribución de trabajo parcial

Si el proceso máster recibe trabajo, por ejemplo, a través de un navegador web, la solicitud seguirá el mismo flujo de datos de la cadena de producción hasta el hilo que descompone la solicitud. Este mismo hilo, u otro encargado únicamente del asunto de distribuir, repartirá el trabajo entre los procesos esclavos mediante un mapeo eficiente que balancee la carga. El equipo de desarrollo debe escoger este mapeo (existen esquemas sencillos de implementar).

En caso de que la lista de procesos esclavos esté vacía, el hilo que distribuye, enviará el trabajo a las calculadoras dentro del proceso máster, de tal forma que siempre se pueda responder al cliente cuando no hayan procesos esclavos. Nótese que al menos dos hilos tendrán acceso a la lista de procesos esclavos. El equipo de desarrollo debe escoger un mecanismo de sincronización adecuado que impida anomalías concurrentes.

Una vez que los hilos calculadores en el proceso esclavo hayan encontrado las soluciones, pondrán en cola estos resultados para que sean enviados de regreso al proceso máster. Si se realiza un buen diseño, es probable que no sea necesario modificar el resto de la cadena de producción. Es decir, el proceso esclavo responderá al proceso máster pensando que está respondiendo a un navegador.

Un HttpConnectionHandler del proceso máster recibirá el trabajo parcial completado. El equipo de desarrollo debe decidir cómo integrar este trabajo parcial completado al resto de la solicitud inicial.

3.5. Diseño de la distribución

El equipo debe actualizar su diagrama de flujo de datos para reflejar la distribución de trabajo entre procesos. En particular:

  1. Diagramar al menos tres procesos: uno máster y dos esclavos. Del máster se incluye la cadena de producción completa. De los esclavos basta con los hilos que sufran cambios.

  2. Reflejar el paso de mensajes con datos concretos, y por lo tanto, mensajes HTTP completos, incluyendo los que se responden a los clientes. Use flechas dirigidas con otra notación para distinguirlas de la comunicación a través de colas de producción y consumo.

  3. Las clases UML de los nuevos hilos y los cambios en hilos existentes debidos a la distribución.

  4. El rol concurrente de cada tipo de hilo: productor, consumidor, ensamblador, o repartidor. Se indica con la relación de herencia en la clase UML o un estereotipo.

3.6. Serialización

Todos los datos que se han de transmitir por una red de computadoras deben serializarse si se encuentran en memoria discontinua, de la misma forma que ocurre si se quieren almacenar en un archivo en memoria secundaria (recuerde, los sockets son archivos). Por ejemplo, si se quiere enviar una cola a otra computadora, considere los siguientes códigos correctos e incorrectos:

// Datos discontinuos en la memoria principal a enviar por red (o un archivo)
std::queue<double> values;

// Mal: Sólo envía el registro de memoria (struct) con el conteo de elementos
// y la dirección de memoria del primer (head) y último elemento (tail), pero
// no los elementos. Las direcciones de memoria (head y tail) no serán válidas
// en la otra máquina
socket.write(&values, sizeof(values));
socket.send();

// Bien: La máquina recibirá la cantidad de elementos y seguida de cada uno de
// ellos, con lo que podrá reconstruir la cola. Los datos se envían en formato
// de texto, separados por espacios en blanco
socket << values.size();
for (const double& value : values) {
  socket << ' ' << value;
}
socket.send();

// Bien: Igual al anterior, pero los datos se envían eficientemente en binario
socket.write(values.size());
for (const double& value : values) {
  socket.write(value);
}
socket.send();

Nótese que sí es posible enviar direcciones de memoria (punteros) a otras computadoras, dado que son números enteros sin signo. Sin embargo, en la computadora receptora no tendrán validez como direcciones de memoria, sólo en la máquina donde fueron generados. Lo mismo ocurre si se guardan direcciones de memoria en un archivo. Si el proceso termina, se crea otro proceso del mismo programa que carga el archivo e intenta acceder a la memoria apuntada, la dirección con altísima probabilidad será inválida en el nuevo proceso.

Se pueden enviar arreglos y registros de memoria (struct, class, union), dado que ambos son regiones continuas de memoria, con la diferencia de que los arreglos almacenan valores del mismo tipo de datos, mientras que el registro de memoria puede almacenar campos de distinto tipo de datos. Este envío es válido si todos los valores no son punteros (direcciones de memoria). Un objeto polimórfico (de una clase con métodos virtuales) fallará en este principio, dado que posee un puntero oculto a la tabla de métodos virtuales de su clase (conocido como vtable en C++). En caso de enviar un registro directamente a disco o red, se enviará también el relleno que el compilador haya podido aplicar debido al alineamiento de palabra entre campos.

Es probable que en su cadena de producción hayan registros polimórficos que no pueden ser serializados directamente. En su lugar el equipo deberá enviar datos básicos (números, arreglos, o textos) que le permitan en la máquina receptora reconstruir objetos polimórficos. Sugerencia: Note que este trabajo ya es realizado por el proceso máster, el cual recibe datos serializados a través de las solicitudes HTTP, y con ellos es capaz de construir objetos polimórficos.

Las decisiones de serialización formarán un protocolo de comunicación entre los procesos máster y esclavos. Una vez decidido este protocolo, escriba los mensajes concretos en el diseño del flujo de datos y su diseño UML para que la cadena de producción esté completa.

3.7. Pruebas de estrés

Al tener distribución, se debería poder dar respuesta a una carga de trabajo mayor que la de avances previos. El equipo debe confirmar este indicio al comparar los resultados de las pruebas de estrés con httperf de avances previos. Estos resultados se deben divulgar en el documento de análisis.

Es importante que el equipo realice pruebas distribuidas en el laboratorio de computadoras días antes de la revisión. Para realizar una prueba de estrés, prepare las siguientes máquinas:

  1. Una computadora que tomará el rol de servidor frontal o máster.

  2. Dos computadoras que serán clientes, y por lo tanto, correrán httperf con los argumentos encontrados en avances previos.

  3. Al menos una computadora con un proceso intermediario, con ASan y luego TSan.

  4. Cuatro computadoras o más que correrán procesos esclavos, uno compilado con ASan, otro con TSan, otros con debug/Memcheck, y otro con debug/Helgrind. Los demás en modo release.

En cada computadora tener visible la línea de comandos y las gráficas del administrador de procesos, que permitan ver la actividad en las computadoras cuando se ejecuten las pruebas de estrés. Idealmente la máquina frontal debería mostrar ligero consumo de CPU, y las máquinas esclavas un alto consumo de CPU.

Las pruebas de estrés serán incrementales. Primero se enviará trabajo al servidor máster, y antes de que lo complete, se le añadirá un proceso esclavo. El trabajo debería pasar por completo al esclavo. Luego se le agregará otro proceso esclavo y el trabajo debería repartirse entre ambos esclavos. Finalmente se agregará un esclavo convirtiendo alguno de los esclavos en un proceso intermediario, lo que debería provocar que el intermediario distribuya el trabajo completo.

Se enviará trabajo directamente a procesos esclavos, quienes deberían responder directamente, junto con el trabajo que puedan estar recibiendo de procesos máster. Finalmente ser harán pruebas de finalización del proceso máster. Se espera que los procesos esclavos finalicen apropiadamente tratando de responder al trabajo residual que haya en ellos.

3.8. Evaluación

Se dará crédito por los siguientes aspectos a evaluar.

  1. [10%] Repositorio y análisis: Buen uso y aporte equilibrado del repositorio y directorios. Actualizar readme con distribución, en especial el manual de uso.

  2. [15%] Pruebas de distribución (httperf). Buen uso de la memoria y de la concurrencia (Sanitizers/Valgrind).

  3. [15%] Diseño distribuido: documento de diseño, diagrama de flujo de datos, clases en UML, pseudocódigo.

  4. [10%] Establecimiento de la conexión entre el proceso esclavo y su proceso máster. Validación de la contraseña. Finalización del esclavo si no se pudo establecer conexión con el máster.

  5. [15%] Dinámicamente mantener la lista de procesos esclavos sin anomalías concurrentes. Distribuir de forma balanceada trabajo a procesos esclavos con mensajes HTTP (servicio web), si los hay. Si no, procesar el trabajo localmente.

  6. [15%] Recibir y procesar trabajo parcial incompleto. Enviar trabajo parcial completado al proceso máster. Agregar el trabajo parcial completado a su solicitud original. Responder al cliente cuando la solicitud está completa.

  7. [10%] Detener los procesos esclavos cuando se detiene el servidor web.

  8. [10%] Modularización en subrutinas y archivos. Convención de estilos (cpplint). Documentación de interfaces (doxygen) e implementaciones (algoritmos).