Manual del estudiante de Ingeniería en Sistemas de UTN/Redes de Información/Utilización de servicios de la capa de transporte

De Wikilibros, la colección de libros de texto de contenido libre.

El modelo cliente-servidor[editar]

La comunicación toma la forma de un par solicitud-respuesta, siempre iniciada por los clientes y nunca por un servidor. Se trata de un modelo asimétrico de interacción entre procesos que refleja la naturaleza de muchos patrones de comunicación en los que un servidor es un proceso que está en condiciones de ofrecer un servicio alcanzable a través de la red y un cliente es un proceso que necesita un servicio, pide por él y espera una respuesta. Los servidores y clientes corren asincrónicamente y únicamente sincronizan cuando se comunican. En particular, podemos clasificar a los procesos servidores según la forma en que se atienden los pedidos provenientes de clientes; tenemos un servidor interactivo cuando procesa las solicitudes de servicio en forma secuencial mientras que tenemos un servidor concurrente cuando se genera un nuevo proceso para atender a los clientes y el original queda nuevamente a la espera de nuevas solicitudes de servicio.

Funciones para el manejo de sockets[editar]

Un socket es una abstracción con la que se denomina a uno de los extremos de una comunicación, es una generalización del mecanismo de acceso al sistema capaz de manejar la comunicación entre procesos que se comunican de manera uniforme sobre una sola máquina o en un ambiente de red. Las primitivas involucradas con los sockets están implementadas como un conjunto de llamadas al sistema que proveen el acceso a los servicios de transporte por parte de los programas de usuario y se convierten así en la interfaz entre las aplicaciones de red y las capas más bajas de la red. El primer paso para trabajar con sockets es decidir qué aplicación va a utilizarlos y qué tipo de servicios se le exigirá al nivel de transporte. Antes de que un socket pueda referenciarse debe creárselo mediante la primitiva adecuada. La llamada al sistema para llevarlo a cabo es:

int socket(int sock_family, int sock_type, int protocol);

El argumento sock_type selecciona un modo de transporte (servicio orientado a conexiones o sin conexión). El argumento sock_family identifica unívocamente a la familia de direcciones que se utilizará para referenciar el socket. El último argumento, protocol, especifica el protocolo de comunicaciones dentro de la familia seleccionada a utilizar sobre el socket. Colocando en cero éste último parámetro se deja que el sistema decida el protocolo más adecuado. Si la operación fue correcta, la llamada socket() retorna un entero denominado socket descriptor que puede utilizarse para referenciar al socket en cualquier otra operación que se efectúe sobre él. En caso de falla, devuelve -1. Después de su creación, un socket debe asociarse a una dirección local que permita su utilización por parte de los procesos interesados. Esta operación se efectúa mediante la llamada: int bind(int sock_descr, struct sockaddr *addr, int addrlen); El argumento sock_descr es el socket descriptor utilizado para referenciar al socket con que se trabaja. El argumento addr es un puntero a la estructura que contiene la dirección que quiere asociarse al socket y addrlen es el tamaño de la estructura en bytes. bind() retorna 0 si la llamada fue exitosa y -1 en caso de error. Los servidores necesariamente deben especificar una dirección para cada uno de sus sockets mientras que los clientes no necesitan obligatoriamente asociarse a una dirección específica pudiendo dejar tales cuestiones al sistema. En interacciones basadas en un modelo cliente-servidor, el programa que actúa como un servidor se encuentra en estado pasivo esperando que lleguen pedidos provenientes de los clientes. El primer paso consisten en “marcar” un socket indicándole su deseo de establecer contacto con clientes remotos. Para ello se recurre a la llamada:

int listen(int sock_descr, int queue_length);

El argumento sock_descr identifica al socket sobre el cual se está efectuando la operación, es decir, por cuál socket se “escuchará”, y el argumento queue_length define el número máximo de conexiones pendientes que pueden permitirse; los pedidos posteriores serán descartados. La llamada listen retorna 0 ante el éxito de la operación y -1 ante una falla. listen() tiene sólo sentido bajo un servicio orientado a conexiones. Después que se ha creado un socket y se le ha asociado una dirección local, el mismo se encuentra en estado desconectado, es decir, no tiene relación alguna con una dirección remota. Para lograr un servicio orientado a conexiones, es decir, poder transferir información bajo un concepto de streams confiable, el programa cliente debe emitir una llamada con la siguiente sintaxis:

