Optimizando Transferencias de Archivos Grandes en Linux con Go - Una Exploración de TCP y Syscall

30 January 2023
Cover image

Mientras experimentaba con Raspberry Pi y otros dispositivos en mi red, he creado una pequeña aplicación que utilizo para diferentes tareas como el descubrimiento de dispositivos usando multicast, recolección de datos y otras.

Una característica clave de esta aplicación es la capacidad de descargar varios datos y métricas de algunos plugins semanalmente. Con tamaños de archivo que varían de 200 MB a 250 MB después de aplicar cierta compresión, es esencial considerar algunos enfoques para enviar estos archivos a través de TCP usando Go.

En este artículo, exploraremos algunos enfoques y consejos para enviar archivos grandes a través de TCP en Linux usando Go, teniendo en cuenta las restricciones de los dispositivos pequeños y la importancia de una transmisión de archivos eficiente y confiable.

Naive approach

func sendFile(file *os.File, conn net.Conn) error {
	// Get file stat
	fileInfo, _ := file.Stat()

	// Send the file size
	sizeBuf := make([]byte, 8)
	binary.LittleEndian.PutUint64(sizeBuf, uint64(fileInfo.Size()))
	_, err := conn.Write(sizeBuf)
	if err != nil {
		return err
	}

	// Send the file contents by chunks
	buf := make([]byte, 1024)
	for {
		n, err := file.Read(buf)
		if err == io.EOF {
			break
		}

		_, err = conn.Write(buf[:n])
		if err != nil {
			fmt.Println("error writing to the conn:", err)
			break
		}
	}

	return nil
}

Aunque el código parece sencillo y correcto, presenta un inconveniente importante en eficiencia. Los datos se mueven en un ciclo desde el búfer del kernel a otro en el espacio del usuario, y luego se copian nuevamente al búfer del kernel de destino. Esta doble copia reduce el rendimiento, ya que el búfer solo actúa como un espacio de almacenamiento temporal.

Aunque aumentar el tamaño del búfer para minimizar el número de llamadas al sistema podría parecer una solución viable, pero en realidad resultaria en un aumento del uso de la memoria, convirtiéndolo en un enfoque ineficiente para dispositivos pequeños.

Además, la doble copia de datos también aumenta el uso de la memoria, ya que tanto los búferes de origen como de destino deben ser asignados y mantenidos en la memoria. Esto puede afectar los recursos disponibles del sistema, particularmente cuando se transfieren archivos grandes y los dispositivos son pequeños.

Container commmunications

El atenrior diagrama proporciona una ilustración simplificada del flujo de datos al enviar archivos a través de TCP. Utilizando el enfoque anterior, es importante notar que los datos se copian cuatro veces antes de que el proceso esté completo:

  • Del disco al búfer de lectura en el espacio del kernel.
  • Del búfer de lectura en el espacio del kernel al búfer de la aplicación en el espacio del usuario.
  • Del búfer de la aplicación en el espacio del usuario al búfer del socket en el espacio del kernel.
  • Finalmente, del búfer del socket en el espacio del kernel al Controlador de Interfaz de Red (NIC, por sus siglas en inglés).

Con esto podemos resaltar la ineficiencia de la implementacion anterior dado que debemos copiar los datos múltiples veces, eso sin mencionar los múltiples cambios de contexto entre user y kernel modo.

Los datos se copian desde el disco al búfer de lectura en el espacio del kernel cuando se usa la llamada read(), y la copia es realizada por acceso directo a memoria (DMA, por sus siglas en inglés). Esto resulta en un cambio de contexto del modo usuario al modo kernel. Luego, los datos se copian del búfer de lectura al búfer de la aplicación por la CPU, lo que requiere otro cambio de contexto del kernel al modo usuario.

Cuando se hace una llamada write()/send(), ocurre otro cambio de contexto del modo usuario al modo kernel, y los datos se copian del búfer de la aplicación a un búfer del socket en el espacio del kernel por la CPU. Luego, ocurre un cuarto cambio de contexto cuando la llamada write()/send() retorna. En ese momento DMA pasa los datos al protocolo de manera asíncrona.

Que es DMA?

DMA significa Acceso Directo a Memoria. Es una tecnología que permite a los dispositivos periféricos acceder directamente a la memoria del computador, sin necesidad de la CPU, para acelerar la transferencia de datos. De esta manera, la CPU queda libre para realizar otras tareas, haciendo el sistema más eficiente. https://es.wikipedia.org/wiki/Acceso_directo_a_memoria

