Práctica de sistema cliente/servidor
La presente práctica les servirá para trabajar con procesos concurrentes que se comunicarán y sincronizarán entre sí mediante herramientas del sistema UNIX. Se simulará un sistema bancario, con un proceso central (un servidor) que tramitará operaciones sobre cuentas bancarias solicitadas por procesos clientes a modo de cajeros automáticos.
Este documento introduce brevemente el modelo de comunicación cliente/servidor. A continuación, en el capítulo 2, se describe en líneas generales la aplicación que hay que desarrollar. El capítulo 3 es una guía de implementación que trata, entre otras cosas, del mecanismo para comunicar a los procesos concurrentes y del protocolo de sincronización.
Finalmente se plantean unas actividades para identificar y corregir ciertos problemas clásicos de concurrencia que pueden manifestarse en este trabajo.
Los procesos clientes y servidores han de seguir un protocolo de comunicaciones que defina: a)cómo se codifican las peticiones; y b)cómo se sincronizan entre sí los procesos.
Los clientes y servidores han de estar de acuerdo en cómo se escriben los mensajes: en qué orden van los posibles parámetros de la petición, cuántos bytes ocupan, etc.
La forma de sincronización nos dice si el cliente puede seguir adelante justo después de enviar la petición (no bloqueante), o por el contrario tiene que esperar a que el servidor le envíe una respuesta (bloqueante). Si la comunicación es no bloqueante, habrá que definir un mecanismo para que el cliente pueda saber si la respuesta del cliente está disponible. En esta práctica se adoptará una comunicación bloqueante: el cliente siempre esperará hasta recibir una respuesta del cliente.
El diálogo cliente/servidor es casi siempre bidireccional. Por un lado, el cliente envía información al servidor (el tipo de servicio solicitado más los parámetros); por otro, el servidor envía información al cliente (los resultados del servicio, códigos de error en caso de producirse, etc.)
La base de datos de cuentas se almacenará en un fichero de texto, con una línea para cada cuenta. Cada línea tendrá el siguiente formato:
número de cuenta <TAB> saldo
El signo <TAB> representa el carácter de tabulación (código ASCII número 9). Veamos un ejemplo de fichero de cuentas:
123456 1000
3179 -200
49571 13000
La cuenta 123456 tiene un saldo a favor de 1000 pesetas, y la 3179 un saldo deudor de 200 pesetas.
lee_saldo (cuenta) (cantidad, result)
escribe_saldo (cuenta,cantidad) (result)
bloquea_cuenta (cuenta) (result)
desbloquea_cuenta (cuenta) (result)
termina_servicio (cuenta) (result)
El parámetro cuenta en todos los casos indica un número de cuenta. En la respuesta siempre hay un parámetro result consistente en un código de resultado. Por ejemplo, un cero si todo ha ido bien y valores positivos para indicar circunstancias de error.
lee_saldo(cuenta) devuelve al cliente el saldo actual de la cuenta, si existe. escribe_saldo(cuenta,cantidad) deposita en la cuenta un nuevo saldo, igual a cantidad.
bloquea_cuenta(cuenta) marca la cuenta como bloqueada. No se podrá cambiar el saldo de la cuenta hasta que se ejecuta una operación de desbloquea_cuenta sobre ella. En concreto, si un cliente intenta solicitar un servicio de escribe_saldo sobre una cuenta bloqueada, queda esperando hasta que la cuenta se desbloquee. Sobre una cuenta bloqueada se pueden efectuar tantas consultas de saldo como se deseen.
termina_servicio indica al servidor que puede morir en paz. El proceso servidor desaparece y no se podrán atender peticiones de clientes hasta que no se arranque un nuevo servidor. El fichero de cuentas no se borra aunque muera el servidor.
servidor fichero_de_cuentas
donde fichero_de_cuentas es la ruta de un fichero de cuentas válido. Al invocar al servidor, quedará en segundo plano (background) en espera de atender las futuras peticiones de los procesos clientes hasta que reciba una orden de termina_servicio.
1. consultar el saldo
2. sacar dinero
3. ingresar dinero
4. transferencia entre cuentas
Si se arranca un cliente sin estar el servidor en ejecución, se devolverá un mensaje de error. No podrá haber presente más que un servidor, pero podrá haber varios clientes trabajando concurrentemente.
sacar (cuenta,cantidad)
S <- lee_saldo(cuenta)
si S<cantidad escribe "vas a quedar en números rojos!!"
escribe_saldo(cuenta,S-cantidad)
ingresar (cuenta,cantidad)
S <- lee_saldo(cuenta)
escribe_saldo(cuenta,S+cantidad)
transferencia (c1,c2,cantidad)
S1 <- lee_saldo(c1)
si S1< cantidad ABORTA
S2 <- lee_saldo(c2)
escribe_saldo(c1,S1-cantidad)
escribe_saldo(c2,S2+cantidad)
2.4.
Problemas de consistencia e interbloqueo
Con estas especificaciones, si dos clientes operan simultáneamente sobre
la misma cuenta, podría haber problemas de inconsistencia. Un ejemplo es
el de un cajero ingresando dinero a la vez que otro lo extrae. Planteen
situaciones comprometidas y creen simulaciones en las que el resultado final
sea incorrecto. Consideren especialmente el caso de la transferencia entre dos
cuentas.
Para facilitar la tarea, implementen versiones de clientes que se ejecuten paso a paso pulsando una tecla. De esta manera podrán controlar mejor las operaciones concurrentes que van ejecutándose.
Las inconsistencias se pueden resolver con las operaciones de bloqueo y desbloqueo de cuentas, que servirán para garantizar la exclusión mutua de las escrituras. Escriban unas versiones de clientes que no presenten problemas de consistencia.
La solución del bloqueo/desbloqueo de cuentas resuelve la consistencia de las operaciones, pero produce un problema nuevo: el interbloqueo. Estudien qué puede ocurrir si se entremezclan dos transferencias concurrentes entre dos cuentas A y B, una de ellas desde A hasta B y otra desde B hasta A.
Para tranquilidad de los lectores, diremos que en los sistemas bancarios, los cajeros automáticos no manejan protocolos tan poco robustos.
...Código de inicialización
for (;;) /* Bucle sin fin de atención de servicios */
{
... espera por una petición
... lee la petición
switch (tipo_de_petición)
{
case función1:
... atiende la función 1 ...
break;
case función2:
... atiende la función 2 ...
break;
...
case termina_servidor:
... adecenta el sistema antes de salir ...
exit();
} /* switch */
} /* for */
Esta es una estructura típica para un proceso servidor, y aparece con frecuencia en los lenguajes de paso de mensajes o los sistemas dirigidos por eventos (por ejemplo, al programar en un entorno de ventanas). Consiste en un bucle sin fin en el que cada iteración consiste en recibir un mensaje y atenderlo, así de simple.
Es altamente recomendable que las rutinas de atención de cada uno de los servicios sean llamadas a función; eviten un pastiche de líneas dentro del switch que a buen seguro les complicarían la vida a la larga.
La primera cuestión es relativamente fácil de solventar. Se dispondrá de un semáforo de clave conocida tanto por clientes como por el servidor. El semáforo estará inicializado a cero. Cada vez que un cliente solicita un servicio, realiza una operación V sobre el semáforo. Por su parte, en cada iteración el servidor haría una operación P sobre el mismo semáforo.
La segunda pregunta tiene más enjundia: ¿cómo le pasa el cliente la información al servidor? El semáforo de sincronización sólo vale para indicar que hay una petición pendiente, pero no dice nada de qué es lo que se pide. El UNIX ofrece mecanismos para pasar información entre procesos: la memoria compartida y las colas de mensajes, a los que ustedes pueden recurrir, aunque no es obligatorio en este trabajo.
El problema estriba en que cada petición debe usar un semáforo diferente de "petición servida", para que el servidor sepa exactamente a qué cliente tiene que desbloquear. Cada vez que arranque un cliente nuevo, creará un semáforo. El programa cliente añadirá a sus peticiones un parámetro más, que será el identificador del semáforo (semid) que devuelve la llamada semget. Así el servidor podrá trabajar con el semáforo y desbloquear al cliente.
El esquema de comunicaciones que se ha descrito es el mismo que pueden ver en el tema dedicado a la entrada/salida dentro de los apuntes de la asignatura, en los algoritmos de comunicación entre el manejador de dispositivo y los procesos solicitantes de E/S con la función DOIO.
Proceso cliente (al realizar una petición):
/* petición_servida es un semáforo creado por el cliente */
P (bloquea_fichero) /* Bloqueo del fichero para exclusión mutua */
/* Se añade petición_servida como componente en el mensaje */
añade_petición ( tipo, proceso, contenido,
petición_servida );
V (bloquea_fichero)
V (petición_pendiente) /* Aviso al servidor */
P (petición_servida) /* Espera por que se complete */
Proceso servidor (al esperar por una petición):
/* "despertador" es un semáforo local al servidor */
/* "servido" es un vector con los semáforos de peticiones servidas */
P (petición_pendiente) /* Espera por una petición */
P (bloquea_fichero) /* Exclusión mutua de acceso */
/* El servidor recoge en el mensaje el semáforo para avisar al cliente */
recoge_petición ( tipo, proceso, contenido, avisador );
servido[proceso] = avisador;
V (bloquea_fichero) /* Libera el fichero */
...
...
/* Para dar por servida una petición */
V (servido[proceso])
En primer lugar, para dejar al servidor ejecutándose mientras proseguimos con el shell, se ha de recurrir a la llamada fork para crear un proceso hijo (el propio servidor) que se dejará concurrente sin esperar por que termine.
Aun así, el proceso no les funcionará a no ser que inhiban la señal SIGTTOU, puesto que de ordinario se deja colgados a los procesos de segundo plano (background) que intentan escribir en la terminal. Puede que les suene a arameo, pero hay que hacerlo.
En fin, la receta completa en C para lanzar el servidor es más o menos la que sigue:
#include <signal.h>
...
...
if ( fork()==0 )
{
signal (SIGTTOU,SIG_IGN); /* Ignora el cuelgue por escribir en TTY */
... código del servidor (el bucle principal y lo demás)
}
...
...
Existe un método más seguro, que consiste en elegir un número preestablecido como clave para el semáforo de peticiones pendientes. El proceso servidor crea ese semáforo cuando arranca. Los procesos clientes intentarán abrir el semáforo cuando quieran sincronizarse con el servidor; si el semáforo no está disponible, el servidor no existe.
Para que este esquema funcione, obligadamente el servidor ha de destruir el semáforo cuado termine su ejecución.
Estos fuentes en C pueden ser un excelente punto de partida para escribir su código. Incluyen unas rutinas para manipular semáforos UNIX de forma algo más cómoda que las llamadas al sistema.
Tengan en cuenta que es un código muy pobre, que no comprueba casi ningún error; que no requiere que el servidor comunique nada a los clientes y que sólo permite mantener un mensaje encolado pendiente de atención.
Los fuentes de la aplicación se encuentran en el directorio /prac/cliser, con su correspondiente fichero makefile. Para utilizarlos, hagan lo siguiente:
1. Copien todos los ficheros a un directorio local y trasládense a él.
2. Ejecuten make. Se generarán los cuatro ejecutables: servidor, lee, escribe y termina.
3. Ejecuten servidor. Quedará el servidor en segundo plano.
4. Comprueben con ps que el servidor está presente.
5. Comprueben con ipcs que se han creado dos semáforos.
6. Ejecuten escribe 15. Esto asignará un 15 a la variable.
7. Ejecuten lee. Debería aparecer un 15 en la terminal.
8. Ejecuten termina. El servidor finaliza.
9. Comprueben con ps e ipcs que no hay rastro del servidor ni de los semáforos.
y, en fin, experimenten con el código.
La documentación más esencial se instalará en el servidor WWW de SOPA en formato HTML.
ipcs visualiza los IPC activos ipcrm destruye IPC
Si un proceso se les queda colgado lo pueden matar con kill -9 pid, donde pid indica el identificador del proceso que quieren destruir. Para saber el pid de la víctima, tecleen ps y verán la lista de procesos asociados a la terminal.
La tecla de suspensión (suspend), que normalmente es ^Z, les puede ser de gran ayuda para mantener varios programas ejecutándose concurrentemente. Si pulsan ^Z, el proceso actual se "suspende", es decir, pasa al segundo plano mientras ustedes prosiguen con el shell. Para regresar un proceso suspendido al primer plano, tecleen fg. Con este mecanismo pueden probar el sistema con múltiples clientes sin tener que acaparar varias terminales, que son más bien escasas.
· identificar pérdidas de consistencia
· resolver problemas de consistencia
· identificar situaciones de interbloqueo
Estas situaciones se plantean en la página 4. Durante la defensa de la práctica, deberán mostrar al profesor tutor la aparición de estos problemas y su posterior corrección, según el caso.
Los criterios de califación serán los habituales, basados sobre todo en la calidad de los programas y la observancia de los principios de la programación modular. Deberán acompañar su trabajo de una breve memoria explicativa del trabajo realizado. En la memoria habrán de comentar los detalles particulares de su labor; se les ruega no añadir información superflua (p.ej. copiar parte de este documento, de apuntes o de libros de texto).
La máxima calificación alcanzable con estas tareas es de un 7 sobre 10.
1. Implementar el sistema usando como herramienta de comunicación las colas de mensajes o la memoria compartida de Unix. Esto supondrá un 20% extra en la nota final.
2. Resolver el problema del interbloqueo. La gestión correrá a cargo del servidor. Una solución es evitar que ocurra alguna de las condiciones necesarias para el interbloqueo (p.ej. espera con retención). Esta actividad supondrá un 10% extra en la nota final.