int connect(int sock_descr, struct sockaddr *peer_addr, int addrlen);

Aquí, sock_descr es un socket descriptor, peer_addr es un puntero a una estructura de direcciones que contiene la dirección del socket de destino, con el que se deberá conectar, y addrlen es la longitud de la dirección en bytes. La llamada retorna 0 en caso de éxito y -1 en caso de falla. Después que un proceso servidor ha ejecutado las llamadas socket(), bind(), y listen() para crear un socket, asociarle una dirección local y definir la cola para almacenar los pedidos entrantes, puede aceptar cada solicitud ejecutando la llamada:

int accept(int sock_descr, struct sockaddr *peer, int addrlen);

El argumento sock_addr señala el socket sobre el que se estaban esperando los pedidos, peer_addr es un puntero a la estructura que guardará la dirección del cliente y addrlen es la longitud de la dirección del cliente. Los dos últimos parámetros son escritos por el sistema. La llamada accept() se bloquea hasta que haya un pedido de conexión proveniente de un cliente en la cola de espera. Cuando llega un pedido, es decir, cuando un cliente emite una llamada connect(), se crea un nuevo socket que será el utilizado para intercambiar datos, y retorna el nuevo socket descriptor y la identificación del cliente que hizo la llamada. Habitualmente cada pedido aceptado se maneja en forma concurrente haciendo que, después de salir de la accept(), el servidor haga un fork generando un proceso que trabaje sobre el socket recientemente creado mientras que el proceso original cierra su copia y llama nuevamente a la función accept() para continuar “escuchando” en el socket original a la espera de otros pedidos. Una vez que se han creado los sockets y se ha establecido una conexión lógica que los vincule, puede procederse a la transferencia de datos entre ellos y, por ende, entre los procesos de usuario que los utilizan. read() y write() pueden utilizarse para el intercambio de datos con la diferencia que el primer parámetro será un socket descriptor en vez de un file descriptor. Mientras que en el caso de trabajar con archivos el write() se limita a transferir datos al caché, al trabajar con sockets el write() se bloquea hasta que los datos puedan transferirse al buffer del socket. Dos funciones similares, denominadas send() y recv(), emplean un cuarto argumento que permite algunas operaciones especiales. Las funciones más importantes para el intercambio de datos son:

int read(int sock_descr, struct msghdr *msg, int length);
int write(int sock_descr, struct msghdr *msg, int length);
int send(int sock_descr, struct msghdr *msg, int length, int flags);
int recv(int sock_descr, struct msghdr *msg, int length, int flags);

En todas, el argumento msg es un puntero a la estructura de datos a enviar o dónde colocar los datos recibidos y length indica el tamaño de tal estructura. El parámetro flags tiene una codificación especial y puede utilizarse para modificar la operación de los protocolos subyacentes. Las llamadas devuelven -1 ante una falla y el número de bytes escritos o leídos en caso de éxito. Debe tenerse en cuenta también que read() y recv() retornan tan pronto como tienen algún resultado y no necesariamente aguardan el arribo de todos los datos que se esperan. Se trata de streams y, por lo tanto, no necesariamente se conservan los límites de los mensajes. Desafortunadamente, una serie de llamadas write() en un extremo no necesariamente conducen a una serie equivalente de llamadas read() en el otro extremo. Para terminar las operaciones sobre un socket en particular puede recurrirse a la llamada:

int shutdown(int sock_descr, int mode);

El argumento mode determina cuán abrupto será el final de la conexión y toma los valores 0, 1 ó 2. Con el valor 0, no se pueden escribir más datos; con 1, se envía cualquier dato pendiente y se suspende la transmisión; con 2, no se puede enviar ni recibir más información. Por último, al igual que un archivos regular, un socket también puede cerrarse mediante la llamada

int close(int sock_descr);

