Sistemas Operativos

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.

1. El modelo cliente/servidor

Un proceso puede proporcionar unos servicios a los restantes procesos del sistema. Estos servicios serán operaciones de diverso tipo, por ejmplo imprimir un documento, leer o escribir una información, etc. En el modelo cliente/servidor, cuando un proceso desea un servicio que proporciona cierto proceso, le envía un mensaje solicitando ese servicio: una petición. El proceso que cumple el servicio se llama servidor y el solicitante se llama cliente.

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.)

2. Descripción general de la práctica

El trabajo propuesto consiste en implementar con procesos concurrentes una simulación de un sistema bancario. Un proceso servidor se encargará de gestionar cuentas bancarias. Sus servicios incluirán operaciones de consulta de saldos y anotaciones en cuenta. Los procesos clientes emularán el comportamiento de cajeros automáticos y proporcionarán la interfaz de usuario para manipular las cuentas bancarias.

2.1. Las cuentas bancarias y el fichero de cuentas

El programa servidor gestionará un conjunto de cuentas bancarias. Cada cuenta bancaria se identifica mediante un número de cuenta, que es un entero positivo. Una cuenta bancaria posee un saldo, que es una cantidad en pesetas. El saldo puede ser negativo (los temidos números rojos). Se supone que tanto el número de cuenta como el saldo pueden caber en un long.

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.

2.2. Servicios ofrecidos

Los servicios proporcionados por el servidor bancario son los que se muestran a continuación. En negrita va el nombre del servicio, entre paréntesis los parámetros que ha de completar el cliente, y después de la flecha, el contenido del mensaje de respuesta.

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.

2.3. Especificaciones del servidor y el cliente

2.3.1 Programa servidor

El programa servidor tendrá la siguiente sintaxis:

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.

2.3.2 Programa cliente

El programa cliente será el simulador de un cajero automático. Le presentará al usuario un menú con las operaciones disponibles, que serán al menos:

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.

2.3.3 Operaciones del cliente

Observen que, salvo la consulta de saldo, ninguna de las operaciones se resuelve con una única llamada al servidor. Los algoritmos para completar las operaciones de ingreso, reintegro y transferencia podrían ser parecidos a estos:

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.

3. Guía de implementación

En este apartado se dan una serie de consejos o guías sobre cómo implementar los distintos programas requeridos en esta práctica. Ante todo, dado que los procesos clientes tendrán que sincronizarse con el servidor, será necesario que empleen alguna modalidad de comunicación entre procesos (IPC). Por ello se les recomienda que acudan a la bibliografía de la asignatura para documentarse sobre el uso de estas herramientas, en especial los semáforos.

3.1. Estructura del servidor

El bloque principal del programa servidor debería tener la siguiente estructura general, escrita aquí en seudo-C:

...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.

3.2. Comunicación entre los clientes y el servidor

Las preguntas del millón en este trabajo bien pueden ser: cómo sabe el servidor que hay un mensaje disponible? y cómo envía el cliente toda la información al servidor? Ambas constituyen el problema fundamental de sincronización y comunicación que hay que resolver.

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.

3.2.1 Fichero de comunicaciones

Como solución más simple al paso de mensajes entre clientes y servidor, les sugerimos que empiecen por utilizar un fichero donde se deposite el contenido de los mensajes que los clientes elaboren. Los clientes actuarán como productores en el fichero de mensajes, mientras que el servidor será el único consumidor del fichero. Algo análogo puede hacerse para las respuestas del servidor a los clientes.

3.2.2 Ficheros FIFO

Una modalidad de comunicación en UNIX muy práctica para este trabajo son los llamados ficheros FIFO. Funcionan como una cola FIFO (de ahí su nombre) puesto que cuando se escribe en uno de ellos, siempre es por el final; cuando se lee, siempre es por el principio y además se borra automáticamente lo que se va leyendo. La función mkfifo(3C) crea un fichero FIFO; consulten las entradas de manual de open(2), close(2), read(2) y write(2) para ver el comportamiento de estas llamadas cuando se trabaja con un fichero FIFO.

3.2.3 Codificación de los mensajes

Sea cual sea el medio de transmisión de las peticiones, habrán de idear un protocolo para saber exactamente qué información se envía al servidor. Una implementación típica consiste en tener un encabezado en cada mensaje indicando qué tipo de servicio se solicita, y a continación los parámetros.

3.3. Bloqueo y desbloqueo de los clientes

Las especificaciones del sistema imponen que un proceso cliente ha de bloquearse hasta que el servidor le envíe una respuesta. Esto se conseguirá con semáforos, igual que la del servidor que espera por peticiones, pero en sentido inverso: el cliente, tras haber enviado su mensaje, se bloqueará con una operación P sobre un semáforo de petición servida. Cuando el servidor complete su petición, ejecutará una V sobre ese mismo semáforo.

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.

3.4. Propuesta de algoritmo de comunicación

Estos son fragmentos algorítmicos que desarrollan las soluciones que acabamos de plantear. Los semáforos bloquea_fichero y petición_pendiente están inicializados a cero.

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])

3.5. Cómo dejar al servidor ejecutándose en segundo plano

Según las especificaciones, el programa servidor ha de mantenerse ejecutándose en segundo plano, esperando continuamente la llegada de peticiones de procesos clientes. Para lograr este comportamiento habrá que realizar algunas operaciones nada triviales que es preciso citar aquí.

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)

}

...

...

3.6. Detección del servidor

Un cliente ha de percibir si el servidor se encuentra en ejecución. Pueden solucionar este problema de múltiples formas. Bien podría ser que el servidor al iniciarse creara un fichero "tonto" para dajar constancia de su existencia. O bien podrían emplear el programa ps.

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.

3.7. Programa de muestra

Para ilustrar todos los aspectos de implementación que se han comentado en las páginas anteriores, se han dispuesto unos fuentes en C en el laboratorio que implementan un sistema cliente/servidor muy sencillo. El servidor contiene una variable entera, que los clientes pueden leer y escribir. La interacción con el servidor corre a cargo de tres programas muy similares en estructura y que se encargan respectivamente de leer la variable, asignarle un nuevo valor y decirle al servidor que finalice.

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.

4. Documentación adicional

Para trabajar con semáforos UNIX y otros IPC, pueden recurrir a la Guía de Operación y Programación en Unix de la asignatura, que se encuentra disponible tanto en el Servicio de Reprografía del edificio como en el Laboratorio de Sistemas Operativos (solicítenla al personal laboral). También pueden encontrar en la Biblioteca el libro Unix Network Programming, donde hay ejemplos sobre semáforos, colas de mensajes y memoria compartida.

La documentación más esencial se instalará en el servidor WWW de SOPA en formato HTML.

5. Consejos prácticos

Los semáforos son entidades que sobreviven a la muerte de sus procesos creadores, como ocurre con los ficheros. Por si algún programa se les queda colgado, deberían hacerse una utilidad para borrar los semáforos "basura" que les vayan surgiendo. Las órdenes UNIX para manipular semáforos y otros IPC son:

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.

6. Objetivos y evaluación del trabajo

La práctica asignada consiste en implementar el sistema cliente/servidor con las especificaciones que se han descrito. Tendrán que desarrollar varias versiones del cliente, en orden a

· 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.

6.1. Actividades suplementarias

Las siguientes actividades no son obligatorias, pero supondrán un incremento en la nota final:

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.