Guía Beej de programación en redes

Anterior


Siguiente


6. Técnicas moderadamente avanzadas

En realidad no son verdaderamente avanzadas, pero se salen de los niveles más básicos que ya hemos cubierto. De hecho, si has llegado hasta aquí, puedes considerarte conocedor de los principios básicos de la programación de redes Unix. ¡Enhorabuena!

Así que ahora entramos en el nuevo mundo de las cosas más esotéricas que querrías aprender sobre los sockets. ¡Ahí las tienes!

6.1. Bloqueo

Bloqueo. Has oído hablar de él--pero ¿qué carajo es? En una palabra, "bloquear" es el tecnicismo para "dormir".  Probablemente te has dado cuenta de que, cuando ejecutas listener, más arriba, simplemente se sienta a esperar a que llegue un paquete. Lo que sucedió es que llamó a recvfrom() y no había datos que recibir. Por eso se dice que recvfrom() se bloquea (es decir, se duerme) hasta que llega algún dato.

Muchas funciones se bloquean. accept() se bloquea. Todas las funciones recv() se bloquean. Lo hacen porque les está permitido hacerlo. Cuando creas un descriptor de socket con socket(), el núcleo lo configura como bloqueante. Si quieres que un socket no sea bloqueante, tienes que hacer una llamada a fcntl():



    #include <unistd.h>
    #include <fcntl.h>
    .
    .
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    fcntl(sockfd, F_SETFL, O_NONBLOCK);
    .
    . 

Al establecer un socket como no bloqueante, puedes de hecho "interrogar" al socket por su información. Si intentas leer de un socket no bloqueante y no hay datos disponibles, la función no está autorizada a bloquearse -- devolverá -1 y asignará a errno el valor EWOULDBLOCK.

En líneas generales, no obstante, este tipo de interrogaciones son una mala idea. Si pones tu programa a esperar sobre un bucle a que un socket tenga datos, estarás consumiendo en vano el tiempo de la CPU como se hacía antaño. Una solución más elegante para comprobar si hay datos esperando que se puedan leer, se presenta en la siguiente sección:   select().

6.2. select() --Multiplexado de E/S síncrona

Esta función es un tanto extraña, pero resulta muy útil. Considera la siguiente situación: eres un servidor y quieres escuchar nuevas conexiones entrantes al mismo tiempo que sigues leyendo de las conexiones que ya tienes.

Sin problemas, dices tú, un simple accept() y un par de recv() . ¡No tan deprisa! ¿qué pasa si te quedas bloqueado en la llamada a accept() ? ¿cómo vas a recibir (recv()) datos al mismo tiempo? "Usa sockets no bloqueantes" ¡De ningún modo! No quieres comerte toda la CPU. Entonces ¿qué?

select() te da la posibilidad de comprobar varios sockets al mismo tiempo. Te dirá cuáles están listos para leer, cuáles están listos para escribir, y cuáles han generado excepciones, si estás interesado en saber eso.

Sin más preámbulos, esta es la sinopsis de select() :



       #include <sys/time.h>
       #include <sys/types.h>
       #include <unistd.h>
       int select(int numfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout); 

La función comprueba "conjuntos" de descriptores de fichero; en concreto readfds, writefds , y exceptfds. Si quieres saber si es posible leer de la entrada estándar y de un cierto descriptor de socket , sockfd, simplemente añade los descriptores de fichero 0 y sockfd al conjunto readfs. El parámetro numfds debe tener el valor del mayor descriptor de fichero más uno. En este ejemplo, deberíamos asignarle el valor sockfd + 1 , puesto que es seguro que tendrá un valor mayor que la entrada estándar (0).

Cuando select() regrese, readfs contendrá los descriptores de fichero que están listos para lectura. Puedes comprobarlos con la macro FD_ISSET() que se muestra a continuación.