Cuando se efectúa un close() sobre un socket antes de efectivizarse se envían todos los datos pendientes y se pierde cualquier mensaje que aún no haya arribado. Después de una llamada close() los recursos asociados al socket se devuelven al sistema. Los sockets pueden también utilizarse para trabajar con servicios son conexión. En este caso, en el momento de crear el socket se debe indicar el deseo de obtener un servicio de datagramas en el segundo parámetro de la llamada socket(). En segundo lugar, los sockets para un servicio de datagramas no necesitan estar conectados antes de su uso dado que permiten un modo de transmisión en el que cada mensaje contiene todos los datos para alcanzar su destino y tampoco necesitan de la función accept(). Los mensajes pueden enviarse y recibirse sin establecer ningún vínculo. Más aún, los sockets de datagramas permiten el envío a múltiples destinos desde un mismo origen y la recepción en un solo socket de información proveniente de varios sistemas remotos. Para la transferencia de datagramas se cuenta con el par de funciones sendto() y recvfrom(), simulares a la send() y recv(), pero suman dos parámetros más que identifican la dirección de la entidad par y el tamaño de tal dirección. Su sintaxis es la siguiente:

int sendto(int sock_descr, struct msghdr *msg, int length, int flags, sockaddr *dest, int destlength);
int recvfrom(int sock_descr, struct msghdr *msg, int length, int flags, sockaddr *from, int fromlenght);

El parámetro msg apunta a los datos a enviar o al lugar donde se guardarán los recibidos y length especifica la longitud del msg; dest y from son punteros a estructuras de direcciones y destlength y fromlength indican el tamaño de las mismas. Independientemente del tipo de servicio, además de las funciones mencionadas, se disponen de varias rutinas de biblioteca que brindan servicio a los programas de aplicación traduciendo valores numéricos en nombres, direcciones de red y denominaciones de protocolos en formatos legibles por humanos. Por ejemplo, gethostbyaddr() devuelve el nombre de un host y algunos datos del mismo dado su dirección de red. gethostbyname() efectúa la operación inversa. getnetbyname() y getnetbyaddr() dan resultados análogos con respecto a la red. Las funciones getprotobyname() y getprotbynumber() actúan sobre los protocolos disponibles en un host dado. getservbyname() y getservbyport() otorgan información sobre los servicios de red disponibles. Los sockets, en definitiva, son una de las posibles interfaces entre las aplicaciones y los servicios de transporte. Las mayores dificultades radican en la representación de los datos que se intercambian, por lo que debería recurrirse previamente a servicios de presentación. Debe sí tenerse en cuenta que los sockets son un concepto de bajo nivel por lo cual su uso no resulta sencillo, exigiendo un conocimiento preciso de los detalles de la red y de la implementación. Por otra parte, tiene la ventaja de un mayor control; por ejemplo, elegir el paradigma que utilizará la aplicación y la biblioteca de representación de datos que se usa en un momento dado; en definitiva, una mayor flexibilidad.

Llamadas a procedimientos remotos (RPCs)[editar]

Si bien es cierto que resulta posible establecer sesiones entre clientes y servidores y luego usar una comunicación semidúplex sobre esas sesiones, frecuentemente la elevada sobrecarga ocasionada por múltiples capas de conexión se hace poco atractiva para las aplicaciones donde la performance es crítica. Una forma de comunicación sin conexión construida directamente encima de una facilidad de datagramas nativa, a menudo, es una elección mucho mejor. Aún cuando los problemas de performance puede solucionarse usando el modo sin conexiones, el modelo aún tiene sus fallas importantes: la base conceptual de toda comunicación es la de entrada/salida (input/output). Los programas se comunican con otros usando comandos tales como X-DATA.request y X-DATA.indication, el primero de los cuales es I/O y el último de los mismos probablemente sea una interrupción. Ellos difícilmente constituyan la herramienta adecuada para construir aplicaciones bien estructuradas. Un modelo radicalmente diferente para el diálogo y el control de errores, basado en un servicio sin conexiones, conocido bajo el nombre de llamadas a procedimientos remotos o RPC (remote procedure calls), ha sido ampliamente implementado en redes y especialmente en sistemas distribuidos. Las RPCs están lógicamente relacionadas con las mismas exigencias que la capa de sesión. Sin embargo, parte del mecanismo está implementado en las capas de presentación y de aplicación. Comercialmente, el producto NFS proporciona una implementación de RPCs mediante tres módulos: la biblioteca de RPC propiamente dicha (ubicable en el nivel de sesión OSI), una biblioteca de presentación de datos denominada XDR (a nivel de presentación) y la interfaz al usuario junto a otras utilidades (a nivel de aplicación). La escuela RPC propone el modelo cliente-servidor desde una perspectiva completamente diferente, que ha sido diseñada para ser rápida y transparente. Una RPC es un método de comunicación entre procesos de una red que permite trabajar con un alto nivel de abstracción, ignorando los mecanismos de comunicación subyacentes y las características de su implementación. La política de trabajo surge a partir de la fusión de las ideas directrices del modelo cliente-servidor con las del mecanismo de llamada a un procedimiento. Un cliente enviando un mensaje al servidor y obteniendo una réplica se comporta de la misma manera que un programa llamando a un procedimiento y consiguiendo un resultado. En ambos casos, el que llama inicia una acción, espera que se complete y los resultados estén disponibles. Para ayudar a ocultar aún más la diferencia entre llamadas remotas y locales, es posible embeber la RPC en el lenguaje de programación. La bondad de este esquema es que la comunicación cliente-servidor ahora toma la forma de llamadas de procedimientos en lugar de comandos de I/O. Todos los detalles sobre cómo trabaja la red se le pueden ocultar al programa de aplicación poniéndolos en los procedimientos locales. Esos procedimientos modificados son llamados stubs. Un procedimiento de este tipo puede enviar un mensaje pidiéndole al servidor que ejecute una operación arbitraria. El objetivo final es hacer que las llamadas a procedimientos remotos no parezcan diferentes de las llamadas a procedimientos locales.

