Zoidberg
Why not Zoidberg?
About
Zoidberg does a couple things. First, it can be a simple way to provide implicit synchronization for thread safety in existing code that is otherwise unsafe. Second, it can provide supervision and pooling. This library is heavily inspired by Celluloid but, while some APIs may look familiar, they do not share a familiar implementation.
Usage
Zoidberg provides a Shell
which can be loaded into a class. After
it has been loaded, new instances will provide implicit synchronization,
which is nifty. For example, lets take a simple Fubar
class that does
a simple thing:
class Fubar
attr_reader :string
def initialize
@string = ''
@chars = []
end
def append
string << char
end
private
def char
if(@chars.empty?)
@chars.replace (A..Z).to_a
end
@chars.shift
end
end
Pretty simple class whose only purpose is to add characters to a string. And it does just that:
inst = Fubar.new
20.times{ inst.append }
inst.string
# => "ABCDEFGHIJKLMNOPQRST"
So this does exactly what we expect it to. Now, lets update this example and toss some threads into the mix:
inst = Fubar.new
20.times.map{ Thread.new{ inst.append } }.map(&:join)
inst.string
# => "ABCDEFGHIJKLMNOPQRST"
Cool, we get the same results! Looks like everything is great. Lets run it again to bask in this multi-threaded awesomeness!
# => "AABCDEFGHIJKLMNOPQRS"
Hrm, that doesn't look quite right. It looks like there's an extra 'A' at the start. Maybe everything isn't so great. Lets try a few more:
inst = Fubar.new
100.times.map do
20.times.map{ Thread.new{ inst.append } }.map(&:join)
end.uniq
# => ["ABCDEFGHIJKLMNOPQRST", "ABCDEDGHIJKLMNOPQRST", "ACDEFGHIJKLMNOPQRST", "BCDEFGHIJKLMNOPQRST", "AABCDEFGHIJKLMNOPQRS", "ABCDEFHGIJKLMNOPQRST"]
Whelp, I don't even know what that is supposed to be, but it's certainly not what we are expecting. Well, we are expecting it because this is an example on synchronization, but lets just pretend at this point we are amazed at this turn of events.
To fix this, we need to add some synchronization so multiple threads aren't
attempting to mutate state at the same time. But, instead of modifying the
class and explicitly adding synchronization, lets see what happens when
we toss Zoidberg::Shell
into the mix (cause it's why everyone is here
in the first place). We can just continue on with our previous examples
and open up our defined class to inject the shell and re-run the example:
require 'zoidberg'
class Fubar
include Zoidberg::Shell
end
inst = Fubar.new
20.times.map{ Thread.new{ inst.append } }.map(&:join)
inst.string
# => "ABCDEFGHIJKLMNOPQRST"
and running it lots of times we get:
100.times.map{20.times.map{ Thread.new{ inst.append } }.map(&:join)}.uniq
# => ["ABCDEFGHIJKLMNOPQRST"]
So this is pretty neat. We had a class that was shown to not be thread safe. We tossed a module into that class. Now that class is thread safe.
Should I really do this?
Maybe?
Features
Originally, we looked at just adding safety but this library provides a handful more of things.
Implicit Locking
Zoidberg automatically synchronizes requests made to an instance. This behavior can be short circuited if the actual instance creates a thread and calls a method on itself. Otherwise, all external access to the instance will be automatically synchronized. Nifty.
This synchronization behavior comes from the shells included within Zoidberg. There are two styles of shells available:
Zoidberg::SoftShell
This is the default shell used when the generic Zoidberg::Shell
module
is included. It will wrap the raw instance and synchronize requests to
the instance.
Zoidberg::HardShell
This shell is still in development and not fully supported yet. The hard shell is an implementation that is more reflective of the actor model with a single thread wrapping an instance and synchronizing access.
Garbage Collection
Garbage collection happens as usual with Zoidberg. When an instance is created the result may look like the instance but really it is a proxy wrapping the raw instance. When the proxy falls out of scope and is garbage collected the raw instance it wrapped will also fall out of scope and be garbage collected. This wrapping behavior is what allows supervised instances to be automatically swapped out on failure state without requiring intervention. It also introduces the ability to add support for destructors, which is pretty cool.
Destructors
Instances can define destructors via the #terminate
method. When the instance
is garbage collected, the #terminate
method will be called prior to the instance
falling out of scope and being removed from the system. This allows the introduction
of destructors:
class Fubar
include Zoidberg::Shell
...
def terminate
puts "I am being garbage collected!"
end
end
Signals
Simple signals are available as well as signals pushing data.
Simple Signals
sig = Zoidberg::Signal.new
Thread.new do
sig.wait(:go)
puts 'Done!'
end
puts 'Ready to signal!'
sleep(1)
sig.signal(:go)
puts 'Signal sent'
Simple Broadcasting
sig = Zoidberg::Signal.new
5.times do
Thread.new do
sig.wait(:go)
puts 'Done!'
end
end
puts 'Ready to signal!'
sleep(1)
sig.broadcast(:go)
puts 'Broadcast sent'
Pushing data
sig = Zoidberg::Signal.new
Thread.new do
value = sig.wait(:go)
puts "Done! Received: #{value.inspect}"
end
puts 'Ready to signal!'
sleep(1)
sig.signal(:go, :ohai)
puts 'Signal sent'
Supervision
Zoidberg can provide instance supervision. To enable supervision on a class, include the module:
class Fubar
include Zoidberg::Supervise
end
This will implicitly load the Zoidberg::Shell
module and new instances
will be supervised. Supervision means Zoidberg will watch for unexpected
exceptions. What are "unexpected exceptions"? They are any exception raised
via raise
. This will cause the instance to be torn down and a new instance
to be instantiated. To the outside observer, nothing will change and no
modification is required.
Pools
Zoidberg allows pooling lazy supervised instances. Unexpected failures will cause the instance to be terminated and re-initialized as usual. The pool will deliver requests to free instances, or queue them until a free instance is available.