El Libro Perdido de las Syscalls Olvidadas. sendfile
LINUX
El Libro Perdido de las Syscalls Olvidadas. sendfile
2017-09-13
Por
Pakito Er Pakete

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 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 mejor sendfile 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;
}
LISTADO 1. Transferencia de ficheros con read/write

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

Pues bien, todo eso, pasa cada vez que llamamos a 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;
}
Listado 2. Transferencia de ficheros con sendfile

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 como read 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 de sendfile 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)); 
(...)
    
Listado 3. Transferencia de ficheros enviando cabecera

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:

Figura 1. Captura de tráfico con TCP_CORK desactivado

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 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));
(...)
    
Listado 4. Transferencia de ficheros con cabecera y TCP_CORK=1

Si ahora repetimos el proceso, wireshark nos mostrará una captura un tanto diferente (Figura 2):

Figura 2. Captura de tráfico con TCP_CORK activado

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 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.
SOBRE Pakito Er Pakete
Aunque todos sus amigos piensan que Pakito es un Pakete, la verdad es que se trata de un crack cuando se trata de protocolos, routers, bridges, firewalls y demás palabros relacionados con las redes de ordenadores. Pakito pasa sus ratos muertos haciendo chocolate que luego distribuye alegremente entre amigos y conocidos a ritmo de una conocida melodía.