Concurrencia y paralelismo de Ruby: un tutorial práctico

Publicado: 2022-03-11

Comencemos aclarando un punto de confusión demasiado común entre los desarrolladores de Ruby; a saber: concurrencia y paralelismo no son lo mismo (es decir, concurrente != paralelo).

En particular, la simultaneidad de Ruby es cuando dos tareas pueden iniciarse, ejecutarse y completarse en períodos de tiempo superpuestos . Sin embargo, no significa necesariamente que ambos se ejecutarán en el mismo instante (p. ej., varios subprocesos en una máquina de un solo núcleo). Por el contrario, el paralelismo es cuando dos tareas se ejecutan literalmente al mismo tiempo (p. ej., varios subprocesos en un procesador multinúcleo).

El punto clave aquí es que los subprocesos y/o procesos concurrentes no necesariamente se ejecutarán en paralelo.

Este tutorial proporciona un tratamiento práctico (en lugar de teórico) de las diversas técnicas y enfoques que están disponibles para la concurrencia y el paralelismo en Ruby.

Para obtener más ejemplos reales de Ruby, consulte nuestro artículo sobre intérpretes y tiempos de ejecución de Ruby.

Nuestro caso de prueba

Para un caso de prueba simple, crearé una clase Mailer y agregaré una función de Fibonacci (en lugar del método sleep() ) para hacer que cada solicitud consuma más CPU, de la siguiente manera:

 class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end

Entonces podemos invocar esta clase Mailer de la siguiente manera para enviar correo:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(Nota: el código fuente de este caso de prueba está disponible aquí en github).

Para establecer una línea de base con fines de comparación, comencemos haciendo un punto de referencia simple, invocando el correo 100 veces:

 puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }

Esto produjo los siguientes resultados en un procesador de cuatro núcleos con MRI Ruby 2.0.0p353:

 15.250000 0.020000 15.270000 ( 15.304447)

Múltiples Procesos vs Multihilo

No existe una respuesta de "talla única" cuando se trata de decidir si usar múltiples procesos o subprocesos múltiples en su aplicación Ruby. La siguiente tabla resume algunos de los factores clave a considerar.

Procesos Hilos
Usa más memoria Usa menos memoria
Si el padre muere antes de que los niños hayan salido, los niños pueden convertirse en procesos zombis. Todos los subprocesos mueren cuando el proceso muere (no hay posibilidad de zombis)
Más costoso para los procesos bifurcados para cambiar de contexto, ya que el sistema operativo necesita guardar y recargar todo Los subprocesos tienen una sobrecarga considerablemente menor ya que comparten espacio de direcciones y memoria
Los procesos bifurcados reciben un nuevo espacio de memoria virtual (aislamiento de procesos) Los subprocesos comparten la misma memoria, por lo que es necesario controlar y tratar los problemas de memoria concurrentes
Requiere comunicación entre procesos Puede "comunicarse" a través de colas y memoria compartida
Más lento para crear y destruir Más rápido para crear y destruir
Más fácil de codificar y depurar Puede ser significativamente más complejo de codificar y depurar

Ejemplos de soluciones de Ruby que utilizan múltiples procesos:

  • Resque: una biblioteca de Ruby respaldada por Redis para crear trabajos en segundo plano, colocarlos en varias colas y procesarlos más tarde.
  • Unicorn: un servidor HTTP para aplicaciones en rack diseñado para servir solo a clientes rápidos en conexiones de baja latencia y alto ancho de banda y aprovechar las características de Unix/kernels similares a Unix.

Ejemplos de soluciones de Ruby que utilizan subprocesos múltiples:

  • Sidekiq: un marco de procesamiento en segundo plano con todas las funciones para Ruby. Su objetivo es ser fácil de integrar con cualquier aplicación Rails moderna y un rendimiento mucho mayor que otras soluciones existentes.
  • Puma: un servidor web Ruby creado para la concurrencia.
  • Thin: un servidor web Ruby muy rápido y simple.

