Práctica de comunicación entre procesos
curso 1996/97
L a 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 trata de construir un intérprete de órdenes al estilo de los shells de UNIX, con un proceso central (un servidor) que ejecutará órdenes solicitadas por otros procesos, que actuarán como clientes suyos.
Este documento introduce brevemente el modelo de comunicación cliente/servidor. A continuación, en las secciones de la a la , se describe en líneas generales la aplicación que hay que desarrollar. La sección 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.
Al finalizar este trabajo, habrán adquirido experiencia en programación de aplicaciones concurrentes en entorno UNIX, utilizando estas herramientas:
* Funciones para ejecutar y sincronizar procesos concurrentes (fork,
wait
y exit
)
* Funciones de ejecución de programas (familia de funciones
exec
...)
* Servicios IPC (semáforos, memoria compartida y colas de mensajes)
Índice
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 devuelve información al cliente (los resultados del servicio, códigos de error en caso de producirse, etc.)
Esta práctica consiste en el diseño e implementación de un intérprete de órdenes al estilo de los shells de UNIX, utilizando una arquitectura cliente/servidor. El intérprete de órdenes será un proceso servidor que se ejecutará en segundo plano. Tal servidor atenderá las peticiones de otros procesos clientes, según un protocolo de comunicaciones conocido por ambas clases de procesos.
Por tanto, en esta práctica hay que escribir el código de un proceso servidor y de un proceso cliente.
El programa servidor tendrá la siguiente sintaxis:
servidor
Al invocar al servidor, quedará automáticamente en segundo plano (background), en espera de atender las futuras peticiones de los procesos clientes hasta que reciba una orden especial de finalización.
El servidor ejecutará las órdenes secuencialmente, una detrás de otra. Esto es, el servidor no puede lanzar varias órdenes concurrentes, y siempre tendrá que esperar a que una orden finalice para tramitar la siguiente.
Los clientes que envíen peticiones mientras el servidor está ejecutando una orden tendrán que esperar. Cuando una orden termine de ejecutarse, el servidor deberá notificar el resultado de la ejecución al proceso cliente que la demandó (el cual estará esperando por la notificación).
Mientras no haya órdenes que atender, el servidor permanecerá bloqueado.
El programa cliente admitirá dos opciones de trabajo:
cliente
arg1 arg2 ... argN
cliente finaliza
El cliente admitirá uno o más argumentos, que constituyen la orden que se transmite al servidor. El cliente quedará bloqueado hasta que la orden haya sido ejecutada por completo, momento en el que el servidor le enviará una notificación.
El primer argumento (arg1) es el nombre del programa que se
quiere ejecutar. Es obligatorio al menos un argumento. En el caso especial de
que el primer argumento sea la cadena finaliza
, se
enviará al servidor una petición de que finalice.
Las órdenes enviadas no contendrán asteriscos, redirecciones ni tuberías (complicarían en exceso la implementación de la práctica).
La salida de datos de la orden ejecutada se mostrará en la misma terminal del cliente.
En caso de que el servidor finalice o aborte durante la ejecución de la orden, el cliente deberá desbloquearse y terminar imprimiendo un mensaje alusivo.
Toda orden que se le envía al servidor para que la ejecute
deberá ser pasada como parámetro de ejecución del programa
cliente, es decir, si por ejemplo queremos que se ejecute un cliente que
demande la ejecución del orden del sistema date
(mostrar fecha y hora actual), entonces se deberá teclear desde el
símbolo del sistema lo siguiente:
$ cliente date
Otro ejemplo: para solicitarle al servidor que muestre el contenido de un
supuesto directorio cuya ruta es /mi_dir
, entonces
habría que teclear desde el símbolo del sistema la
línea:
$ cliente ls /mi_dir
Las órdenes con caracteres comodín, como
ls *.txt
, no les van a funcionar correctamente, así
que no las empleen.
Los clientes y el servidor han de transferirse información (las peticiones y las respuestas). Además, los procesos del sistema tendrán que sincronizarse en ciertos momentos (ej. cuando un cliente espera a que se complete una petición).
El sistema operativo UNIX ofrece múltiples mecanismos para realizar comunicaciones entre procesos: utilizar un fichero normal, un fichero FIFO o pipe, zonas de memoria compartida o colas de mensajes. Los ficheros FIFO y las colas de mensajes facilitan además la sincronización.
El UNIX tiene semáforos como herramienta especialmente destinada a la sincronización entre procesos.
Para esta práctica, ustedes tendrán que optar entre uno de estos dos conjuntos de herramientas:
* Memoria compartida y semáforos
* Colas de mensajes
Las comunicaciones se establecerán a través de algún mecanismo de comunicación entre procesos, que ustedes habrán de elegir. No obstante, la interfaz de comunicaciones será impuesta, tal y como se define en este documento.
Básicamente, se tienen sendas estructuras para que el cliente o el servidor envíen mensajes, así como dos parejas de funciones de envío y recepción de peticiones y respuestas.
Las estructuras de datos empleadas serán las siguientes:
/* Constantes */ #define DIM_ORDEN 200 /* Long. máxima de la orden */ #define DIM_CANAL 128 /* Long. máx. nombre de tty */ #define FINALIZA 7 /* Código de finalización */ /* Códigos de error */ typedef enum { ERR_OK = 0, ERR_HIJO = 1000, ERR_CARGA = 2000, ERR_EJEC = 3000 } Codigo_error; /* Estructuras para peticiones y respuestas */ struct Peticion { char orden[DIM_ORDEN]; /* orden para ejecutar */ char canal[DIM_CANAL]; /* fichero donde se visualiza */ }; struct Respuesta { Codigo_error resultado; };
El cliente enviará al servidor una petición con la estructura
struct Peticion
. El campo orden
contendrá la orden completa. En caso de una orden de finalizar, el campo
orden[0]
tendrá el valor 7 (constante
FINALIZA
). El campo canal
es una cadena
de caracteres donde se guarda la ruta de la terminal del cliente.
Las funciones para efectuar las comunicaciones han de tener estos prototipos:
/* Funciones de comunicación para el cliente */ int envia_peticion ( const struct Peticion* ); int espera_respuesta ( struct Respuesta* ); /* Funciones de comunicación para el servidor */ int recibe_peticion ( struct Peticion* ); int envia_respuesta ( const struct Respuesta* );
Estas cuatro funciones devuelven un número entero, cuyo significado se describe a continuación:
Valor
|
Significado
|
0
|
La
operación se completó satisfactoriamente
|
1
|
El
servidor ha finalizado antes de tramitar la orden (en las funciones propias del
cliente)
|
-1
|
Hubo
algún error
|
Las estructuras y prototipos de funciones que conforman la interfaz de
comunicaciones están ya definidas en el archivo
/prac/cliser/comunicacion.h
El proceso intérprete de órdenes deberá esperar a que algún proceso cliente le solicite sus servicios. Mientras esto no ocurra permanecerá bloqueado. Cuando se le demande un servicio, saldrá del estado de bloqueo y actuará. Básicamente el esquema de funcionamiento de este proceso es el siguiente:
Generar el proceso demonio. Establecer mecanismos de comunicación. for (;;) { recibe_petición (&mi_peticion); if ( orden de finalizar ) break; Procesar línea de órdenes. Ejecutar orden. Esperar por la finalización de la orden. envia_respuesta (codigo_de_respuesta); } Eliminar los mecanismos de comunicación. Terminar
Observe cómo se trata de un proceso que entra en un
bucle infinito, del cual se sale cuando algún proceso cliente le ordene
al servidor su finalización. 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.
Seguidamente se detallan los aspectos más importantes de cada punto de la ejecución.
Consiste en la creación de un nuevo proceso que se deberá ejecutar en segundo plano (proceso demonio). Una vez creado el nuevo proceso, el proceso deberá finalizar. De esta forma, el servidor no acapara la terminal donde se ha lanzado.
Consiste en la creación de los recursos que permitirán la comunicación entre el servidor y los clientes. Para esta práctica se podrán utilizar dos clases de herramientas de intercomunicación: colas de menajes, o bien usar conjuntamente semáforos y memoria compartida.
El servidor habrá de bloquearse mientras no tenga ninguna solicitud de servicio. Así pues, se tendrá que diseñar un mecanismo de sincronización y comunicación que garantice que mientras no haya solicitud, el servidor permanezca en estado de bloqueo (sin consumo de CPU). Además, este mecanismo deberá garantizar la integridad de las peticiones; esto es, una petición que se está recogiendo no podrá ser destruida por la llegada de una nueva.
En este paso se comprueba si el servicio requerido se corresponde con
una orden de finalización del servidor; o, por contra, se solicita la
ejecución de un programa. Como se ha dicho, esto viene expresado por el
valor del campo orden[0]
de la petición. Si vale
FINALIZA
, el servidor sale del bucle, elimina los recursos
IPC que haya creado y termina su ejecución.
En este paso se produce el análisis de la cadena de caracteres
que contiene la orden a ejecutar. Este tratamiento tiene la finalidad de
obtener: a) el nombre del fichero ejecutable y b) cada uno de los argumentos
del programa. Las funciones de rastreo de cadenas, como
strchr
, pueden ser útiles para trocear la
línea de órdenes.
Cada uno de los datos extraídos se empleará para invocar
posteriormente al programa solicitado, haciendo uso de alguna función
exec...
del UNIX.
El programa servidor creará un nuevo proceso hijo mediante la
función fork
. El proceso padre deberá esperar
a que el hijo recién lanzado finalice su ejecución; la espera se
hará mediante la función wait
.
Por su parte, el proceso hijo deberá cargar y ejecutar el programa
solicitado con los argumentos especificados en la línea de
órdenes mediante el uso de alguna de las funciones
exec
.
La salida estándar de la orden habrá de redirigirse al archivo
especificado en el campo canal
de la petición
recibida. Más adelante se explica cómo se realiza esto.
Algunas de las situaciones de error que pueden surgir, y que deberán ser notificadas al cliente en la respuesta, son:
1. No se pudo crear el proceso hijo con el fork
(devolver
ERR_HIJO
)
2. No se pudo lanzar el programa con exec
(devolver
ERR_CARGA
)
3. La orden dio un resultado erróneo (devolver
ERR_EJEC
)
Si no ocurre nada anómalo, se le entrega al cliente el valor
ERR_OK
Tal y como se ha indicado, esto lo realizará el servidor mediante
el uso de la llamada al sistema wait
. Lo único a
reseñar es que no se deberá llegar a este punto si se produce un
error en la invocación a la función fork
.
Si el error se produce en la carga o en la ejecución del programa
solicitado, entonces, se podrá detectar consultando el argumento
utilizado en la llamada a la función wait
.
El servidor deberá comunicar al cliente que le solicitó el
servicio si la orden demandada se ha realizado o no. Para ello se
utilizará la función envia_respuesta()
, que
ustedes habrán de implementar.
Los pasos básicos de ejecución de un cliente son:
Construir petición en mi_peticion
envia_peticion(&mi_peticion);
recibe_respuesta(&resultado);
Comprobar resultado de la ejecución.
Finalizar.
Seguidamente se describe lo que hace cada uno de estos pasos:
Las primeras instrucciones del servidor consisten en elaborar la
petición que se enviará al servidor. Se tendrá que leer
los parámetros argc
y argv[]
de
la función main
en el programa cliente, y
convertirlos en un struct peticion
.
Por otra parte, el cliente deberá rellenar el campo
canal
de la estructura peticion
, con la
ruta de la terminal o el archivo donde se desea visualizar el resultado. Para
conocer la terminal actual, se invoca a la función
función()
.
Una vez que el cliente ha construido la petición, deberá solicitar el servicio al proceso servidor. Deberá hacer uso del mecanismo de sincronización y comunicación diseñado por ustedes, basado en algún IPC de UNIX. La implementación deberá garantizar que el cliente se mantenga bloqueado hasta que el servidor le notifique que se ha completado su petición, o bien hasta que el servidor finalice.
Observe que el servidor puede encontrarse ocupado con peticiones de otros clientes, y que no se deben interferir unas peticiones con otras.
Una vez que se le ha notificado que el servicio ha sido realizado, el
cliente deberá consultar el campo resultado
de la
estructura struct Respuesta
, para determinar si la
solicitud de tramitó correctamente o no.
En este trabajo, las cuestiones principales en materia de concurrencia pueden ser: ¿cómo sabe el servidor que hay una orden pendiente? ¿cómo sabe el cliente que su orden se ha realizado? y ¿de qué manera el cliente y el servidor se envían información?
Las anteriores preguntas constituyen el problema fundamental de sincronización y comunicación que hay que resolver. Los dos apartados que vienen a continuación tratan de este asunto, según decidan emplear memoria compartida o colas de mensajes. Lean el apartado que se corresponda con su elección.
Los últimos apartados de esta sección tratan de asuntos generales de implementación, válidos para cualquiera de las herramientas IPC que empleen.
La primera cuestión es cómo indica el cliente al servidor que hay una orden pendiente. Se puede disponer de un semáforo, cuya clave conozcan tanto los clientes como 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.
Un proceso cliente ha de bloquearse hasta que el servidor le envíe una respuesta. Esto se puede conseguir con semáforos, igual en el caso anterior, 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.
Para realizar las transferencias de información, habrán de utilizar una o varias zonas de memoria compartida. Tendrán que tener cuidado si varios procesos, por ejemplo varios clientes, desean escribir simultáneamente en la zona. Así que tendrán que emplear un semáforo para garantizar la exclusión mutua.
Estos son fragmentos algorítmicos que desarrollan las soluciones que acabamos de plantear, y que estarán en el interior de las funciones de envío y recepción (véase interfaz de comunicaciones). En estos algoritmos se supone que la misma zona de memoria se emplea para la emisión de peticiones y de respuestas.
El semáforo cerrojo_memoria
inicialmente vale uno,
mientras que los de petición_pendiente y
petición_
servida están inicializados a cero.
Proceso cliente (al realizar una petición):
P (cerrojo_memoria) /* Bloqueo de la memoria compartida */ ... escribe petición en la memoria ... V (petición_pendiente) /* Aviso al servidor */ P (petición_servida) /* Espera por que se complete */ ... recoge la respuesta de la memoria ... V (cerrojo_memoria) /* Desbloqueo de la memoria compartida */
Proceso servidor (al esperar por una petición):
P (petición_pendiente) /* Espera por una petición */ ... recoge la petición de la memoria ... ... atiende la petición ... ... escribe la respuesta en la memoria ... /* Para dar por servida una petición */ V (petición_servida)
Si utilizan colas de mensajes, no son necesarias herramientas
adicionales para sincronizar procesos. En primer lugar, el proceso que quiera
recibir información ejecutará la operación
msgrcv
con el parámetro msgflg a cero.
Esto significa que el proceso quedará bloqueado mientras no llegue un
mensaje a la cola.
En este trabajo hay dos flujos de comunicación: uno va de los clientes
al servidor y otro en sentido contrario. Pueden utilizar una cola para cada
flujo, o bien hacer todas las transferencias con una sola cola de mensajes. En
este último caso han de asignar tipos diferentes para los mensajes de
peticiones y de respuestas (el tipo son los primeros bytes del mensaje).
La llamada msgrcv
permite especificar que se atiendan
sólo los mensajes de un tipo determinado. El problema en este caso es
cómo conseguir que cada cliente utilice un identificador diferente (una
buena idea es utilizar el pid del proceso, que es único).
A continuación se muestran unas líneas de código con las operaciones de comunicación entre los clientes y el servidor, haciendo uso de una única cola de mensajes. Hay bastantes detalles que no aparecen en estos algoritmos, así que no las tomen al pie de la letra.
Proceso cliente:
/* Construye la petición y la envía al servidor */ struct { long mtype; long tipo; ... otros campos ... } mi_mensaje; mi_mensaje.mtype = CLAVE_SERVIDOR; mi_mensaje.tipo = MI_CLAVE_UNICA; ... prepara el resto del mensaje ... msgsnd (cola,&mi_mensaje,tamaño1,0); /* Recibe la respuesta */ /* Sólo reconoce el mensaje cuyo tipo sea MI_CLAVE_UNICA */ msgrcv (cola,&mi_respuesta,tamaño2,MI_CLAVE_UNICA,0);
Proceso servidor:
struct { long mtype; long tipo; ... otros campos ... } mi_petición; /* Espera por una petición y la recibe */ msgrcv (cola,&mi_petición,tamaño1,CLAVE_SERVIDOR,0); ... atiende la petición y genera la respuesta... /* Envía la respuesta a la cola de mensajes, con el tipo del cliente para que sea sólo él quien la reciba */ struct { long mtype; ... otros campos ... } mi_respuesta; mi_respuesta.mtype = mi_petición.tipo; msgsnd (cola,&mi_respuesta,tamaño2,0);
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) }
La salida estándar se corresponde con el descriptor de fichero número 1. Si se cierra el descriptor 1 e inmediatamente después se abre un fichero, se le asigna el descriptor 1. El resultado es que la salida estándar se redirige a este fichero:
close(1); /* se cierra la salida estándar */
open("mi_fichero.txt"); /* se le asigna el descriptor 1
*/
Deberán escribir dos líneas parecidas a estas
justo antes de invocar a una función
exec()
en el
código del servidor.
¿Cómo sabe un cliente que el servidor ha finalizado? La
forma más simple de detectarlo consiste en que una vez que un cliente se
desbloquea, comprueba el valor de retorno de las funciones que ha invocado para
operar con los recursos IPC utilizados; si se detecta retorno por error y la
variable global del sistema errno
contiene un código
que indica que los recursos IPC utilizados ya no existen, entonces es que el
servidor ha finalizado y los ha borrado.
Probablemente esta es la primera vez que tengan que acometer la construcción de una aplicación concurrente. El desarrollo de esta clase de sistemas exige ser más cuidadoso y metódico. En este apartado le proponemos unas recomendaciones que pueden ayudarles a completar el trabajo con un mínimo de complicaciones.
Esto es, requerimientos y funcionamiento general. Si tiene dudas pregunte al profesor que tiene asignado para la tutorización y corrección de su práctica.
Antes de pasar al diseño de los programas del servidor y cliente céntrese en los requerimientos de sincronización y comunicación que se plantean y estudie, mediante trazas, las distintas situaciones de comunicación que se pueden dar en un sistema con el proceso servidor y varios clientes. Una vez estudiado el problema planteado, diseñe una solución mediante las técnicas explicadas en clase. Algunos casos de estudio vistos en el aula son muy similares al problema de este trabajo.
Una vez diseñada la solución al problema de comunicación, pase a la implementación en el entorno UNIX. Para ello es prerrequisito conocer el funcionamiento general de los mecanismos IPC de UNIX. Una vez dominado este aspecto, pase a la implementación, en este estado del diseño, y como consecuencia de su conocimiento del funcionamiento de las herramientas IPC, es posible que se plantee un rediseño de los mecanismos de comunicación debido a que se dará cuenta que los mecanismos IPC son más potentes y flexibles que los mecanismos vistos en la teoría, el llevar a cabo esta optimización es opcional.
Cada vez que el servidor atiende una petición, debe crear un
proceso hijo, esperar a que el nuevo proceso se ejecute y tras ello recoger
información sobre cómo terminó el hijo. Estas actividades
se resuelven con llamadas al sistema de UNIX, como son
fork()
, wait()
y
exit()
. Para dominar estas herramientas, le recomendamos
que implemente un programa sencillo que genere un proceso hijo, espere por su
finalización y cuando ésta ocurra obtenga el código de
finalización del proceso generado. Este programa le podrá servir
de esqueleto para una parte del programa servidor.
La ejecución de la función fork()
produce la creación de un nuevo proceso que es imagen del proceso que la
invocó originalmente (proceso padre). En nuestro caso el proceso hijo
ejecutará un programa distinto al padre. Por tanto, habrá de
reemplazar su código y sus datos por los del programa que se desea
ejecutar. La familia de funciones exec...
del UNIX se
encargan de ello.
Basándose en el programa sencillo del apartado anterior, intente que el
proceso hijo lance un programa, especificado con una cadena de caracteres. Una
vez que lo anterior funcione, englóbelo en una función que reciba
como parámetro un char*
con la orden para ejecutar.
Como el proceso servidor deberá ejecutarse en forma de demonio, será necesario conocer los pasos necesarios para dejar un proceso en segundo plano y desvinculado de una terminal.
Observe que si ha seguido al pie de la letra las recomendaciones anteriores tendrá mucho camino recorrido. Dispone de una biblioteca de funciones que resuelven los problemas básicos de la práctica.
Realice un diseño general de los programas del servidor y del cliente. Exponga sus resultados al profesor tutor. Si éste le da el visto bueno, ya puede codificar la práctica y terminar su trabajo.
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:
|
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.