skip to content
A logo Linell asked a nice AI to create. Linell Bonnette

Ruby vs. Elixir vs. Go: Concurrency Review

A review of how concurrency works in both Elixir and Go

I’ve spent most of my professional career building different versions of ETL systems and event sourcing systems, but it’s been almost entirely in Ruby. The use of concurrency in these kind of systems is pretty straightforward: we want to be able to process as much data as possible as quickly as possible. In Ruby, my go-to concurrency library is Sidekiq, although plain old ActiveJob and SolidQueue have been my choices lately.

While Ruby has been the predominat language at work, I’ve done a fair amount of exploration into using Elixir for some of my side projects. I’ve always been impressed by how positively people talk about the language, and in particular how much they like the concurrency model. My first impressions were very positive, and I’ve been eager for years for the chance to use it in a real-world project.

In a recent conversation, I was asked about the concurrency model of Elixir. I stumbled a bit, realizing that I didn’t actually remember how Elixir’s concurrency worked or how that compared to my typical Sidekiq-based approach. The conversation was actually centered around Go, and it made me realize that I also wasn’t quite sure how Go’s concurrency model worked. I learned all about threads and processes and forks way back in college, so I should know, right?

🤦 Oof. So let’s fix that.

Ruby

I’ve been working with Ruby for a long time, but I’ve never really had to think about concurrency past “let’s send this to Sidekiq”. I’ve done a few experiments, and I have implemented thread-based concurrent tool calling in AI-agents, but that’s all I can remember.

The first refresher is concurrency versus parallelism. Concurrency is where multiple tasks are in progress at overlapping times, but not necessarily at the same time. Parallelism is where multiple tasks are in progress at the exact same time. Note that the global interpreter lock (GIL)1 prevents true parallelism, but it does allow for concurrency (and there are other Ruby implementations that don’t have a GIL, too).

You can use threads, fibers, processes, and ractors.

Primitives

Threads

Ruby threads are mapped to native OS threads, and the operating system handles the scheduling and the context switching.

threads = []
3.times do |i|
threads << Thread.new { puts "Thread #{i}" }
end
threads.each(&:join)

Fibers

Fibers are lightweight, user-managed coroutines. Coroutines are lightweight, managed at the user level, often within a single thread. In comparison, threads are heavyweight, relying on the operating system for scheduling and context switching.

fiber = Fiber.new do
3.times do |i|
puts "Fiber #{i}"
Fiber.yield
end
end
3.times { fiber.resume }

Processes

Processes are forked from the current process, and they are isolated from the parent process. They don’t share memory, and require explicit inter-process communication.

3.times do |i|
pid = fork do
puts "Process #{i}"
end
Process.wait(pid)
end

Ractors

Ractors provide actor-model concurrency, where each actor is a separate entity that can receive and send messages to other actors.

ractors = 3.times.map do |i|
Ractor.new(i) do |n|
puts "Ractor #{n}"
end
end
ractors.each(&:take)

These do allow for true parallelism.

Sidekiq

That all makes sense, but in real life, I’m not using threads, fibers, processes, or ractors directly very often. I’m using Sidekiq. So what does Sidekiq do?

Sidekiq uses a single process with multiple threads, the number of which is configurable. Each thread handles its own fetching and processing of jobs, so if you’ve configured it to run ten threads then you’ll be able to process up to ten jobs simultaneously. You can scale this both vertically, by adding more threads per process, and horizontally, by adding more processes.

Note that SolidQueue is pretty similar, with the difference mostly being that instead of using Redis to store jobs, it uses your application database.

Elixir

Elixir is built on top of the BEAM VM2, which is a virtual machine that is designed to be used for concurrent programming. The VM manages lightweight processes, which aren’t OS threads, but user-level processes. Each process is an actor, with its own memory and state and communication is done exclusively via asynchronous message passing.

Elixir just makes it very easy to use these processes, including great support for dealing with failures and retries. It’s this fault tolerance that really seems to make people love Elixir.

I’m sure you noticed that Elixir’s concurrency model sounds a lot like Ruby’s ractors.

tasks = for i <- 0..2 do
Task.async(fn -> IO.puts("Task #{i}") end)
end
Enum.each(tasks, &Task.await/1)

Go

Go uses goroutines and channels for concurrency. Goroutines are much lighter than OS threads and are managed by the Go runtime. Communication between goroutines is done via channels, which are typed conduits for passing data3. Unlike Elixir, fault tolerance isn’t “built in” but error handling is explicit and manual, which is a bit more verbose but also more explicit.

package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("Goroutine %d\n", n)
}(i)
}
wg.Wait()
}

Conclusion

The tl;dr is:

  • Ruby is best for I/O-bound concurrency and web applications where simplicity is valued. It’s not that you can’t do concurrency, but if concurrency is your goal there may be better choices.
  • Elixir is great for building fault-tolerant, highly concurrent, and distributed systems that uses the actor model and the BEAM VM for reliability and scalability.
  • Go offers a pragmatic, high-performance approach that is well suited for scalable servers and systems programming, although it requires more explicit error handling.

Footnotes

  1. The GIL and MRI

  2. BEAM (Erlang VM)

  3. Communicating Sequential Processes