Ruby
Article
By Benjamin Tan Wei Hao

Learn Concurrency by Making a Countdown Latch in Ruby

By Benjamin Tan Wei Hao

A Countdown Latch is a concurrency abstraction that allows one or more threads to wait until all other threads are done with what they are doing. Therefore, a countdown latch is often referred to as a thread synchronization primitive.

How are Countdown Latches Useful?

Let say you have a bunch of threads that are fetching, say, Chuck Norris jokes. You create ten threads and you want to ensure that they all complete before you proceed on with the next step.

In this instance, you can create a countdown latch with a counter of ten. Then, right before your next step in the code, you can wait for your threads to complete. Until then, your code cannot proceed until all the threads are done.

When each thread fetches a joke, it will decrement the counter. Eventually as all ten threads are done fetching jokes, the counter in the countdown latch will eventually hit zero. That’s when the code is allowed to proceed.

Implementing Your Own Countdown Latch: Test First!

In a previous article, we went though how to implement Futures in Ruby. In this article, we are going test-drive an implementation of a Countdown Latch. Let’s get started!

Setting Up

Let’s bootstrap a new Ruby project. My favorite way is to create a Ruby gem:

% bundle gem countdown_latch -t
Creating gem 'countdown_latch'...
MIT License enabled in config
      create  countdown_latch/Gemfile
      create  countdown_latch/.gitignore
      create  countdown_latch/lib/countdown_latch.rb
      create  countdown_latch/lib/countdown_latch/version.rb
      create  countdown_latch/countdown_latch.gemspec
      create  countdown_latch/Rakefile
      create  countdown_latch/README.md
      create  countdown_latch/bin/console
      create  countdown_latch/bin/setup
      create  countdown_latch/LICENSE.txt
      create  countdown_latch/.travis.yml
      create  countdown_latch/.rspec
      create  countdown_latch/spec/spec_helper.rb
      create  countdown_latch/spec/countdown_latch_spec.rb
Initializing git repo in /Users/benjamintan/workspace/countdown_latch

Did you notice the -t flat appended? This flag adds RSpec as a development dependency.

Next, go into the project directory:

% cd countdown_latch

Run bin/setup to install the dependencies:

% bin/setup
Resolving dependencies...
Using rake 10.4.2
Using bundler 1.10.6
Using countdown_latch 0.1.0 from source at .
Using diff-lcs 1.2.5
Using rspec-support 3.3.0
Using rspec-core 3.3.2
Using rspec-expectations 3.3.1
Using rspec-mocks 3.3.2
Using rspec 3.3.0
Bundle complete! 4 Gemfile dependencies, 9 gems now installed.
Use `bundle show [gemname]` to see where a bundled gem is installed.

Creating the Countdown Latch

Now, on to the fun stuff! Since we are doing this test-first. The tests will help us drive towards the implementation that we will flesh out in order to get the tests to pass.

As a sanity check, we can quickly make sure everything is hooked up as expected:

% rspec                                                                        
CountdownLatch
  has a version number
  does something useful (FAILED - 1)

Failures:

  1) CountdownLatch does something useful
     Failure/Error: expect(false).to eq(true)

       expected: true
            got: false

       (compared using ==)
     # ./spec/countdown_latch_spec.rb:9:in `block (2 levels) in <top (required)>'

Finished in 0.01957 seconds (files took 0.07757 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./spec/countdown_latch_spec.rb:8 # CountdownLatch does something useful

Excellent! RSpec is working fine! Now, open up the test file located at spec/countdownlatchspec.rb. We are going to write the first test.

A Countdown Latch Requires a Non-negative Integer as an Argument

You should delete everything in spec/countdownlatchspec.rb and replace it with this skeleton:

module CountdownLatch

  describe CountdownLatch do

  end
end

The first test is to make sure that the argument passed into the constructor of the countdown latch is a non-negative integer:

module CountdownLatch
  describe CountdownLatch do
    it "requires a non-negative integer as an argument" do
      latch = CountdownLatch.new(3)
      expect(latch.count).to eq(3)
    end
  end
end

In order to get this to pass, we need a constructor that accepts a non-negative integer as an argument. Getting this test to pass is simple:

module CountdownLatch
  class CountdownLatch

    def initialize(count)
      if count.is_a?(Fixnum) && count > 0
        @count = count
      end
    end

  end
end

At this point, the test will pass. Let’s also make sure that 0 is accepted:

module CountdownLatch
  describe CountdownLatch do

    it "zero is a valid argument" do
      latch = CountdownLatch.new(0)
      expect(latch.count).to eq(0)
    end
  end
end

Whoops! Our test caught something:

Failures:

  1) CountdownLatch::CountdownLatch zero is a valid argument
     Failure/Error: expect(latch.count).to eq(0)

       expected: 0
            got: nil

       (compared using ==)

Turns out, we have an off by one error when comparing count and zero:

module CountdownLatch
  class CountdownLatch

    def initialize(count)
      if count.is_a?(Fixnum) && count >= 0
        @count = count
      end
    end

  end
