loading...

Introduction to Concurrency Models with Ruby. Part II

exaspark profile image exAspArk ・9 min read

Introduction to concurrency models with Ruby. Part II

In the second part of our series we will take a look at more advanced concurrency models such as Actors, Communicating Sequential Processes, Software Transactional Memory and of course Guilds – a new concurrency model which may be implemented in Ruby 3.

If you haven’t read our first post in the series, I’d definitely recommend reading it first. There I described Processes, Threads, GIL, EventMachine and Fibers which I’ll be referring to in this post.

Actors, CSP, STM, Guilds

Actors

Actors are concurrency primitives which can send messages to each other, create new Actors and determine how to respond to the next received message. They keep their own private state without sharing it, so they can affect each other only through messages. Since there is no shared state, there is no need for locks.

Do not communicate by sharing memory; instead, share memory by communicating.

Erlang and Scala both implement the Actor model in the language itself. In Ruby, Celluloid is one of the most popular implementations. Under the hood, it runs each Actor in a separate thread and uses fibers for every method invocation to avoid blocking methods while waiting for responses from other Actors.

Here is a basic example of Actors with Celluloid:

# actors.rb
require 'celluloid'
class Universe
  include Celluloid
  def say(msg)
    puts msg
    Celluloid::Actor[:world].say("#{msg} World!")
  end
end
class World
  include Celluloid
  def say(msg)
    puts msg
  end
end
Celluloid::Actor[:world] = World.new
Universe.new.say("Hello")
$ ruby actors.rb
Hello
Hello World!

Pros:

  • No manual multithreaded programming and no shared memory mean almost deadlock-free synchronization without explicit locks.

  • Similarly to Erlang, Celluloid makes Actors fault-tolerant, meaning that it’ll try to reboot crashed Actors with Supervisors.

  • The Actor model is designed to address the problems of distributed programs, so it is great for scaling across multiple machines.

Cons:

  • Actors may not work if a system needs to use shared state or you need to guarantee a behavior that needs to occur in a specific order.

  • Debugging can be tricky – imagine following system flow through multiple Actors, or what if some of the Actors mutate the message? Remember that Ruby is not an immutable language, right?

  • Celluloid allows to build complex concurrent systems much quicker compared to dealing with threads manually. But it does it with a runtime cost (e.g. 5x slower and 8x more memory).

  • Unfortunately, Ruby implementations are not that great at using distributed Actors across multiple servers. For example, DCell, which uses 0MQ, is still not production ready.

Examples:

  • Reel – event-based web server, which works with Celluloid-based apps. Uses one Actor per connection. Can be used for streaming or WebSockets.

  • Celluloid::IO – brings Actors and evented I/O loops together. Unlike EventMachine, it allows to use as many event loops per process as you want by creating multiple Actors.

Communicating Sequential Processes

Communicating Sequential Processes (CSP) is a paradigm which is very similar to the Actor model. It’s also based on message-passing without sharing memory. However, CSP and Actors have these 2 key differences:

  • Processes in CSP are anonymous, while actors have identities. So, CSP uses explicit channels for message passing, whereas with Actors you send messages directly.

  • With CSP the sender cannot transmit a message until the receiver is ready to accept it. Actors can send messages asynchronously (e.g. with async calls in Celluloid).

CSP is implemented in such programming languages as Go with goroutines and channels, Clojure with the core.async library and Crystal with fibers and channels. For Ruby, there are a few gems which implement CSP. One of them is the Channel class implemented in concurrent-ruby library:

# csp.rb
require 'concurrent-edge'
array = [1, 2, 3, 4, 5]
channel = Concurrent::Channel.new
Concurrent::Channel.go do
  puts "Go 1 thread: #{Thread.current.object_id}"
  channel.put(array[0..2].sum) # Enumerable#sum from Ruby 2.4
end
Concurrent::Channel.go do
  puts "Go 2 thread: #{Thread.current.object_id}"
  channel.put(array[2..4].sum)
end
puts "Main thread: #{Thread.current.object_id}"
puts channel.take + channel.take
$ ruby csp.rb
Main thread: 70168382536020
Go 2 thread: 70168386894280
Go 1 thread: 70168386894880
18

So, we basically ran 2 operations (sum) in 2 different threads, synchronized the results and calculated the total value in the main thread. Everything is done through the Channel without any explicit locks.

Under the hood, each Channel.go runs in a separate thread from the thread pool, which increases its size automatically if there is no free thread left. In this case, using this model is useful during blocking I/O operations, which release GIL (see the previous post to find more information). On the other hand, core.async in Clojure, for example, uses a limited number of threads and tries to "park" them, but this approach may be a problem during I/O operations which may block out any other work.

Pros:

  • CSP Channels can only hold maximum one message, which makes it much easier to reason about. While with the Actor model it’s more like having a potentially infinite mailbox with messages.

  • Communicating Sequential Processes allow you to avoid coupling between the producer and the consumer by using channels; they don’t have to know about each other.

  • In CSP messages are delivered in the order they were sent.

Clojure may eventually support the actor model for distributed programming, paying the price only when distribution is required, but I think it is quite cumbersome for same-process programming. Rich Hickey

Cons:

  • CSP is generally used on a single machine, it’s not that great as the Actor model for distributed programming.

  • In Ruby, most of the implementations don’t use M:N threading model, so each “goroutine” actually uses a Ruby thread, which is equal to an OS thread. This means that Ruby “goroutines” are not so lightweight.

  • Using CSP with Ruby in not so popular. So, there are no actively developed, stable and battle-tested tools.

Examples:

  • Agent – another Ruby implementation of CSP. This gem runs each go-block in a separate Ruby thread as well.

