DEV Community

Davide Santangelo
Davide Santangelo

Posted on • Edited on

Performance of Exception Handling in Ruby

Intro

One of Ruby's key features is its exception handling mechanism, which allows developers to handle errors and exceptions in a clean and organized manner. However, using exceptions for error handling can have a negative impact on the performance of a Ruby application, especially if they are used excessively or inappropriately. In this article, we will examine the performance implications of using exceptions in Ruby and discuss some best practices for minimizing their impact on your application's performance.

How exceptions work in Ruby

In Ruby, exceptions are objects that represent an error or exceptional condition that occurs during the execution of a program. When an exception is raised, it is propagated up the call stack until it is caught and handled by an appropriate exception handler. If no exception handler is found, the program will terminate with an unhandled exception error.

Exceptions are raised using the raise keyword, which takes an optional message argument and an optional exception class argument. For example, the following code raises a RuntimeError with the message "Something went wrong":

raise "Something went wrong"
Enter fullscreen mode Exit fullscreen mode

You can also raise a specific exception class, such as ArgumentError:

raise ArgumentError, "Invalid argument"
Enter fullscreen mode Exit fullscreen mode

To handle exceptions, you can use the begin-rescue-end block, which allows you to specify a block of code that may raise an exception and a block of code that will handle the exception if it is raised. For example:

begin
  # code that may raise an exception
rescue
  # code to handle the exception
end
Enter fullscreen mode Exit fullscreen mode

You can also specify a specific exception class or multiple exception classes to rescue:

begin
  # code that may raise an exception
rescue StandardError
  # code to handle StandardError and its subclasses
rescue ArgumentError
  # code to handle ArgumentError and its subclasses
end
Enter fullscreen mode Exit fullscreen mode

Finally, you can use the ensure keyword to specify a block of code that will always be executed, regardless of whether an exception is raised or not:

begin
  # code that may raise an exception
rescue
  # code to handle the exception
ensure
  # code that will always be executed
end
Enter fullscreen mode Exit fullscreen mode

Performance implications of exceptions

Using exceptions for error handling can have a significant impact on the performance of a Ruby application, especially if they are used excessively or inappropriately. This is because raising and handling exceptions involves a significant amount of overhead, including creating and manipulating exception objects, unwinding the call stack, and executing exception handling code.

Here are some ways in which the use of exceptions can affect the performance of a Ruby application:

  • Object creation overhead: Every time an exception is raised, a new exception object is created and initialized with the appropriate message and exception class. This involves allocating memory and initializing the object, which can be expensive, especially if the exception is raised frequently.

  • Unwinding the call stack: When an exception is raised, the interpreter must unwind the call stack to find the appropriate exception handler. This involves traversing the call stack and checking each frame for an exception handler, which can be time-consuming and add significant overhead to the program.

  • Exception handling code: The code in the rescue block is executed every time an exception is raised and handled, which can add additional overhead to the program. If the exception handling code is complex or performs a lot of computations, it can further degrade the performance of the application.

  • Increased memory usage: Exceptions use more memory than traditional error handling mechanisms, such as returning error codes or using nil values to indicate an error. This is because exception objects are created and stored on the call stack, which can lead to increased memory usage and slower garbage collection.

  • Slower code execution: The overhead associated with raising and handling exceptions can slow down the overall execution of the program. This is especially noticeable in tight loops or in code that is called frequently.

To minimize the performance impact of exceptions in your Ruby application, it is important to use them appropriately and only when necessary. Here are some best practices for using exceptions in Ruby:

  • Use exceptions for exceptional situations: Exceptions should be used to handle truly exceptional situations, such as unexpected input, system failures, or other conditions that cannot be handled in a normal way. Do not use exceptions for control flow or as a substitute for traditional error handling mechanisms.

  • Avoid raising and handling exceptions in tight loops: Avoid raising and handling exceptions in tight loops or in code that is called frequently. This can significantly degrade the performance of the application.

  • Use specific exception classes: Use specific exception classes, rather than the generic StandardError class, to clearly communicate the nature of the error and make it easier to handle.

  • Avoid rescuing Exception: Do not rescue the Exception class, as this will catch all exceptions, including those that should not be handled, such as Interrupt and SystemExit. Instead, rescue specific exception classes or use a more general class, such as StandardError, which does not catch system-level exceptions.

  • Consider using other error handling mechanisms: In some cases, it may be more appropriate to use other error handling mechanisms, such as returning error codes or using nil values to indicate an error. This can be more efficient than using exceptions, especially in cases where the error handling code is called frequently or the overhead of raising and handling exceptions is significant.

Benchmark

Here is a simple benchmark example that compares the performance of using exceptions versus traditional error handling mechanisms in Ruby:

require "benchmark"

# Traditional error handling using return codes
def divide_using_return_codes(x, y)
  return nil if y == 0
  x / y
end

# Exception-based error handling
def divide_using_exceptions(x, y)
  raise ZeroDivisionError if y == 0
  x / y
rescue ZeroDivisionError
  nil
end

# Benchmark the two methods
n = 1_000_000

Benchmark.bm do |bm|
  bm.report("return codes") do
    n.times do
      divide_using_return_codes(1, 0)
    end
  end

  bm.report("exceptions") do
    n.times do
      divide_using_exceptions(1, 0)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
       user     system      total        real
return codes  0.044149   0.000053   0.044202 (  0.044223)
exceptions  0.508261   0.011618   0.519879 (  0.520129)
 => 