Implementación de las llamadas a procedimientos remotos[editar]

En la figura, la llamada remota toma 10 pasos, en el primero de los cuales el programa cliente (o procedimiento) llama al procedimiento stub enlazado en su propio espacio de direcciones. Los parámetros pueden pasarse de la manera usual.


El stub cliente reúne luego los parámetros (parameter marshalling) y los empaqueta en un mensaje. Después que se ha construido el mensaje, se lo pasa a la capa de transporte para su transmisión (paso 2). En un sistema LAN con un servicio sin conexiones, la entidad de transporte probablemente sólo le agrega al mensaje un encabezamiento y lo coloca en la subred sin mayor trabajo (paso 3). En una WAN, la transmisión real puede ser más complicada. Cuando el mensaje llega al servidor, la entidad de transporte lo pasa al stub del servidor (paso 4), que desempaqueta los parámetros. El stub servidor llama luego al procedimiento servidor (paso 5), pasándole los parámetros de manera estándar. El procedimiento servidor no tiene forma de saber que está siendo activado remotamente, debido a que se lo llama desde un procedimiento local. Únicamente el stub sabe que está ocurriendo algo particular. Después que ha completado su trabajo, el procedimiento servidor retorna (paso 6) de la misma forma en que retornan otros procedimientos cuando terminan y, desde luego, puede retornar un resultado a un llamador. El stub servidor empaqueta luego el resultado en un mensaje y lo entrega a la interfaz con transporte (paso 7), posiblemente mediante una llamada al sistema, al igual que en el paso 2. Después que la respuesta retorna a la máquina cliente (paso 8), la misma se entrega al stub cliente (paso 9) que desempaqueta las respuestas. Finalmente, el stub cliente retorna a su llamador, el procedimiento cliente y cualquier valor devuelto por el servidor en el paso 6, se entrega al cliente en el paso 10. Ya que el cliente no puede saber que el servidor es remoto, se dice que el mecanismo es transparente. El principal problema ocurre con el paso de parámetros. El paso de enteros, números de coma flotantes y cadenas de caracteres por valor es fácil. En el peor de los casos, puede necesitarse una conversión a algún formato de red estándar pero tales conversiones son parte de la capa de presentación y no afectan la operación de los stubs. El inconveniente surge cuando el lenguaje permite el paso de parámetros por referencia, más que por valor. Para un llamado local, normalmente se le pasa al procedimiento llamado un puntero (la dirección del parámetro) y el procedimiento llamado sabe que se está tratando con un parámetro por referencia, así que puede seguir el puntero para acceder al parámetro propiamente dicho, es decir, a los valores que le interesan. Esta estrategia falla completamente para una llamada remota. Cuando el compilador estándar produce el código para el servidor, no sabe nada sobre las RPCs y genera las instrucciones usuales para el seguimiento de punteros. Desde luego, el objeto apuntado ni siquiera está en la máquina del servidor. Como resultado, cuando el servidor intenta usar un parámetro por referencia, obtiene un valor equivocado y el cálculo falla. Una posible solución es reemplazar el mecanismo de llamadas por referencia por el de llamadas tipos copie/restaure. Con copie/restaure, el stub cliente ubica el ítem apuntado y lo pasa al stub servidor, quien lo coloca en algún lugar de memoria y pasa un puntero al procedimiento servidor. Cuando el procedimiento servidor retorna el control al stub, éste envía de regreso el ítem de datos al stub cliente, quien lo usa para sobreescribir los valores apuntados en el parámetro por referencia original. Aunque el mecanismo copie/restaure trabaja frecuentemente, puede fallar en ciertas situaciones patológicas.

