Funciones para el manejo de sockets

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.

Un socket es un mecanismo de comunicación. Un socket normalmente es identificado por un entero que puede ser llamado socket descriptor. El mecanismo de socket fue introducido por primera vez en 1983 en el sistema Unix BSD 4.2 en conjunto con los protocolos TCP/IP que aparecieron por primera vez a finales de 1981 en el Sistema Unix BSD 4.1.

Formalmente un socket es definido por un grupo de cuatro números, a saber:

  • El número de identificación o dirección del host remoto
  • El número puerto del host remoto
  • El número de identificación o dirección del host local
  • El número puerto del host local

Para los programadores de aplicaciones el mecanismo de sockets es accedido mediante un número de funciones o primitivas. Las primitivas involucradas con los sockets están implementadas como un conjunto de llamadas al sistema (systems calls) 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. A partir de aquí, analizaremos las llamadas más comunes trabajando bajo una arquitectura cliente-servidor y un servicio de comunicaciones orientado a conexiones.

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. Con las respuestas a mano, antes de que un socket pueda referenciarse debe creárselo mediante la primitiva adecuada. La llamada al sistema para llevar a cabo tal operación tiene la forma:

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

El argumento sock_type selecciona un modo de transporte, entiéndase un servicio orientado a conexiones o uno 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, específica 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 (análogo a un file 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 bind(), cuyo formato es el siguiente:

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 (por ejemplo, si el socket apuntando ya está siendo usado por otro proceso). 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.

Las conocidas llamadas 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. Sin embargo, la semántica es diferente cuando estas llamadas se aplican sobre sockets. Mientras que en el caso de trabajar con archivos el write() se limita a transferir datos al caché (la escritura en disco se hará más tarde), 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. En resumen, 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.

No debe olvidarse que 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);

También aquí la semántica es diferente con respecto a los archivos regulares. 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.

Puede observarse que el sencillo esquema descripto hasta ahora sólo permite que el servidor atienda un pedido y finalice. En un entorno más sofisticado, podría diseñarse el mismo de manera que entre en un ciclo compuesto por las funciones accept(), read() y write() generando nuevos procesos para cada pedido.

Aunque hasta aquí hemos utilizado un esquema de conexiones, 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 las 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 que, por razones históricas y técnicas, se ha convertido en la interfaz de programación más importante en un entorno de red; está disponible en un cantidad importante de plataformas, lo que sugiere que permanecerá en vigencia un buen tiempo.

El paradigma utilizado ha demostrado su eficacia en multitud de situaciones y el desarrollo de aplicaciones puede hacerse altamente portable si se toman las medidas adecuadas desde el comienzo. En este último punto, 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 aunque lo parezca a primera vista, 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.

Jue, 19/10/2006 - 22:37