Benchmarking de fibras, hilos y procesos

Hace un tiempo, me propuse analizar el rendimiento de Fiber y cómo ha mejorado en las últimas versiones de Ruby. ¡Después de todo, la concurrencia es uno de los tres pilares de Ruby 3×3 ! Además, ha habido algunas aceleraciones importantes en la clase Ruby’s Fiber de Samuel Williams .

No es difícil escribir un microbenchmark para algo como Fiber.yield . Pero es más difícil y más interesante escribir un punto de referencia que sea útil y representativo.

Espera, espera, espera…

Bien, primero un resumen rápido: ¿qué son las fibras?  Y no me hagas empezar con el paralelismo ...

¿Sabes cómo puedes bifurcar un proceso o crear un hilo y de repente hay este código que también se está ejecutando, junto con tu código? Quiero decir, claro, no necesariamente se ejecuta literalmente al mismo tiempo. Pero hay otro flujo de control y, a veces, se está ejecutando. Todo esto se llama concurrencia por parte de los desarrolladores que son exigentes con el vocabulario.

Una fibra es así. Sin embargo, cuando tiene varias fibras en ejecución, no cambian automáticamente de una a otra. En cambio, cuando una fibra llama a Fiber.yield , Ruby cambiará a otra fibra. Siempre y cuando todas las fibras obtengan rendimiento regularmente, todas tienen la oportunidad de funcionar y el resultado es muy eficiente.

Las fibras, como los hilos, se ejecutan dentro de su proceso. En comparación, si llama «fork» para un nuevo proceso, entonces, por supuesto, no está en el mismo proceso. Así como un proceso puede contener múltiples hilos, un hilo puede contener múltiples fibras. Por ejemplo, podría escribir una aplicación con diez procesos, cada uno con ocho hilos, y cada uno de esos hilos podría tener seis fibras.

Un hilo es más liviano que un proceso, y múltiples pueden ejecutarse dentro de un proceso. Una fibra es más liviana que un hilo, y pueden ejecutarse múltiples dentro de un hilo. Y a diferencia de los subprocesos o procesos, las fibras tienen que alternar manualmente de un lado a otro llamando a «rendimiento». Pero a cambio, en muchos casos obtienen menos uso de memoria y menos sobrecarga del procesador que los subprocesos.

¿Tener sentido?

También hablaremos sobre el Global Interpreter Lock , o GIL , que en estos días se llama más correctamente Global VM Lock o GVL, pero nadie lo hace, así que lo llamo el GIL aquí. Básicamente, múltiples hilos o fibras de Ruby dentro de un solo proceso solo pueden tener uno de ellos ejecutando Ruby a la vez . Eso puede hacer una gran diferencia en el rendimiento . No vamos a profundizar en el GIL aquí, pero es posible que desee investigar más a fondo si este tema le interesa.

¿Por qué no los servidores de aplicaciones?

Algunos de ustedes piensan, «pero comparar hilos y fibras no es nada difícil». Después de todo, hago muchos puntos de referencia HTTP aquí. ¿Por qué no simplemente comparar Puma , que usa hilos, contra Falcon , que usa fibras, y llamarlo un día?

Muchas razones. Es un buen logo, ¿no?

Uno: hay muchas diferencias entre Falcon y Puma. Análisis HTTP, manejo de múltiples procesos, cómo se escribe el reactor. Y, de hecho, ambos pasan mucho tiempo en código que no es de Ruby a través de nio4r , lo que le permite a Ruby usar algunas bibliotecas C (muy geniales, muy eficientes) para hacer el trabajo pesado. Eso es genial, y creo que es una elección maravillosa … Pero no es realmente una evaluación comparativa de Ruby, ¿verdad?

No, necesitamos algo mucho más simple para ver el rendimiento de la fibra sin procesar.

Además, Ruby 3×3 usa Ruby 2.0 como línea de base. Falcon, nio4r y Puma reciente requieren razonablemente Ruby más reciente que eso. Cualquiera que sea el punto de referencia que use, quiero poder comparar todo el camino hasta Ruby 2.0. Puma 2.11 puede hacer eso, pero ninguna versión de Falcon puede hacerlo.

Algunos enfoques que no funcionaron

¿Solo te interesa el remate? Salta esta sección. ¿Curioso sobre la metodología? Sigue leyendo.

Intenté armar un cliente y servidor HTTP realmente simple. El cliente estaba inicialmente inestable mientras que el servidor era en realidad tres servidores diferentes: un subproceso, un proceso y una fibra. Lo tengo en parte funcionando

Pero todo falló . Mal.

Específicamente, wrk es intencionalmente exigente y quisquilloso. Si el servidor cierra el socket demasiado pronto, da un error. Muchos errores Errores de lectura y errores de escritura, dependiendo. Simplemente es

Si evito la estrategia y la visión, puedo reducir el alcance de mis fallas. Eso es lo correcto, estoy seguro.

cribir un servidor HTTP con Ruby’s TCPSocket es más difícil de lo que parece, básicamente, si quiero que un cliente exigente lo trate como razonable. Curl piensa que está bien. Wrk quiere resultados de referencia limpios y dice que no.

Si, vale, bien. Creo que no quiero resultados de referencia limpias. Tal vez.

Eso también falló.De acuerdo, entonces, ¿tal vez solo un servidor de socket TCP? ¿Cliente C rápido y sin procesar, tres servidores diferentes basados ​​en TCPServer, un subproceso, un proceso, una fibra? Tomó algo de trabajo, pero hice todo eso .

Eso también falló.

Específicamente, lo tengo todo trabajando con hilos: a menudo son los más fáciles. Y una ejecución de 10,000 solicitudes tomó de 3 segundos a 30 segundos. Eso parece mucho. Pensé, bueno, tal vez los hilos son malos en esto, y lo probé con fibras. El mismo problema.

Así que lo probé con código lineal no concurrente para el servidor. El mismo problema. ¿Qué pasa con un reactor simple basado en selección para la versión de fibra para ver si algo de concurrencia ayuda? No El mismo problema.

Resulta que solo abrir un socket TCP / IP, incluso en localhost, agrega una gran cantidad de variación al tiempo para la prueba. Tanta variación que inunda lo que estoy tratando de medir. Me podría haber simplemente correr muchos, muchos ensayos a (en su mayoría) media el ruido. Pero tener más ruido de medición que señal para medir es una muy mala idea.

Entonces: de vuelta al tablero de dibujo .

Sin HTTP Sin TCP No hay grandes servidores de aplicaciones complicados, por lo que no podría ir más complicado.

¿Qué fue lo siguiente?

Menos complicado

¿Qué es más predecible y menos variable que los sockets TCP / IP? Sockets locales de proceso a proceso sin protocolo de red en el medio. En Ruby, una forma fácil de hacerlo es IO.pipe.  Estoy empezando a disfrutar de cuán tremendamente malas son las explicaciones visuales de los tubos de carcasa. Tal vez esa es una forma de síndrome de Estocolmo?

Puede armar un patrón maestro / trabajador simple bastante agradable haciendo que el maestro configure un grupo de trabajadores, cada uno con una tubería en forma de concha. Es muy rápido de configurar y muy rápido de usar. Esta es la misma forma en que shells como bash configura operadores de tubería para «cat myfile | sort | uniq» para ejecutar la salida a través de varios programas antes de que se haga.

Entonces eso fue lo que hice. Usé hilos como trabajadores para la primera versión. El código para eso es bastante simple.

Básicamente:

  • Configurar tuberías de lectura y escritura

  • Configure hilos como trabajadores, listos para leer y escribir

  • Inicie el código maestro / controlador en el proceso principal de Ruby y el hilo

  • Sigue corriendo hasta que termines, luego limpia

Hay un breve código del reactor para el maestro para asegurarse de que solo lee y escribe en las tuberías que están actualmente listas. Pero es muy corto, ciertamente bajo diez líneas de «extra».

La versión multiproceso es apenas diferente: es tan similar que hay aproximadamente cinco líneas de diferencia entre ellas .

Y ahora, fibras

La versión de fibra es un poco más complicada. Hablemos de eso.

Subprocesos y procesos tienen multitarea preventiva . Por lo tanto, si configura uno de ellos y se olvida de él, sucede lo correcto. Tu maestro y tus trabajadores intercambiarán bastante bien entre ellos. No todo funciona perfectamente todo el tiempo, pero las cosas básicamente tienden a funcionar bien.

Las fibras son diferentes. Una fibra tiene que ceder el control manualmente cuando se hace. Si una fibra solo lee o escribe en el momento incorrecto, puede bloquear todo el programa hasta que esté listo. Ese no es un problema tan grave con IO.pipe como con TCP / IP. Pero En la multitarea cooperativa, mantiene la sonrisa tonta en su rostro y cambia cuando lo desea. En la multitarea preventiva, no puede pasar demasiado tiempo en el teléfono celular o la mano con el libro lo abofetea.sigue siendo una buena idea usar un patrón llamado reactor para asegurarse de que solo está leyendo cuando hay datos disponibles y solo escribiendo cuando hay espacio en la tubería para ello.

