La llamada al sistema
sendfile
, aunque desconocida por muchos desarrolladores, es una de las armas secretas detrás de muchos servicios de red de alto rendimiento. Por distintas razones, entre ellas el hecho de no ser portable, esta potente llamada al sistema no goza de demasiada popularidad.
La llamada al sistema Usando
Ahora vamos a re-escribir nuestro programa de ejemplo utilizando
Como podéis ver, nuestra cabecera se envía como un paquete independiente, al que siguen los trozos de 1K en los que estamos partiendo la transferencia... Ahora modifiquemos el programa para activar
Como podéis observar, ahora solo estamos enviando un paquete bastante grande que también incluye la cabecera. Esto es así ya que estamos utilizando el dispositivo
■
send_file
, nos permite copiar datos entre dos descriptores de ficheros. Dicho así no parece nada del otro mundo, pero lo realmente interesante de esta llamada al sistema es que esta copia de datos se realiza en el kernel!.
Como siempre, vamos a utilizar un ejemplo para entender mejor como funciona esta desconocida joya.
Sirviendo Ficheros
Para conocer mejorsendfile
vamos a utilizar un ejemplo clásico. Un servidor de ficheros. Este programa, simplemente escuchará en un determinado puerto TCP, y cuando reciba una conexión enviará al cliente un fichero almacenado en el disco duro. Nada especial verdad?. Seguro que la mayoría de vosotros habréis escrito algo como esto en infinidad de ocasiones.
La forma más directa de escribir este programa es utilizando las llamadas al sistema read
y write
, o si lo preferís, recv
y send
. El código sería algo como lo que os mostramos en el Listado 1:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #define PORT 5000 #define BUFSIZE 1024 #define PURGATUS_EST(s) {perror(s);exit(1);} int main (int argc, char **argv) { int s, s1,fd; unsigned char buf[BUFSIZE]; struct sockaddr_in saddr; struct sockaddr_in caddr; int clen = sizeof(caddr); int optval = 1; struct stat st; int fsize; int off, n = 0; if ((s = socket (AF_INET, SOCK_STREAM, 0)) < 0) PURGATUS_EST("socket:"); setsockopt (s, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int)); bzero ((char *) &saddr, sizeof(saddr)); saddr.sin_family = AF_INET; saddr.sin_addr.s_addr = htonl (INADDR_ANY); saddr.sin_port = htons ((unsigned short) PORT); if (bind (s, (struct sockaddr *) &saddr, sizeof(saddr)) < 0) PURGATUS_EST("bind:"); if (listen (s, 1) < 0) PURGATUS_EST("listen:"); // Abre fichero y obtiene su tamaño if ((stat (FNAME, &st)) < 0) PURGATUS_EST ("stat:"); if ((fd = open (argv[1], O_RDONLY)) < 0) PURGATUS_EST ("open:"); fprintf (stderr, "Fichero %s : %ld bytes\n", argv[1], st.st_size); while (1) { if ((s1 = accept (s, (struct sockaddr *) &caddr, &clen)) < 0) PURGATUS_EST("accept:"); // Transfiere el fichero off = st.st_size; while (off > 0) { bzero (buf, BUFSIZE); if ((n = read (fd, buf, BUFSIZE)) < 0) PURGATUS_EST ("read:"); write (s1, buf, n); off -= n; } if (lseek (fd,0,SEEK_SET) < 0) PURGATUS_EST("lseek:"); close (s1); } return 0; }El programa está bastante claro. Lo que a nosotros nos interesa en el bucle
while
hacia el final. Como podéis ver lo que hace el programa es leer datos del fichero con read
para, inmediatamente, enviarlos a través de la red con write
.
Veamos en detalle que es lo que pasa cuando nuestro programilla de ejemplo enviar el fichero a través de la red.
El flujo de datos
Como ya hemos dicho, el programa del Listado 1, hace uso de dos llamadas al sistema. Cada vez que utilizamos una llamada al sistema en nuestro programa, suceden una serie de cosas de forma automática:- Los parámetros de la llamada al sistema se copian del espacio de usuario al espacio del kernel, los cuales están separados por muy buenas razones
- El procesador pasa a un modo expecial al que llamaremos modo kernel, almacenando la información relativa al proceso actual de forma que se pueda restaurar su ejecución cuando la llamada al sistema se haya completado
- La llamada al sistema se ejecuta, en ese caso se accede o bien al disco o a la tarjeta de red utilizando los driver que ofrece el kernel
- Una vez que la llamada al sistema se haya completado, los resultados deben transferirse de nuevo del espacio del kernel al espacio del usuario
read
y a write
. En este caso, como los datos que recibimos, los estamos enviando sin ningún tipo de modificación, lo que realmente estamos haciendo, es copiar una serie de datos que mantiene el kernel en el espacio de usuario, para inmediatamente volver a copiar esos datos desde el espacio de usuario al kernel... Lo cual, a primera vista no parece muy eficiente.
Usando sendfile
Ahora vamos a re-escribir nuestro programa de ejemplo utilizando sendfile
. El Listado 2 muestra el resultado.
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #include <sys/sendfile.h> #include <sys/socket.h> #include <netinet/in.h> #define PORT 5000 #define BUFSIZE 1024 #define PURGATUS_EST(s) {perror(s);exit(1);} int main (int argc, char **argv) { int s, s1,fd; unsigned char buf[BUFSIZE]; struct sockaddr_in saddr; struct sockaddr_in caddr; int clen = sizeof(caddr); int optval = 1; struct stat st; int fsize; off_t off = 0; int n = 0; if ((s = socket (AF_INET, SOCK_STREAM, 0)) < 0) PURGATUS_EST("socket:"); setsockopt (s, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int)); bzero ((char *) &saddr, sizeof(saddr)); saddr.sin_family = AF_INET; saddr.sin_addr.s_addr = htonl (INADDR_ANY); saddr.sin_port = htons ((unsigned short) PORT); if (bind (s, (struct sockaddr *) &saddr, sizeof(saddr)) < 0) PURGATUS_EST("bind:"); if (listen (s, 1) < 0) PURGATUS_EST("listen:"); // Abre el fichero y obtiene su tamaño if ((stat (argv[1], &st)) < 0) PURGATUS_EST ("stat:"); if ((fd = open (argv[1], O_RDONLY)) < 0) PURGATUS_EST ("open:"); fprintf (stderr, "Fichero %s : %ld bytes\n", argv[1], st.st_size); while (1) { if ((s1 = accept (s, (struct sockaddr *) &caddr, &clen)) < 0) PURGATUS_EST("accept:"); // Transfiere el fichero n = st.st_size; while (n > 0) { if ((n -= sendfile (s1, fd, &off, BUFSIZE)) == -1) PURGATUS_EST("sendfile:"); } if (lseek (fd,0,SEEK_SET) < 0) PURGATUS_EST("lseek:"); off = 0; close (s1); } return 0; }Como podéis ver, la llamada a
sendfile
sustituye el combo read
/write
de nuestra versión anterior. Lo importante no es que hayamos ahorrado unas líneas, sino que ahora, los datos no salen del kernel. La llamada al sistema sendfile
se ejecuta en el espacio del kernel, coge los datos del disco y los manda por la tarjeta de red, sin moverlos al espacio de usuario... Mola que no?
Si que mola, pero...
La verdad que si que mola mogollón, así que supongo que os preguntaréis por que no es tan popular comoread
o write
. Hay dos razones fundamentales, al menos, según lo que comenta todo el mundo en internet.
La primera razón es que sendfile
no es portable. Lo que esto significa es que los parámetros que esta llamada al sistema espera recibir son diferentes para distintos sistemas operativos. En nuestro ejemplo, hemos utilizado la versión Linux de sendfile
cuyo prototipo es:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);Al parecer, Solaris proporciona el mismo prototipo, pero HP-UX, otra versión de UNIX, el prototipo cambia ligeramente:
sbsize_t sendfile(int s, int fd, off_t offset, bsize_t nbytes, const struct iovec *hdtrl, int flags);Y lo mismo sucede con FreeBSD, que ofrece un prototipo más parecido a HP-UX que a Linux:
int sendfile(int fd, int s, off_t offset, size_t nbytes, struct sf_hdtr *hdtr, off_t *sbytes, int flags);La otra razón por la que
sendfile
no es tan popular es debido a un ominoso legado de malas implementaciones. Al parecer, y esto es algo que no he podido comprobar por mi mismo, la implementación de esta llamada al sistema contenía bugs en varios sistemas operativos. Por tanto, no se antoja como una buena solución para escribir aplicaciones portables que pretendamos poder compilar y ejecutar en una amplia variedad de sistemas operativos.
Algunos Detalles Más
Para el caso concreto de Linux,sendfile
está disponible desde la versión 2.2 del kernel. Aquella primera versión no permitía que el descriptor de salida (el primer parámetro de la función) fuera un fichero regular. Fue con la versión 2.4 con la que esa limitación pasó a la historia. Es esa misma versión, también se incorporó senfile64
para soportar ficheros grandes. Para nosotros, programadores C, la librería C estándar se encarga de llamar a la version de sendfile
adecuada.
Poniendo el Corcho
Si leéis detenidamente la página del manual desendfile
veréis una mención en la sección de notas a la opción TCP_CORK
. Lo que dice la página del manual es que, si queremos utilizar sendfile
para transferir ficheros, lo más seguro es que necesitemos transmitir una cabecera.
Así que vamos a modificar nuestro programa, para transmitir una cabecera. En nuestro caso esta cabecera será simplemente, el nombre del fichero y su tamaño. Suena como la cantidad mínima de información necesaria para que el cliente pueda leer los datos y grabarlos con un nombre apropiado :).
Modificamos el bucle principal del programa como muestra el Listado 3, donde las puntos suspensivos representa nuestro código original
(...) off_t off = 0; int n = 0; char header[1024]; (...) snprintf (header, 1024,"%s:%ld\n", argv[1], st.st_size); while (1) { if ((s1 = accept (s, (struct sockaddr *) &caddr, &clen)) < 0) PURGATUS_EST("accept:"); // Transfer File write (s1, header, strlen(header)); (...)Bien, ahora podemos lanzar wireshark (o el sniffer que más rabia os dé) y veamos que es lo que se envia por la red. Para nuestra prueba, en un terminal lanzaremos nuestro terminal, pasándole como parámetro un fichero de texto. En otro terminal nos conectaremos con
netcat
de forma que podamos ver fácilmente el resultado (de ahí lo del fichero de texto).
Si realizamos este test, wireshark mostrará algo similar a lo que podemos ver en la Figura 1:
TCP_CORK
. El Listado 4 muestra los cambios necesarios.
(...) #include <netinet/in.h> #include <netinet/tcp.h> // TCP_CORK (...) setsockopt (s, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int)) optval = 1; setsockopt (s, IPPROTO_TCP, TCP_CORK, (const void *)&optval , sizeof(int)); (...)Si ahora repetimos el proceso,
wireshark
nos mostrará una captura un tanto diferente (Figura 2):
loopback
, de lo contrario los paquetes sería menores... del tamaño de la MTU configurada para el interfaz que usemos. Así, la opción TCP_CORK
evita que enviemos paquetes parciales, intentando optimizar el uso del hardware de red. No vamos a incluir aquí la típica discursión sobre las diferencias entre TCP_CORK
y TCP_NODELAY
ni hablar sobre el algoritmo de Nagle... Ya nos contaréis si os interesa ese tema.
Hasta la próxima
En el próximo número seguiremos explorando las llamadas al sistema olvidadas, esos héroes anónimos y olvidados capaces de marcar la diferencia allí donde otros solo puede fallar :P.■
CLICKS: 3160