Por qué Discord migra de Go to Rust



Rust se está convirtiendo en un lenguaje de primera clase en una amplia gama de campos. Nosotros en Discord lo usamos con éxito tanto en el servidor como en el cliente. Por ejemplo, en el lado del cliente en la canalización de codificación de video para Go Live, y en el lado del servidor para las funciones Elixir NIF (Funciones implementadas nativas).

Recientemente, mejoramos drásticamente el rendimiento de un solo servicio, reescribiéndolo desde Go to Rust. Este artículo explicará por qué tenía sentido reescribir el servicio, cómo lo hicimos y cuánta productividad mejoró.

Servicio de seguimiento de estado de lectura (estados de lectura)


Nuestra empresa se basa en un solo producto, así que comencemos con un contexto, lo que transferimos exactamente de Go to Rust. Este es un servicio de lectura de estados. Su única tarea es realizar un seguimiento de los canales y mensajes que lee. Se accede a los estados de lectura cada vez que se conecta a Discord, cada vez que envía un mensaje y cada vez que lo lee. En resumen, los estados se leen de forma continua y se encuentran en una "ruta activa". Queremos asegurarnos de que Discord sea siempre rápido, por lo que la verificación de estado debe ser rápida.

La implementación del servicio en Go no cumplió con todos los requisitos. La mayoría de las veces funcionó rápidamente, pero cada pocos minutos hubo fuertes retrasos, notorios para los usuarios. Después de examinar la situación, determinamos que los retrasos se debieron a las características clave de Go: su modelo de memoria y recolector de basura (GC).

Por qué Go no cumple con nuestros objetivos de rendimiento


Para explicar por qué Go no cumple con nuestros objetivos de rendimiento, primero debemos analizar las estructuras de datos, la escala, los patrones de acceso y la arquitectura de servicio.

Para almacenar información de estado, utilizamos una estructura de datos, que se llama: Leer estado. Hay miles de millones de ellos en Discord: un estado para cada usuario por canal. Cada estado tiene varios contadores, que deben actualizarse atómicamente y, a menudo, restablecerse a cero. Por ejemplo, uno de los contadores es el número @mentionen el canal.

Para actualizar rápidamente el contador atómico, cada servidor de Estados de lectura tiene un caché menos utilizado recientemente (LRU). Cada caché tiene millones de usuarios y decenas de millones de estados. El caché se actualiza cientos de miles de veces por segundo.

Por seguridad, el caché se sincroniza con el clúster de base de datos Cassandra. Cuando una clave es expulsada de la caché, ingresamos los estados de este usuario en la base de datos. En el futuro, planeamos actualizar la base de datos en 30 segundos con cada actualización de estado. Esto es decenas de miles de registros en la base de datos cada segundo.

El siguiente gráfico muestra el tiempo de respuesta y la carga de la CPU en el intervalo de tiempo pico para el servicio Go 1. Se puede ver que los retrasos y las explosiones de carga en la CPU ocurren aproximadamente cada dos minutos.



Entonces, ¿de dónde viene el crecimiento de los retrasos cada dos minutos?


En Go, la memoria no se libera inmediatamente cuando se saca una clave del caché. En cambio, el recolector de basura se ejecuta periódicamente y busca porciones de memoria no utilizadas. Esto es mucho trabajo que puede ralentizar un programa.

Es muy probable que las ralentizaciones periódicas de nuestro servicio estén asociadas con la recolección de basura. Pero escribimos un código Go muy eficiente con una cantidad mínima de asignación de memoria. No debería quedar mucha basura. ¿Cuál es el problema?

Al hurgar en el código fuente de Go, aprendimos que Go inicia forzosamente la recolección de basura al menos cada dos minutos . Independientemente del tamaño del almacenamiento dinámico, si el GC no se inició durante dos minutos, Go lo forzará a iniciarse.

Decidimos que si ejecuta GC con más frecuencia, puede evitar estos picos con grandes demoras, por lo que establecemos un punto final en el servicio para cambiar el valor de porcentaje de GC sobre la marcha . Desafortunadamente, la configuración de GC Percent no afectó nada. ¿Cómo pudo pasar esto? Resulta que GC no quería comenzar con más frecuencia, porque no asignamos memoria con la frecuencia suficiente.

