Welcome to my personal website.
Salut! I'm a Ruby/Rust/Crystal developer with a devops background and leadership experience. You can also find me here: GitHub / Twitter / LinkedIn / Email.
UPDATE 2: My patch to the original parallel
macro made it into Crystal 0.25.0, which also provides proper documentation for this quite useful language feature.
UPDATE 1: I openend a PR to propose my changes to Crystal.
Concurrency can be achieved in Crystal by using Fibers. Communication between Fibers is handled via Channels. The documentation on these topics is quite comprehensive so I won’t go into detail here.
This post will focus on the parallel
macro, present one of its drawbacks when dealing with unhandled exceptions and introduce a solution: the parallel!
macro.
One useful tool that didn’t make it into the Crystal Book is the parallel
macro. It allows firing up and waiting for several concurrent jobs in a more succinct manner:
def say(word)
puts word
end
parallel(
say("a"),
say("b"),
say("c"),
)
The real beauty of this macro comes when you’re interested in capturing the return values of your concurrent jobs:
def say(word : String) : String
word
end
a, b, c =
parallel(
say("a"),
say("b"),
say("c"),
)
puts a, b, c
uninitialized
in parallel
Exceptions raised from Fibers don’t propagate to the main thread. Though there are ways to re-raise these exceptions, the parallel
macro doesn’t implement this.
For the same reason, the following code will get you in trouble:
def say(word : String) : String
raise Exception.new("boom")
word
end
a, b, c =
parallel(
say("a"),
say("b"),
say("c"),
)
puts a, b, c
This program will crash because of an Invalid memory access (signal 11)
.
The problem here lies in the usage of the a
, b
, c
variables on the last line, after the Fibers silently swallowed the exception. More specifically, the parallel
macro implementation (which is quite easy to read) has to define these variables before actually evaluating them. This is achieved by initially marking them as uninitialized
:
{% for job, i in jobs %}
%ret{i} = uninitialized typeof({{job}})
# ...
{% end %}
Yes - Crystal allows declaring uninitialized variables, and no - it’s probably not the best idea to use this unless you know what you’re doing. This is unsafe code.
Also note that placing checks before using the variable:
if a
puts a
end
…will not solve the issue and there’s no way to tell if a variable is initialized or not.
As long as your Fibers don’t raise unhandled exceptions, you can safely use the parallel
macro. The moment you start raising undhandled exceptions, you’ll want to replace the parallel
macro with some more explicit code:
def say(word : String) : String
raise Exception.new("boom")
word
end
channel = Channel(Nil).new
a : String? = nil
b : String? = nil
c : String? = nil
spawn do
a = say("a")
ensure
channel.send(nil)
end
spawn do
b = say("b")
ensure
channel.send(nil)
end
spawn do
c = say("c")
ensure
channel.send(nil)
end
3.times { channel.receive }
# In this case a nil-check is not needed, but for other method calls you might need it.
puts a, b, c
Notice how we had to compromise on the type of our variables here. Also, this solution is pretty verbose.
parallel!
macroI’m not a big fan of verbose (which is why I really enjoy Crystal and Ruby), so it was time for a macro to hide away all this code. Because I didn’t want to compromise on the variable type, I decided to re-raise the exceptions to the main thread.
Make way for the parallel!
macro:
macro parallel!(*jobs)
%channel = Channel(Exception | Nil).new
{% for job, i in jobs %}
%ret{i} = uninitialized typeof({{job}})
spawn do
begin
%ret{i} = {{job}}
rescue e : Exception
%channel.send e
else
%channel.send nil
end
end
{% end %}
{{ jobs.size }}.times do
%value = %channel.receive
if %value.is_a?(Exception)
raise %value
end
end
{
{% for job, i in jobs %}
%ret{i},
{% end %}
}
end
Note that prepending variable names with
%
inside macros will generate unique names for them, so that they won’t collide with your other variable names outside the macro implementation.
The implementation is mostly based on the original parallel
implementation, the difference being that exceptions from Fibers will be re-raised in the main thread.
The following code will raise Exception.new("boom")
even before the last line, instead of that nasty Invalid memory access
:
def say(word : String) : String
raise Exception.new("boom")
word
end
a, b, c =
parallel!(
say("a"),
say("b"),
say("c"),
)
puts a, b, c
If you enjoyed my blog post, please spread the news: