December 25, 2020
This blog is part of our Ruby 3.0 series.
For all Rubyists, 2020 was a special year. Why wouldn't it be? Ruby 2 was released in 2013. We have been using Ruby 2.x for almost 7 years and we have been waiting to see Ruby 3 get released. Finally, the wait is over now. Ruby 3.0.0 has been released. It's time to unwrap the gift box and see all the Ruby 3 features we got.
The number 3 is very significant in the Ruby 3 release. Be it release version number, making performance 3x faster, or the trio of core contributors(Matz, TenderLove, Koichi). Similarly, there were 3 major goals of Ruby 3: being faster, having better concurrency, and ensuring correctness.
One of the major focuses for Ruby 3 was the performance. In fact, the initial discussion for Ruby 3 was started around it. Matz had set a very ambitious goal of making Ruby 3 times faster.
Before discussing this, let's revisit Ruby's core philosophy.
"I hope to see Ruby help every programmer in the world to be productive, and to enjoy programming, and to be happy." - Matz
About Ruby 3x3, some asked whether the goal was to make Ruby the fastest language? The answer is no. The main goal of Ruby 3x3 was to make Ruby 3 times faster than Ruby 2.
“No language is fast enough.” - Matz
Ruby was not designed to be fastest and if it would have been the goal, Ruby wouldn't be the same as it is today. As Ruby language gets performance boost, it definitely helps our application be faster and scalable.
"In the design of the Ruby language we have been primarily focused on productivity and the joy of programming. As a result, Ruby was too slow." - Matz
There are two areas where performance can be measured: memory and CPU.
Some enhancements in Ruby internals have been made to improve the speed. The Ruby team has optimized the JIT(Just In Time) compiler from previous versions. Ruby MJIT compiler was first introduced in Ruby 2.6. Ruby 3 MJIT comes with better security and seems to improve web apps’ performance to a greater extent.
MJIT implementation is different from the usual JIT. When methods get called repeatedly e.g. 10000 times, MJIT will pick such methods which can be compiled into native code and put them into a queue. Later MJIT will fetch the queue and convert them to native code.
Please check JIT vs MJIT for more details.
Ruby 3 comes with an enhanced garbage collector. It has python's buffer-like API which helps in better memory utilization. Since Ruby 1.8, Ruby has continuously evolved in Garbage collection algorithms.
The latest change in garbage collection is Garbage Compaction. It was introduced in Ruby 2.7 where the process was a bit manual. But in version 3 it is fully automatic. The compactor is invoked aptly to make sure proper memory utilization.
The garbage compactor moves objects in the heap. It groups dispersed objects together at a single place in the memory so that this memory can be used even by heavier objects later.
Concurrency is one of the important aspects of any programming language. Matz feels that Threads are not the right level of abstraction for Ruby programmers to use.
“I regret adding Threads.” - Matz
Ruby 3 makes it a lot easier to make applications where concurrency is a major focus. There are several features and improvements added in Ruby 3 related to concurrency.
Fibers are considered a disruptive addition in Ruby 3. Fibers are light-weight workers which appear like Threads but have some advantages. It consumes less memory than Threads. It gives greater control to the programmer to define code segments that can be paused or resumed resulting in better I/O handling.
Falcon Rack web server uses Async Fibers internally. This allows Falcon to not block on I/O. Asynchronously managing I/O gives a great uplift to the Falcon server to serve requests concurrently.
Fiber Scheduler
is an experimental feature
added in Ruby 3.
It was introduced
to intercept blocking operations such as I/O.
The best thing is that
it allows lightweight concurrency
and
can easily integrate into the existing codebase without changing the original logic.
It's an interface
and
that can be implemented by creating a wrapper
for a gem like EventMachine
or Async
.
This interface design allows
separation of concerns between
the event loop implementation
and
the application code.
Following is an example to send multiple HTTP
requests concurrently using Async
.
require 'async'
require 'net/http'
require 'uri'
LINKS = [
'https://bigbinary.com',
'https://basecamp.com'
]
Async do
LINKS.each do |link|
Async do
Net::HTTP.get(URI(link))
end
end
end
Please check fibers for more details.
As we know Ruby’s global VM lock (GVL)
prevents most Ruby Threads
from computing in parallel.
Ractors
work around the GVL
to offer better parallelism.
Ractor is an Actor-Model like concurrent abstraction designed
to provide a parallel execution
without thread-safety concerns.
Ractors allows Threads in different Ractors to compute at the same time. Each Ractor has at least one thread, which may contain multiple fibers. In a Ractor, only a single thread is allowed to execute at a given time.
The following program returns the square root of a really large number. It calculates the result for both numbers in parallel.
# Math.sqrt(number) in ractor1, ractor2 run in parallel
ractor1, ractor2 = *(1..2).map do
Ractor.new do
number = Ractor.recv
Math.sqrt(number)
end
end
# send parameters
ractor1.send 3**71
ractor2.send 4**51
p ractor1.take #=> 8.665717809264115e+16
p ractor2.take #=> 2.251799813685248e+15
We need tests to ensure correctness of our program. However by its very nature tests could mean code duplication.
“I hate tests because they aren't DRY.” - Matz
To ensure the correctness of a program, static analysis can be a great tool in addition to tests.
The static analysis relies on
inline type annotations
which aren't DRY.
The solution
to address this challenge
is having .rbs
files parallel
to our .rb
files.
RBS is a language to describe the structure of a Ruby program. It provides us an overview of the program and how overall classes, methods, etc. are defined. Using RBS, we can write the definition of Ruby classes, modules, methods, instance variables, variable types, and inheritance. It supports commonly used patterns in Ruby code, and advanced types like unions and duck typing.
The .rbs
files are
something similar to .d.ts
files in TypeScript.
Following is a small example
of how a .rbs
file looks like.
The advantage of having a type definition is
that it can be validated against both implementation
and
execution.
The below example is pretty self-explanatory.
One thing we can note here though, each_post
accepts a block or returns an enumerator.
# user.rbs
class User
attr_reader name: String
attr_reader email: String
attr_reader age: Integer
attr_reader posts: Array[Post]
def initialize: (name: String,
email: String,
age: Integer) -> void
def each_post: () { (Post) -> void } -> void
| () -> Enumerator[Post, void]
end
Please check RBS gem documentation for more details.
Introducing type definition was a challenge because there is already a lot of existing Ruby code around and we need a tool that could automatically generate the type signature. Typeprof is a type analysis tool that reads plain Ruby code and generates a prototype of type signature in RBS format by analyzing the methods, and its usage. Typeprof is an experimental feature. Right now only small subset of ruby is supported.
“Ruby is simple in appearance, but is very complex inside, just like our human body.” - Matz
Let's see an example.
# user.rb
class User
def initialize(name:, email:, age:)
@name, @email, @age = name, email, age
end
attr_reader :name, :email, :age
end
User.new(name: "John Doe", email: '[email protected]', age: 23)
Output
$ typeprof user.rb
# Classes
class User
attr_reader name : String
attr_reader email : String
attr_reader age : Integer
def initialize : (name: String,
email: String,
age: Integer) -> [String, String, Integer]
end
In the 7-year period, the Ruby community has seen significant improvement in performance and other aspects. Apart from major goals, Ruby 3 is an exciting update with lots of new features, handy syntactic changes, and new enhancements. In this section, we will discuss some notable features.
“We are making Ruby even better.” - Matz
Previously one-line pattern matching
used the keyword in
.
Now it's replaced with =>
.
{ name: 'John', role: 'CTO' } in {name:}
p name # => 'John'
{ name: 'John', role: 'CTO' } => {name:}
p name # => 'John'
The
find pattern
was introduced in Ruby 2.7
as an experimental feature.
This is now part of Ruby 3.0
.
It is similar to pattern matching in
Elixir
or Haskell
.
users = [
{ name: 'Oliver', role: 'CTO' },
{ name: 'Sam', role: 'Manager' },
{ role: 'customer' },
{ name: 'Eve', city: 'New York' },
{ name: 'Peter' },
{ city: 'Chicago' }
]
users.each do |person|
case person
in { name:, role: 'CTO' }
p "#{name} is the Founder."
in { name:, role: designation }
p "#{name} is a #{designation}."
in { name:, city: 'New York' }
p "#{name} lives in New York."
in {role: designation}
p "Unknown is a #{designation}."
in { name: }
p "#{name}'s designation is unknown."
else
p "Pattern not found."
end
end
"Oliver is the Founder."
"Sam is a Manager."
"Unknown is a customer."
"Eve lives in New York."
"Peter's designation is unknown."
"Pattern not found."
This is another syntax enhancement that is optional to use. It enables us to create method definitions without end keyword.
def: increment(x) = x + 1
p increment(42) #=> 43
Sometimes while working on a non Rails app
I get undefined method except
.
The except
method was available only in Rails.
In Ruby 3 Hash#except
was
added to Ruby
itself.
user = { name: 'Oliver', age: 29, role: 'CTO' }
user.except(:role) #=> {:name=> "Oliver", :age=> 29}
This is again an experimental feature. This is a C-API that will allow extension libraries to exchange raw memory area. Extension libraries can also share metadata of memory area that consists of shape and element format. It was inspired by Python’s buffer protocol.
Arguments forwarding (...)
now supports leading arguments.
It is helpful in method_missing
,
where we need method name as well.
def method_missing(name, ...)
if name.to_s.end_with?('?')
self[name]
else
fallback(name, ...)
end
end
# frozen-string-literal: true
is used.Many other changes can be checked at Ruby 3 News for more details.
A lot of core libraries have been modified to fit the Ruby 3 goal needs. But this doesn't mean that our old applications will suddenly stop working. The Ruby team has made sure that these changes are backward compatible. We might see some deprecation warnings in our existing code. The developers can fix these warnings to smoothly transition from an old version to the new version. We are all set to use new features and get all new performance improvements.
With great improvements in performance, memory utilization, static analysis, and new features like Ractors and schedulers, we have great confidence in the future of Ruby. With Ruby 3, the applications can be more scalable and more enjoyable to work with. The coming 2021 is not just a new year but rather a new era for all Rubyists. We at BigBinary thank everyone who contributed towards the Ruby 3 release directly or indirectly.
Happy Holidays and Happy New Year folks!!!
If this blog was helpful, check out our full blog archive.