Como probablemente sepas, Ruby tiene unas cuantas implementaciones, como MRI, JRuby, Rubinius, Opal, RubyMotion, etc., y cada una de ellas puede utilizar un patrón diferente de ejecución de código. Este artículo se centrará en las tres primeras y comparará MRI
Como probablemente sepas, Ruby tiene unas cuantas implementaciones, como MRI, JRuby, Rubinius, Opal, RubyMotion, etc., y cada una de ellas puede utilizar un patrón diferente de código ejecución. Este artículo se centrará en los tres primeros y comparará MRI (actualmente la implementación más popular) con JRuby y Rubinius mediante la ejecución de algunos scripts de ejemplo que se supone que evalúan la idoneidad de la bifurcación y la creación de hilos en diversas situaciones, como el procesamiento de algoritmos intensivos de CPU, la copia de archivos, etc. Antes de empezar a "aprender haciendo", es necesario revisar algunos términos básicos.
Horquilla
es un nuevo proceso hijo (una copia del proceso padre)
se comunica con otros a través de canales de comunicación entre procesos (IPC) como colas de mensajes, archivos, sockets, etc.
existe incluso cuando el proceso padre finaliza
es una llamada POSIX - funciona principalmente en plataformas Unix
Hilo
es "sólo" un contexto de ejecución, que trabaja dentro de un proceso
comparte toda la memoria con los demás (por defecto utiliza menos memoria que un fork)
se comunica con otros mediante objetos de memoria compartida
muere con un proceso
introduce los típicos problemas de multihilo, como la inanición, los bloqueos, etc.
Existen multitud de herramientas que utilizan bifurcaciones (forks) e hilos (threads), que se utilizan a diario, por ejemplo, Unicorn (bifurcaciones) y Puma (hilos) a nivel de servidores de aplicaciones, Resque (bifurcaciones) y Sidekiq (hilos) a nivel de trabajos en segundo plano, etc.
La siguiente tabla presenta el soporte para bifurcaciones e hilos en las principales implementaciones de Ruby.
Aplicación de Ruby
Bifurcación
Enhebrado
IRM
Sí
Sí (limitado por el GIL**)
JRuby
–
Sí
Rubinio
Sí
Sí
Otras dos palabras mágicas vuelven como un boomerang en este tema - paralelismo y concurrencia - tenemos que explicarlas un poco. En primer lugar, estos términos no pueden utilizarse indistintamente. En pocas palabras, podemos hablar de paralelismo cuando dos o más tareas se procesan exactamente al mismo tiempo. La concurrencia tiene lugar cuando dos o más tareas están siendo procesadas en periodos de tiempo solapados (no necesariamente al mismo tiempo). Sí, es una explicación amplia, pero lo suficientemente buena como para ayudarte a notar la diferencia y entender el resto de este artículo.
La siguiente tabla presenta el soporte para paralelismo y concurrencia.
Aplicación de Ruby
Paralelismo (mediante bifurcaciones)
Paralelismo (mediante hilos)
Concurrencia
IRM
Sí
No
Sí
JRuby
–
Sí
Sí
Rubinio
Sí
Sí (desde la versión 2.X)
Sí
Se acabó la teoría: ¡vamos a verlo en la práctica!
Tener memoria separada no implica necesariamente consumir la misma cantidad que el proceso padre. Existen algunas técnicas de optimización de memoria. Una de ellas es Copy on Write (CoW), que permite al proceso padre compartir la memoria asignada con el proceso hijo sin copiarla. Con CoW se necesita memoria adicional sólo en el caso de que un proceso hijo modifique la memoria compartida. En el contexto Ruby, no todas las implementaciones son amigables con CoW, por ejemplo MRI lo soporta completamente desde la versión 2.X. Antes de esta versión cada fork consumía tanta memoria como un proceso padre.
Una de las mayores ventajas/desventajas de MRI (tachar la alternativa inapropiada) es el uso de GIL (Global Interpreter Lock). En pocas palabras, este mecanismo se encarga de sincronizar la ejecución de los hilos, lo que significa que sólo se puede ejecutar un hilo a la vez. Pero espera... ¿Significa esto que no tiene sentido utilizar hilos en la RM? La respuesta viene con la comprensión de las interioridades de GIL... o al menos echando un vistazo a los ejemplos de código de este artículo.
Caso de prueba
Para presentar cómo funcionan la bifurcación y el roscado en las implementaciones de Ruby, he creado una clase sencilla llamada Prueba y algunas otras que heredan de ella. Cada clase tiene una tarea diferente que procesar. Por defecto, cada tarea se ejecuta cuatro veces en un bucle. Además, cada tarea se ejecuta contra tres tipos de ejecución de código: secuencial, con bifurcaciones y con hilos. Además, Comparativa.bmbm ejecuta el bloque de código dos veces: la primera para poner en marcha el entorno de ejecución y la segunda para medir. Todos los resultados presentados en este artículo se obtuvieron en la segunda ejecución. Por supuesto, incluso bmbm no garantiza un aislamiento perfecto, pero las diferencias entre varias ejecuciones de código son insignificantes.
exigir "benchmark"
clase Prueba
IMPORTE = 4
def ejecutar
Benchmark.bmbm do |b|
b.report("sequential") { secuencial }
b.report("forking") { bifurcación }
b.report("threading") { threading }
end
end
privado
def secuencial
AMOUNT.times { perform }
end
def bifurcación
AMOUNT.times do
bifurcar do
perform
end
end
Proceso.waitall
rescue NotImplementedError => e
# el método fork no está disponible en JRuby
puts e
end
def threading
hilos = []
AMOUNT.times do
hilos << Thread.new do
realizar
end
end
threads.map(&:join)
end
def realizar
raise "no implementado"
end
end
Prueba de carga
Ejecuta cálculos en bucle para generar una gran carga de CPU.
class PruebaDeCarga < Prueba
def realizar
1000.veces { 1000.veces { 2**3**4 } }
end
end
Vamos a ejecutarlo...
LoadTest.new.run
...y comprueba los resultados
IRM
JRuby
Rubinio
secuencial
1.862928
2.089000
1.918873
bifurcación
0.945018
–
1.178322
roscado
1.913982
1.107000
1.213315
Como puede ver, los resultados de las ejecuciones secuenciales son similares. Por supuesto, hay una pequeña diferencia entre las soluciones, pero está causada por la implementación subyacente de los métodos elegidos en varios intérpretes.
La bifurcación, en este ejemplo, tiene una ganancia de rendimiento significativa (el código se ejecuta casi dos veces más rápido).
La ejecución con hilos da resultados similares a la bifurcación, pero sólo para JRuby y Rubinius. Ejecutar la muestra con hilos en MRI consume un poco más de tiempo que el método secuencial. Hay al menos dos razones. En primer lugar, GIL fuerza la ejecución secuencial de hilos, por lo que en un mundo perfecto el tiempo de ejecución debería ser el mismo que para la ejecución secuencial, pero también se produce una pérdida de tiempo para las operaciones GIL (cambio entre hilos, etc.). En segundo lugar, también se necesita cierto tiempo de sobrecarga para crear hilos.
Este ejemplo no nos da una respuesta a la pregunta sobre el sentido de los hilos de uso en la RM. Veamos otro.
Prueba Snooze
Ejecuta un método de suspensión.
clase SnoozeTest < Test
def realizar
dormir 1
end
end
Estos son los resultados
IRM
JRuby
Rubinio
secuencial
4.004620
4.006000
4.003186
bifurcación
1.022066
–
1.028381
roscado
1.001548
1.004000
1.003642
Como puedes ver, cada implementación da resultados similares no sólo en las ejecuciones secuenciales y de bifurcación, sino también en las de hilos. Entonces, ¿por qué MRI tiene la misma ganancia de rendimiento que JRuby y Rubinius? La respuesta está en la implementación de dormir.
IRM dormir se aplica con rb_thread_wait_for C, que utiliza otra llamada sueño_nativo. Echemos un vistazo a su implementación (el código se ha simplificado, la implementación original se puede encontrar en aquí):
La razón por la que esta función es importante es que, además de utilizar el contexto estricto de Ruby, también cambia al del sistema para realizar allí algunas operaciones. En situaciones como esta, el proceso Ruby no tiene nada que hacer... ¿Gran ejemplo de pérdida de tiempo? En realidad no, porque hay un GIL que dice: "¿No hay nada que hacer en este hilo? Cambiemos a otro y volvamos aquí después de un rato". Esto podría hacerse desbloqueando y bloqueando el GIL con GVL_UNLOCK_BEGIN() y GVL_UNLOCK_END() funciones.
La situación se aclara, pero dormir rara vez es útil. Necesitamos más ejemplos de la vida real.
Prueba de descarga de archivos
Ejecuta un proceso que descarga y guarda un archivo.
require "net/http"
class DownloadFileTest < Prueba
def realizar
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
end
fin
No es necesario comentar los siguientes resultados. Son bastante similares a los del ejemplo anterior.
1.003642
JRuby
Rubinio
secuencial
0.327980
0.334000
0.329353
bifurcación
0.104766
–
0.121054
roscado
0.085789
0.094000
0.088490
Otro buen ejemplo podría ser el proceso de copia de archivos o cualquier otra operación de E/S.
Conclusiones
Rubinio soporta completamente tanto la bifurcación como los hilos (desde la versión 2.X, cuando se eliminó GIL). Tu código podría ser concurrente y ejecutarse en paralelo.
JRuby hace un buen trabajo con hilos, pero no soporta la bifurcación en absoluto. El paralelismo y la concurrencia podrían lograrse con hilos.
IRM soporta la bifurcación, pero la ejecución de hilos está limitada por la presencia de GIL. La concurrencia podría lograrse con hilos, pero sólo cuando el código en ejecución sale del contexto del intérprete Ruby (por ejemplo, operaciones IO, funciones del núcleo). No hay forma de conseguir paralelismo.