2 puntos por GN⁺ 2024-05-10 | 1 comentarios | Compartir por WhatsApp
  • Los problemas de latencia en sistemas distribuidos a menudo se resuelven simplemente activando TCP_NODELAY, lo que sugiere que el comportamiento predeterminado de TCP puede no encajar con las cargas de trabajo modernas
  • El algoritmo de Nagle fue creado en 1984 en el RFC896 para reducir el costo de cabeceras de paquetes TCP pequeños, y funciona impidiendo enviar un nuevo segmento antes de recibir un ACK
  • Cuando se usa junto con delayed ACK, un lado espera el ACK y el otro espera datos de respuesta o un temporizador, lo que perjudica a las aplicaciones en pipeline sensibles a la latencia
  • Aunque el RTT dentro de un datacenter sea de alrededor de 500μs, los servidores modernos pueden hacer mucho trabajo en ese tiempo, así que no está claro el beneficio de retrasar un envío por un RTT completo
  • En los sistemas distribuidos modernos, por TLS, la codificación, la serialización y el tamaño de los mensajes de la aplicación, el problema de paquetes de un solo byte ha disminuido, y en entornos sensibles a la latencia es más natural desactivar el algoritmo de Nagle

La configuración que se revisa primero al depurar latencia

  • Cuando aparece un problema de latencia en un sistema distribuido, lo primero que suele revisarse es si TCP_NODELAY está habilitado
  • Muchos desarrolladores de sistemas distribuidos han perdido tiempo con problemas que se resuelven con esta simple opción de socket
  • Esta repetición sugiere que el comportamiento predeterminado de TCP no se adapta bien a los sistemas distribuidos actuales, o que el propio algoritmo de Nagle podría estar desactualizado

El problema que el algoritmo de Nagle intentaba resolver

  • RFC896 es un documento de 1984 que trató el problema de los paquetes pequeños
  • En ese momento, al enviar por TCP datos que llegaban un carácter a la vez, como la entrada de teclado, se producía la ineficiencia de agregar una cabecera de 40 bytes por cada 1 byte de datos
    • Por cada byte útil de datos se añadían 40 bytes de cabecera, generando un overhead del 4000%
    • Podía tolerarse con cargas ligeras, pero perjudicaba el throughput de la red
  • El objetivo del algoritmo de Nagle era mejorar el throughput amortizando mejor el costo de las cabeceras TCP
    • Los paquetes pequeños solían provenir de aplicaciones interactivas humanas como un shell, o de implementaciones que entregaban datos al kernel poco a poco mediante varias llamadas a write
  • El comportamiento central consiste en no enviar inmediatamente nuevos datos como un segmento TCP separado si los datos enviados anteriormente todavía no han sido confirmados con ACK
  • A menudo se explica el algoritmo de Nagle junto con temporizadores, pero el propio RFC896 no usa un temporizador aparte del round-trip time (RTT) de la red

La latencia que aparece al combinarlo con delayed ACK

  • delayed ACK consiste en no enviar inmediatamente la confirmación de recepción de un paquete, sino esperar hasta que haya datos para devolver o hasta que expire un temporizador
  • RFC813 es un documento temprano de 1982 que propuso retrasar ACK, y trata cómo en ciertas situaciones el receptor puede posponer el envío del ACK y programar un temporizador para enviarlo después
  • RFC1122 formalizó más el delayed ACK
  • Ambas funciones son razonables por separado, pero juntas pueden introducir latencia
    • El algoritmo de Nagle espera recibir un ACK antes de enviar más datos
    • delayed ACK retrasa el envío del ACK hasta que haya datos de respuesta listos o expire un temporizador
    • Esto ayuda a llenar mejor los paquetes, pero no es bueno para aplicaciones en pipeline sensibles a la latencia
  • Un comentario de John Nagle en Hacker News también ve el problema no como prevención de tinygrams, sino como la combinación de ACK retrasado y temporizadores fijos
  • Es un ejemplo de cómo dos funciones razonables de un protocolo pueden combinarse para producir un comportamiento no deseado, y de por qué estas interacciones dificultan el diseño de protocolos