Samuel Williams tiene una presentación sobre las fibras de Ruby que utilicé mucho como fuente para esta publicación. Incluye un patrón de reactor simple para fibras que usaré para clasificar a mis trabajadores. Al igual que el maestro en el código anterior, este reactor usa IO.select para averiguar cuándo leer y escribir y cómo transferir el control entre las diferentes fibras. El patrón del reactor también se puede usar para hilos o procesos, pero el código de Samuel está escrito para fibras.

Inicialmente, puse a todos los trabajadores en un reactor en un hilo, y el maestro con un reactor IO.select en otro hilo . Eso es muy similar a cómo se configura el hilo y el código de proceso, por lo que es claramente comparable. Pero resultó que el rendimiento de esa versión no es excelente.

Pero parece una tontería decir que está probando fibras mientras usa hilos para cambiar de un lado a otro … Así que escribí una versión «remasterizada» del código, con el código maestro usando una fibra por trabajador. ¿Sería esto realmente lento ya que duplicaba el número de fibras …? No tanto.

De hecho, usar solo fibras y un solo reactor duplicó la velocidad para grandes cantidades de mensajes.

Y con eso, tuve un buen código de hilo, proceso y fibra comparable que es casi todo E / S.

¿Cómo se realiza?

Lo puse a prueba localmente en mi Macbook Pro con Ruby 2.6.2. Tome esto como un desempeño «vagamente sugestivo», en otras palabras, no como un desempeño «muy investigado». Pero creo que da un comienzo razonable. Validaré en instancias más grandes de Linux EC2 antes de que te des cuenta, nos hemos conocido antes.

Aquí hay una cantidad de trabajadores y solicitudes junto con el tipo de trabajador y cuánto tiempo lleva procesar esa cantidad de solicitudes:

TraposProcesosFibras con Master de estilo antiguoFibras con Fast Master
5 trabajadores con 20,000 requisitos cada uno2.60.714.21.9
10 trabajadores con 10,000 requisitos cada uno2.50.674.01.7
100 trabajadores con 1,000 requisitos cada uno2.50.763.91.6
1000 trabajadores con 100 requisitos cada uno2.82.55.02.4
10 trabajadores con 100,000 requisitos cada uno255.84116

Algunas notas rápidas: los procesos dan una muestra sorprendente , en parte porque no tienen GIL. Los hilos superan a las fibras con un maestro roscado, por lo que combinar hilos y fibras demasiado cerca parece ser dudoso. Pero con un maestro basado en fibra adecuado, son más rápidos que los hilos, como es de esperar y esperar.

También puede notar que los procesos no se escalan correctamente a 1000 trabajadores, mientras que los hilos y las fibras funcionan mucho mejor. Eso es normal y esperado, pero es bueno ver que los datos lo confirman.

Esa fila final tiene 10 veces más solicitudes totales que todas las otras filas. Por eso sus números son aproximadamente diez veces más altos.

Una base sólida para el rendimiento

Este tipo acaba de obtener el código de Ruby Fiber para trabajar. Se nota por la postura.

Este artículo es definitivamente lo suficientemente largo, así que no probaré esto desde la versión 2.0 de Ruby a 2.7 … Aún. ¡Sin embargo, puede esperarlo pronto!

Queremos mostrar que el rendimiento de la fibra ha mejorado con el tiempo, y nos gustaría ver si los hilos o procesos han cambiado mucho. Así que probaremos esas versiones de Ruby.

También queremos comparar hilos, procesos y fibras en diferentes niveles de concurrencia. Esta no es una prueba perfectamente justa. ¡No hay tal cosa! Pero aún puede enseñarnos algo útil.

Y también nos gustaría una línea de base para comenzar a analizar varias propuestas de «autofibra»: variaciones en las fibras que producen automáticamente al hacer E / S para que no necesite la envoltura adicional del reactor para lecturas y escrituras. Eso simplifica sustancialmente el código, dando algo mucho más parecido al hilo o al código de proceso. Hay al menos dos propuestas de autofibra, una de Eric Wong y otra de Samuel Williams .

No esperes todo eso para la misma publicación de blog, por supuesto. Pero el trabajo de fondo que acabamos de hacer prepara el escenario para todo.