wandres.dev
ZERO-COPY Y MMAP DE DRIVERS · DMA a userspace

sendfile y splice: empalmar descriptores en el kernel

Mover datos de un fichero a un socket sin que suban jamás a un buffer de userspace. sendfile fue la primera respuesta; splice la generalizó con una tubería como conducto interno que transporta referencias a páginas, no bytes. vmsplice para regalar páginas de usuario a una tubería y tee para duplicar un flujo. Por qué el kernel mueve punteros de página en lugar de copiar, y dónde se para la copia cero.

⏱ 16 min

El nivel anterior compartía memoria con el usuario. Este hace lo contrario: mantiene los datos enteramente dentro del kernel, moviéndolos de un descriptor a otro sin que el proceso los toque nunca. sendfile conecta un fichero a un socket de un plumazo; splice generaliza la idea a cualquier par de descriptores usando una tubería como fontanería interna. Y el truco que lo hace zero-copy es sutil: por la tubería no viajan bytes, viajan referencias a las páginas donde ya están.

🎯 Al terminar esta lección sabrás
  • Usar sendfile para transferir de un fichero a un socket sin buffer de usuario.
  • Usar splice con una tubería para empalmar cualquier par de descriptores.
  • Entender vmsplice y tee: regalar páginas de usuario y duplicar flujos.
  • Comprender por qué la tubería transporta referencias a páginas y dónde acaba la copia cero.

sendfile: fichero → socket de un plumazo

Todo el bucle read+write del nivel 42.1 —con sus dos copias por CPU y sus cuatro cambios de contexto— colapsa en una sola llamada.

#include <sys/sendfile.h>

off_t off = 0;
ssize_t sent;

/* Copia in_fd -> out_fd dentro del kernel, sin buffer de usuario */
while ((sent = sendfile(sock_fd, file_fd, &off, len)) > 0)
	len -= sent;   /* 'off' avanza solo; reintenta hasta agotar */

Los datos van del page cache al socket sin pasar por userspace. La copia 2 (copy_to_user) y la copia 3 (copy_from_user) desaparecen. En hardware con scatter-gather DMA, la NIC puede incluso leer directamente del page cache: entonces ni siquiera hay una copia CPU dentro del kernel y el camino es de copia cero verdadera. La limitación de sendfile es su rigidez: out_fd debía ser un socket y in_fd un fichero mmapeable. Por eso llegó algo más general.

splice: mover páginas a través de una tubería

splice desacopla la idea: mueve datos entre un descriptor y una tubería, y la tubería es el conducto universal. Para empalmar fichero a socket, encadenas dos splice con una tubería en medio.

#include <fcntl.h>

int p[2];
pipe(p);                                  /* la tuberia: fontaneria interna */

for (;;) {
	/* fichero -> tuberia: mueve REFERENCIAS a paginas del page cache */
	ssize_t in = splice(file_fd, NULL, p[1], NULL, 65536,
	                    SPLICE_F_MOVE | SPLICE_F_MORE);
	if (in <= 0)
		break;

	/* tuberia -> socket: consume esas referencias hacia el sk_buff */
	while (in > 0) {
		ssize_t out = splice(p[0], NULL, sock_fd, NULL, in,
		                    SPLICE_F_MOVE | SPLICE_F_MORE);
		if (out < 0)
			goto err;
		in -= out;
	}
}

La clave está en qué es una tubería por dentro. Un pipe es un anillo de struct pipe_buffer, y cada pipe_buffer no contiene datos: contiene un puntero a un struct page, un offset y una longitud. Cuando haces splice del fichero a la tubería, el kernel no copia los bytes: mete en el anillo una referencia a la página del page cache donde ya viven. El segundo splice, hacia el socket, saca esa referencia y se la entrega al sk_buff. Los bytes nunca se movieron; solo cambió de manos un puntero de página. SPLICE_F_MOVE es la pista de que se prefiere mover la página en vez de copiarla, y SPLICE_F_MORE avisa a la pila de red de que vendrá más (equivale a TCP_CORK).

flowchart LR
F[Fichero en page cache] -->|splice: ref de pagina| P[Tuberia: anillo de pipe_buffer]
P -->|splice: ref de pagina| S[Buffer del socket]
S -->|DMA| N[Tarjeta de red]
style F fill:#89b4fa,color:#11111b
style P fill:#cba6f7,color:#11111b
style S fill:#a6e3a1,color:#11111b
style N fill:#94e2d5,color:#11111b

vmsplice y tee: regalar páginas y duplicar flujos