void doubleincr(int x, int y)
{
   x++;
   y++;
}
void main()
{
   int a= 0;
   doubleincr(a, a);
   println(a);
}

Cuando este programa corre localmente, ambos parámetros en la llamada a doubleinc son apuntados por a, que sufre un incremento doble. Se imprime el número 2. Si doubleinc es llamado como un procedimiento remoto usando copie/restaure, el stub cliente procesa cada parámetro separadamente, así que envía dos copias de a al stub servidor. El procedimiento servidor incrementa cada copia una vez y ambas son pasadas de regreso al stub cliente, que sencillamente las restaura. El valor final es 1. Los punteros dan problemas similares a los parámetros por referencia. Los mismos son especialmente molestos si apuntan el medio de listas complejas, gráficos o estructuras de registros variables. Los parámetros que identifican procedimientos o funciones también son difíciles de manejar, si bien el stub servidor puede crear procedimientos locales a la máquina del servidor que invoquen RPCs inversas a los procedimientos sobre la máquina del cliente especificados en los parámetros. Algunos sistemas de RPC evitan todo el problema prohibiendo el uso de parámetros por referencia y/o punteros en las llamadas remotas. Tal decisión hace más fácil la implementación, pero rompe la transparencia debido a que las reglas para llamadas locales y remotas son diferentes. ¿Cómo sabe el stub cliente a quién llamar para localizar el procedimiento que necesita? En redes con servicios orientados a conexiones tradicionales las sesiones se establecen entre SSAPs, cada uno de las cuales tiene un número de identificación fijo. Para los RPCs se necesita un esquema más simple y aún más dinámico. Bissel y Nelson han descrito un esquema que comprende no sólo a clientes y servidores sino también a una clase especializada de base de datos. En su método, que se ilustra en la figura, cuando arranca un servidor, el mismo se registra en el sistema de base de datos, enviando un mensaje que contiene su nombre (cadena ASCII), su dirección de red (por ejemplo, un NSAP, TSAP o SSAP) y un identificador único. Esta registración se efectúa mediante la llamada a un procedimiento export(), que es manejado por el stub (pasos 1 y 2).


Más tarde, cuando el cliente hace su primera llamada (paso 3) y su stub se enfrenta al problema de ubicar el servidor, el stub envía al sistema de base de datos (paso 4) el nombre del servidor, en ASCII. El sistema de base de datos retorna luego la dirección de red del servidor y el identificador único (paso 5). Este proceso se conoce con el nombre de binding. De aquí en más, el stub sabe cómo ubicar el servidor, de manera que no se requiere el proceso binding en las llamadas subsiguientes. El identificador único de 32 bits se incluye en cada RPC. El mismo es utilizado por la entidad de transporte en la máquina del servidor para indicar a cuál de los varios stubs servidores darle el mensaje entrante. Además tiene otro rol; si el servidor colapsa y rearranca, el servicio se registra nuevamente en la base de datos usando un nuevo identificador único. El intento de los clientes por comunicarse con él usando el identificador único anterior fallará, haciéndoles notar el desperfecto y forzando a un nuevo binding. Otra exigencia de implementación clave es el protocolo usado. En el caso más simple, el protocolo RPC puede constar de dos mensajes: una solicitud y una réplica (request-reply). Tanto la solicitud como la réplica contienen un número único identificando al servidor, un identificador de transacción y los parámetros. Cuando envía una solicitud, el stub cliente (en algunos sistemas) puede inicializar un temporizador. Si el mismo finaliza su período antes que retorne la respuesta, el stub puede interrogar al servidor para ver si ha llegado la solicitud. En caso negativo, puede retransmitirla. El propósito del identificador de transacción es permitir que el servidor reconozca y rechace solicitudes duplicadas. Téngase en cuenta que el reconocimiento de solicitudes duplicadas sólo es posible si el servidor mantiene la pista de los identificadores más recientes de cada cliente. Un criterio final de implementación es el manejo de excepciones. A diferencia de las llamadas a procedimientos locales, donde nada puede ir mal, muchas cosas pueden ir mal con una RPC. Por ejemplo, el servidor podría estar fuera de servicio. Si el lenguaje de programación lo permite, la ocurrencia de un error en la RPC no debería darle el control al que iniciara el llamado, sino que podría provocar una excepción a ser manejada por el administrador de excepciones. El diseño de un mecanismo de excepciones depende del lenguaje pero en cualquier caso es necesario prever un método para distinguir las llamadas exitosas de las fallidas.