Antes de progresar más, te contaré como manipular los conjuntos de descriptores. Cada conjunto es del tipo fd_set. Las siguientes macros funcionan sobre ese tipo.

Por último, ¿qué es esa extraña estructura struct timeval? Bueno, a veces no quieres esperar toda la vida a que alguien te envíe datos. Quizás cada 96 segundos quieras imprimir "Aún estoy vivo..." aunque no haya sucedido nada. Esta estructura de tiempo de permite establecer un período máximo de espera. Si transcurre ese tiempo y select() no ha encontrado aún ningún descriptor de fichero que esté listo, la función regresará para que puedas seguir procesando.

La estructura struct timeval tiene los siguientes campos:



    struct timeval {
        int tv_sec;     // segundos
        int tv_usec;    // microsegundos
    }; 

Establece tv_sec al número de segundos que quieres esperar, y tv_usec al número de microsegundos. si,  microsegundos, no milisegundos. Hay 1.000 microsegundos en un milisegundos, y 1.000 milisegundos en un segundo. Así que hay 1.000.000 microsegundos en un segundo. ¿Por qué se llama "usec"? Se supone que la "u" es la letra griega ? (Mu) que suele usarse para abreviar "micro". Además, cuando select() regresa, timeout podría haberse actualizado al tiempo que queda para que el temporizador indicado expire. Depende del sistema Unix que estés usando.

¡Fantástico! ¡Tenemos un reloj con precisión de microsegundos! No cuentes con ello. El límite en un sistema Unix estándar está alrededor de los 100 milisegundos, así que seguramente tendrás que esperar eso como mínimo, por muy pequeño que sea el valor con que establezcas la estructura struct timeval.

Otras cosas de interés: si estableces los campos de struct timeval a 0, select() regresará inmediatamente después de interrogar todos tus descriptores de fichero incluidos en los conjuntos. Si estableces el parámetro timeout a NULL el temporizador nunca expirará y tendrás que esperar hasta que algún descriptor de fichero esté listo. Si no estás interesado en esperar sobre algún conjunto de descriptores de fichero sólo tienes que usar el parámetro NULL en la llamada a select().

El siguiente fragmento de código espera 2.5 segundos a que algo aparezca por la entrada estándar:



    /*
    ** select.c -- ejemplo de select()
    */
    #include <stdio.h>
    #include <sys/time.h>
    #include <sys/types.h>
    #include <unistd.h>
    #define STDIN 0  // descriptor de fichero de la entrada estándar
    int main(void)
    {
        struct timeval tv;
        fd_set readfds;
        tv.tv_sec = 2;
        tv.tv_usec = 500000;
        FD_ZERO(&readfds);
        FD_SET(STDIN, &readfds);
        // no nos preocupemos de writefds and exceptfds:
        select(STDIN+1, &readfds, NULL, NULL, &tv);
        if (FD_ISSET(STDIN, &readfds))
            printf("A key was pressed!\n");
        else
            printf("Timed out.\n");
        return 0;
    } 

Si estás en un terminal con buffer de líneas, tendrás que pulsar la tecla RETURN porque en cualquier otro caso  el temporizador expirará.

Ahora, algunos de vosotros podrías pensar que esta es una forma excelente de esperar datos sobre un socket de datagramas --y teneis razón: podría serlo. Algunos Unix permiten usar select() de este modo mientras que otros no. Deberías consultar las páginas locales de man sobre este asunto antes de intentar usarlo.

Algunos Unix actualizan el tiempo en struct timeval para mostrar el tiempo que falta para que venza el temporizador. Pero otros no. No confíes en que esto ocurra si quieres ser portable. (Usa gettimeofday() si necesitas controlar el tiempo transcurrido. Sé que es un palo, pero así son las cosas)

¿Qué pasa si un socket en el conjunto de lectura cierra la conexión? En este caso select() retorna con el descriptor de socket marcado como "listo para leer". Cuando uses recv(), devolverá 0. Así es como sabes que el cliente ha cerrado la conexión.

Una nota más de interés acerca de select(): si tienes un socket que está escuchando (listen()), puedes comprobar si hay una nueva conexión poniendo ese descriptor de socket en el conjunto readfs .

Y esto, amigos míos, es una visión general sencilla de la todopoderosa función select().

Pero, por aclamación popular, ahí va un ejemplo en profundidad. Desgraciadamente, la diferencia entre el sencillo ejemplo anterior y el que sigue es muy importante. Pero échale un vstazo y lee las descripciones que siguen.

Este programa actúa como un simple servidor multiusuario de chat. Inicialo en una ventana y luego atácalo con telnet ("telnet hostname 9034 ") desde varias ventanas distintas. Cuando escribas algo en una sesión telnet, tiene que aparecer en todas las otras.



    /*
    ** selectserver.c -- servidor de chat multiusuario
    */
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #define PORT 9034   // puerto en el que escuchamos
    int main(void)
    {
        fd_set master;   // conjunto maestro de descriptores de fichero
        fd_set read_fds; // conjunto temporal de descriptores de fichero para select()
        struct sockaddr_in myaddr;     // dirección del servidor
        struct sockaddr_in remoteaddr; // dirección del cliente
        int fdmax;        // número máximo de descriptores de fichero
        int listener;     // descriptor de socket a la escucha
        int newfd;        // descriptor de socket de nueva conexión aceptada
        char buf[256];    // buffer para datos del cliente 
        int nbytes;
        int yes=1;        // para setsockopt() SO_REUSEADDR, más abajo
        int addrlen;
        int i, j;
        FD_ZERO(&master);    // borra los conjuntos maestro y temporal
        FD_ZERO(&read_fds);
        // obtener socket a la escucha
        if ((listener = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
            perror("socket");
            exit(1);
        }
        // obviar el mensaje "address already in use" (la dirección ya se está usando)
        if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes,
                                                            sizeof(int)) == -1) {
            perror("setsockopt");
            exit(1);
        }
        // enlazar
        myaddr.sin_family = AF_INET;
        myaddr.sin_addr.s_addr = INADDR_ANY;
        myaddr.sin_port = htons(PORT);
        memset(&(myaddr.sin_zero), '\0', 8);
        if (bind(listener, (struct sockaddr *)&myaddr, sizeof(myaddr)) == -1) {
            perror("bind");
            exit(1);
        }
        // escuchar
        if (listen(listener, 10) == -1) {
            perror("listen");
            exit(1);
        }
        // añadir listener al conjunto maestro
        FD_SET(listener, &master);
        // seguir la pista del descriptor de fichero mayor
        fdmax = listener; // por ahora es éste
        // bucle principal
        for(;;) {
            read_fds = master; // cópialo
            if (select(fdmax+1, &read_fds, NULL, NULL, NULL) == -1) {
                perror("select");
                exit(1);
            }
            // explorar conexiones existentes en busca de datos que leer
            for(i = 0; i <= fdmax; i++) {
                if (FD_ISSET(i, &read_fds)) { // ¡¡tenemos datos!!
                    if (i == listener) {
                        // gestionar nuevas conexiones
                        addrlen = sizeof(remoteaddr);
                        if ((newfd = accept(listener, (struct sockaddr *)&remoteaddr,
                                                                 &addrlen)) == -1) { 
                            perror("accept");
                        } else {
                            FD_SET(newfd, &master); // añadir al conjunto maestro
                            if (newfd > fdmax) {    // actualizar el máximo
                                fdmax = newfd;
                            }
                            printf("selectserver: new connection from %s on "
                                "socket %d\n", inet_ntoa(remoteaddr.sin_addr), newfd);
                        }
                    } else {
                        // gestionar datos de un cliente
                        if ((nbytes = recv(i, buf, sizeof(buf), 0)) <= 0) {
                            // error o conexión cerrada por el cliente
                            if (nbytes == 0) {
                                // conexión cerrada
                                printf("selectserver: socket %d hung up\n", i);
                            } else {
                                perror("recv");
                            }
                            close(i); // bye!
                            FD_CLR(i, &master); // eliminar del conjunto maestro
                        } else {
                            // tenemos datos de algún cliente
                            for(j = 0; j <= fdmax; j++) {
                                // ¡enviar a todo el mundo!
                                if (FD_ISSET(j, &master)) {
                                    // excepto al listener y a nosotros mismos
                                    if (j != listener && j != i) {
                                        if (send(j, buf, nbytes, 0) == -1) {
                                            perror("send");
                                        }
                                    }
                                }
                            }
                        }
                    } // Esto es ¡TAN FEO!
                }
            }
        }
        
        return 0;
    } 

