Threads and asynchronous environments are initially a bit tricky. Without a good mental model to organize interaction, it is easy to get into trouble and end up with unexpected results. On top of that, testing asynchronous code can be difficult without the right tools or test patterns.
Thinking about threads as people and shared objects as ‘things’ that can be owned helps to organize the working of a multithreaded system. In this episode we will go through an example to learn all about testing asynchronous Ruby code.
If you are using Rails or Rack or really any application that as a web browser front end you are in an asynchronous environment. The Rack #call
is always called asynchronously. So whether you know it or not, there is a good chance you are already using multithreaded components.
Testing: Trigger, collect and check
Testing an asynchronous callback API can be made synchronous by following a pattern of three steps; trigger, collect, and check. Think if each thread as a separate individual and objects as, well, things that can be owned by just one individual at a time.
We’ll use the example of Batman and his 7 different suits. Because that is a practical example and we can understand the importance of knowing whether all suits are with Alfred to be washed when you are about to run out and save the city.
Example: Laundry day at the batcave
The example is Alfred washing Batman's suits. The SuitWashScheduler is a scheduler that invokes a callback for each washing event. The scheduler makes seven callbacks at one-second intervals starting one second after starting. The trigger is the
creation of the SuitWashScheduler
.
class SuitWashScheduler
def initialize(cnt)
Thread.new {
cnt.times {
sleep(1.0)
yield
}
}
end
end
Collecting
Collecting results must be thread safe to avoid race conditions. Any object shared across more than one thread has to be protected. Protection is a way of keeping track of the owner of an object. Only the owner can make changes or view the object. A suit can only be with Batman to use in fights, or with Alfred to be washed.
To stay friendly a thread (in the metaphor Batman or Alfred) only takes ownership for short times and then gives up the ownership. A Mutex
is usually used to keep track of the owner. The SuitwashScheduler
callback will own the result counter when the counter is incremented. The callback that is run in the SuitWashScheduler
thread signals that all results have been received when the counter hits the target.
Writing the example starts with setting up some globals. In a real application the globals would be replaced by class or object attributes.
$main_thread = Thread.current
$mu = Mutex.new
$count = 0
$target = 7
Management and owners
The $main_thread
and $mu
are used to manage the threads and waiting for completion of the test while the $target
and $count
track the test results. Remember this is a trivial test so collecting and checking results has to be simple.
The test is started by creating a new instance of the SuitWashScheduler
, giving the initializer the $target
number of iterations. In this case, the 7 suits that need washing.
The block provided will be run in the SuitWashScheduler
thread. For each iteration the $count
is incremented and printed.
Looking ahead we realize that the main, test thread is going to be checking the$count
also which means it will need ownership of the $count
as well so a means of taking ownership of $count
is needed. The $mu
Mutex
instance is the ownership token. In the block passed to the SuitWashScheduler.new
call a $mu.synchronize
block takes ownership long enough to set the $count
and check the results. More on the results check in a moment.
SuitWashScheduler.new($target) {
$mu.synchronize {
$count += 1
puts $count
$main_thread.wakeup if $target <= $count
}
}
Check: are all suits done?
Back at the main thread we need to wait for the test to finish. Batman needs to wait before all 7 suits are done. There are two conditions to check for; the tests updates the $count
as expected or Batman gets bored waiting for the test to finish and times out. Before checking the $count
to see if it has reached the $target
, the ownership of $count
is needed. Just like in the block for the SuitWashScheduler
a call to $mu.synchronize
is used.
But that can't be right, if we lock up the main thread how can the SuitWashScheduler
thread ever change the $count
? Luckily for us there is a neat trick that takes care of this. The Mutex
class has a #sleep
method that gives up ownership and waits until either it times out or is woken. Once woken either through the timeout or a #wakeup
call to the main thread $mu
attempts to take ownership again before continuing. Once ownership has been achieved the results can be checked and a determination of the pass or fail state of the test can be made.
$mu.synchronize {
$mu.sleep($target + 1)
if $target != $count
puts 'FAILED'
else
puts 'Passed! All suits are washed and clean'
end
}
If you want to get deeper into this, you can make the example a bit more interesting by trying to create multiple schedulers and see how the Mutex keep the $count
changes from colliding. As if Batman sends some suits to Alfred to be washed, and some others to the dry cleaners. Make sure to change the logic to make sure the $target
check is a total of all the expected yields.
Roundup
Working with threads and asynchronous environment gets easier with the right mental model.
In this post we used people as a metaphor for threads and physical objects (suits) as metaphor for shared objects, that can only be owned by one thread or person at a time. We think this way to abstract makes it easier to understand and remember.
We hope the examples with make you remember the mechanisms of async, but we hope the image of Batman running out while all his suits are being washed won’t stick with you for too long.
PS If you are done with all the batman metaphors on the blog, let us know.
Peter Ohler creates quite bit of a high performance code, and writes about it too, every now and then. He made the Agoo gem, which is a pretty cool high performance HTTP server.
Top comments (0)