Software Transactional Memory

While Actors and CSP are concurrency models which are based on message passing, Software Transactional Memory (STM) is a model which uses shared memory. It’s an alternative to lock-based synchronization. Similarly to DB transactions, these are the main concepts:

  1. Values within a transaction can be changed, but these changes are not visible to others until the transaction is committed.

  2. Errors that happened in transactions abort them and rollback all the changes.

  3. If a transaction can’t be committed due to conflicting changes, it retries until it succeeds.

The concurrent-ruby gem implements TVar, which is based on Clojure’s Refs. Here is an example, which implements money transferring from one bank account to another:

# stm.rb
require 'concurrent'
account1 = Concurrent::TVar.new(100)
account2 = Concurrent::TVar.new(100)
Concurrent::atomically do
  account1.value -= 10
  account2.value += 10
end
puts "Account1: #{account1.value}, Account2: #{account2.value}"
$ ruby stm.rb
Account1: 90, Account2: 110

TVar is an object which contains a single value. Together with atomically they implement data mutation in transactions.

Pros:

  • Using STM is much simpler compared to lock-based programming. It allows to avoid deadlocks, simplifies reasoning about concurrent systems since you don’t have to think about race conditions.

  • Much easier to adapt as you don’t need to restructure your code as it’s required with Actors (use models) or CSP (use channels).

Cons:

  • Since STM relies on transaction rollbacks, you should be able to undo the operation in the transaction at any point in time. In practice, it’s difficult to guarantee if you make I/O operations (e.g. POST HTTP requests).

  • STM doesn’t scale well with Ruby MRI. Since there is GIL, you can’t utilize more than a single CPU. At the same time, you also can’t use the advantage of running concurrent I/O operations in threads because it’s hard to undo such operations.

Examples:

  • TVar from concurrent-ruby – implements STM and also contains some benchmarks which compare lock-based implementations and STM in MRI, JRuby and Rubinius.

Guilds

Guild is a new concurrency model proposed for Ruby 3 by Koichi Sasada – a Ruby core developer who designed the current Ruby VM (virtual machine), fibers and GC (garbage collector). These are the main points which lead to creating Guilds:

  • The new model should be compatible with Ruby 2 and allow better concurrency.

  • Forcing immutable data structures similar to Elixir may be unacceptably slow since Ruby utilizes many “write” operations. So, it’s better to copy shared mutable objects similarly to Racket (Place) but the copying must be fast for this to be successful.

  • If it’s necessary to share mutable objects, there should be special data structures similar to Clojure (e.g. STM).

These ideas resulted in the following main concepts of Guilds:

  • A Guild is a concurrency primitive which may contain multiple threads, which may contain multiple fibers.

  • Only a Guild-owner can access its mutable objects, so there is no need to use locks.

  • Guilds can share data by copying objects or by transferring membership (“moving” objects) from one Guild to another.

  • Immutable objects can be accessed from any Guild by using a reference without copying. E.g. numbers, symbols, true, false, deeply frozen objects.

So, our example of money transferring from one bank account to another with Guilds may look like:

bank = Guild.new do
  accounts = ...
  while acc1, acc2, amount, channel = Guild.default_channel.receive
    accounts[acc1].balance += amount
    accounts[acc2].balance -= amount
    channel.transfer(:finished)
  end
end

channel = Guild::Channel.new
bank.transfer([acc1, acc2, 10, channel])
puts channel.receive
# => :finished

All data about account balances are stored in a single Guild (bank), so, only this Guild is responsible for data mutations which can be requested through channels.

Pros:

  • No mutable shared data between Guilds means there is no need for locking mechanisms, so there are no deadlocks. Communication between Guilds is designed to be safe.

  • Guilds encourage using immutable data structures since they are the fastest and the easiest way to share data across multiple Guilds. Start freezing as much data as possible now, for example, by adding # frozen_string_literal: true at the beginning of your files.

  • Guilds are fully compatible with Ruby 2, meaning that your current code will simply run within a single Guild. You are not required to use immutable data structures or make any changes in your code.

  • At the same time, Guilds enable better concurrency with MRI. It’ll finally allow us to use multiple CPUs within a single Ruby process.

Cons:

  • It’s too early to make predictions about performance, but communicating and sharing mutable objects between Guilds will probably have a bigger overhead compared to threads.

  • Guilds are more complex concurrency primitives because they allow using multiple concurrency models at once. For example: CSP for inter-Guild communication through channels, STM with special data structures for sharing mutable data for better performance, multi-threaded programming within a single Guild, etc.

  • Even though running multiple Guilds within a single process will be cheaper compared to running multiple processes from a resource usage point of view, Guilds are not so lightweight. They will be heavier than Ruby threads, meaning that you won’t be able to handle, let’s say, tens of thousands of WebSocket connections with just Guilds.

Examples:

There are no examples since Ruby 3 wasn’t released yet. But I see a bright future where developers will start building Guild-friendly tools like web servers, background job processings, etc. Most probably all these tools will allow using hybrid approaches: running multiple processes with multiple Guilds with multiple threads in each. But for now, you can read the original PDF presentation by Koichi Sasada.

Conclusion

There is no silver bullet. Each concurrency model described in the post has its own pros and cons. The CSP model works best on a single machine without deadlocks. The Actor model can easily scale across several machines. STM allows to write concurrent code much simpler. But all these models are not first class citizens in Ruby and can’t be fully adapted from other programming languages; mostly because in Ruby they are all implemented with standard concurrency primitives like threads and fibers. However, there is a chance that Guilds will be released with Ruby 3, which is a big step towards a much better concurrency model!

Originally published on Medium.

Posted on by:

Discussion

markdown guide