A Detailed Look at Ruby 3’s New Features

Ruby 3 has been quite the buzz after the core committers of Ruby announced it at the NoRuKo Ruby “unconference” earlier this year. Ruby has had a tradition of releasing updates on Christmas. Ruby 3 has been on the charts for a really long time and has also made some really strong promises. A while back, the core team had announced that Ruby 3 will be released before the Tokyo Olympics. But since the Olympics got postponed to 2021, there was an air of uncertainty around whether Ruby 3 too will be postponed or not. To everyone’s delight, ‘Matz’ (aka  Yukihiro Matsumoto, chief designer of the Ruby language) announced in his presentation at NoRuKo earlier this year that Ruby 3 will not be delayed, citing that open source must continue to evolve, or it might die. So here we are, just a few weeks away from the full-fledged release of Ruby 3!

Ruby 3 promises some very exciting new updates, including support for multi-threading and improved type checking. A preview version was released earlier this year, and lots of Ruby developers got a chance to test out the new changes being brought in. We got our hands on it too, and we’re going to describe what we think of it!

We are going to cover the new additions and improvements in Ruby 3 in great detail, so feel free to navigate around the piece with these links:

How is Ruby 3.0 Different?

Ruby 2 vs Ruby 3

Typing

Parallel Execution

Scheduler

Improvements to existing features

Performance

Memory

Concurrency

Static Analysis

Ruby 3 Release Date

How is Ruby 3.0 Different?

Ruby 3’s main selling point is that it’s 3 times faster than Ruby 2. Matz and the team have been trying to bring in a lot of features in the latest version of Ruby, but their foremost priority has been to ensure that performance is improved drastically, and that backward compatibility is maintained. There are a whole bunch of new additions like type checking, ractor, and scheduler, along with improvements to the currently existing paradigms, like performance, fibers, memory, concurrency, and static analysis. We’ll take a deeper look at each one of them one-by-one.

Ruby 2 vs. Ruby 3

As Ruby 3 is going to be a whole new era in the timeline of the language, it brings a great number of updates. Also, it brings the question “Ruby 2 vs Ruby 3” in the scene for Ruby and Ruby on Rails developers all around the world. Let’s take a bird’s eye view of the major differences between the two versions before we dive in and analyze each improvement individually.

The list is long, and instead of continuing this way, let’s now dive deeper into individual updates and find out what importance they hold in the future of Ruby development!

Typing

Choosing between static and dynamic typing has always been an issue for programming languages. On the one hand, statically typed languages are suitable for larger projects, cutting down on flexibility, on the other hand, dynamically typed languages make building software easy, but increase the complexity in scaling it up. 

When Matz faced this issue as the language designer to choose between the two, he declared that Ruby 3 will support static type checking. This was around 4 years ago. Since then, the Ruby team has been working to build a foundation for the community to develop type checkers on. Now, with the release of Ruby 3, the ability to write type signatures for programs as well as standard libraries are being shipped as well. This has been made possible via RBS.

RBS is a new, type-signature language developed by the Ruby team themselves. It can be used to describe types and definitions of classes, methods, instance variables, mix-ins, and modules in Ruby. The RBS signatures are stored in .rbs files which are isolated from the Ruby code. You can consider the .rbs files equivalent to the .d.ts files in TypeScript. The main benefit of having the type logic separate is that it does not force you to modify your Ruby Code for it to be typed. This builds further on one of the main aims of the upgrade - backward compatibility.

Here’s how a sample .rbs file would look: 

# sig/dog.rbs

class Dog
  attr_reader id: String
  attr_reader name: String
  attr_reader age: Integer
  attr_reader owner: String

  def initialize: (id: String, name: String, age: Integer) -> void

  def adopt: (owner: String) -> void

end

The above example is written for a hypothetical class called Dog, which has an id, name, age, and an owner. The RBS file defines the data type for each one of these, and also defines the signature for the two methods, initialize and adopt, that will be later implemented in the class. This helps a reader understand the class and functionalities better.

While this might seem one of the most basic uses of a type declaration system, RBS can be used to define more complex paradigms, such as Duck Typing. Duck Typing is a programming style in which it is assumed that a certain type of object will respond to a certain set of methods. This style removes the dependency on inheritance, mixins, or interface implementations. The issue with this style is that it assumes that the called method is present in the object. On the whole, it defies the entire concept of static type declaration by making objects dynamic enough to behave correctly depending upon their environments. RBS handles this with the help of interface types. The interface type represents a set of methods that are independent of concrete classes or modules.