Comenzamos a cavar más. Resultó que no se producen demoras tan grandes debido a la gran cantidad de memoria liberada, sino porque el recolector de basura escanea todo el caché LRU para verificar toda la memoria. Luego decidimos que si disminuimos el caché LRU, entonces el volumen de escaneo disminuirá. Por lo tanto, agregamos un parámetro más al servicio para cambiar el tamaño de la caché LRU, y cambiamos la arquitectura, dividiendo la LRU en muchas cachés separadas en cada servidor.

Y así sucedió. Con cachés más pequeños, se reducen los retrasos máximos.

Desafortunadamente, el compromiso con la disminución de la caché LRU aumentó el percentil 99 (es decir, el valor promedio para una muestra del 99% de los retrasos aumentó, excluyendo los picos). Esto se debe a que la disminución de la memoria caché reduce la probabilidad de que el estado de lectura del usuario esté en la memoria caché. Si no está aquí, entonces debemos recurrir a la base de datos.

Después de una gran cantidad de pruebas de carga en diferentes tamaños de caché, encontramos una configuración aceptable. Aunque no era ideal, era una solución satisfactoria, por lo que dejamos el servicio durante mucho tiempo para trabajar así.

Al mismo tiempo, implementamos Rust con mucho éxito en otros sistemas Discord y, como resultado, tomamos la decisión colectiva de escribir marcos y bibliotecas para nuevos servicios solo en Rust. Y este servicio parecía ser un gran candidato para portar a Rust: es pequeño y autónomo, y esperamos que Rust solucione estos arrebatos con retrasos y, en última instancia, haga que el servicio sea más agradable para los usuarios 2.

Gestión de memoria en óxido


Rust es increíblemente rápido y eficiente con la memoria: en ausencia de un entorno de tiempo de ejecución y un recolector de basura, es adecuado para servicios de alto rendimiento, aplicaciones integradas y se integra fácilmente con otros idiomas. 3

Rust no tiene un recolector de basura, por lo que decidimos que no habría tales demoras, como Go.

En la gestión de la memoria, utiliza un enfoque bastante único con la idea de "poseer" memoria. En resumen, Rust realiza un seguimiento de quién tiene derecho a leer y escribir en la memoria. Él sabe cuándo un programa usa memoria e inmediatamente lo libera tan pronto como ya no se necesita memoria. Rust aplica las reglas de memoria en tiempo de compilación, lo que prácticamente elimina la posibilidad de errores de memoria en tiempo de ejecución. 4 4No necesita rastrear manualmente la memoria. El compilador se encargará de esto.

Por lo tanto, en la versión Rust, cuando Read State se excluye de la memoria caché de LRU, la memoria se libera inmediatamente. Esta memoria no se sienta y no espera al recolector de basura. Rust sabe que ya no está en uso y lo libera de inmediato. No hay ningún proceso en tiempo de ejecución para escanear qué memoria liberar.

Óxido asincrónico


Pero había un problema con el ecosistema Rust. En el momento de la implementación de nuestro servicio, no había funciones asincrónicas decentes en la rama estable de Rust. Para un servicio de red, la programación asincrónica es imprescindible. La comunidad ha desarrollado varias bibliotecas, pero con una conexión no trivial y mensajes de error muy estúpidos.

Afortunadamente, el equipo de Rust trabajó duro para simplificar la programación asincrónica, y ya estaba disponible en el canal inestable (Nightly).

Discord nunca tuvo miedo de aprender nuevas tecnologías prometedoras. Por ejemplo, fuimos uno de los primeros usuarios de Elixir, React, React Native y Scylla. Si alguna tecnología parece prometedora y nos da una ventaja, entonces estamos listos para enfrentar la inevitable dificultad de implementación y la inestabilidad de las herramientas avanzadas. Esta es una de las razones por las que hemos llegado tan rápidamente a una audiencia de 250 millones de usuarios con menos de 50 programadores en el estado.

La introducción de nuevas funciones asincrónicas desde el canal Rust inestable es otro ejemplo de nuestra voluntad de adoptar una tecnología nueva y prometedora. El equipo de ingeniería decidió implementar las funciones necesarias sin esperar su soporte en la versión estable. Junto con otros representantes de la comunidad, hemos superado todos los problemas que han surgido, y ahora el óxido asincrónicomantenido en una rama estable. Nuestra tasa ha valido la pena.

Implementación, pruebas de resistencia y lanzamiento.


