Why are subshells slow?

I have noticed that executing subshells makes a program very slow. For example, here code 1 runs way slower than code 2:

1.

#!/usr/bin/env ruby
$-v = true
require 'io/console'

unless STDOUT.tty?
	res = [40, 40].freeze
	IO.tap { |x| x.undef_method(:winsize) }.tap { |x| x.define_method(:winsize) { res } }
end

i = 0

while true
	i += 1
	h, w = STDOUT.winsize
	print `clear`, ?\n.freeze.*(h / 2), Time.new.strftime("%H:%M:%S:%2N::#{i}").center(w)
end

2.

#!/usr/bin/env ruby
$-v = true
require 'io/console'

unless STDOUT.tty?
	res = [40, 40].freeze
	IO.tap { |x| x.undef_method(:winsize) }.tap { |x| x.define_method(:winsize) { res } }
end

i = 0

while true
	i += 1
	h, w = STDOUT.winsize
	print "\e[2J\e[H\e[3J", ?\n.freeze.*(h / 2), Time.new.strftime("%H:%M:%S:%2N::#{i}").center(w)
end

Preview

Explanation

The program shows the current time, and the ::n shows the iteration count.


What I wanted to say is that executing commands in a subshell makes it a lot slower than doing that same thing in Ruby. Here clear can be replaced with \e[2J\eH\e[3J which works 100 times faster.

Why is executing commands in the subshell way slower?

I have noticed this a year ago when my friend was telling me to use clear instead of fancy \e[2J\e[H\e[3J ANSI escape sequence. I told him it’s always slower to execute clear because:

  1. Ruby has to create a new shell and then execute the command.

  2. The shell even has to find the binary from the exported PATH every time it re-executes in the loop.

  3. Also, the Linux Kernel has to create a brand new process table for that process, which also increases the process count like so. Each time the loop executes clear, a new process is created.

Here’s a program (p.rb) to demonstrate that new process is created with every single iteration by 1.rb. The program p.rb just looks at the /proc/ directory. Take a look at this animation. The 1.rb and 2.rb are are same as in the question.

Here 1.rb is creating new processes while 2.rb doesn’t.
Here’s the code of p.rb:

#!/usr/bin/env ruby
$-v = true
max, anims = 0, %w(| / - \\)

while true
        pids = Dir['/proc/[0-9]*'.freeze].map! { |x| x.split(?/)[-1].to_i }
        max = pids.max if max < pids.max
        print "\e[1;33m #{anims.rotate![0]} #{max}\e[0m\r"
        sleep 0.05
end

Point 3 will happen for things like shell’s cat vs. IO.read like in PTY.spawn('cat /proc/self/comm')[2] vs. IO.read('/proc/self/comm') where IO.read(...) will be faster and won’t create new processes. The same will happen if you do something like Open3.popen3('/usr/bin/echo something').

On the other hand point 3 makes it harder to maintain a server running such server because the process count will be in millions!

So it’s recommended to stick with what ruby offers!


These are some reasons that came to my mind. I appreciate replies!