[#<Benchmark::Tms:0x000000014106b598
  @cstime=0.0,
  @cutime=0.0,
  @label="return codes",
  @real=0.04422300006262958,
  @stime=5.2999999999997494e-05,
  @total=0.04420199999999999,
  @utime=0.044148999999999994>,
 #<Benchmark::Tms:0x000000015486d8f0
  @cstime=0.0,
  @cutime=0.0,
  @label="exceptions",
  @real=0.5201290000695735,
  @stime=0.011618000000000003,
  @total=0.5198790000000001,
  @utime=0.5082610000000001>] 
Enter fullscreen mode Exit fullscreen mode

Apple Mac Book Pro 13-inch, M1, 2020 16GB RAM

The output of the benchmark will show the elapsed time for each method, allowing you to compare the performance of the two approaches. You can also modify the benchmark to test different scenarios, such as handling different types of errors or handling errors in tight loops.

Keep in mind that the performance implications of using exceptions will vary depending on the specific use case and the complexity of the error handling code. It is always a good idea to benchmark and profile your code to determine the most appropriate error handling mechanism for your specific needs.

Conclusion

While exceptions serve as a cornerstone for robust error handling in Ruby, their indiscriminate use can incur a significant performance penalty. The very mechanisms that empower exceptions, such as creating and raising them, traversing the call stack, and executing exception handlers, introduce overhead that can slow down your application. This performance degradation can manifest in various forms, from minor hiccups to severe latency issues, depending on the frequency and context of exception handling.

Fortunately, there are well-established practices to mitigate the performance impact of exceptions. The first line of defense lies in judicious use. Exceptions are ideal for signaling unexpected or exceptional circumstances that disrupt the normal flow of your program. Conversely, for well-defined error conditions that can be anticipated and gracefully handled through regular control flow, exceptions are unnecessary. By reserving exceptions for truly exceptional scenarios, you can significantly reduce their performance overhead.

Beyond selective use, the choice of exception classes also plays a crucial role. Employing specific exception classes tailored to the error condition at hand offers several advantages. First, it enhances code readability and maintainability by conveying the nature of the error more precisely. Second, it allows for more targeted exception handling, enabling you to implement specialized logic for different error types. This fine-grained approach can streamline exception handling and potentially improve performance.

Furthermore, techniques like exception chaining can be leveraged to construct a hierarchy of exceptions, providing context and facilitating more efficient handling. In essence, exception chaining allows you to create composite exceptions that encapsulate the root cause along with any subsequent exceptions that may have been triggered during handling. This approach can simplify debugging and potentially improve performance by enabling more concise exception handling logic.

By adhering to these principles, you can harness the power of exceptions for robust error management in your Ruby applications without compromising performance. Remember, exceptions are a valuable tool, but just like any tool, they require careful consideration and proper use to yield optimal results.

Top comments (10)

Collapse
 
faraazahmad profile image
Syed Faraaz Ahmad

Really informative article! thank you! I ran it on my machine and saw a big difference:

❯ ruby ruby/exceptions.rb --yjit
       user     system      total        real
return codes  0.048979   0.000000   0.048979 (  0.049000)
exceptions  1.322026   0.000516   1.322542 (  1.322560)
Enter fullscreen mode Exit fullscreen mode

With the other runs also returning more or less the same result

Collapse
 
daviducolo profile image
Davide Santangelo

thanks!

Collapse
 
mdesantis profile image
Maurizio De Santis • Edited

Hi Davide, very interesting!

I noticed that divide_using_exceptions method has a redundant line that can be removed, since x / y already raises ZeroDivisionError when y == 0:

def divide_using_exceptions(x, y)
  # raise ZeroDivisionError if y == 0 raised by next line anyway
  x / y
rescue ZeroDivisionError
  nil
end
Enter fullscreen mode Exit fullscreen mode

But the surprising part about that is that it performs way worse than the original divide_using_exceptions method!

> puts Benchmark.measure { 1_000_000.times { divide_using_exceptions 1, 0 } }
  1.590894   0.000000   1.590894 (  1.591697)

> puts Benchmark.measure { 1_000_000.times { divide_using_exceptions_without_first_line 1, 0 } }
  1.970392   0.000000   1.970392 (  1.972153)
Enter fullscreen mode Exit fullscreen mode

divide_using_exceptions_without_first_line is 20% slower than divide_using_exceptions even with a line less! Pretty counterintuitive to me, I'd expect it to perform the same as divide_using_exceptions, or even slightly faster O.o

Collapse
 
daviducolo profile image
Davide Santangelo

thanks Maurizio for your contribution! Very interesting!

Collapse
 
katafrakt profile image
Paweł Świątkowski

"Use exception for exceptional situations" is a good one. Especially when deciding what to use in such exceptional situation is not your responsibility (example: a database driver should not decide what to do if the database in unreachable). Because of that, in general, exceptions belong rather in library code than in application code.

Small nitpick to the blog post though: raise "some string" will raise RuntimeError, not StandardError.

Collapse
 
vishaldeepak profile image
VISHAL DEEPAK

Very informative. I think we should really be avoiding exception handling in tight loops, since in that case the run time might increase considerably

Collapse
 
daviducolo profile image
Davide Santangelo

yes I agree!

Collapse
 
danlee1996 profile image
Daniel Lee

Really great post. Ran into this problem this morning. Added a rescue block and it increased latency on a read heavy workload.

Something to add, just by adding a begin/rescue block (not even raising) can cause performance decreases.

Collapse
 
faraazahmad profile image
Syed Faraaz Ahmad

I'm curious, does it slow down only when raise is used or is it the same if I return an object of an Error class

Collapse
 
kikonen profile image
Kari Ikonen

When talking about "raise" in ruby, should mention also its' cousin "throw".