Múltiples Procesos

Antes de analizar las opciones de subprocesos múltiples de Ruby, exploremos el camino más fácil de generar múltiples procesos.

En Ruby, la llamada al sistema fork() se usa para crear una "copia" del proceso actual. Este nuevo proceso está programado a nivel del sistema operativo, por lo que puede ejecutarse simultáneamente con el proceso original, al igual que cualquier otro proceso independiente. ( Nota: fork() es una llamada al sistema POSIX y, por lo tanto, no está disponible si está ejecutando Ruby en una plataforma Windows).

Bien, ejecutemos nuestro caso de prueba, pero esta vez usando fork() para emplear múltiples procesos:

 puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }

( Process.waitall espera a que todos los procesos secundarios finalicen y devuelve una serie de estados de proceso).

Este código ahora produce los siguientes resultados (nuevamente, en un procesador de cuatro núcleos con MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

¡No está nada mal! Hicimos el correo ~5 veces más rápido simplemente modificando un par de líneas de código (es decir, usando fork() ).

Sin embargo, no te emociones demasiado. Aunque puede ser tentador usar bifurcaciones ya que es una solución fácil para la concurrencia de Ruby, tiene un gran inconveniente que es la cantidad de memoria que consumirá. La bifurcación es algo costosa, especialmente si el intérprete de Ruby que está utilizando no utiliza una copia en escritura (CoW). Si su aplicación utiliza 20 MB de memoria, por ejemplo, bifurcarla 100 veces podría consumir hasta 2 GB de memoria.

Además, aunque los subprocesos múltiples también tienen sus propias complejidades, hay una serie de complejidades que deben tenerse en cuenta al usar fork() , como descriptores de archivos compartidos y semáforos (entre procesos bifurcados primarios y secundarios), la necesidad de comunicarse a través de conductos , y así.

Multiproceso Ruby

Bien, ahora tratemos de hacer que el mismo programa sea más rápido usando técnicas de subprocesos múltiples de Ruby.

Múltiples subprocesos dentro de un solo proceso tienen una sobrecarga considerablemente menor que una cantidad correspondiente de procesos, ya que comparten memoria y espacio de direcciones.

Con eso en mente, revisemos nuestro caso de prueba, pero esta vez usando la clase Thread de Ruby:

 threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Este código ahora produce los siguientes resultados (nuevamente, en un procesador de cuatro núcleos con MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

Gorrón. ¡Eso seguro que no es muy impresionante! Entonces, ¿qué está pasando? ¿Por qué esto produce casi los mismos resultados que obtuvimos cuando ejecutamos el código sincrónicamente?

La respuesta, que es la ruina de la existencia de muchos programadores de Ruby, es el Global Interpreter Lock (GIL) . Gracias a GIL, CRuby (la implementación de MRI) realmente no admite subprocesos.

El bloqueo de intérprete global es un mecanismo utilizado en los intérpretes de lenguaje informático para sincronizar la ejecución de subprocesos de modo que solo se pueda ejecutar un subproceso a la vez. Un intérprete que usa GIL siempre permitirá que se ejecute exactamente un subproceso y solo un subproceso a la vez , incluso si se ejecuta en un procesador multinúcleo. Ruby MRI y CPython son dos de los ejemplos más comunes de intérpretes populares que tienen un GIL.

Volviendo a nuestro problema, ¿cómo podemos aprovechar los subprocesos múltiples en Ruby para mejorar el rendimiento a la luz de GIL?

Bueno, en la resonancia magnética (CRuby), la respuesta desafortunada es que básicamente estás atascado y hay muy poco que los subprocesos múltiples pueden hacer por ti.

Sin embargo, la simultaneidad de Ruby sin paralelismo aún puede ser muy útil para tareas que requieren mucha E/S (por ejemplo, tareas que necesitan esperar con frecuencia en la red). Por lo tanto, los hilos aún pueden ser útiles en la resonancia magnética, para tareas pesadas de IO. Hay una razón por la cual los subprocesos, después de todo, se inventaron y se usaron incluso antes de que los servidores multinúcleo fueran comunes.

Pero dicho esto, si tiene la opción de usar una versión que no sea CRuby, puede usar una implementación alternativa de Ruby como JRuby o Rubinius, ya que no tienen un GIL y admiten subprocesos de Ruby paralelos reales.

enhebrado con JRuby

Para probar el punto, estos son los resultados que obtenemos cuando ejecutamos exactamente la misma versión de subprocesos del código que antes, pero esta vez la ejecutamos en JRuby (en lugar de CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

¡Ahora estamos hablando!

Pero…

Los hilos no son gratis

El rendimiento mejorado con múltiples subprocesos podría llevar a creer que podemos seguir agregando más subprocesos, básicamente infinitamente, para hacer que nuestro código se ejecute cada vez más rápido. Eso sería bueno si fuera cierto, pero la realidad es que los hilos no son gratuitos y, por lo tanto, tarde o temprano, te quedarás sin recursos.

Digamos, por ejemplo, que queremos ejecutar nuestro programa de correo de muestra no 100 veces, sino 10 000 veces. Veamos qué pasa:

 threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

¡Auge! Recibí un error con mi OS X 10.8 después de generar alrededor de 2000 subprocesos:

 can't create Thread: Resource temporarily unavailable (ThreadError)

Como era de esperar, tarde o temprano comenzamos a luchar o nos quedamos sin recursos por completo. Por lo tanto, la escalabilidad de este enfoque está claramente limitada.

Agrupación de subprocesos

Afortunadamente, hay una mejor manera; es decir, agrupación de subprocesos.

Un grupo de subprocesos es un grupo de subprocesos reutilizables creados previamente que están disponibles para realizar el trabajo según sea necesario. Los grupos de subprocesos son particularmente útiles cuando hay una gran cantidad de tareas cortas para realizar en lugar de una pequeña cantidad de tareas más largas. Esto evita tener que incurrir en la sobrecarga de crear un hilo una gran cantidad de veces.

Un parámetro de configuración clave para un grupo de subprocesos suele ser el número de subprocesos en el grupo. Estos subprocesos se pueden instanciar todos a la vez (es decir, cuando se crea el grupo) o de forma diferida (es decir, según sea necesario hasta que se haya creado el número máximo de subprocesos en el grupo).

Cuando al grupo se le entrega una tarea para realizar, asigna la tarea a uno de los subprocesos actualmente inactivos. Si no hay subprocesos inactivos (y ya se ha creado el número máximo de subprocesos), espera a que un subproceso complete su trabajo y quede inactivo y luego asigna la tarea a ese subproceso.

Agrupación de subprocesos

Entonces, volviendo a nuestro ejemplo, comenzaremos usando Queue (ya que es un tipo de datos seguro para subprocesos) y emplearemos una implementación simple del grupo de subprocesos:

requieren “./lib/mailer” requieren “benchmark” requieren 'hilo'

 POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)

En el código anterior, comenzamos creando una cola de jobs para los trabajos que deben realizarse. Usamos Queue para este propósito ya que es seguro para subprocesos (por lo que si varios subprocesos acceden a él al mismo tiempo, mantendrá la coherencia), lo que evita la necesidad de una implementación más complicada que requiera el uso de una exclusión mutua.

Luego empujamos las ID de los remitentes a la cola de trabajos y creamos nuestro grupo de 10 subprocesos de trabajo.

Dentro de cada subproceso de trabajo, extraemos elementos de la cola de trabajos.

Por lo tanto, el ciclo de vida de un subproceso de trabajo es esperar continuamente a que las tareas se coloquen en la cola de trabajos y ejecutarlas.

Entonces, la buena noticia es que esto funciona y escala sin ningún problema. Desafortunadamente, sin embargo, esto es bastante complicado incluso para nuestro sencillo tutorial.

Celuloide

Gracias al ecosistema Ruby Gem, gran parte de la complejidad de los subprocesos múltiples se encapsula perfectamente en una serie de Ruby Gems listos para usar y fáciles de usar.

Un gran ejemplo es Celluloid, uno de mis rubíes favoritos. El marco de celuloide es una forma simple y limpia de implementar sistemas concurrentes basados ​​en actores en Ruby. Celluloid permite a las personas construir programas concurrentes a partir de objetos concurrentes tan fácilmente como construyen programas secuenciales a partir de objetos secuenciales.

En el contexto de nuestra discusión en esta publicación, me estoy enfocando específicamente en la función Pools, pero hágase un favor y verifíquela con más detalle. Al usar Celluloid, podrá crear programas Ruby multiproceso sin preocuparse por problemas desagradables como interbloqueos, y le resultará trivial usar otras características más sofisticadas como Futures y Promises.

Así de simple es una versión multiproceso de nuestro programa de correo utilizando Celluloid:

 require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end

Limpio, fácil, escalable y robusto. ¿Qué más puedes pedir?

Trabajos en segundo plano

Por supuesto, otra alternativa potencialmente viable, dependiendo de sus requisitos y limitaciones operativas, sería emplear trabajos de fondo. Existen varios Ruby Gems para admitir el procesamiento en segundo plano (es decir, guardar trabajos en una cola y procesarlos más tarde sin bloquear el hilo actual). Los ejemplos notables incluyen Sidekiq, Resque, Delayed Job y Beanstalkd.

Para esta publicación, usaré Sidekiq y Redis (un almacenamiento y caché de clave-valor de código abierto).

Primero, instalemos Redis y ejecútelo localmente:

 brew install redis redis-server /usr/local/etc/redis.conf

Con nuestra instancia local de Redis en ejecución, echemos un vistazo a una versión de nuestro programa de correo de muestra ( mail_worker.rb ) usando Sidekiq:

 require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end

Podemos activar Sidekiq con el archivo mail_worker.rb :

 sidekiq -r ./mail_worker.rb

Y luego de IRB:

 ⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100

Impresionantemente simple. Y puede escalar fácilmente simplemente cambiando la cantidad de trabajadores.

Otra opción es usar Sucker Punch, una de mis bibliotecas de procesamiento RoR asincrónico favoritas. La implementación usando Sucker Punch será muy similar. Solo necesitaremos incluir SuckerPunch::Job en lugar de Sidekiq::Worker y MailWorker.new.async.perform() en lugar MailWorker.perform_async() .

Conclusión

La alta concurrencia no solo se puede lograr en Ruby, sino que también es más simple de lo que piensa.

Un enfoque viable es simplemente bifurcar un proceso en ejecución para multiplicar su poder de procesamiento. Otra técnica es aprovechar los subprocesos múltiples. Aunque los subprocesos son más ligeros que los procesos y requieren menos sobrecarga, aún puede quedarse sin recursos si inicia demasiados subprocesos al mismo tiempo. En algún momento, puede encontrar necesario utilizar un grupo de subprocesos. Afortunadamente, muchas de las complejidades de los subprocesos múltiples se simplifican al aprovechar cualquiera de las gemas disponibles, como Celluloid y su modelo Actor.

Otra forma de manejar los procesos que consumen mucho tiempo es mediante el procesamiento en segundo plano. Hay muchas bibliotecas y servicios que le permiten implementar trabajos en segundo plano en sus aplicaciones. Algunas herramientas populares incluyen marcos de trabajos respaldados por bases de datos y colas de mensajes.

La bifurcación, el enhebrado y el procesamiento en segundo plano son alternativas viables. La decisión de cuál usar depende de la naturaleza de su aplicación, su entorno operativo y sus requisitos. Esperamos que este tutorial haya proporcionado una introducción útil a las opciones disponibles.