Programando en Ruby

La Guía de los "Programadores Pragmáticos"

Hilos y procesos



Ruby te proporciona dos métodos básicos para organizar tu programa de modo que puedas ejecutar distintas partes de él ``al mismo tiempo.'' Puedes dividir tareas que cooperan entre ellas dentro del propio programa usando varios hilos, o puedes separarlas entre diferentes programas usando varios procesos. Echémosle un vistazo a cada una de las soluciones.

Multihilo

Muchas veces, la manera mas sencilla de realizar dos tareas al mismo tiempo es usar los hilos Ruby. Éstos pertenecen al proceso, el cuál está dentro del intérprete de Ruby. Ésto hace que los hilos Ruby sean completamente portables---no existe relación con el sistema operativo---pero a cambio perderás ciertos beneficios con respecto a los hilos nativos. Puedes llegar a experimentar la inanición de un hilo (ésto sucede cuando un hilo no tiene la prioridad suficiente como para llegar a ejecutarse). Si los gestionas de modo que queden interbloqueados, el proceso completo puede quedarse parado. Y si algún hilo hace alguna llamada al sistema operativo que dure cierto tiempo, hará que el resto de los hilos se bloqueen hasta que el intérprete retome el control. Sin embargo, en principio no deberías descartar esta opción---Los hilos Ruby son una manera rápida y eficiente para aumentar el paralelismo de tu código.

Crear hilos Ruby

La forma de crear nuevos hilos es bastante directa. Aquí está un sencillo trozo de código que descarga un conjunto de páginas Web en paralelo. Para cada una de las solicitudes realizadas, el programa crea un hilo diferente que gestiona una transacción HTTP.

require 'net/http'

pages = %w( www.rubycentral.com             www.awl.com             www.pragmaticprogrammer.com            )

threads = []

for page in pages   threads << Thread.new(page) { |myPage|

    h = Net::HTTP.new(myPage, 80)     puts "Fetching: #{myPage}"     resp, data = h.get('/', nil )     puts "Got #{myPage}:  #{resp.message}"   } end

threads.each { |aThread|  aThread.join }


        
produce:
Fetching: www.rubycentral.com
Fetching: www.awl.com
Fetching: www.pragmaticprogrammer.com
Got www.rubycentral.com:  OK
Got www.pragmaticprogrammer.com:  OK
Got www.awl.com:  OK

Analicemos el código con más detalle, ya que hay ciertos elementos sutiles en él.

Los hilos se crean con la llamada a Thread.new . Recibe como parámetro un código que contiene lo que se ejecutará en cada hilo. En nuetro caso, el bloque usa la librería net/http para coger la página principal de cada uno de los sitios indicados. La traza muestra claramente cómo las solicitudes se ejecutan en paralelo.

Cuando creamos el hilo, le pasamos la página solicitada como parámetro. Este parámetro es pasado al bloque como myPage. ¿Por qué hacemos ésto, y no cogemos directamente el valor de la variable page desde el interior del bloque?

Cada hilo comparte todas las variables locales, globales y de instancia que existen en el momento en que se inicia. Alguien con un hermano pequeño te podría decir que compartir no es algo bueno. En este caso, los tres hilos compartirían la variable page. El primer hilo empieza, y page se establece a http://www.rubycentral.com. Mientras tanto, el bucle que genera los hilos se sigue ejecutando. En la segunda iteración, page se establece a http://www.awl.com. Si el primer hilo no había terminado de usar la variable page, leerá de repente su nuevo valor. Éstos bugs son difíciles de controlar.

Sin embargo, las variables locales creadas dentro del bloque del hilo son realmente locales a ese hilo---cada uno de los hilos tendrá su propia copia de las variables. En nuestro caso, la variable myPage se inicializará cada vez que un hilo se cree, y cada hilo tendrá su propia copia de las direcciones de las páginas.

Manipular hilos

Hay otro detalle en la última línea del programa. ¿Por qué se llama a join en cada hilo que creamos?

Cuando un programa Ruby termina, todos los hilos en ejecución se matan, independientemente de su estado. Sin embargo, puedes esperar a que termine un hilo en particular llamándo al método del hilo Thread#join . El hilo que llama al método quedará bloqueado hasta que el hilo llamado termina. Invocando a join en cada uno de los hilos que solicitan la página, te aseguras de que las tres peticiones se completan antes de que finalice el programa principal.