end

The countdown latch cannot be initialized with a negative number, or a Float, for example, since those two cases do not make sense. Let’s add the tests:

module CountdownLatch
  describe CountdownLatch do

    it "throws ArgumentError for negative numbers" do
      expect { CountdownLatch.new(-1) }.to raise_error(ArgumentError)
    end

    it "throws ArgumentError for non-integers" do
      expect { CountdownLatch.new(1.0) }.to raise_error(ArgumentError)
    end
  end
end

Now the tests will fail:

Failures:

  1) CountdownLatch::CountdownLatch throws ArgumentError for negative numbers
     Failure/Error: expect { CountdownLatch.new(-1) }.to raise_error(ArgumentError)
       expected ArgumentError but nothing was raised
     # ./spec/countdown_latch_spec.rb:17:in `block (2 levels) in <module:CountdownLatch>'

  2) CountdownLatch::CountdownLatch throws ArgumentError for non-integers
     Failure/Error: expect { CountdownLatch.new(1.0) }.to raise_error(ArgumentError)
       expected ArgumentError but nothing was raised
     # ./spec/countdown_latch_spec.rb:21:in `block (2 levels) in <module:CountdownLatch>'

Thankfully, this too is an easy fix. We just have to get the initializer to raise an ArgumentError for well, errors in the argument. In lib/countdownlatch/countdownlatch.rb:

module CountdownLatch
  class CountdownLatch

    def initialize(count)
      if count.is_a?(Fixnum) && count >= 0
        @count = count
        @mutex = Mutex.new
        @condition = ConditionVariable.new
      else
        raise ArgumentError
      end
    end

  end
end

Now the tests should all pass.

Counting Down

A countdown latch has to know how to count down its own internal counter.

module CountdownLatch
  describe CountdownLatch do

    it "#count decreases when #count_down is called" do
      latch = CountdownLatch.new(3)
      latch.count_down
      expect(latch.count).to eq(2)
    end

  end
end

Now we need have access to the @count field. That is easy enough:

module CountdownLatch
  class CountdownLatch

    def initialize(count)
      # ...
    end

    def count
      @count
    end

  end
end

The count_down method looks almost simplistic:

module CountdownLatch
  class CountdownLatch

    def count_down
      @count -= 1
    end

  end
end

Resist the temptation to add more behavior and “smarter” logic until the tests are forcing you to do something about it. In this case, the tests should be all green now.

However, our tests are not complete. The countdown latch should only count down to zero and nothing less. Let’s add a test for that:

module CountdownLatch
  describe CountdownLatch do

    it "#count never reaches below zero" do
      latch = CountdownLatch.new(0)
      latch.count_down
      expect(latch.count).to eq(0)
    end

  end
end

The above test fails:

  1) CountdownLatch::CountdownLatch #count never reaches below zero
     Failure/Error: expect(latch.count).to eq(0)

       expected: 0
            got: -1

       (compared using ==)

We just need to check if @count is zero:

module CountdownLatch
  class CountdownLatch

    def count_down
      unless @count.zero?
        @count -= 1
      end
    end

  end
end

Everything should be green!

Awaiting for Threads

Till now, we simply have a glorified countdown object. It is not even thread-safe! This is because count_down can be called by multiple threads and since @count is not synchronized in anyway, race conditions can occur.

This is the first test that will drive us to implement the concurrency features in our countdown latch:

module CountdownLatch
  describe CountdownLatch do

    it "#await will wait for a thread to finish its work" do
      latch = CountdownLatch.new(1)

      Thread.new do
        latch.count_down
      end

      latch.await
      expect(latch.count).to eq(0)
    end

  end
end

The test creates a countdown latch initialized to 1. We then create a thread that decrements the latch. Outside of the thread, we call latch.await.

This is basically saying that the program will wait for the thread to finish its work. Again, once the thread is done with its work, it will call latch.count_down. Therefore, we expect that the count of the latch to be zero.

In order to see the test fail correctly, we need an empty implementation of await:

module CountdownLatch
  class CountdownLatch

    def await
    end

  end
end

When you run the tests, the following will fail:

Failures:

  1) CountdownLatch::CountdownLatch #await will wait for a thread to finish its work
     Failure/Error: expect(latch.count).to eq(0)

       expected: 0
            got: 1

       (compared using ==)

Since await does nothing, the program doesn’t wait for the thread to tell the countdown latch that it’s OK to proceed. Instead, the program simply runs straight through. In order to get this test to pass, you need to know about condition variables.

Condition Variables

A condition variable is essentially a synchronization primitive (recall that a countdown latch is also a synchronization primitive) that allows threads to wait until some condition occurs. In the case of a countdown latch, that condition is when @count hits zero.

When that happens, the broadcast method has to be called on the condition variable to tell all the waiting threads that they can stop waiting and proceed.

First, we’ll add the condition variable to the implementation and tell it to broadcast to all waiting threads when the condition has been met. Do not forget to include require "thread" too:

require "thread"  # <----