Semántica de las llamadas a procedimientos remotos[editar]

También, a diferencia de los procesos locales, las llamadas a procedimientos remotos están sujetas a pérdidas de mensajes, caídas del servidor y de los clientes. Si el servidor se cae después de haber iniciado la resolución de la solicitud pero antes de enviar la respuesta. Hay, por lo menos, tres posible maneras de programar el stub cliente para enfrentar esta solución:

  • Sólo colgarse esperando una respuesta que no vendrá.
  • Esperar por el vencimiento del temporizador y provocar un mensaje de excepción o un reporte de falla al cliente.
  • Esperar por el vencimiento del temporizador y retransmitir la solicitud.

El primer enfoque requiere intervención manual para abortar el programa. En la segunda postura, si hay habilitado un administrador apropiado, la excepción será capturada y procesada; en caso contrario, el programa abortará. En la tercera postura, dado que el servidor se registrará después de la caída con un nuevo identificador único en la base de datos del sistema, la retransmisión será rechazada por la entidad de transporte sobre la máquina del servidor cuando vea al antiguo identificador, ahora válido. Si la entidad de transporte envía una respuesta de error, el stub cliente puede, o bien abandonar, o bien hacer un rebind e intentarlo nuevamente. Si se hace esto último y el stub cliente repite la llamada, resulta posible que la operación sea llevada a cabo dos veces. De hecho, podemos conseguir la ejecución repetida aún sin caídas si la respuesta se pierde y permitirle al stub cliente intentarlo nuevamente. Si se acepta o no la ejecución repetida depende de la clase de operación que se está ejecutando. Si la operación consiste en la lectura del bloque 4 de un archivo, no hay ningún daño si se repite mil veces. Cada vez produce el mismo resultado y no hay efectos colaterales. En cambio, si la operación consiste en el agregado de un bloque al final de un archivo, importa mucho cuántas veces se ejecuta la operación. Cuando las operaciones pueden repetirse sin ningún daño se dicen que son idempotentes. Si todas las operaciones pudieran proyectarse en una forma idempotente, obviamente la tercer postura sería la mejor. Desafortunadamente, algunas operaciones son inherentemente no-idempotentes. Como consecuencia de ello, la semántica exacta de los sistemas de llamada a procedimiento remoto puede clasificarse de varias maneras. La clase más deseable se conoce como exactamente una vez, donde cada llamada se lleva a cabo exactamente una vez. Esta meta resulta difícil de alcanzar debido a que después de una caída del servidor no siempre resulta posible si la operación fue ejecutada o no. Se logra una forma más débil de la semántica “exactamente una vez” si el lenguaje de programación soporta el manejo de excepciones. En esta variante, si una llamada retorna normalmente, significa que no ha ocurrido ninguna anormalidad y que la operación ha sido ejecutada exactamente una vez. Si el servidor se cae o si se detecta otro error serio, la llamada no devuelve nada. En su lugar, se produce una excepción y se invoca el administrador apropiado. Con esa semántica, los procedimientos clientes no tienen que ser programados para enfrentarse con los errores sino que lo hacen los administradores de excepciones. Una segunda clase es la denominada a lo sumo una vez en la que, cuando se utiliza, el control siempre retorna al llamador. Si todo fue correcto, la operación se ejecuta exactamente una vez. Sin embargo, si se detecta una caída del servidor, el stub cliente renuncia y retorna un código de error sin llevar a cabo ningún intento de retransmisión. En este caso, el cliente sabe que la operación ha sido ejecutada cero o una vez, pero no más; la posterior recuperación es una cuestión del cliente. Una tercera clase se conoce como al menos una vez. El stub cliente permanece intentando una y otra vez hasta que consigue una respuesta adecuada. Cuando el control retorna al llamador, éste sabe que la operación ha sido ejecutada una o más veces. Para operaciones idempotentes esta situación es la ideal. Para operaciones no-idempotentes, podemos distinguir distintas variantes. Probablemente, la más útil de ellas es la que garantiza que el resultado retornado es el resultado de la última operación, nunca el de las anteriores. Si el stub cliente usa un identificador de transacción distinto en cada retransmisión, será posible determinar fácilmente qué respuestas pertenecen a qué solicitudes y así filtrar todas excepto la última. Esta semántica es conocida como la última de varias. En resumen, el objetivo de una semántica RPC totalmente transparente es considerablemente complicado por la posibilidad de caídas que afecten al servidor pero no al cliente. En un sistema de procesador único, esto no puede darse debido a que un crash que destruye al servidor también arrastra al cliente y a todo el sistema con ellos.

