
Using threads in Ruby
- Tutorial
Many Ruby developers ignore threads , although this is a very useful tool. In this article, we will consider the creation of IO flows in Ruby and show how Ruby copes with flows in which a lot of computational operations take place. Let’s try to apply alternative Ruby implementations, as well as find out what results can be achieved using the DRb module . At the end of the article, we will see how these principles are used in various servers for Ruby on Rails applications .
Consider a small example:
If we need to access two servers, for example, to clear the cache, and we will call this function twice in succession:
then our program will work for 6 seconds.
We can speed up program execution if we use threads, for example, like this:
We created two threads, in each thread we turned to our server and the #join commands said that the main program (main thread) should wait for them to finish. Now our program runs successfully twice as fast in 3 seconds.
Consider a more complex example, in which we will try to get all the closed bugs and problems with GitHub about the Jekyll project through the provided API .
Since we don’t want to make a DoS attack on GitHub , we need to limit the number of simultaneous threads, plan them, launch them and collect the results as they become available.
The standard Ruby library does not provide ready-made tools for solving such problems, so I implemented my own FutureProof library for creating thread groups in Ruby , which I want to talk more about using.
Its principle is simple - you need to create a new group, specifying the maximum allowable number of simultaneous threads:
add tasks to it:
and ask for their meaning:
Thus, to get the information we need about the Jekyll project , the following code will be enough:
The implementation of the FutureProof library is based on the Queue class , which allows creating queues that are safe for working with multiple threads, ensuring that several threads do not write values to the queue simultaneously on top of each other, and do not consider the same value at the same time.
The library has also developed exception handling - if this happened during thread execution, thread_pool will still be able to return an array from the received values and will throw an exception only if the programmer tries to directly access a specific element of the array.
The implementation of thread groups is an attempt to bring Ruby's ability to work with threads to Java andjava.util.concurrent , from where part of the inspiration came from.
Using the FutureProof library, you can perform tasks that include working with IO streams, much more convenient and efficient. The library supports Ruby versions 1.9.3, 2.0, as well as Rubinius .
Considering the successful experience of improving the performance of the program using threads, we will conduct two tests, in one of which we will calculate the factorial 1000 twice in two times in succession, and in the other in parallel.
As a result, we got a rather unexpected result (using Ruby 2.0) - parallel execution took a second longer:
One of the reasons - we complicated the code by scheduling threads, and the second - Ruby at one time used only one core to execute this program. Unfortunately, the opportunity to force Ruby to use several cores for one ruby process is currently not provided.
Let me show you the results of executing the same script on jRuby 1.7.4:
As you can see, the result has become better. Because the measurement took place on a computer with two cores, and one of the cores was used only by 75%, the improvement was not 200%. But, therefore, on a computer with a large number of cores, we could do even more parallel threads and further improve our result.
jRuby is an alternative implementation of Ruby on the JVM , bringing very great features into the language itself.
When choosing the number of simultaneous threads, it is necessary to remember that without loss of performance we can arrange many threads that are involved in IO operations on one core. But we will lose a little in performance if the number of threads exceeds the number of cores in the case of computational operations.
In the case of the original Ruby implementation ( MRI ), it is recommended to use only one thread for computational operations. True parallelism with threads can only be achieved using jRuby and Rubinius .
As we now know, Ruby MRI for one ruby process (on Unix systems) can use resources of only one core at a time. One of the ways we can get around this shortcoming is to use process forks, like this:
The fork of the process, at the time of creation, copies the value of the result variable equal to 5, but the main process does not see further change of the variable inside the fork, so we needed to establish a message between the fork and the main process using IO.pipe .
This method is effective, but rather bulky and inconvenient. Using the DRb module for distribution programming, you can achieve more interesting results.
We use the DRb module for process synchronization
The DRb module is part of the standard Ruby library and is responsible for distribution programming capabilities. The basis of his idea is the ability to give access to one Ruby object to any computer on the network. The results of all manipulations with this object, its internal state, is visible to all connected computers, and is constantly synchronized. In general, the module features are very wide, and worthy of a separate article.
I came up with the idea of using Rinda :: TupleSpace tuples together with this DRb feature to create a Pthread module responsible for executing code in separate processes both on the main program computer and on other connected machines. Rinda :: TupleSpaceIt offers access to tuples by name and, like Queue objects , they allow writing and reading tuples to only one thread or process at a time.
Thus, a solution appeared that allows Ruby MRI to execute code on several cores:
As you may have noticed, the code that needs to be executed is served as a string, because in the case of procedures, DRb transfers to another process only a link to it, and for its execution uses the resources of the process that created this procedure. In order to get rid of the context of the main process, I submit the code to other processes as a string, and the values of the string variables in an additional dictionary. An example of how to connect additional machines to the code execution can be found on the project home page .
The Pthread library supports MRI versions 1.9.3 and 2.0.
Servers for Ruby on Rails and libraries for performing background tasks can be divided into two groups. The first uses forks to process user requests or perform background tasks - additional processes. Thus, using the MRI and these servers and libraries, we can process several requests at once, and perform several tasks simultaneously.
However, this method has a drawback. Forks of processes copy the memory of the process that created them, and in this way a Unicorn server with three “workers” can take 1GB of memory as soon as it starts. The same goes for libraries for performing background tasks, such as Resque . Puma
server creators forRuby on Rails took into account the features of jRuby and Rubinius , and released a server focused primarily on these two implementations. Unlike the same Unicorn , Puma uses threads that require much less memory to process requests simultaneously. Thus, Puma will be a great alternative when used in conjunction with jRuby or Rubinius . Therefore, the Sidekiq library is tripled in principle .
Threads are a very powerful tool that allows you to do several things at once, especially when it comes to long IO operations or calculations. In this article, we examined some of the possibilities and limitations of Ruby and its various implementations, and also used two third-party libraries to simplify working with streams.
Thus, the author of the article recommends playing around with Ruby , threads, and when starting future Rails projects, look towards alternative implementations - jRuby and Rubinius .
IO flows in Ruby
Consider a small example:
def call_remote(host)
sleep 3 # симулируем долгий запрос к серверу
end
If we need to access two servers, for example, to clear the cache, and we will call this function twice in succession:
call_remote 'host1/clear_caches'
call_remote 'host2/clear_caches'
then our program will work for 6 seconds.
We can speed up program execution if we use threads, for example, like this:
threads = []
['host1', 'host2'].each do |host|
threads << Thread.new do
call_remote "#{host}/clear_caches"
end
end
threads.each(&:join)
We created two threads, in each thread we turned to our server and the #join commands said that the main program (main thread) should wait for them to finish. Now our program runs successfully twice as fast in 3 seconds.
More flows are good and different
Consider a more complex example, in which we will try to get all the closed bugs and problems with GitHub about the Jekyll project through the provided API .
Since we don’t want to make a DoS attack on GitHub , we need to limit the number of simultaneous threads, plan them, launch them and collect the results as they become available.
The standard Ruby library does not provide ready-made tools for solving such problems, so I implemented my own FutureProof library for creating thread groups in Ruby , which I want to talk more about using.
Its principle is simple - you need to create a new group, specifying the maximum allowable number of simultaneous threads:
thread_pool = FutureProof::ThreadPool.new(5)
add tasks to it:
thread_pool.submit 2, 5 do |a, b|
a + b
end
and ask for their meaning:
thread_pool.values
Thus, to get the information we need about the Jekyll project , the following code will be enough:
require 'future_proof'
require 'net/http'
thread_pool = FutureProof::ThreadPool.new(5)
10.times do |i|
thread_pool.submit i do |page|
uri = URI.parse(
"https://api.github.com/repos/mojombo/jekyll/issues?state=close&page=#{page + 1}&per_page=100.json"
)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.request(Net::HTTP::Get.new(uri.request_uri)).body
end
end
thread_pool.perform
puts thread_pool.values[3] # [{"url":"https://api.github.com/repo ...
The implementation of the FutureProof library is based on the Queue class , which allows creating queues that are safe for working with multiple threads, ensuring that several threads do not write values to the queue simultaneously on top of each other, and do not consider the same value at the same time.
The library has also developed exception handling - if this happened during thread execution, thread_pool will still be able to return an array from the received values and will throw an exception only if the programmer tries to directly access a specific element of the array.
The implementation of thread groups is an attempt to bring Ruby's ability to work with threads to Java andjava.util.concurrent , from where part of the inspiration came from.
Using the FutureProof library, you can perform tasks that include working with IO streams, much more convenient and efficient. The library supports Ruby versions 1.9.3, 2.0, as well as Rubinius .
Threads and Computing
Considering the successful experience of improving the performance of the program using threads, we will conduct two tests, in one of which we will calculate the factorial 1000 twice in two times in succession, and in the other in parallel.
require 'benchmark'
factorial = Proc.new { |n|
1.upto(n).inject(1) { |i, n| i * n }
}
Benchmark.bm do |x|
x.report('sequential') do
10_000.times do
2.times do
factorial.call 1000
end
end
end
x.report('thready') do
10_000.times do
threads = []
2.times do
threads << Thread.new do
factorial.call 1000
end
end
threads.each &:join
end
end
end
As a result, we got a rather unexpected result (using Ruby 2.0) - parallel execution took a second longer:
user system total real
sequential 24.130000 1.510000 25.640000 (25.696196)
thready 24.600000 2.420000 27.020000 (26.877708)
One of the reasons - we complicated the code by scheduling threads, and the second - Ruby at one time used only one core to execute this program. Unfortunately, the opportunity to force Ruby to use several cores for one ruby process is currently not provided.
Let me show you the results of executing the same script on jRuby 1.7.4:
user system total real
sequential 33.180000 0.690000 33.870000 (33.090000)
thready 37.820000 3.830000 41.650000 (24.333000)
As you can see, the result has become better. Because the measurement took place on a computer with two cores, and one of the cores was used only by 75%, the improvement was not 200%. But, therefore, on a computer with a large number of cores, we could do even more parallel threads and further improve our result.
jRuby is an alternative implementation of Ruby on the JVM , bringing very great features into the language itself.
When choosing the number of simultaneous threads, it is necessary to remember that without loss of performance we can arrange many threads that are involved in IO operations on one core. But we will lose a little in performance if the number of threads exceeds the number of cores in the case of computational operations.
In the case of the original Ruby implementation ( MRI ), it is recommended to use only one thread for computational operations. True parallelism with threads can only be achieved using jRuby and Rubinius .
Process Level Concurrency
As we now know, Ruby MRI for one ruby process (on Unix systems) can use resources of only one core at a time. One of the ways we can get around this shortcoming is to use process forks, like this:
read, write = IO.pipe
result = 5
pid = fork do
result = result + 5
Marshal.dump(result, write)
exit 0
end
write.close
result = read.read
Process.wait(pid)
puts Marshal.load(result)
The fork of the process, at the time of creation, copies the value of the result variable equal to 5, but the main process does not see further change of the variable inside the fork, so we needed to establish a message between the fork and the main process using IO.pipe .
This method is effective, but rather bulky and inconvenient. Using the DRb module for distribution programming, you can achieve more interesting results.
We use the DRb module for process synchronization
The DRb module is part of the standard Ruby library and is responsible for distribution programming capabilities. The basis of his idea is the ability to give access to one Ruby object to any computer on the network. The results of all manipulations with this object, its internal state, is visible to all connected computers, and is constantly synchronized. In general, the module features are very wide, and worthy of a separate article.
I came up with the idea of using Rinda :: TupleSpace tuples together with this DRb feature to create a Pthread module responsible for executing code in separate processes both on the main program computer and on other connected machines. Rinda :: TupleSpaceIt offers access to tuples by name and, like Queue objects , they allow writing and reading tuples to only one thread or process at a time.
Thus, a solution appeared that allows Ruby MRI to execute code on several cores:
Pthread::Pthread.new queue: 'fact', code: %{
1.upto(n).inject(1) { |i, n| i * n }
}, context: { n: 1000 }
As you may have noticed, the code that needs to be executed is served as a string, because in the case of procedures, DRb transfers to another process only a link to it, and for its execution uses the resources of the process that created this procedure. In order to get rid of the context of the main process, I submit the code to other processes as a string, and the values of the string variables in an additional dictionary. An example of how to connect additional machines to the code execution can be found on the project home page .
The Pthread library supports MRI versions 1.9.3 and 2.0.
Concurrency in Ruby on Rails
Servers for Ruby on Rails and libraries for performing background tasks can be divided into two groups. The first uses forks to process user requests or perform background tasks - additional processes. Thus, using the MRI and these servers and libraries, we can process several requests at once, and perform several tasks simultaneously.
However, this method has a drawback. Forks of processes copy the memory of the process that created them, and in this way a Unicorn server with three “workers” can take 1GB of memory as soon as it starts. The same goes for libraries for performing background tasks, such as Resque . Puma
server creators forRuby on Rails took into account the features of jRuby and Rubinius , and released a server focused primarily on these two implementations. Unlike the same Unicorn , Puma uses threads that require much less memory to process requests simultaneously. Thus, Puma will be a great alternative when used in conjunction with jRuby or Rubinius . Therefore, the Sidekiq library is tripled in principle .
Conclusion
Threads are a very powerful tool that allows you to do several things at once, especially when it comes to long IO operations or calculations. In this article, we examined some of the possibilities and limitations of Ruby and its various implementations, and also used two third-party libraries to simplify working with streams.
Thus, the author of the article recommends playing around with Ruby , threads, and when starting future Rails projects, look towards alternative implementations - jRuby and Rubinius .