Además de join, existen unas cuantas sentencias más que se utilizan para controlar los hilos. En primer lugar, el hilo actual es siempre accesible mediante Thread.current . Puedes obtener una lista de todos los hilos con Thread.list , que devuelve todos los objetos Thread que están en ejecución o parados. Para conocer el estado de un hilo en particular, puedes usar Thread#status y Thread#alive? .

También puedes ajustar la prioridad de un hilo mediante Thread#priority= . Los hilos de mayor prioridad se ejecutarán antes que los de menor prioridad. Hablaremos más tarde sobre la planificación de hilos, y sobre comenzarlos y pararlos.

Variables de hilo

Como describimos en la sección anterior, un hilo puede acceder a cualquier variable declarada en el ámbito de cuando se crea. Las variables locales al bloque de un hilo son locales al hilo, y no están compartidas.

Pero si necesitas que variables propias de un hilo puedan ser accedidas desde otros hilos---¿incluído el hilo principal? Thread proporciona una funcionalidad especial que permite crear y acceder a variables locales a un hilo por el nombre. Simplemente tienes que tratar el objeto del hilo como si fuera una Hash, guardando elementos mediante []= y recuperándolos con []. En este ejemplo, cada hilo guarda el valor actual de la variable count en una variable local con la clave mycount. (Existe una situación incontrolada en este código, pero no hemos hablado de sincronización todavía, por lo que de momento lo ignoraemos.)

count = 0
arr = []
10.times do |i|
  arr[i] = Thread.new {
    sleep(rand(0)/10.0)
    Thread.current["mycount"] = count
    count += 1
  }
end
arr.each {|t| t.join; print t["mycount"], ", " }
puts "count = #{count}"
produce:
8, 0, 3, 7, 2, 1, 6, 5, 4, 9, count = 10

El hilo principal espera a que acaben los subhilos e imprime el valor de count guardado por cada uno. Para hacerlo aún más interesante, hemos hecho que cada hilo espere un tiempo aleatorio antes de guardar el valor.

Hilos y excepciones

¿Qué sucedería si un hilo lanza una excepción sin capturar? Depende del valor al que esté establecido el flag http://abort_on_exception, documentado en las páginas 384 y 387.

Si abort_on_exception está a false, el valor por defecto, una excepción sin capturar simplemente mata el hilo actual---los restantes siguen ejecutándose. En el ejemplo siguiente, el hilo número 3 aborta y no produce ninguna salida. Sin embargo, todavía puedes ver la traza del resto de los hilos.

threads = []
6.times { |i|
  threads << Thread.new(i) {
    raise "Boom!" if i == 3
    puts i
  }
}
threads.each {|t| t.join }
produce:
01
2

45prog.rb:4: Boom! (RuntimeError) from prog.rb:8:in `join' from prog.rb:8 from prog.rb:8:in `each' from prog.rb:8

Sin embargo, si estableces abort_on_exception a true, una excepción no capturada finalizará todos los hilos. Una vez que el hilo 3 muere, no se produce más salida.