Dónde no encaja con los sistemas distribuidos modernos

  • Incluso sin delayed ACK, el comportamiento del algoritmo de Nagle puede no coincidir con lo que quieren los sistemas distribuidos modernos
  • En el entorno actual, el propio RTT ya es un costo nada despreciable
    • Un RTT único dentro de un datacenter suele ser de alrededor de 500μs
    • El RTT entre datacenters de la misma región es de varios ms
    • En rutas globales puede llegar a varios cientos de ms
  • Los servidores modernos pueden procesar mucho trabajo incluso en unos pocos cientos de μs, así que no es evidente que retrasar el envío de datos por un RTT completo aporte un beneficio claro
  • La justificación original del algoritmo de Nagle era reducir el overhead de cabecera 40 veces mayor que aparecía en paquetes de un solo byte
  • Las bases de datos distribuidas y los sistemas distribuidos modernos por lo general no envían paquetes de un solo byte
    • Los datos que envía la aplicación ya son más grandes
    • Se añade overhead de protocolos como TLS
    • También se suma overhead de codificación y serialización
  • El problema de evitar mensajes pequeños sigue siendo importante, pero la responsabilidad se ha trasladado de forma efectiva a la capa de aplicación
  • Enviar datos envueltos en JSON un byte a la vez no es eficiente, independientemente del algoritmo de Nagle

Por qué ver TCP_NODELAY como la opción predeterminada

  • Si se construye un sistema distribuido sensible a la latencia sobre hardware moderno de nivel datacenter, se puede habilitar TCP_NODELAY para desactivar el algoritmo de Nagle
  • Considerando el tráfico, la composición de las aplicaciones y el rendimiento del hardware moderno, puede que el algoritmo de Nagle ya no sea necesario
  • Incluso es posible defender que TCP_NODELAY debería ser el valor predeterminado
  • El código que llama a write por cada byte puede volverse más lento con TCP_NODELAY como valor predeterminado
  • Si la eficiencia importa, ese código debería corregir su implementación en la aplicación en lugar de depender del algoritmo de Nagle

TCP_QUICKACK está más cerca de ser una opción complementaria

  • TCP_QUICKACK puede mencionarse como alternativa, pero por su falta de portabilidad y su significado peculiar no es fácil tomarlo como primera opción
  • Conviene verificar directamente su significado en la página man de tcp en Linux
  • Un problema más importante es que TCP_QUICKACK no resuelve el problema de fondo: que el kernel retenga datos más tiempo del que el programa pretende
  • Si un programa llamó a write(), espera que write() realmente se lleve a cabo