If we were to declare a method grow for all animal classes, which our Dog class would be a part of, this is how it would be defined in the .rbs file: 

interface _Growable
    # Defining the << operator which accepts an Integer
    def <<: (Integer) -> void
end
# Defining the common method
def grow: (_Growable) -> Integer

This is better than traditional Duck Typing as it ensures that documentation and editor plugins can expose the otherwise implicit interfaces with proper documentation.

Parallel Execution (Ractor)

One of the most exciting, yet experimental, features that Ruby 3 will ship with is Ractor, aka Ruby Actor Model, which will offer parallel computation capabilities to Ruby apps. This is an equivalent of service workers in JavaScript, which can be registered and de-registered as needed, and utilized to implement proper threading between processes.

One of the important features of Ractors is that they are thread-safe, meaning they are less prone to deadlocks and livelocks. They communicate via message passing, and they are substantially different from the conceptual threads.

Ractors can run in parallel, and there's always one main Ractor. If the main Ractor terminates, all other ractors terminate as well. The main difference between Ractors and threads is that a Ractor can house multiple threads, but only one thread can execute at once. This makes the overhead of creating a Ractor similar to that of creating a thread.

Also, unlike threads, Ractors don’t share everything. Most objects in Ruby are themselves unshareable objects, but some others are, like immutable, class/module objects, the Ractor object itself etc. These are shared among Ractors as and when needed.

Ractors can be created using the Ractor.new() call. Citing the official docs, here is how you can declare Ractors:

# Ractor.new with a block creates new Ractor
r = Ractor.new do
  # This block will be run in parallel
end

# You can name a Ractor with `name:` argument.
r = Ractor.new name: 'test-name' do
end

# and Ractor#name returns its name.
r.name #=> 'test-name'

One important thing to note is that if you pass in arguments to Ractor.new(), they become block parameters for the given block. But, the interpreter does not pass them as object references, rather as messages. Citing the official docs again here’s an example of passing in an argument to a Ractor.new() call:

r = Ractor.new 'ok' do |msg|
  msg #=> 'ok'
end
r.take #=> 'ok'

OR

r = Ractor.new do
  msg = Ractor.receive
  msg
end
r.send 'ok'
r.take #=> 'ok'

For inter-ractor communication, two kinds of messaging are supported: push type and pull type. In push-type communications, the sending Ractor knows about the receiving Ractor, but the receiving Ractor has no clue about the sender. Ractor#send() is used by sender Ractor, which is received by the receiver using Ractor.receive(). In pull-type communications, the scenario is just the opposite; the sender has no clue about the receiver, but the receiver knows the sender and takes the message from it. Ractor.yield(obj) is used by the sender to mark a message as up for grabs. Ractor#take() is then used by the receiver Ractor to grab the message.

Scheduler

The scheduler is another hot and experimental feature introduced in Ruby 3, which is allegedly going to be used to intercept blocking operations. This will further enhance concurrency, by introducing light-weight improvements, without changing existing code. Not much is known about Scheduler in detail, apart from the fact that it will probably be a wrapper for a gem like EventMachine or Async, and be referenced via Thread#scheduler. It will support many classes/methods, including Mutex#lock, Mutex#unlock, Mutex#sleep, Thread#join, etc. A test scheduler can be found at Async::Scheduler.

It is also being emphasized strongly by the Ruby team that Scheduler currently available in the preview version of Ruby 3 is highly experimental, and it might change in the final release.

Improvements to Existing Features

Apart from introducing the new long-awaited feature support in the latest version of the Ruby language, the authors have also worked on a lot of other areas, to ensure that a great developer experience is maintained. The two major focuses of Matz in the Ruby 3 transition, performance and backward-compatibility seem to have been pulled off perfectly in the update. With several improvements for performance optimization, Ruby 3 is bound to perform better on the benchmark tests.

Performance

Performance has been a very crucial point of focus in the latest update to Ruby 3. Some improvements in how the language functions have been made as an attempt to improve how fast the language performs. One of the key changes in this regard has been the enhancements in the Ruby JIT compiler.