Thread.abort_on_exception = true
threads = []
6.times { |i|
  threads << Thread.new(i) {
    raise "Boom!" if i == 3
    puts i
  }
}
threads.each {|t| t.join }
produce:
01
2
prog.rb:5: Boom! (RuntimeError)
	from prog.rb:7:in `initialize'
	from prog.rb:7:in `new'
	from prog.rb:7
	from prog.rb:3:in `times'
	from prog.rb:3

Controlar el planificador de hilos

En una aplicación bien diseñada, normalmente dejarás que cada hilo realice su trabajo; crear dependencias temporales en una aplicación multihilo es considerado generalmente un mal diseño.

Sin embargo, existen ciertas situaciones donde es necesario controlar los hilos. Quizás el jukebox tenga un hilo que muestre una luz, y podríamos necesitar detenerlo temporalmente cuando la música para. Podrías tener dos hilos en una relación clásica de productor-consumidor, donde el consumidor debe detenerse si el productor consigue acceso al buffer.

La clase Thread proporciona una serie de métodos para controlar el planificador de hilos. Invocando Thread.stop se detiene el hilo actual, mientras Thread#run se preocupa de que un hilo particular se ejecute. Thread.pass desplanifica el hilo actual, permitiendo a otros ejecutarse, y Thread#join y Thread#value detienen el hilo invocador hasta que el hilo llamado finalice.

Podemos mostrar estas características en el siguiente código, que no hace nada especialmente interesante.

t = Thread.new { sleep .1; Thread.pass; Thread.stop; }
t.status » "sleep"
t.run
t.status » "run"
t.run
t.status » false

Sin embargo, intentar usar estas primitivas para conseguir cualquier tipo de sincronización real es, en el mejor de los casos, equivocarse; siempre existirán situaciones no controladas esperando para fastidiar. Y cuando estés trabajando con datos compartidos, éstas situaciones te garantizarán largas y frustrantes sesiones de depuración. Afortunadamente, los hilos disponen de una característica adicional---la idea de sección crítica. Usando ésto podremos seguir una serie de modelos de sincronización segura.

Exclusión mutua

El método de más bajo nivel para impedir a otros hilos ejecutarse emplea una condición global ``hilo crítico''. Cuando la condición esté establecida a true (usando el método Thread.critical= ), el planificador no dejará que ningún otro hilo existente se ejecute. Sin embargo, ésto no impide que nuevos hilos se creen y ejecuten. Ciertas operaciones sobre hilos (como parar o matar un hilo, dormirlo, o lanzar una excepción) pueden provocar que un hilo se ejecute aún estando en una sección crítica.

Usar Thread.critical= directamente es en cierto modo factible, pero es terriblemente desaconsejado. Afortunadamente, Ruby dispone de varias alternativas. De todas ellas, las dos mejores son la clase Mutex y la clase ConditionVariable, disponibles en el módulo de la librería thread; consulta la documentáción que comienza en la página 457.

La clase Mutex

Mutex es una clase que implementa un sencillo semáforo para programar acceso mutuamente exclusivo a recursos compartidos. Ésto es, sólamente un hilo puede mantener el bloqueo en un momento dado. Los demás hilos pueden escoger entre esperar a que se quite el bloqueo, o simplemente dar un mensaje de error indicando que existe un bloqueo.

Los mutex se usan normalmente cuando las operaciones sobre los datos tienen que ser atómicas. Imagínate que necesitamos actualizar dos variables durante una transacción. Podemos simular ésto en un sencillo programa incrementando unos cuantos contadores. Se supone que las actualizaciones deberían ser atómicas---el resto del mundo no debería ver nunca valores diferentes en los contadores. Sin ningún tipo de control con semáforos, ésto simplemente no sucede.

count1 = count2 = 0
difference = 0
counter = Thread.new do
  loop do
    count1 += 1
    count2 += 1
  end
end
spy = Thread.new do
  loop do
    difference += (count1 - count2).abs
  end
end
sleep 1
Thread.critical = 1
count1 » 184846
count2 » 184846
difference » 58126

Éste ejemplo demuestra que el hilo ``spy'' se ejecuta un gran número de veces con los valores de count1 y count2 inconsistentes.

Afortunadamente podemos arreglar ésto usando un mutex.

require 'thread'
mutex = Mutex.new

count1 = count2 = 0 difference = 0 counter = Thread.new do   loop do     mutex.synchronize do       count1 += 1       count2 += 1     end   end end spy = Thread.new do   loop do     mutex.synchronize do       difference += (count1 - count2).abs     end   end end

sleep 1
mutex.lock
count1 » 21192
count2 » 21192
difference » 0

Colocando los accesos a los datos compartidos bajo el control de un mutex, aseguramos su consistencia. Desafortunadamente, como podrás comprobar en los números, también sufrimos una penalización en el tiempo de ejecución.

Variables condition

Usar un mutex para protejer datos críticos es muchas veces insuficiente. Supongamos que estás en una sección crítica, pero necesitas esperar por un recurso en concreto. Si tu hilo se duerme esperando por ese recurso, es posible que ningún otro hilo sea capaz de liberar el recurso porque no puede entrar en la sección crítica---el proceso original aún está bloqueado. Sería necesario poder liberar temporalmente el acceso exclusivo a la sección crítica y simultaneamente decirle al resto de la gente que estás esperando por un recurso. Cuando el recurso estuviese disponible, necesitarías ser capaz de obtenerlo y reactivar el bloqueo en la sección crítica, todo en un solo paso.

Aquí es donde las variables condition entran en juego. Una variable condition es simplemente un semáforo que está asociado a un recurso y que es usado durante la protección de un mutex en particular. Cuando necesitases un recurso no disponible, esperarías en una variable condition. Esta acción libera el bloqueo del mutex correspondiente. Cuando algún otro proceso anuncia que el recurso está disponible, el hilo original sale de la espera y simultaneamente recupera el bloqueo en la sección crítica.

require 'thread'
mutex = Mutex.new
cv = ConditionVariable.new

a = Thread.new {   mutex.synchronize {     puts "A: I have critical section, but will wait for cv"     cv.wait(mutex)     puts "A: I have critical section again! I rule!"   } }

puts "(Later, back at the ranch...)"

b = Thread.new {   mutex.synchronize {     puts "B: Now I am critical, but am done with cv"     cv.signal     puts "B: I am still critical, finishing up"   } } a.join b.join
produce:
A: I have critical section, but will wait for cv(Later, back at the ranch...)

B: Now I am critical, but am done with cv B: I am still critical, finishing up A: I have critical section again! I rule!

Para implementaciones alternativas de métodos de sincronización, mira monitor.rb y sync.rb en el subdirectorio lib de la distribución.

Ejecutar múltiples procesos

Algunas veces podrías necesitar separar una tarea en diferentes procesos---o quizás necesites ejecutar un proceso independiente que no está escrito en Ruby. Ésto no es un problema: Ruby dispone de una serie de métodos mediante los cuales puedes invocar y gestionar procesos diferenciados.

Crear nuevos procesos

Existen diversos métodos para invocar un proceso separado; el más fácil es ejecutar un comando y esperar a que finalice. Podrías usar ésta técnica para ejecutar comandos separados o para recoger datos del sistema. Ruby hace esto por ti con los métodos system y los acentos graves.

system("tar xzf test.tgz") » tar: test.tgz: Cannot open: No such file or directory\ntar: Error is not recoverable: exiting now\ntar: Child returned status 2\ntar: Error exit delayed from previous errors\nfalse
result = `date`
result » "Sun Jun  9 00:08:50 CDT 2002\n"

El método Kernel::system ejecuta el comando recibido en un subproceso; devuelve true si el comando se encontró y ejecutó correctamente, y false en caso contrario. Si ocurre algún fallo, encontrarás el código de salida del subproceso en la variable global $?.

Un problema con system es que la salida del comando va al mismo lugar que la salida de tu programa, que puede ser algo que no desees. Para capturar la salida estándar de un subproceso, puedes usar los acentos graves, como en `date` en el ejemplo anterior. Recuerda que quizá podrías necesitar usar String#chomp en result para eliminar los caracteres especiales de la línea de edición.

Bien, ésto está bien para casos sencillos---podemos ejecutar otros procesos y obtener su código de salida. Pero muchas veces necesitamos un poco más de control sobre ésto. Podriamos querer entablar una conversación con el subproceso enviándole o recogiendo datos de él. El método IO.popen hace precisamente ésto. El método popen ejecuta un comando como subproceso y conecta las salida y entrada estándar al objeto Ruby IO. Se puede escribir en el objeto IO, y el subproceso leerá de él en su entrada estándar. Y cualquier cosa que él proceso escriba estará disponible en el programa mediante la lectura en el objeto IO.

Por ejemplo, una de las aplicaciones más útiles en nuestros sistemas es pig, un programa que lee palabras desde la entrada estándar y las imprime en Latín pig (o atinlay igpay). Podríamos usar este programa cuando queramos que nuestros programas Ruby den su salida de modo que un niño de 5 años no sea capaz de entenderla.

pig = IO.popen("pig", "w+")
pig.puts "ice cream after they go to bed"
pig.close_write
puts pig.gets
produce:
iceway eamcray afterway eythay ogay otay edbay

Éste ejemplo ilustra tanto la aparente simplicidad como la complejidad real que implica manejar procesos a través de tuberías. El código parece verdaderamente sencillo: abrir la tubería, escribir una frase, y leer la respuesta. Pero hay que prestar atención a que el programa pig no finaliza la salida que escribe. Nuestro intento inicial en este ejemplo, fue hacer pig.puts seguido pig.gets, provocando un bloqueo indefinido. El programa pig procesaba nuestra entrada, pero la respuesta nunca aparecía escrita en la tubería. Tuvimos que insertar la línea pig.close_write. Ésto manda un carácter de fin de fichero a la entrada estándar de pig, y la salida que estamos esperando se actualiza una vez que pig termina.

Existe un truco más con popen. Si el comando que le pasas es un signo menos (``--''), popen creará un nuevo intérprete Ruby. Tanto éste como el intérprete original continuarán justo después del valor devuelto por popen. El proceso orignal recibirá un objeto IO, mientras que el hijo recibirá nil.

pipe = IO.popen("-","w+")
if pipe
  pipe.puts "Get a job!"
  $stderr.puts "Child says '#{pipe.gets.chomp}'"
else
  $stderr.puts "Dad says '#{gets.chomp}'"
  puts "OK"
end
produce:
Dad says 'Get a job!'
Child says 'OK'

A mayores de popen, las llamadas tradicionales de Unix Kernel::fork , Kernel::exec , y IO.pipe estarán disponibles en las plataformas que las soporten. Siguiendo el convenio en los nombres de archivos de muchos métodos de IO y en Kernel::open , también se crearán subprocesos si añades un ``|'' como primer carácter del nombre del archivo (mira la introducción de la clase IO en la página 325 para más detalles). Cabe destacar que no puedes crear tuberías usando File.new ; ésto es solo para ficheros.

Hijos independientes

Algunas veces no desearemos estar tan pendientes de tantos detalles: nos gustaría poder crear un proceso con su propio entorno y seguir adelante con nuestras propias tareas, y pasado un tiempo comprobaríamos si ese proceso ha terminado. Por ejemplo, podríamos intentar separar una ordenación externa que tenga una duración considerable.

exec("sort testfile > output.txt") if fork == nil
# The sort is now running in a child process
# carry on processing in the main program

# then wait for the sort to finish Process.wait

La llamada a Kernel::fork devuelve el id del nuevo proceso al padre, y nil al hijo, por lo que en nuestro ejemplo, el hijo realiza la llamada a Kernel::exec y ejecuta sort. Un tiempo más tarde, hacemos una llamada a Process::wait , que espera a que sort termine (y devuelve su identificador de proceso).

Si prefieres que se te notifique cuando el hijo termina (en lugar de esperar activamente por él), puedes instalar un manejador de señales usando Kernel::trap (descrito en la página 427). A continuación ponemos un manejador para la señal SIGCLD, que es la señal enviada cuando sucede ``la muerte de un proceso hijo.''

trap("CLD") {
  pid = Process.wait
  puts "Child pid #{pid}: terminated"
  exit
}

exec("sort testfile > output.txt") if fork == nil

# do other stuff...

produce:
Child pid 31842: terminated

Bloques y subprocesos

IO.popen funciona con un bloque de un modo muy parecido al que lo hace File.open . Al pasar a popen un comando, como podría ser date, el bloque recibirá un objeto IO como parámetro.

IO.popen ("date") { |f| puts "Date is #{f.gets}" }
produce:
Date is Sun Jun  9 00:08:50 CDT 2002

El objeto IO se cerrará automáticamente cuando el código del bloque termine, tal y como ocurre con File.open .

Si asocias un bloque con Kernel::fork , el código en el bloque se ejecutará en un subproceso Ruby, y el padre continuará después del bloque.

fork do
  puts "In child, pid = #$$"
  exit 99
end
pid = Process.wait
puts "Child terminated, pid = #{pid}, exit code = #{$? >> 8}"
produce:
In child, pid = 31849
Child terminated, pid = 31849, exit code = 99

Un último detalle. ¿Por qué desplazamos 8 bits a la derecha el código de salida en $? antes de mostrarlo? Ésta es una característica de los sistemas Posix: los 8 bits menos significativos de un código de salida contienen la razón por la que el programa terminó, mientras que los 8 más significativos contienen el código de salida propiamente dicho.


Extraído del libro "Programming Ruby - The Pragmatic Programmer's Guide"
Copyright © 2000 Addison Wesley Longman, Inc. liberado bajo los términos de la Open Publication License V1.0.
La referencia está disponible en: download.