Observa que en el código uso dos conjuntos de descriptores de fichero: master y read_fds. El primero, master , contiene todos los descriptores de fichero que están actualmente conectados, incluyendo el descriptor de socket que está escuchando para nuevas conexiones.

La razón por la cual uso el conjunto master es que select() va a cambiar el conjunto que le paso para reflejar que sockets están listos para lectura. Como tengo que recordar las conexiones activas entre cada llamada de select(), necesito almacenar ese conjunto en algún lugar seguro. En el último momento copio master sobre read_fs y entonces llamo a select().

Pero, ¿eso no significa que, cada vez que llegue una nueva conexión, tengo que añadirla al conjunto master? ¡Yup! y cada vez que una conexión se cierra, ¿no tengo que borrarla del conjunto master . Efectivamente.

Fíjate que compruebo si el socket listener está listo para lectura. Si lo está tengo una nueva conexión pendiente: la acepto (accept() ) y la añado al conjunto master. Del mismo modo, cuando una conexión de cliente está lista para lectura y recv() devuelve 0, sé que el cliente ha cerrado la conexión y tengo que borrarlo del conjunto master.

Sin embargo, si el cliente recv() devuelve un valor distinto de cero, sé que se han recibido datos así que los leo y recorro la lista master para enviarlos a todos los clientes conectados.

Y esto, amigos míos, es una visión general no tan sencilla de la todopoderosa función select().

6.3. Gestión de envíos parciales con send()s

¿Recuerdas antes en la seción sobre send() , cuando dije que send() podría no enviar todos los bytes que pediste? Es decir, tú quieres enviar 512 bytes, pero send() devuelve el valor 412. ¿qué le ocurrió a los restantes 100 bytes ?

Bien, siguen estando en tu pequeño buffer esperando ser enviados. Debido a circunstancias que escapan a tu control, el núcleo decidió no enviar todos los datos de una sola vez, y ahora, amigo mío, depende de ti que los datos se envíen.

También podrías escribir una función como esta para conseguirlo:



    #include <sys/types.h>
    #include <sys/socket.h>
    int sendall(int s, char *buf, int *len)
    {
        int total = 0;        // cuántos bytes hemos enviado
        int bytesleft = *len; // cuántos se han quedado pendientes
        int n;
        while(total < *len) {
            n = send(s, buf+total, bytesleft, 0);
            if (n == -1) { break; }
            total += n;
            bytesleft -= n;
        }
        *len = total; // devuelve aquí la cantidad enviada en realidad
        return n==-1?-1:0; // devuelve -1 si hay fallo, 0 en otro caso
    } 

En este ejemplo, s es el socket al que quieres enviar los datos, buf es el buffer que contiene los datos, y len es un puntero a un int que contiene el número de bytes que hay en el buffer.

La función devuelve -1 en caso de error (y errno está establecido por causa de la llamada a send().) Además, el número de bytes enviados realmente se devuelven en len. Este será el mismo número de bytes que pediste enviar, a menos que sucediera un error. sendall() hara lo que pueda tratando de enviar los datos, pero si sucede un error regresará en seguida.