module CountdownLatch
  class CountdownLatch

    def initialize(count)
      if count.is_a?(Fixnum) && count >= 0
        @count = count
        @condition = ConditionVariable.new # <----
      else
        raise ArgumentError
      end
    end

    def count_down
      unless @count.zero?
        @count -= 1
      else
        @condition.broadcast # <----
      end
    end

  end
end

Now, to deal with the other side of the equation: Making threads suspend execution and wait till the condition is met. Here’s the first attempt. ConditionVariable has a method called wait:

module CountdownLatch
  class CountdownLatch

    def await
      @condition.wait
    end

  end
end

However, this doesn’t work because ConditionVariable#wait requires an argument that takes in a Mutex object. A mutex is essentially a lock that protects a section of code from being entered by more than one thread. Recall that I mentioned @count is , until now, not thread-safe. We are going to fix this.

In this case, we need to supply a mutex to the condition variable, so that only one thread can read/write to the condition variable at any one time.

First, we will create a mutex for the countdown latch:

module CountdownLatch
  class CountdownLatch

    def initialize(count)
      if count.is_a?(Fixnum) && count >= 0
        @count = count
        @condition = ConditionVariable.new
        @mutex = Mutex.new <----
      else
        raise ArgumentError
      end
    end

  end
end

Next, we will use @mutex.synchronize to demarcate a critical section. A critical section is an area of code where only one thread can enter. First, let’s handle the count_down method:

module CountdownLatch
  class CountdownLatch

    def count_down
      @mutex.synchronize {
        unless @count.zero?
          @count -= 1
        else
          @condition.broadcast
        end
      }
    end

  end
end

We finally have a mutex to pass into @condition.wait :

module CountdownLatch
  class CountdownLatch

    def await
      @condition.wait(@mutex)
    end

  end
end

However, just like the count_down method, you need a critical section. Here it is:

module CountdownLatch
  class CountdownLatch

    def await
      @mutex.synchronize {
        @condition.wait(@mutex)
      }
    end

  end
end

While we are add it, let’s wrap the @count in the count method with a mutex too:

module CountdownLatch
  class CountdownLatch

    def count
      @mutex.synchronize { 
        @count
      }
    end

  end
end

Run the test again and we should be green! Woot!

A Sample Run

Create a folder under lib called sample. In that folder, I create a file called chucky.rb with the following contents:

require 'open-uri'
require 'json'

module CountdownLatch

  class Chucky
    URL = 'http://api.icndb.com/jokes/random'

    def get_fact
      open(URL) do |f|
        f.each_line { |line| puts JSON.parse(line)['value']['joke'] }
      end
    end

    def get_facts(num)
      latch = CountdownLatch.new(num) # <---- Latch

      facts = []
      (1..num).each do |x|
        Thread.new do
          facts << get_fact
          latch.count_down
        end
      end

      latch.await

      facts
    end

  end
end

This class fetches jokes from a third-party API, parses the JSON response and returns the joke as a String. We can use a test to drive Chucky and get us some facts:

require 'spec_helper'
require 'sample/chucky' # <---- Remember to add this!

module CountdownLatch
  describe CountdownLatch do

    it "sample run", :speed => 'slow' do
      chucky = Chucky.new
      facts = chucky.get_facts(5)

      expect(facts.size).to eq(5)
    end

  end
end

As expected, the tests will pass. Try removing the latch and see what happens.

--ADVERTISEMENT--

Limitations, Acknowledgments, and Where to Learn More

This implementation doesn’t handle spurious wakeups, an interesting phenomenon where threads can wake up even though the condition variable hasn’t signaled/broadcasted yet.

If you want a more solid and refined implementation of a countdown latch, take a look at the fantastic Ruby Concurrency GitHub repository.

Thanks for Reading!

I hope you have learned something new about condition variables, mutexes, and, of course, creating your own countdown latch! More importantly, I hope you had lots of fun following along. You can grab the full source here. Thanks for reading!

More:
  • Yeong Sheng Tan

    Benjamin, very much appreciate this article on specific concurrency using native stdlib from ruby.

    How would things look like using the ‘concurrent-ruby’ gem?

    Just a little question/observation, wouldn’t ‘count’ be needed to be exposed as an ‘attr_reader :count’ for the instance var of latch.count to work correctly (specifically MRI ruby-2.3.1)?

    • It should look pretty similar with `concurrent-ruby`.

      `count` is a method call, so I think it should work as expected. :)

  • Informative article, however the code in the count_down method does not really work as expected.

    def count_down
    @mutex.synchronize {
    unless @count.zero?
    @count -= 1
    else
    @condition.broadcast
    end
    }
    end

    For a initial @count of 1, the counter will de decremented and set to 0 but it will not call broadcast
    on the conditional variable. The implementation that worked for me was something along the lines:

    def count_down
    @mutex.synchronize do
    @count -= 1 unless @count.zero?

    if @count.zero?
    @condition.broadcast
    end
    end
    end

Recommended
Sponsors
Get the latest in Ruby, once a week, for free.