Solo reescribir el código fue fácil. Comenzamos con una transmisión aproximada, luego la redujimos a lugares donde tenía sentido. Por ejemplo, Rust tiene un excelente sistema de tipos con un amplio soporte para genéricos (para trabajar con datos de cualquier tipo), por lo que descartamos en silencio el código Go, que compensó la falta de genéricos. Además, el modelo de memoria Rust tiene en cuenta la seguridad de la memoria en diferentes subprocesos, por lo que descartamos las gorutinas protectoras.

Las pruebas de carga mostraron inmediatamente un excelente resultado. El rendimiento del servicio en Rust resultó ser tan alto como el de la versión Go, ¡ pero sin estas explosiones de mayor retraso !

Por lo general, prácticamente no optimizamos la versión Rust. Pero incluso con las optimizaciones más simples, Rust pudo superar a una versión cuidadosamente ajustada de Go.Esta es una prueba elocuente de lo fácil que es escribir programas eficaces de Rust en comparación con profundizar en Go.

Pero no satisfacemos el simple rendimiento quo. Después de un pequeño perfil y optimización, superamos a Go en todos los aspectos . Retardo, CPU y memoria: todo mejoró en la versión Rust.

Las optimizaciones de rendimiento de óxido incluyen:

  1. Cambiar a BTreeMap en lugar de HashMap en la caché LRU para optimizar el uso de la memoria.
  2. Reemplazar la biblioteca original de métricas con una versión con soporte para la concurrencia moderna Rust.
  3. Disminuya el número de copias en la memoria.

Satisfechos, decidimos implementar el servicio.

El lanzamiento fue bastante fluido, ya que realizamos pruebas de estrés. Conectamos el servicio a un nodo de prueba, descubrimos y solucionamos varios casos límite. Poco después, lanzaron una nueva versión a todo el parque de servidores.

Los resultados se muestran a continuación.

El gráfico púrpura es Go, el gráfico azul es Rust.



Aumentar el tamaño del caché


Cuando el servicio funcionó con éxito durante varios días, decidimos aumentar el caché de LRU nuevamente. Como se mencionó anteriormente, en la versión Go, esto no se pudo hacer, porque aumentó el tiempo para la recolección de basura. Como ya no hacemos la recolección de basura, puede aumentar el caché contando con un aumento aún mayor en el rendimiento. Por lo tanto, hemos aumentado la memoria en los servidores, optimizado la estructura de datos para un menor uso de memoria (por diversión) y aumentado el tamaño de la memoria caché a 8 millones de estados de estado de lectura.

Los resultados a continuación hablan por sí mismos. Tenga en cuenta que el tiempo promedio ahora se mide en microsegundos, y el retraso máximo @mentionse mide en milisegundos.



Desarrollo de ecosistemas


Finalmente, Rust tiene un ecosistema maravilloso que está creciendo rápidamente. Por ejemplo, recientemente una nueva versión del tiempo de ejecución asíncrono que usamos es Tokio 0.2. Actualizamos, y sin ningún esfuerzo de nuestra parte, redujimos automáticamente la carga en la CPU. En el gráfico a continuación, puede ver cómo ha disminuido la carga desde aproximadamente el 16 de enero.



Pensamientos finales


Discord actualmente usa Rust en muchas partes de la pila de software: para GameSDK, captura y codificación de video en Go Live, Elixir NIF , varios servicios de back-end y mucho más.

Al comenzar un nuevo proyecto o componente de software, definitivamente estamos considerando usar Rust. Por supuesto, solo donde tiene sentido.

Además del rendimiento, Rust ofrece a los desarrolladores muchos otros beneficios. Por ejemplo, su tipo de seguridad y verificador de préstamos simplifican enormemente la refactorización a medida que cambian los requisitos del producto o se introducen nuevas características de idioma. El ecosistema y las herramientas son excelentes y se están desarrollando rápidamente.

Dato curioso: el equipo de Rust también usa Discord para coordinar. Incluso hay un muy útilServidor de la comunidad Rust , donde a veces chateamos.



Notas al pie


  1. Gráficos tomados de Go versión 1.9.2. Probamos las versiones 1.8, 1.9 y 1.10 sin ninguna mejora. La migración inicial de Go to Rust se completó en mayo de 2019. [regresar]
  2. Para mayor claridad, no recomendamos reescribir todo en Rust sin ningún motivo. [regresar]
  3. Cita del sitio oficial. [regresar]
  4. Por supuesto, hasta que use inseguro . [regresar]

Source: https://habr.com/ru/post/undefined/


All Articles