Este es un ejemplo completo de como usar la función:



    char buf[10] = "Beej!";
    int len;
    len = strlen(buf);
    if (sendall(s, buf, &len) == -1) {
        perror("sendall");
        printf("We only sent %d bytes because of the error!\n", len);
    } 

¿Qué ocurre en el extremo receptor cuando llega un paquete? Si los paquetes tienen longitud variable, ¿cómo sabe el receptor cuando un paquete ha finalizado? Efectivamente, las situaciones del mundo real son un auténtico quebradero de dabeza. Probablemnte tengas que encapsular tus datos (¿te acuerdas de esto, en la sección de encapsulación de datos más arriba al principio?) ¡Sigue leyendo para más detalles!

6.4. Consecuencias de la encapsulación de datos

En definitiva, ¿qué significa realmente encapsular los datos? En el caso más simple, significa que añadirás una cabecera con cierta informatión de identidad, con la longitud del paquete, o con ambas cosas.

¿Cómo tendría que ser esta cabecera? Bien, sencillamente algunos datos en binario que representen cualquier información que creas que es necesaria para tus propósitos.

Un poco impreciso, ¿no?.

Muy bien, por ejemplo, supongamos que tienes un programa de chat multiusuario que usa SOCK_STREAM. Cuando un usuario escribe ("dice") algo, es necesario transmitir al servidor dos tipos de información: qué se ha dicho y quién lo ha dicho.

¿Hasta aquí bien? "¿Cuál es el problema?", estás pensando.

El problema es que los mensajes pueden ser de longitudes distintas. Una persona que se llame "tom" podría decir "Hi" ["hola"], mientras que otra persona que se llame "Benjamin" podría decir "Hey guys what is up?" ["Hey, ¿qué pasa tíos?"]

Así qué envías (send()) todo eso a los clientes tal y como llega. Tu cadena de datos salientes se parece a esto:



    t o m H i B e n j a m i n H e y g u y s w h a t i s u p ?

Y así siempre. ¿Cómo sabe un cliente cuándo empieza un mensaje y cuándo acaba? Si quisieras, podrías hacer que todos los mensajes tuvieran la misma longitud, y simplemente llamar a la función sendall() que hemos implementado, más arriba . ¡Pero así desperdicias el ancho de banda! no queremos enviar (send() ) 1024 bytes sólo para que "tom" pueda decir "Hi".

Así que encapsulamos los datos en una pequeña estructura de paquete con cabecera. Tanto el servidor como el cliente deben saber cómo empaquetar y desempaquetar los datos (algunas veces a esto se le llama, respectivamente, "marshal" y "unmarshal"). No mires aún, pero estamos empezando a definir un protocolo que describe cómo se comunican el servidor y el cliente.

En este caso, supongamos que el nombre de usuario tiene una longitud fija de 8 carácteres, rellenados por la derecha con '\0'. y supongamos que los datos son de longitud variable, hasta un máximo de 128 carácteres. Echemos un vistazo a un ejemplo de estructura de paquete que podríamos usar en esta situación:

  1. len (1 byte, sin signo) -- La longitud total del paquete, que incluye los 8 bytes del nombre de usuario, y los datos de chat.

  2. name (8 bytes) -- El nombre de usuario, completado con carácteres NUL si es necesario.

  3. chatdata (n-bytes) -- Los datos propiamente dichos, hasta un máximo de 128 bytes. La longitud del paquete se calcula como la suma de la longitud de estos datos más 8 (la longitud del nombre de usuario).

¿Porqué elegí limites de 8 y 128 bytes? Los tomé al azar, suponiendo que serían lo bastante largos. Sin embargom talvez 8 bytes es demasiado restrictivo  para tus necesidades, y quieras tener un campo nombre de 30 bytes, o más. La decisión es tuya.

Usando esta definición de paquete, el primer paquete constaría de la siguiente información (en hexadecimal y ASCII):



      0A     74 6F 6D 00 00 00 00 00      48 69
 (longitud)  T  o  m    (relleno)         H  i