Para optimizar el proceso de transferencia de archivos, debemos minimizar el número de copias de búfer y cambios de contexto y reducir la sobrecarga de mover datos de un lugar a otro.

Utilizando una llamada al sistema especializada 'sendfile'

Golang proporciona acceso a la funcionalidad de bajo nivel del sistema operativo a través del paquete syscall, que contiene una interfaz para varios primitivos del sistema.

func sendFile(file *os.File, conn net.Conn) error {
	// Get file stat
	fileInfo, _ := file.Stat()

	// Send the file size
	sizeBuf := make([]byte, 8)
	binary.LittleEndian.PutUint64(sizeBuf, uint64(fileInfo.Size()))
	if _, err := conn.Write(sizeBuf); err != nil {
		return err
	}

	tcpConn, ok := conn.(*net.TCPConn)
	if !ok {
		return errors.New("TCPConn error")
	}

	tcpF, err := tcpConn.File()
	if err != nil {
		return err
	}

	// Send the file contents
	_, err = syscall.Sendfile(int(tcpF.Fd()), int(file.Fd()), nil, int(fileInfo.Size()))
	return err
}

sendfile() copia datos entre un descriptor de archivo y otro. Debido a que esta copia se realiza dentro del kernel, sendfile() es más eficiente que la combinación de read(2) y write(2), que requeriría transferir datos hacia y desde el espacio del usuario. https://man7.org/linux/man-pages/man2/sendfile.2.html

La llamada al sistema sendfile es más eficiente para transferir datos que los métodos estándar de lectura y escritura. Al evitar el búfer de la aplicación, los datos se mueven directamente del búfer de lectura al búfer del socket, reduciendo el número de copias de datos y cambios de contexto y mejorando el rendimiento. Además, el proceso podría requerir menos intervención de la CPU, permitiendo una transferencia de datos más rápida y liberando la CPU para otras tareas.

La llamada al sistema sendfile es conocida como un método de zero-copy porque transfiere datos de un descriptor de archivo a otro sin la necesidad de una copia de datos intermedia en la memoria del espacio del usuario.

Por supuesto, esta zero-copy es desde el punto de vista de una aplicación en modo usuario.

Container commmunications

Este escenario tiene dos copias realizadas por DMA + una copia por el CPU, y solo dos cambios de contexto.

La llamada al sistema sendfile se vuelve aún más eficiente cuando el NIC admite Scatter/Gather. Con SG, la llamada al sistema puede transferir directamente los datos del búfer de lectura al NIC, convirtiendo la transferencia en una operación de copia cero que reduce la carga de la CPU y mejora el rendimiento.

Gather" (Reunir) se refiere a la capacidad de una Tarjeta de Interfaz de Red (NIC, por sus siglas en inglés) para recibir datos de múltiples ubicaciones de memoria y combinarlos en un único búfer de datos antes de transmitirlos por la red. La característica de dispersar/reunir (scatter/gather) de una NIC se utiliza para aumentar la eficiencia de la transferencia de datos reduciendo el número de copias de memoria necesarias para transmitir los datos. En lugar de copiar los datos en un único búfer, la NIC puede reunir datos de múltiples búferes en un solo, reduciendo la carga de la CPU y aumentando el rendimiento de la transferencia. https://en.wikipedia.org/wiki/Gather/scatter_(vector_addressing)

Nic con soporte de gather

Container commmunications

Este escenario tiene solo dos copias realizadas por DMA y dos cambios de contexto.

Por lo tanto, reducir el número de copias desde el búfer no solo mejora el rendimiento sino que también reduce el uso de la memoria, haciendo que el proceso de transferencia de archivos sea más eficiente y escalable.

Quiero resltar que las ilustraciones y escenarios anteriores están altamente simplificados y no representan completamente la complejidad de estos procesos. Sin embargo, el objetivo era presentar la información de una manera directa y fácil de entender.

¿Por qué se recomienda frecuentemente "io.Copy" en Go?

func sendFile(file *os.File, conn net.Conn) error {
	// Get file stat
	fileInfo, _ := file.Stat()

	// Send the file size
	sizeBuf := make([]byte, 8)
	binary.LittleEndian.PutUint64(sizeBuf, uint64(fileInfo.Size()))
	_, err := conn.Write(sizeBuf)
	if err != nil {
		return err
	}

	// Send the file contents
	_, err = io.Copy(conn, file)
	return err
}

La recomendación de usar la función io.Copy en Go se debe a su simplicidad y eficiencia. Esta función ofrece una manera simplificada de copiar datos desde un io.Reader a un io.Writer, gestionando el buffering y dividiendo los datos en fragmentos para minimizar el uso de memoria y reducir las llamadas al sistema (syscalls). Además, io.Copy maneja cualquier error potencial durante el proceso de copia, convirtiéndola en una opción conveniente y confiable para la copia de datos en Go.

