Search code examples
rubymultithreadingpopen3

Running multi-threaded Open3 call in Ruby


I have a large loop where I'm trying to run the call to Open3.capture3 in threads instead of running linearly. Each thread should run independently and there's no deadlock in terms of accessing data.

The issue is, the threaded version is so much slower and it hogs my CPU.

Here's an example of the linear program:

require 'open3'

def read(i)
  text, _, _ = Open3.capture3("echo Hello #{i}")
  text.strip
end

(1..400).each do |i|
  puts read(i)
end

And here's the threaded version:

require 'open3'
require 'thread'

def read(i)
  text, _, _ = Open3.capture3("echo Hello #{i}")
  text.strip
end

threads = []
(1..400).each do |i|
  threads << Thread.new do
    puts read(i)
  end
end

threads.each(&:join)

A Time comparison:

$ time ruby linear.rb
ruby linear.rb  0.36s user 0.12s system 110% cpu 0.433 total
------------------------------------------------------------
$ time ruby threaded.rb 
ruby threaded.rb  1.05s user 0.64s system 129% cpu 1.307 total

Solution

  • Each thread should run independently and there's no deadlock in terms of accessing data.

    Are you sure about that?

    threads << Thread.new do
      puts read(i)
    end
    

    Your threads are sharing stdout. If you look at your output, you'll see that you aren't getting any interleaved text output, because Ruby is automatically ensuring mutual exclusion on stdout, so your threads are effectively running in serial with a bunch of useless construction/deconstruction/switching wasting time.

    Threads in Ruby are only effective for parallelism if you're calling out to some Rubyless context*. That way the VM knows that it can safely run in parallel without the threads interfering with each other. Look at what happens if we just capture the shell output in the threads:

    threads = Array.new(400) { |i| Thread.new { `echo Hello #{i}` } }
    threads.each(&:join)
    # time: 0m0.098s
    

    versus serially

    output = Array.new(400) { |i| `echo Hello #{i}` }
    # time: 0m0.794s
    

    * In truth, it depends on several factors. Some VMs (JRuby) use native threads, and are easier to parallelize. Certain Ruby expressions are more parallelizable than others (depending on how they interact with the GVL). The easiest way to ensure parallelism is to run a single external command such as a subprocess or syscall, these generally are GVL-free.