Procesos huérfanos[editar]

Una vez que el procedimiento servidor ha sido desencadenado, su proceso continúa en ejecución, aún cuando se ha caído su padre (cliente). Un servidor que está corriendo sin ningún proceso padre en espera se conoce con el nombre de huérfano. Los huérfanos pueden bloquear archivos y otros objetos. Si un cliente es rearrancado y comienza nuevamente todas las RPCs, los resultados devueltos por los huérfanos pueden causar confusión. Se han descrito cuatro maneras de enfrentarse con los huérfanos. En la primera, llamada exterminio, cuando una máquina se recupera después de una caída, verifica si tenía alguna RPC en ejecución al tiempo de producirse la caída. Si así fuera, pide al servidor que mate cualquier proceso que se encuentre corriendo en su nombre. Para usar el algoritmo de exterminación, además del algoritmo propiamente dicho, es necesario que el stub cliente registre las RPCs antes de generarlas. Cuando las RPCs se completan, sus entradas se borran de los registros de RPCs pendientes. Por supuesto, los registros deben mantenerse de tal manera que sobrevivan a las caídas del procesador. Además, debe tenerse en cuenta que los huérfanos pueden a su vez hacer llamadas a procedimientos remotos creando así nuevos huérfanos. Por ello, el algoritmo de exterminio debe ejecutarse recursivamente para eliminar por completo toda la cadena. La segunda manera de eliminar huérfanos es la expiración, una técnica en la que no se requiere ninguna registración. Cuando comienza una RPC, el servidor tiene una cierta cantidad de tiempo o quantum para completar la llamada. Si la llamada no se completa en el intervalo original, el stub servidor debe pedir al stub cliente una nueva cantidad de tiempo. Si la máquina cliente se ha caído o ha sido rearrancada, este evento será detectado debido a la falta de renovación del temporizador y, en consecuencia, el servidor podrá acabar el proceso. Cuando se utiliza la expiración, todo lo que un cliente tiene que hacer para una operación confiable es estar seguro que ha transcurrido un quantum desde la última caída antes de emitir la primera RPC. La tercera técnica para librarse de los huérfanos se conoce como reencarnación. Puede suceder que la exterminación falle en la eliminación de todos los huérfanos. Por ejemplo, si la red está partida cuando se ejecuta el algoritmo de exterminio, algunos huérfanos pueden ser inalcanzables. En este caso, puede tomarse alguna iniciativa drástica que mate toda la actividad remota dentro de la red. En este método, el tiempo se divide en épocas numeradas secuencialmente. Cuando después de su recuperación, un cliente falla en el exterminio de sus huérfanos, difunde a todas las máquinas el comienzo de una nueva época y éstas reaccionan eliminando todos sus procesos servidores. Dado que todas las solicitudes y réplicas siempre contienen el número de la época en que fueron comenzados, cualquier respuesta que eventualmente regrese de los huérfanos tendrá un número de época obsoleto y de este modo será detectada. La cuarta propuesta para eliminar los huérfanos es similar a la tercera pero con una política menos agresiva. La misma también usa épocas, pero en lugar de que cada máquina elimine toda actividad remota cuando se declara una nueva época, intenta ubicar al cliente que desencadenó el proceso servidor. Únicamente si ese cliente no pudo ser ubicado, se elimina el proceso servidor. Este algoritmo se llama reencarnación gentil. Un huérfano puede estar corriendo en una región crítica o haber acabado de bloquear algunos archivos. Bajo esas circunstancias, la eliminación de los huérfanos puede crear inconsistencias en las bases de datos y abrazos mortales en el sistema. Además, un huérfano puede haber planificado un trabajo futuro. Por ejemplo, un huérfano puede haber hecho una entrada en una cola de impresión o alguna otra acción ha ser tomada en el futuro. Aún cuando el huérfano en sí mismo sea eliminado, el trabajo encolado puede ser eventualmente ejecutado y deben preverse las implicancias del caso.