Obviamente los beneficios de usar io.Copy en Go van más allá de su gestión de búfer de 32k y optimización src.

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
	...
	if wt, ok := src.(WriterTo); ok {
		return wt.WriteTo(dst)
	}

	if rt, ok := dst.(ReaderFrom); ok {
		return rt.ReadFrom(src)
	}
	...
}

Cuando el destino cumple con la interfaz ReadFrom, io.Copy la usa para gestionar la copia. Por ejemplo, si dst es una TCPConn, io.Copy usa la función correspondiente para hacer la copia src.

func (c *TCPConn) readFrom(r io.Reader) (int64, error) {
	if n, err, handled := splice(c.fd, r); handled {
		return n, err
	}
	if n, err, handled := sendFile(c.fd, r); handled {
		return n, err
	}
	return genericReadFrom(c, r)
}

Como puede ver, al enviar un archivo a través de una conexión TCP, io.copy utiliza la llamada al sistema sendfile para una transferencia de datos eficiente.

Al ejecutar el programa y usar la herramienta strace para registrar todas las llamadas al sistema, puede observar el uso de la llamada al sistema sendfile en acción:

...
[pid 67436] accept4(3,  <unfinished ...>
...
[pid 67440] epoll_pwait(5,  <unfinished ...>
[pid 67436] sendfile(4, 9, NULL, 4194304) = 143352
...

Como se observa en la implementación de ReadFrom, io.Copy no solo intenta usar sendfile, sino también la llamada al sistema splice, otra llamada al sistema útil para transferir datos eficientemente a través de tuberías (pipes).

Además, cuando el source satisface el método WriteTo, io.Copy lo utilizará para la copia, evitando cualquier asignación y reduciendo la necesidad de copias adicionales. Es por eso que los expertos recomiendan usar io.Copy siempre que sea posible para copiar o transferir datos.

Otros consejos posibles para Linux.

También intente mejorar el rendimiento en Linux para ciertos escenarios genéricos aumentando el tamaño de la MTU (Unidad Máxima de Transmisión) de las interfaces de red y cambiando el tamaño del búfer TCP.

Los parámetros del kernel de Linux tcp_wmem y tcp_rmem controlan el tamaño del búfer de transmisión y recepción para las conexiones TCP, respectivamente. Estos parámetros pueden ser utilizados para optimizar el rendimiento de los sockets TCP.

tcp_wmem determina el tamaño del búfer de escritura para cada socket, almacenando datos salientes antes de que se envíen a la red. Los búferes más grandes aumentan la cantidad de datos enviados de una vez, mejorando la eficiencia de la red.

tcp_rmem establece el tamaño del búfer de lectura para cada socket, sosteniendo los datos entrantes antes de que la aplicación los procese. Esto ayuda a prevenir la congestión de la red y mejora la eficiencia.

Nota: aumentar ambos valores demandará un mayor uso de memoria.

Read more.

# See current tcp buffer values
$ sysctl net.ipv4.tcp_wmem
net.ipv4.tcp_wmem = 4096 16384 4194304

# Change the values
$ sysctl -w net.ipv4.tcp_wmem="X X X"

# Change MTU
$ ifconfig <Interface_name> mtu <mtu_size> up

Debo mencionar que para mí, estas optimizaciones no lograron ofrecer una mejora sustancial debido a ciertas restricciones, como las limitaciones de algunos dispositivos, la red local, etc.

En conclusión.

En el artículo se discute formas de enviar archivos grandes a través de TCP en Linux utilizando Go, considerando las restricciones de los dispositivos pequeños y la importancia de una transmisión de archivos eficiente y confiable. El enfoque ingenuo de copiar datos múltiples veces se consideró ineficiente y aumentó el uso de la memoria, causando tensión en los recursos del sistema. Se presentó un enfoque alternativo, utilizando la llamada al sistema especializada 'sendfile' y, más importante aún, io.Copy, que usa sendfile internamente para este escenario para minimizar el número de copias de búfer e intercambios de contexto y reducir la sobrecarga para lograr una transferencia de archivos más eficiente.

Gracias por tomarse el tiempo para leer este artículo. Espero que haya proporcionado información útil. Trabajo constantemente para mejorar mi comprensión y conocimiento, así que agradezco sus comentarios o correcciones. Gracias nuevamente por su tiempo y consideración.

Repo

Share article