vmsplice mete páginas del espacio de usuario en una tubería. Con SPLICE_F_GIFT, el proceso regala sus páginas al kernel: promete no volver a tocarlas, y el kernel puede quedárselas sin copiar. Es la forma de que datos generados en userspace entren en la maquinaria de splice sin copia.

struct iovec iov = { .iov_base = buf, .iov_len = len };

/* Regala las paginas de 'buf' a la tuberia: cero copias si esta alineado */
vmsplice(p[1], &iov, 1, SPLICE_F_GIFT);
splice(p[0], NULL, sock_fd, NULL, len, SPLICE_F_MOVE);

tee duplica el contenido de una tubería en otra sin consumirlo —también por referencias, sin copiar los bytes— y sirve para bifurcar un flujo: enviarlo a un socket y a la vez a un fichero de log, por ejemplo.

📄

sendfile

Fichero → socket de un plumazo. Simple y directo, pero rígido: in_fd mmapeable, out_fd un socket. Hoy es, por dentro, un splice con la tubería oculta.

🔧

splice

Empalma cualquier par de descriptores con una tubería explícita en medio. Transporta referencias a páginas del page cache. La herramienta general del zero-copy interno.

🎁

vmsplice + tee

vmsplice regala páginas de usuario a la tubería con SPLICE_F_GIFT; tee duplica un flujo entre tuberías sin copiar. Para datos nacidos en userspace y para bifurcar.

Un detalle que a veces sorprende: en splice uno de los dos extremos siempre debe ser una tubería. No puedes empalmar directamente dos sockets ni dos ficheros; la tubería es el conducto obligatorio porque es donde el kernel aparca las referencias a página mientras cambian de dueño. Por eso el patrón fichero→socket necesita dos splice encadenados y no uno.

⚠️
El regalo tiene condiciones estrictas

SPLICE_F_GIFT solo es zero-copy si el buffer está alineado a página y del tamaño de una página; si no, el kernel copia de todas formas. Y una vez regalada la página, el proceso no debe escribir en ella hasta que el splice la haya consumido: hacerlo es una carrera que puede enviar datos corruptos o filtrar memoria. El regalo es un contrato, no una sugerencia.

La tubería no transporta datos: transporta el derecho a leerlos

Detente en la idea que hace posible todo esto, porque reordena tu modelo mental de la E/S. Creías que una tubería era un tubo por el que fluyen bytes, y que splice los empujaba de un extremo a otro. Es falso. Una tubería del kernel es un anillo de descriptores de página, y splice no mueve contenido: mueve propiedad. Lo que viaja del fichero al socket no son los kilobytes del dato, sino una referencia a los marcos físicos donde ya estaban desde que el disco los trajo por DMA. El dato se queda inmóvil en el page cache; lo único que se desplaza es el conocimiento de dónde está y el permiso para leerlo desde allí. Esta es una de las inversiones más elegantes de la ingeniería de sistemas: el problema de “copiar datos” se resuelve dándote cuenta de que copiar los datos era innecesario, que bastaba con copiar un puntero de dieciséis bytes en lugar de sesenta y cinco mil bytes de carga útil. La tubería, que nació como el conducto más humilde de Unix —el que une ls con grep—, resulta ser el mecanismo genérico de contabilidad de páginas del kernel: un lugar donde aparcar referencias mientras cambian de dueño entre subsistemas. Cuando interiorizas que en el kernel mover datos casi siempre significa mover el derecho a leerlos, dejas de ver copias por todas partes y empiezas a ver lo que de verdad ocurre: una coreografía de referencias sobre un conjunto de páginas que apenas se mueve. Ese es el corazón conceptual del zero-copy interno, y splice es su expresión más limpia.

⚔️ Empalma sin copiar y demuéstralo
  1. Reescribe el servidor del nivel 42.1 con sendfile y mide la caída de uso de CPU frente al bucle read+write.
  2. Reimpleméntalo con dos splice y una tubería; verifica con perf que las funciones copy_user_* desaparecen del perfil.
  3. Genera datos en userspace y mételos en la tubería con vmsplice y SPLICE_F_GIFT; comprueba experimentalmente cuándo cae a copia (desalinea el buffer y compara).
  4. Usa tee para bifurcar un flujo hacia un socket y un fichero simultáneamente, sin duplicar los bytes en memoria.
  5. Explica, mirando struct pipe_buffer, por qué la tubería transporta referencias a páginas y qué le pasa al conteo de referencias de cada página a lo largo del empalme.