Skip to content

A short ruby diversion: cost of flow control under Ruby

A couple days ago I decided to finally get back to working on threach to try to deal with problems it had — essentially, it didn’t deal well with non-local exits due to calls to break or even something simple like a NoMethodError.

[BTW, I think I managed it. As near as I can tell, threach version 0.4 won’t deadlock anymore]

Along the way, while trying to figure out how threads affect the behavior of different non-local exits, I noticed that in some cases there was still work being done by one or more threads long after there was an exception raised.

I re-discovered something that a lot of people already know: raise/rescue under MRI is slow, and under JRuby can be unbearably slow. How slow?

Let’s look at four simple blocks that exercise four different block exit strategies: break, catch and throw, raise with the normal single (or zero) arguments, as well as the three-argument version of raise.

Simple break Catch/Throw
 range.each do |i|   break end       
 catch(:benchmarking) do    range.each do |i|    throw(:benchmarking)  end end       
Raise (1 arg) Raise (3 args)
  begin    range.each do |i|      raise StandardError    end  rescue   # do nothing  end      
 begin   range.each do |i|     raise StandardError, :hi, nil   end rescue  # do nothing end       

In each case, we immediately exit the block without doing any work; the idea is to measure how long it takes to break out for each case.

So….let’s run them each 100K times and see what happens, shall we? Times are in seconds, averaged over two runs.

Ruby 1.8 Ruby 1.9 JRuby JRuby –1.9
break 0.12 0.07 0.29 0.21
catch/throw 0.35 0.28 0.64 0.48
raise (1 arg) 1.78 2.10 26.60 22.06
raise (3 arg) 1.85 2.13 0.45 0.45

The first thing to note is that this is 100K iterations. Three of the strategies are fast enough that you’d have to work really, really hard to notice them.

In terms of speed, raise (3 args), catch/throw, and break are fast enough that you shouldn’t bother worrying about them (although you should choose the method that makes your code easy to understand).

The second things to note is Holy Camoli! JRuby is slow there!

This Jira ticket tells the tale: The creation of the backtrace is very, very expensive for JRuby. That nil at the end of the raise (3 args) call suppresses the creation of that backtrace, so the speed is fine.

Three things worth saying here:

  • If you’re using raise/rescue for flow control, you’re already doing it wrong. Reserve exceptions for, well, exceptional conditions that are only going to be raised once or twice, not all the time.
  • If you’re writing code that, for some ungodly reason, is planning on raising a crapload of exceptions, use the three-arg version. I’m looking at you, gem authors.
  • If you’re writing your code without worrying about how it will work under multiple threads, well, please don’t do that. Everyone has multi-core systems these days, and it’s silly to not be able to use them. Plus, counting on Matz to never move to a VM with real threads is a big gamble.