-
Notifications
You must be signed in to change notification settings - Fork 1
Home
En esta primera práctica se ha implementado un servidor web en C, basado en el protocolo HTTP/1.1 (conexiones persistentes, sin pipelining). Procedemos a describir los rasgos más importantes de este proyecto, desde las decisiones de diseño hasta la estructura del código.
Son varias las decisiones que se han tenido que tomar a lo largo de todo el desarrollo.
La primera gran decisión a la que tuvimos que enfrentarnos fue el tipo de servidor a desarrollar. Este paso puede considerarse como uno de los más importantes del proyecto, ya que determina, sin lugar a dudas, el rendimiento final del servidor. Esta decisión puede estructurarse en los siguientes pasos:
-
Procesos o hilos: estaba claro desde el principio que el servidor debía contar con cierto grado de paralelismo, pues los servidores iterativos son totalmente ineficientes en la gestión de peticiones simultáneas y no explotan al máximo los recursos de las máquinas donde se ejecutan. Sin embargo, debíamos decidir si usar una mezcla de procesos e hilos o simplemente usar hilos. El principal argumento a favor de usar varios procesos es que en algunos sistemas, el kernel tan sólo es consciente de la existencia de los procesos y no de los hilos. Esto es lo que se conoce como hilos a nivel de usuario y tienen principalmente dos desventajas. La primera es que cuando un hilo hace una llamada bloqueante al sistema, como un accept, por ejemplo, el resto de hilos también se bloquean. La segunda es que una aplicación multihilo no podría aprovechar varios núcleos del procesador. Leyendo el manual de pthreads de Linux, vemos que la implementación (es importante recalcar que pthreads es una interfaz, y que cada sistema operativo lo implementa de una manera distinta) es 1:1, es decir, cada thread corresponde a una kernel scheduling entity. Esto es, el sistema operativo es consciente de la existencia de los threads. Algo similar ocurre en el caso de macOS, aunque la implementación de pthreads es distinta, ya que usa Mach threads. Es por esto que decidimos implementar el servidor usando threads, pues son ligeros, fáciles de utilizar, y cumplen con la funcionalidad necesaria.
-
Thread por cliente o thread-pool: el siguiente paso era determinar qué tipo exacto de servidor íbamos a implementar, una vez que teníamos claro el uso de threads. Las dos alternativas eran crear un thread por cliente o desarrollar un pool de threads. La primera posibilidad parecía a priori muy fácil de implementrar (aunque como siempre el diablo está en los detalles). Un pequeño inconveniente que tendría esto sería esperar a la generación de un hilo cada vez que el accept retornase. No obstante, el tiempo de generación de un hilo es prácticamente despreciable, del orden de décimas de milisegundo. Como hemos comentado antes, el problema está en los detalles. Por ejemplo, tenemos que almacenar los pthread_t de cada hilo que esté corriendo para poder pedirles que paren en el caso de recibir la señal de apagado y para hacer el pthread_join después. Para almacenar los pthread_t de manera eficiente, deberíamos saber cuándo un hilo ha terminado para eliminar el pthread_t de la lista. En definitiva, a la hora de implementarlo surgirían ciertos problemas, algunos de los cuales comunes a ambas alternativas de diseño. Es por eso que decidimos usar un thread-pool, ya que es lo que se nos recomendó en clase, no tanto por su rapidez, pues los hilos ya están pre-creados, sino por la facilidad de su gestión.
-
Pool estático o dinámico: una vez que nos decantamos por el pool de hilos, nos planteamos si desarrollar un pool estático, en el que se creara desde el inicio el número de hilos máximo, o bien implementar un pool dinámico, que se autogestionase en función del número de hilos en ejecución en cada momento. Si bien la primera opción es mucho más sencilla y garantiza que cada cliente va a ser atendido inmediatamente, supone un gasto innecesario de recursos. Un thread en Linux ocupa aproximadamente 16 KiB de memoria, lo cual es casi despreciable en los tiempos que corren. No obstante, para realizar un servidor escalable hemos optado por un pool dinámico, ya que en el caso de tener un número máximo de conexiones muy elevado (del orden de 10000) pero muy poca carga de trabajo se estarían desperdiciando los recursos del ordenador. A continuación se muestra el uso de memoria del servidor en idle en función del número de threads activos (bajo Ubuntu 18.04).
Número de threads Memoria usada 5 564 KiB 10 660 KiB 20 828 KiB 50 1.3 MiB 100 2.1 MiB 500 8.6 MiB 1000 16.6 MiB Como comparación,
Apache
en su configuración por defecto y en idle usa aproximadamente 8 MiB de memoria RAM. Por esto decidimos que nuestro servidor por defecto crease 100 threads y pudiese aumentar esta cantidad hasta llegar a los 1000.
Pasando a la implementación concreta, la gestión la lleva a cabo un hilo aparte dentro del pool (el watcher_thread
), que cada cierto tiempo comprueba cuántos hilos se encuentran ocupados, determinando si es necesario crear más o destruir algunos de los existentes. Este tiempo no debe ser muy grande, ya que necesitamos reaccionar rápido a sobrecargas repentinas. Además, no consume apenas recursos realizar esta comprobación. Cabe mencionar también que el pool tiene dos formas de destruirse, soft
y hard
, para poder cerrar de inmediato todas las conexiones o bien esperar a que los hilos acaben de atender las peticiones activas en ese momento para cerrar después. Esto lo asociamos a un restart o a un stop de un servicio, siendo el restart el modo soft
(necesitamos que el servicio vuelva a levantarse, pero preferimos no cortar transmisiones), y el stop el modo hard
(necesitamos que el servicio pare de inmediato).
Por otro lado, hemos considerado que si los threads detectan un error, por ejemplo, al reservar memoria, lo gestionen de una manera "local", esto es, que eviten acceder a posiciones de memoria no inicializadas y que aborten la acción concreta que dependiese de dicha reserva de memoria. Lo que no se hace (intencionadamente) es detener todo el programa si falla una reserva de memoria. Si esto ocurriese, es que el sistema se está quedando sin memoria, y no es responsabilidad del servidor web decidir si parar o no, sino del sistema operativo.
Al ejecutar sudo make install
, se copiarán los siguientes ficheros:
Se copiará el ejecutable ./cmake-build-config/rask
a /usr/local/bin
. Esto permitirá ejecutar el servidor llamando a rask
desde cualquier terminal. Cabe mencionar que si se ejecuta directamente de esta manera no correrá en modo demonio. El motivo por el que hemos escogido /usr/local/bin
y no /usr/bin
es doble:
- En Linux
/usr/bin
está pensado para programas administrados por el gestor de paquetes (comoapt
en Ubuntu opacman
en Arch), mientras que/usr/local/bin
es preferido para programas compilados localmente. - En macOS no se puede modificar el directorio
/usr/bin
a no ser que se desactive SIP (System Integrity Protection).
Se copiará el archivo de configuración ./files/rask.conf
a /etc/rask/
, directorio donde es usual almacenar los archivos de configuración según el manual (ver hier
).
Se copiará todo el contenido de ./www
a /var/www
. Este es el directorio que usa por defecto Apache
.
Se copiará el fichero ./files/rask.service
a /lib/systemd/system/
si la distribución de Linux soporta systemd. Con respecto al contenido de este archivo, una de las lineas se encuentra comentada. Esto se debe a que usa funcionalidad de la versión 244 de systemd
, que quizás no esté instalada en el sistema todavía (fue publicada el 29 de noviembre de 2019). Esta línea indica a systemd
qué señal debe mandar al proceso cuando ejecutamos un restart
, inicializándola a SIGINT
, señal que nuestro proceso interpreta como un soft kill
(explicado una líneas más arriba). Cabe comentar que si está línea se descomenta y la versión de systemd
es anterior a la 244, simplemente es ignorada y salta un warning, pero no afecta a la funcionalidad.
Inicialmente implementamos el demonio según el libro de referencia (Unix Networking Programming), pero posteriormente acabamos por emplear systemd
en Linux, por ser más sencillo, moderno y cómodo de utilizar. No obstante, en srclib/daemon
se encuentra el código que escribimos en un primero momento. Cabe mencionar que usar systemd
tiene un aspecto negativo, y es que aunque actualmente la gran mayoría de distribuciones de Linux usan este sistema de inicio, no es demasiado portable. Es por esto que queda como tarea pendiente para futuras versiones del servidor el soportar otros sistemas de inicio, como launchd
de Apple, o usar el código que desarrollamos al empezar la práctica en aquellos entornos que no dispongan de systemd
ni launchd
.
En srclib
se encuentran los archivos con funcionalidad independiente al servidor. En algunos de ellos, se han tomado decisiones importantes:
Contiene el código encargado de ejecutar scripts en un proceso aparte, pasándole los argumentos por entrada estándar (stdin). Para ello, como es necesario escribir y leer del proceso que ejecuta el script, necesitamos hacer uso de pipes y no podemos usar popen
, ya que
este último solo permite la comunicación en un solo sentido. Además, hemos implementado un timeout. Aunque esto también pueda realizarse en los propios scripts, como desarrolladores de un servidor web genérico no podemos asumir que todos los usuarios de nuestro servidor vayan a implementar un timeout en sus scripts.
Agrupa las funciones relacionadas con la gestión de los sockets. Comentamos brevemente las funciones más destacadas
-
socket_open
: llama a las rutinassocket
,bind
ylisten
. -
socket_set_timeout
: fija un timeout para un socket concreto. Esto es usado en el servidor para establecer un límite de tiempo en el que nos bloqueamos en el read esperando la petición del cliente. -
ip_to_string
: es una función interna que devuelve una cadena de caracteres con la representación usual de una dirección IP. Usamos esto para imprimir en un formato comprensible la dirección IP de los clientes del servidor.
Como la librería picohttpparser
nos devuelve los campos de la cabecera con pares char *
- int
decidimos crear una estructura pública string
, conteniendo tanto el puntero al inicio de la cadena de caracteres como el tamaño de esta. Esto simplifica el paso de argumentos. Además, se incluyen dos funciones, una para comprobar si dos estructuras strings son iguales y otra para comprobar si una estructura string es igual a una cadena de caracteres terminada en \0
.
Como en todo gran proyecto se ha de informar al usuario del estado del programa. Es por esto que desde el principio decidimos crear esta librería. Tiene una interfaz muy parecida a printf
en el sentido que recibe primero una cadena de caracteres con el formato (usando "%s", "%d", etc.) seguida de un número variable de argumentos. El objetivo de esta librería es doble:
- Por una parte, se puede controlar el nivel de verbosidad del servidor. En la etapa de desarrollo, querremos ver todos los mensajes en el log. Sin embargo, en la fase de despliegue sólo querremos ver los mensajes de mayor severidad (advertencia y errores)
- Lograr que los logs sean iguales independientemente de si el servidor corre en modo daemon o en modo normal. Si los mensajes se ven a través de
systemd
, el sistema añadirá automáticamente la hora en la que fueron emitidos. Sin embargo, si se corre normalmente, esto no ocurriría de no ser por esta librería.
Esta librería es una abstracción de un buffer dinámico, esto es, un buffer al que se le añaden cosas, y que si se queda sin espacio hace un realloc
para crecer. Internamente se mantiene una cadena de caracteres del tamaño que el cliente especifique y dos enteros para saber la última posición ocupada y el tamaño del buffer. Su utilidad es doble:
- A la hora de leer la respuesta de un script no sabemos cómo de larga va a ser. No podríamos usar un buffer de tamaño fijo e ir mandándolo "a trozos" ya que debemos conocer el tamaño de la respuesta a la hora de escribir el
Content-Length
. Es por esto que un buffer dinámico es la solución perfecta. - A la hora de crear una respuesta HTTP nos da mucha flexibilidad, ya que no tenemos que preocuparnos de que la cabecera no quepa en el buffer. Además, hemos incorporado numerosas funcionalidades: añadir una cadena de caracteres, un número, un fichero (entero), un fichero (por partes) y el contenido leído de un descriptor de fichero (últil para leer de un pipe). A la hora de mandar ficheros con el servidor se pueden utilizar las dos funciones. Nosotros hemos optado por mandar el fichero por partes, es decir, se leerá el fichero hasta el final del buffer (que por defecto tiene 4096 bytes, y, a no ser que la cabecera sea enorme, será el tamaño de este), se enviará, y se repetirá esta operación tantas veces como sea necesario. Más sencilla era nuestra implementación inicial, que era leer el archivo entero en el buffer y mandarlo directamente. La desventaja de esto era que si el fichero era muy grande el servidor consumía muchísima memoria.
En relación con los scripts que ejecuta el servidor, realizamos los scripts propuestos en Python, que se encargan de gestionar los argumentos recibidos por entrada estándar en formato url-encode
, y se pueden ejecutar tanto con el método GET
como con el método POST
. Añadimos al index.html
un campo y un botón para poder ejecutarlos desde la web. Se considera además que se solicita la ejecución de un script a través de GET
cuando el archivo solicitado tiene una extensión ejecutable (.py
o .php
), sin importar si se recibe argumentos o no. En cuanto a POST
, consideramos que siempre se nos va a solicitar ejecutar un script (si no es de extensión ejecutable se responde con Not Implemented).
Es importante comentar que la implementación de nuevos códigos de respuesta de error, con la abstracción actual, consistiría simplemente en llamadas a _response_error
indicando código y mensaje. Por el momento se han desarrollado los siguientes códigos de respuesta HTTP:
- 200 OK: se devuelve si la operación ha sido realizada con éxito.
- 304 Not Modified: se devuelve si no hay necesidad de transmitir los recursos solicitados, ya que el cliente los tiene en caché.
- 400 Bad Request: se devuelve en caso de error al parsear la respuesta por la librería
picohttpparser
o si se intenta acceder a directorios no autorizados - 400 Bad Request - Request Too Long: se devuelve en caso de request demasiado larga.
- 404 Not Found: se devuelve si el recurso solicitado no se encuentra.
- 500 Internal Server Error: se devuelve en caso de error interno del servidor de cualquier tipo.
- 501 Not Implemented: se devuelve en caso de solicitar un método no soportado, de solicitar la ejecución de un script de extensión no soportada o de solicitar un archivo de extensión no soportada.
Cabe mencionar brevemente el código de error 304 Not Modified. Comparando el rendimiento de nuestro servidor con Apache
, vimos que éste último era mucho más rápido. Esto era debido a que el navegador guardaba en caché los recursos solicitados y no tenía que volver a descargarlos. El proceso es basicamente el siguiente:
- El navegador solicita un recurso
- El servidor contesta, poniendo en la cabecera un campo denominado
etag
. Dicho campo debería depender del archivo solicitado y de la última modificación. La implementación final del campoetag
depende del servidor, nosotros por simplicidad simplemente concatenamos la fecha de última modificación con el nombre del fichero. - El navegador, cuando vuelva a solicitar el mismo recurso, incluirá en la cabecera de la petición un campo llamado
If-None-Match
con eletag
recibido en la petición anterior - El servidor comprueba si el
etag
que le ha pasado el cliente es el mismo que devolvería él en el caso de enviar el recurso. Si la respuesta es afirmativa, significa que el archivo no ha sido modificado y que el cliente puede usar el almacenado localmente en su caché. En ese caso, devolverá el código 304 Not Modified y no enviará el recurso de nuevo.
-
A grandes rasgos, las posibles librerías desarrolladas se encuentran en el directorio srclib. Estos son ficheros independientes en gran medida a las variantes de implementación del servidor, es decir, se ha intentado que fueran archivos que pudieran ser usados en cualquier otro proyecto, independientemente de las características del mismo. Cumplen su función sin depender del resto de módulos.
-
Los ficheros que el servidor ofrece a sus clientes se encuentran en el directorio www, organizado en las carpetas media y scripts.
-
El código fuente principal del servidor, encargado de la gestión, se encuentra en la carpeta src. Estos son los archivos dedicados a parsear el fichero de configuración del servidor, el archivo que gestiona el thread pool (en este directorio pues depende de la implementación del servidor), y los módulos encargados de procesar la petición (
request.c
), de construir la respuesta (response.c
) y de entrelazar ambos (connection_handler.c
). Finalmente, el main del servidor se encuentra enserver.c
. -
Las cabeceras del código fuente se encuentran en el directorio includes. Además, aquí se encuentra el archivo
utils.h
, que contiene diversas macros con códigos de estado de peticiones o respuestas, necesarios en múltiples ficheros.
De los diversos tests que hemos ido realizando a lo largo de esta práctica sólo nos ha parecido relevante incluir un test de estrés desarrollado en Python, tests/client.py
. Dicho test abre num_threads
conexiones paralelas y desde cada una de ellas se descarga un fichero messages_per_thread
veces. Todos los archivos descargados se guardan en la carpeta tests/received
con nombres aleatorios para que no haya colisiones. Para probarlo simplemente hay que configurar 3 variables:
- SERVER_ADDRESS: por defecto está
localhost
, pero se puede poner una IP como string - SERVER_PORT: por defecto está el puerto 8080
- RESOURCE: es el archivo que se descargará
Este proyecto ha sido una de las prácticas más completas a la que nos hemos enfrentado desde que empezamos la carrera. Ha requerido un esfuerzo extra tanto en el plano de la programación como en el de la organización. Con respecto al primero, programar en un lenguaje de bajo nivel como C siempre es una tarea tediosa, y más al tratarse de un proyecto tan grande. Además, la libertad que se nos ha dado para desarrollar esta práctica nos ha hecho emplear muchas horas en leer documentación y artículos de internet, pero es sin duda una forma diferente de aprender. En línea con esta libertad de implementación, hemos tenido que planificar de forma especial la organización y estructura del proyecto, dedicando una parte importante del tiempo a esta faceta antes de programar. Por otro lado, consideramos que el hecho de haber sido "forzados" a usar git es muy positivo para nosotros, ya que veníamos usándolo para otras prácticas pero sin tomarnos en serio la estructura. Ahora, hemos aprendido a distribuir el desarrollo en distintas ramas, a usar más funcionalidades... Por último, hemos podido aplicar los conocimientos aprendidos en las clases teóricas, pudiendo interiorizarlos mejor y observando en qué consisten en la realidad. En definitiva, esta práctica ha sido muy positiva para nuestro aprendizaje.