Ruby 2.7 was released in an attempt to improve Ruby apps performance by fixing the issues in Ruby JIT compiler which was first introduced in Ruby 2.6. As it turned out, 2.7 couldn’t make any significant upgrades in the benchmarks. The team made a fallback on Ruby 3 to have production-ready JIT available for users, with improved security as well. 

Before we dive into how Ruby 3 has improved in JIT, let’s first analyze what JIT is, and what meaning it holds for a programming language.

JIT refers to Just In Time compilation scheme implemented in programming languages, that allows them to compile code during its execution, at run time, rather than before execution. More often than not, this includes source code or bytecode translation to machine code, which can then be directly executed on the machine.

JIT combines two traditional approaches of translation - Ahead of Time Compilation and Interpretation. It takes the best of two and presents a dynamic approach to compiling code. This means that the execution speed matches that of compiled code while is as flexible as interpretation, with minimal overhead.

JIT raises security concerns as well, as instead of converting and marking executable from source code beforehand, JIT dumps the executable binary into memory and executes it right away. This can leave room for attackers to sideload their binaries in the memory and trick the interpreter into executing their binaries rather than the compiled ones.

Ruby 3’s JIT promises to improve web apps’ performance by 50-500%! Also, a lot of security issues have already been tackled in Ruby 2.7, by leveraging the ability to mark regions of memory as executable, and revoking the permission to any other programs to create or modify files in these regions.

Memory

Memory has been another stronghold where a lot of work has been done in the latest version of the Ruby programming language. With improved garbage collector and python’s buffer-like API, Ruby 3 is set to see major advances in memory utilization.

Garbage Collection Upgrades

Ruby 2.6 has seen some major memory issues with Rails regarding memory management. A major chunk of it has been from Ruby’s garbage collection overheads. While new methods like incremental garbage collection have been around for quite some time, a recent yet unhighlighted feature that Ruby 2.7 came with was garbage compaction.

While the traditional garbage cleaning process is aimed at clearing redundant memory allocations and making memory available for use again, garbage compaction addresses a different issue. When several small objects are allocated memory across the heap, they might scatter around, and fragment the heap. The garbage compactor resolves this by grouping those scattered objects together at one place in the memory. This leaves enough room for heavier objects to be easily allocated, thereby utilizing the memory to its fullest.

Garbage compaction was an add-on in Ruby 2.7. You could invoke it using GC.compact. Ruby 3 makes this process entirely automatic! The compactor is invoked at suitable times to ensure that the memory remains defragmented throughout the execution of Ruby programs.

Experimental Memory View

While not much is known about this feature, in particular, it is expected to be an equivalent of the Buffer protocol in python and will allow extension libraries to exchange raw memory areas, such as a numeric array, or a bitmap image. These libraries will also be able to share metadata of the memory area, which can consist of the shape, the element form, etc.

Concurrency

Apart from memory and performance upgrades, concurrency is another domain that has been worked on in the latest Ruby update. Several features and improvements focusing on enhancing concurrency have been introduced in Ruby 3. Let’s take a look at some of the prominent ones:

Fibers

Fibers have been a ground-breaking addition to Ruby. Fibers are light-weight workers which closely resemble threads, but have been deliberately made different from threads to introduce additional benefits. Fibers are light-weight, and unlike threads, they eat much less memory. Fibers allow programmers to define code blocks which can be paused or resumed, just like threads, but are in total control of the programmers. This helps immensely in improving I/O handling. 

ioquatix, one of the core committers of Ruby demonstrates the use of Fibers in his Falcon Rack web server, which uses his async fibers under the hood. One of the biggest advantages that Falcon is successfully able to offer over generic Ruby webservers is that it does not get blocked on I/O. As programmers, it is needless to mention how crucial I/O delay is in benchmarking the speed and performance of any language’s runtime.

Guilds (or Ractors)

Having understood Fibers and Ractors individually, lets now connect the dots to form a more meaningful picture. Ractors (or Guilds, as called when ideated) are implemented using Threads and Fibers. Each Ractor consists of at least one thread, which in turn may contain multiple Fibers. Multiple Ractors can run in parallel, but only one thread in a Ractor can execute at a time, which is handled by the language runtime. Each thread’s Fibers are in absolute control of the programmer and can be started and stopped whenever needed. This adds a great touch of customizability to how parallel computations are coordinated and controlled in Ruby.