1 comentarios

 
GN⁺ 2024-05-10
Opiniones de Hacker News
  • A lo largo de mi carrera corregí varias veces problemas de latencia causados por el algoritmo de Nagle, y ahora es lo primero que sospecho.
    La lógica en sí es válida, pero no encaja con algunas cargas de trabajo; creo que el ingeniero debería elegirlo explícitamente al crear el socket, no dejarlo como valor predeterminado del sistema operativo.
    El problema no es si es una opción buena o mala, sino que existe una configuración que cambia de forma bastante agresiva cómo se transmiten los datos y mucha gente ni sabe que existe.

    • A mí me pasa algo parecido: cada vez que veo un framework RPC nuevo, tengo el hobby de abrir un issue en GitHub preguntando “¿consideraron TCP_NODELAY, o este framework solo puede hacer 20 llamadas por segundo?”.
      Hasta ahora encontré un bug todas las veces.
      Ej.: https://cloud-haskell.atlassian.net/browse/DP-108 o https://github.com/agentm/curryer/issues/3
      Dicho eso, no estoy de acuerdo con lo de que “no es una opción buena/mala”.
      Es una heurística del lado del kernel para “arreglar mágicamente” aplicaciones mal escritas y, como dice el artículo, una aplicación normal no hace llamadas de sistema write() de red de 1 byte.
      Ese software debería corregirse.
      El único caso en que esta función tiene sentido, en mi opinión, es el de un administrador de sistemas del kernel que, por razones como la política interna del equipo, no puede arreglar el software que corre en la máquina.
      Fuera de eso, vuelve más complejo al software normal.
      Significa que hay que desactivar explícitamente una magia rara introducida para aumentar un poco el throughput de software mal escrito, y que en software bien escrito genera latencias grandes y sorprendentes.
      John Nagle dice en el hilo enlazado aquí que los ACK retrasados son peores, y estoy de acuerdo con eso.
      Pero el patrón Send/Send/Receive que empeora el algoritmo de Nagle es un caso de uso completamente válido y común: aplica a cualquier cosa que haga RPC con pipeline sobre TCP.
      Creo que tanto los ACK retrasados como el algoritmo de Nagle deberían estar desactivados por defecto.
      El nombre también debería ser algo como TCP_DELAY, y solo debería activarse cuando uno no quiera implementar un buffering básico en espacio de usuario.
      La gente no debería tener que saber estas cosas, y el comportamiento predeterminado no debería ser sorprendente.
    • Si el objetivo es principalmente arreglar aplicaciones con mal comportamiento de write, una opción para activar TCP_DELAY se vuelve bastante extraña.
      Implica que necesitas un ingeniero de software lo bastante listo como para conocer esa opción, pero no lo bastante listo como para agrupar bien las llamadas a write o crear por su cuenta un buffering mejor, estilo Nagle, adecuado para su aplicación.
    • De acuerdo. En el ámbito del trading de alta frecuencia/baja latencia, desactivar el algoritmo de Nagle es algo bastante conocido desde hace mucho, probablemente más de 15 años, y es una de las primeras cosas que reviso.
    • Lo que realmente uno quiere es fijar la demora en n microsegundos, pero no hay una buena forma de hacerlo salvo poner directamente buffering en espacio de usuario antes de la llamada de sistema.
      Si no tienes algo como io_uring que compense el costo de las llamadas de sistema, el lado de espacio de usuario funciona mejor.
    • Esta lógica originalmente estaba pensada para cosas como sesiones Telnet.
      Según recuerdo, esa era la motivación principal.
  • La conclusión es un poco rara. El algoritmo de Nagle era claramente un intento de agrupar escrituras, y en algunos casos agrupar escrituras es mejor, independientemente del hardware, la red, la aplicación o el caso de uso.
    Incluso hoy, mucha computación usa escrituras agrupadas, y las aplicaciones de red también se benefician.
    Protocolos de más alto nivel y más nuevos, como QUIC, agrupan escrituras y, en la práctica, trasladan al espacio de usuario el manejo independiente de conexiones y errores de TCP, haciendo que el protocolo empuje los datos a la aplicación lo antes posible y que la conexión y el manejo de errores de cada stream individual queden a cargo de la aplicación, no del stack TCP/IP del host ni de los routers.
    Si las redes vuelven a saturarse como antes, el algoritmo de Nagle regresará en forma de modificaciones a QUIC, probablemente más profundo dentro del código de la aplicación, esperando para enviar paquetes QUIC hasta alcanzar ciertos criterios.
    Todo en tecnología se reinventa cuando el hardware o el software llega a un cuello de botella. Como el rendimiento de ambos no crece al mismo ritmo, al final siempre ocurre.
    Además del ancho de banda, cuando la saturación se da por la cantidad de paquetes por segundo debido a paquetes pequeños, el algoritmo de Nagle resulta útil.

    • La diferencia entre QUIC y TCP está en el pecado original de TCP y sus predecesores: imitar una conexión de puerto serial asíncrono donde la capa de mensajes no es visible.
      Eso permitió conectarse a servicios con un teletipo físico, pero hizo que TCP no conociera los límites de los mensajes; aunque hoy se pueda inyectar parte de ese conocimiento, el software inicial no podía.
      En cambio, muchos protocolos que no son TCP, como QUIC, SCTP y TP4, proporcionan explícitamente límites de mensajes.
      La interfaz con el sistema no es un puerto serial emulado, sino, como mucho, una basada en mensajes que se reensamblan.
    • Cierto, pero esta implementación concreta dependía de una heurística para decidir cómo agrupar, y parece que esa suposición no se cumplió.
    • El agrupamiento debe estar controlado por la aplicación, no por el protocolo.
      El protocolo no tiene suficiente contexto para agrupar correctamente.
  • A la inversa, ¿qué tal desactivar los ACK retrasados?
    El problema es el comportamiento patológico que aparece cuando interactúan la prevención de paquetes pequeños y los ACK retrasados.
    La opción expuesta para desactivar la prevención de paquetes pequeños es TCP_NODELAY, pero ¿cómo se pueden desactivar los ACK retrasados?
    Es decir, cuando uno quiere hacer benchmarks de las cuatro combinaciones y ver cuál encaja mejor.
    Buscando un poco, en Linux existe la opción de socket TCP_QUICKACK, pero hay que configurarla cada vez que se recibe algo.
    También están /proc/sys/net/ipv4/tcp_delack_min y /proc/sys/net/ipv4/tcp_ato_min.
    En FreeBSD están net.inet.tcp.delayed_ack y net.inet.tcp.delacktime.

    • TCP_QUICKACK corrige la peor forma, pero no resuelve el problema completo.
      El algoritmo de Nagle todavía puede esperar hasta un tiempo de ida y vuelta completo antes de enviar datos y, si se sigue el RFC, casi no aporta beneficios y solo agrega latencia.
    • Exacto. Que haya que configurar TCP_QUICKACK cada vez que se recibe algo... ¿en qué estaban pensando?
      ¿Por qué alguien querría tenerlo desactivado solo parte del tiempo?
    • En CentOS/RedHat se puede agregar quickack 1 al final de una ruta para desactivar los ACK retrasados en ese camino.
  • En un mundo donde el ancho de banda era limitado y el tamaño mínimo de paquete era de 64 bytes, además de requerir el intervalo entre tramas, enviar un paquete TCP por cada byte era un enorme desperdicio de ancho de banda.
    En la mayoría de las redes Ethernet ese mínimo sigue siendo así, y lo mismo aplica a enviar ACK vacíos.
    Pero mi postura básica es esta: no es TCP_NODELAY, es simplemente TCP.

    • Me gustaría que existiera un protocolo con algún mecanismo incorporado para darse cuenta de que la tubería del otro lado se cortó por cualquier motivo.
    • ¿No se supone que QUIC(https://en.wikipedia.org/wiki/QUIC) resuelve problemas de TCP como la latencia?
  • No me convence mucho el argumento de que Nagle ya no es necesario.
    Telnet no es importante hoy, pero parece que todavía hay muchas aplicaciones que hacen cosas como:
    write(fd, "Host: "), write(fd, hostname), write(fd, "\r\n"), write(fd, "Content-type: "), etc.
    Aunque esto no implique una sobrecarga de 40 veces, sí podría ser de unas 5 veces.

    • Hay que arreglar la aplicación.
      Uno no escribe a un archivo de esta forma y espera rendimiento mágico, aunque el sistema operativo también tenga su propio búfer.
      No hay razón para esperar algo distinto solo al escribir en un socket, y Nagle, para empezar, tampoco te salva de la sobrecarga de las llamadas al sistema.
    • Al ver la mención a Telnet me dio curiosidad qué hace OpenSSH, y configura TCP_NODELAY en todas las conexiones, incluidas las sesiones interactivas.
      Lo confirmé tanto leyendo el código como observando el comportamiento con strace.
    • Si asumimos E/S asíncrona, poner los datos en un búfer en lugar de bloquearse con cada write(2) pequeño es la única forma que tiene sentido, así que no creo que este patrón sea tan común ahora.
      En servidores normalmente se necesita E/S asíncrona para escalar bien, y en clientes bloquearse por llamadas de red también da una mala experiencia.
      Más todavía en el entorno actual, con cambios frecuentes de red y muchas situaciones fuera de cobertura.
    • Todo Internet no debería ser castigado porque algunos desarrolladores escriban código pésimo.
    • Para empezar, no debería hacerse así.
      Incluso dejando de lado el aspecto de red, las llamadas al sistema son bastante caras, así que es malo para el rendimiento.
  • Me pregunto si alguien conoce una buena forma de activar TCP_NODELAY en un socket cuando no se tiene acceso al código fuente de la aplicación.
    No encontré ninguna configuración del kernel para aplicarlo de forma permanente ni ningún comando para cambiarlo a posteriori.
    Pude desactivar los ACK retrasados poniendo quickack 1 en la tabla de ruteo, pero activar TCP_NODELAY desde fuera de la aplicación parece especialmente difícil.
    Últimamente estoy sufriendo exactamente el problema descrito aquí entre una aplicación que poseo y una aplicación de código cerrado con la que interactúa.

    • ¿No funcionaría algo como una intercepción con LD_PRELOAD sobre socket(2)?
      Sería llamar a la función real, luego hacer algo como setsockopt y devolver el socket modificado.
    • Dependiendo de la situación concreta, quizás se pueda poner socat en el medio.
      Si originalmente era your_app —> server, hacer que sea your_app -> localhost_socat -> server.
      socat tiene una opción de línea de comandos para configurar tcp_nodelay.
      Eso sí, habría que convencer a la app de código cerrado de conectarse a localhost.
      Si hace una consulta DNS, es posible que una entrada en /etc/hosts permita hacer que se conecte a localhost.
      Como la app se comunica con socat mediante un socket local, el tcp_nodelay del lado de la app no afecta.
    • ¿No bastaría con adjuntar un depurador y llamar a setsockopt mediante ptrace?
    • Abrir /proc//fd/ y configurar la opción del socket podría funcionar. No lo probé.
    • LD_PRELOAD
  • Hace unos 15 años jugué un MMO con requisitos de tiempo real muy fuertes, y toda la comunicación era TCP.
    Si hacía clic en un botón, mi acción ni siquiera aparecía en pantalla hasta que volvía el paquete de respuesta.
    Al final, los chicos que jugábamos ese juego, incluido yo, descubrimos que al activar TCP_NODELAY el juego se volvía mucho más fluido.
    El efecto era especialmente grande para los jugadores de California, que estaban cerca del servidor del juego.

    • No sé si se refiere a WoW, pero por esa época una actualización del juego hizo exactamente este cambio, y probablemente también cambió otras cosas.
      Un efecto secundario interesante era que, antes del cambio, si el flujo TCP se detenía, el juego se congelaba un momento y luego reproducía muy rápido los eventos recibidos que se habían perdido.
      Normalmente, ese evento era mi muerte.
      Después del cambio, en lugar de eso simplemente se desconectaba.
  • Episodio relacionado del podcast Oxide and Friends: https://www.youtube.com/watch?v=mqvVmYhclAg

    • Fue un episodio excelente y mostró con muchísima claridad la importancia de la visualización.
  • Si usas un lenguaje moderno que activa TCP_NODELAY por defecto, como Go, esto no aplica :-)

  • No siempre es eso. A veces es DNS

    • Una vez, una line card dañada en un router convertía en 0 el último bit de las direcciones IPv4, y eso terminó en un ticket de “solo se puede acceder a direcciones IPv4 pares”
    • En mi caso, una vez era vidrio sucio
      En un router cerca de una obra, el polvo se había asentado en el espacio entre el láser y la fibra óptica, atenuando lo suficiente la señal, y se veía una pérdida de paquetes de 40 a 50%
      Tras ubicar el punto de pérdida, el NOC le envió un correo al operador de transporte correspondiente, y al día siguiente el técnico enviado respondió contando esa historia
    • Una vez cada 50 años, a 2.000 millones de km de distancia, puede ser un chip de memoria defectuoso
      Aun así, normalmente se puede aplicar un parche de desvío, así que no es gran cosa
    • Tampoco hay que olvidarse de BGP, o de que un disco se llene sin avisar
    • Si falla, es DNS; si simplemente deja de moverse, es TCP_NODELAY o buffering de streams
      La Web, que es un sistema realmente complejo, también falla por la caché