Y el segundo sería muy similar:



      14     42 65 6E 6A 61 6D 69 6E      48 65 79 20 67 75 79 73 20 77 ...
 (longitud)  B  e  n  j  a  m  i  n       H  e  y     g  u  y  s     w  ...

(La longitud sigue la Ordenación de bytes de la red, por supuesto. En este caso no importa, porque se trata sólo de un byte, pero en general, querrás que todos tus enteros binarios de tus paquetes sigan la Ordenación de bytes de la red).

Al enviar estos datos, deberías ser prudente y usar una función del tipo de sendall() , así te aseguras de que se envían todos los datos incluso si hacen falta varias llamadas a send() para conseguirlo.

Del mismo modo, al recibir estos datosm necesitas hacer un poco de trabajo extra. Para asegurarte, deberías suponer que puedes recibir sólo una parte del paquete (por ejemplo, en el caso anterior podríamos recibir sólo " 00 14 42 65 6E" del nombre "Benjamin" en nuestra llamada a recv() ). Así que necesitaremos llamar a recv() una y otra vez hasta que el paquete completo se reciba.

Pero, ¿cómo? Bueno, sabemos el número total de bytes que hemos de recibir para que el paquete esté completo, puesto que ese número está al principio del paquete. También sabemos que el tamaño máximo de un paquete es 1+8+128, es decir 137 bytes (lo sabemos porque así es como hemos definido el paquete)

Lo que puedes hacer es declarar un vector [array] lo bastante grande como para contener dos paquetes. Este será tu buffer de trabajo donde reconstruirás los paquetes a medida que lleguen.

Cada vez que recibas (recv()) datos los meterás en tu buffer y comprobarás si el paquete está compelto. Es decir, si el número de bytes en el buffer es mayor igual a la longitud indicada en la cabecera (+1, porque la longitud de la cabecera no incluye al propio byte que indica la longitud). Si el número de bytes en el buffer es menor que 1, el paquete, obviamente, no está completo. Sin embargo, tienes que hacer un caso especial para esto ya que el primer byte es basura y no puedes confiar en que contenga una longitud de paquete correcta.

Una vez que el paquete está completo, puedes hacer con él lo que quieras. Úsalo y bórralo del buffer.

¡Bueno! ¿Aún estás dándole vueltas en la cabeza? Bien, ahí llega la segunda parte: en una sola llamada a recv(), podrías haber leído más allá del final de un paquete, sobre el principio del siguiente. Es decir, tienes un buffer con un paquete completo y una parte del siguiente paquete. Maldita sea. (Pero esta es la razón por la que hiciste que tu buffer fuera lo bastante grande como para contener dos paquetes-- ¡Por si sucedía esto!)

Como, gracias a la cabecera, sabes la longitud del primer paquete y además has controlado cuántos bytes del buffer has usado, con una sencilla resta puedes calcular cuántos de los bytes del buffer corresponden al segundo paquete (incompleto). Cuando hayas procesado el primer paquete puedes borrarlo del buffer y mover el fragmento del segundo paquete al principio del buffer para poder seguir con la siguiente llamada a recv().

(Algunos de vosotros os habeis dado cuenta de que mover el fragmento del segundo paquete al principo de buffer lleva tiempo, y que el programa se puede diseñar de forma que esto no sea necesario por medio del uso de un buffer circular. Desgraciadamente para el resto, el examen de los buffers circulares va más allá del alcance de este artículo. Si todavía sientes curiosidad, píllate un libro de estructuras de datos y consúltalo.)

Nunca dije que fuera fácil. Está bien, sí que lo dije. Y lo es. Sólo necesitas práctica y muy pronto resultará para ti de lo más natural. ¡Lo juro por Excalibur!


Anterior

Inicio

Siguiente

Modelo Cliente-Servidor


Referencias adicionales