Discusión de las RPCs[editar]

Las consideraciones claves que enfrenta el diseñador de cualquier sistema de RPC caen en cuatro amplias categorías:

Diseño de la interfaz

El corazón del asunto es cuánta transparencia se aspira alcanzar. Dado los problemas con los parámetros por referencia y con los punteros, y la literal imposibilidad de alcanzar semánticas exactamente una vez, algunos autores argumentan que en el caso de tener que enfrentar caídas, la transparencia ni siquiera debería intentarse, y están a favor del agregado de una palabra clave: remote, en la declaración del cualquier procedimiento que puede ser llamado remotamente para advertirse al compilador y evitar el engaño del programador. Por otro lado, en la práctica, las caídas son muy raras y la mayoría de los problemas pueden ser manejados con un diseño cuidadoso del stub y del compilador. Para producir los stubs, una posibilidad es tenerlos todos escritos a mano. Otra es tener un compilador que la produzca como un subproducto del proceso de compilación y, de hecho, hay varios productos en el mercado para estas tareas (RPC compilers o similares). Otra exigencia relativa al lenguaje es el manejo de excepciones. Si no hay algún mecanismo adecuado para los errores de RPC capturados por alguna parte del programa distinta del procedimiento cliente, entonces cada uno de estos procedimientos tendrá que chequear todos los posibles errores retornados y estar preparado para manejarlos. Por último, vienen las consideraciones de binding. Más precisamente, cómo los servidores exportan sus nombres, cómo los clientes pueden seleccionar un caso específico de un servidor cuando hay varios idénticos, entre otras; son todas exigencias que necesitan una atención cuidadosa.

Diseño del cliente

Las principales cuestiones en el diseño del cliente involucran el agotamiento de temporizadores y los huérfanos. ¿El stub cliente debería indicar un agotamiento de temporizador después de no lograr ninguna respuesta, o sólo debería esperar? Si se utilizan temporizadores, ¿qué acción se debería tomar, qué semántica se debería proveer y cómo se deberían manejar los huérfanos?

Diseño del servidor

La principal exigencia que interesa en el diseño del servidor es el paralelismo. En un extremo, cada vez que viene un pedido para la ejecución de un procedimiento servidor, se crea un nuevo proceso y el procedimiento corre como parte de ese proceso. Si vienen otros pedidos de otros clientes antes de que el primero haya terminado, se crean más procesos y todos ellos corren en paralelo, independientemente entre sí. En el otro extremo, existe un único proceso asociado con cada procedimiento servidor. Si entra un segundo pedido antes de que el primero haya terminado, debe esperar su turno. El primer enfoque soporta la carga de la creación de procesos en cada RPC, pero potencialmente permite la ejecución de llamadas en paralelo. El segundo es más simple y más rápido si hay únicamente una llamada a la vez, pero no permite ningún paralelismo si hay múltiples llamadas.

Diseño del protocolo

La principal exigencia en el diseño del protocolo es lograr alta performance. Muchos investigadores creen que la única manera de hacer que la RPC funcione rápido es correrla sobre la interfaz de la capa de red, lo que implica que deberá haber dentro de las bibliotecas de RPC soluciones a los problemas originados a partir de la ausencia de las capas intermedias. Finalmente, dado el relativo bajo nivel de las funciones involucradas con sockets, el paradigma de alto nivel denominado RPC resulta mucho más fácil de utilizar y tiende a reemplazar a los sockets en los casos más comunes o en aplicaciones relativamente simples. Escribir una aplicación distribuida utilizando RPCs es casi tan fácil como escribir una aplicación que correrá sobre un único equipo haciendo transparente casi todas las cuestiones relativas a la red. Con ellas la situación es muy similar a la de un código local, aunque cambie la semántica como vimos oportunamente. Debe tenerse en cuenta que aquí no se cuenta con la flexibilidad de los sockets ni con el control que puede ejercerse sobre el sistema que se está desarrollando, pero puede considerarse este hecho como el precio a pagar en pos de la transparencia.