Static Analysis

Here’s the last update that we’re going to discuss in Ruby 3 that is crucial from a developer’s perspective. Static analysis is a fundamental tool in any language to eliminate silly errors on compile-time, and also improve code accessibility and documentation with the support of IDEs. The only downside to this is that it adds to the repetitions in code, as it depends mostly on type annotation. Ruby 3 brings in two major solutions for improving static analysis: 

Steep

Steep uses parallel files to store type annotations. This isolates the repetitive code and also does not force developers to modify their current codebase to accommodate static analysis. Let’s take an example to understand how Steep works.

Assuming we have the same class from the previous example:

class Dog
  attr_reader :id
  attr_reader :name
  attr_reader :age
  attr_reader :owner

  def initialize: (id: , name: , age: )
    @id = id,
    @name = name
    @age = age
  end

  def adopt(owner: )
    @owner = owner
  end

end

You can use the following command to scaffold out a generic dog.rbi type annotations file:

steep scaffold dog.rb > dog.rbi

This command will create a fresh, standard .rbi file for the Dog class:

class Dog
  @id: any
  @name: any
  @age: any
  @owner: any

  def initialize:  (id: any, name: any, age: any) -> any

  def adopt: (owner: any) -> any

end

The reason why we’re calling this file a standard one is because it assumes the type ‘any’ for all properties and methods. This is not helpful, as it does not define the exact type of data we’ll want to associate with the properties. To fix that, you can now update the types in the file manually according to your requirements:

class Dog
  @id: String
  @name: String
  @age: Integer
  @owner: String

  def initialize:  (id: String, name: String, age: Integer) -> any

  def adopt: (owner: String) -> any

end

Now you can check the types using the following command:

steep check dog.rb

Sorbet

While steep has been around for quite some time, Sorbet is the new buzz in the town with Ruby 3. Sorbet was a tool written in C++ by Stripe engineers who used it in production and was close-sourced. It has been open-sourced now and is recommended by Ruby authors as it offers a seamless integration experience for developers.

Sorbet focuses on inline type annotation, thereby removing the need for an additional file per class to accommodate type definitions. This, however, can force you to modify your code to include static analysis, but will always beat steep in terms of performance.

While these two have already been around for a while, Ruby 3’s prime focus is to unify them. It aims at bringing a system of type definition that allows any kind of type checkers to analyze and report type errors in files. This is the reason why RBS has been standardized as the primary type annotation system in Ruby 3. It paves the way for integrating type checkers like Steep and Sorbet together by making Ruby code independent of them.

Ruby 3 Release Date

Assuming that everything goes well, the all-new Ruby 3 will be released on December 25, 2020. Ruby 3 appears to be fully loaded with features that have been awaited by Ruby developers for a long time. While this update is set to fix a lot of shortcomings in the language, the Ruby team has failed to deliver on a few occasions. This will mark the beginning of a whole new era for Ruby developers and enthusiasts, with enhanced performance and additional features to build on Ruby’s expertise in scaffolding and scaling web apps quickly.

Wrapping Up

Summing up on the discussion, Ruby 3 is set to turn a lot of heads. With huge improvements in performance, memory management, static analysis, and coding standards, as well as the introduction of ground-breaking features (at least to Ruby developers) like Ractors, automatic schedulers, unified typing, and so on, the third version of the language is set to provide a generous boost to its name. Ruby has already been a go-to language for startups, Ruby 3 will hopefully push it in corporations as well.

Not to forget, Ruby 3 also prioritizes backward-compatibility for all of its changes. This would mean that no old codebase will have to be suddenly re-written all over again, and apps built on old Ruby versions will still function normally. However, starting from December 25th, the old versions of Ruby will start throwing a deprecated warning for modules that are in the process of being completely removed from the later versions to help developers make a smooth transition to the new and improved runtime.

The promises of three times faster language sound greatly ambitious, but it wouldn’t be a surprise if the team can pull it off. The core committers have been working for a very long time on these updates, and they have tried some of these out in the previous versions as well. So, hopefully, Ruby 3 will be an end to all the experiments that we’ve seen till now, and will bring forward everything we’ve all been waiting for!