Статьи

Изучите параллелизм, сделав обратный отсчет в Ruby

Обратный отсчет — это абстракция параллелизма, которая позволяет одному или нескольким потокам ждать, пока все другие потоки не выполнят то, что они делают. Поэтому защелка обратного отсчета часто упоминается как примитив синхронизации потока .

Чем полезны защелки обратного отсчета?

Допустим, у вас есть куча тем, которые извлекают, скажем, шутки Чака Норриса. Вы создаете десять потоков и хотите убедиться, что все они завершены, прежде чем переходить к следующему шагу.

В этом случае вы можете создать защелку обратного отсчета со счетчиком десять. Затем, прямо перед следующим шагом в коде, вы можете дождаться завершения ваших потоков. До тех пор ваш код не может продолжаться, пока не завершены все потоки.

Когда каждый поток получает шутку, он уменьшает счетчик. В конце концов, когда все десять потоков закончили извлекать шутки, счетчик в защелке обратного отсчета в конечном итоге достигнет нуля. Это когда код разрешено продолжить.

Внедрение вашей собственной блокировки обратного отсчета: сначала протестируйте!

В предыдущей статье мы рассмотрели, как реализовать Futures в Ruby . В этой статье мы собираемся протестировать реализацию механизма обратного отсчета. Давайте начнем!

Настройка

Давайте загрузим новый проект Ruby. Мой любимый способ — создать драгоценный камень Ruby:

% 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 

Вы заметили, что -t квартира добавлена? Этот флаг добавляет RSpec как зависимость разработки.

Далее перейдите в каталог проекта:

 % cd countdown_latch 

Запустите bin/setup чтобы установить зависимости:

 % 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. 

Создание защелки обратного отсчета

Теперь самое интересное! Так как мы делаем этот тест в первую очередь . Тесты помогут нам перейти к реализации, которую мы конкретизируем, чтобы пройти тесты.

В качестве проверки работоспособности мы можем быстро убедиться, что все подключено, как и ожидалось:

 % 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 

Отлично! RSpec работает нормально! Теперь откройте тестовый файл, расположенный в spec / countdown latch spec.rb. Мы собираемся написать первый тест.

В качестве аргумента обратного отсчета требуется неотрицательное целое число

Вы должны удалить все в spec / countdown latch spec.rb и заменить его следующим скелетом:

 module CountdownLatch describe CountdownLatch do end end 

Первый тест — убедиться, что аргумент, переданный в конструктор защелки обратного отсчета, является неотрицательным целым числом:

 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 

Чтобы это передать, нам нужен конструктор, который принимает неотрицательное целое число в качестве аргумента. Пройти этот тест очень просто:

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

В этот момент тест пройдёт. Давайте также удостоверимся, что 0 принято:

 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 

Упс! Наш тест поймал что-то:

 Failures: 1) CountdownLatch::CountdownLatch zero is a valid argument Failure/Error: expect(latch.count).to eq(0) expected: 0 got: nil (compared using ==) 

Оказывается, у нас есть ошибка на одну ошибку при сравнении count и нуля:

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

Защелка обратного отсчета не может быть инициализирована с отрицательным числом или, например, с Float , так как эти два случая не имеют смысла. Давайте добавим тесты:

 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 

Теперь тесты не пройдут:

 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>' 

К счастью, это тоже легко исправить. Нам просто нужно получить инициализатор, чтобы вызвать ArgumentError для ошибок в аргументе. В lib / countdown latch / countdown latch.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 

Теперь все тесты должны пройти.

Отсчет

Защелка обратного отсчета должна знать, как отсчитать свой внутренний счетчик.

 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 

Теперь нам нужен доступ к полю @count . Это достаточно просто:

 module CountdownLatch class CountdownLatch def initialize(count) # ... end def count @count end end end 

Метод count_down выглядит почти упрощенно:

 module CountdownLatch class CountdownLatch def count_down @count -= 1 end end end 

Не поддавайтесь искушению добавить больше поведения и «разумной» логики, пока тесты не заставят вас что-то с этим сделать. В этом случае тесты теперь должны быть зелеными.

Однако наши тесты не завершены. Задвижка обратного отсчета должна только отсчитывать до нуля и не меньше Давайте добавим тест для этого:

 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 

Вышеуказанный тест не пройден:

  1) CountdownLatch::CountdownLatch #count never reaches below zero Failure/Error: expect(latch.count).to eq(0) expected: 0 got: -1 (compared using ==) 

Нам просто нужно проверить, @count ли @count нулю:

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

Все должно быть зеленым!

В ожидании Темы

До сих пор у нас просто есть прославленный объект обратного отсчета. Это даже не потокобезопасный! Это связано с тем, что count_down может вызываться несколькими потоками, а @count в любом случае не синхронизируется, могут возникнуть условия гонки.

Это первый тест, который заставит нас реализовать функции параллелизма в нашей защелке обратного отсчета:

 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 

Тест создает защелку обратного отсчета, инициализированную в 1 . Затем мы создаем поток, который уменьшает защелку. Вне latch.await мы называем latch.await .

По сути, это говорит о том, что программа будет ожидать завершения работы потока. Опять же, как только поток завершит свою работу, он вызовет latch.count_down . Поэтому мы ожидаем, что count защелок будет равно нулю.

Чтобы увидеть, что тест не пройден правильно , нам нужна пустая реализация await :

 module CountdownLatch class CountdownLatch def await end end end 

При запуске тестов произойдет следующее:

 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 ==) 

Поскольку await ничего не делает, программа не ждет, пока поток сообщит защелке обратного отсчета, что все в порядке, чтобы продолжить. Вместо этого программа просто проходит прямо через. Чтобы пройти этот тест, вам нужно знать об условных переменных .

Переменные условия

Переменная условия по сути является примитивом синхронизации (напомним, что защелка обратного отсчета также является примитивом синхронизации), который позволяет потокам ждать, пока не произойдет какое-либо условие. В случае защелки обратного отсчета это условие, когда @count достигает нуля.

Когда это происходит, метод broadcast должен вызываться для переменной условия, чтобы сообщить всем ожидающим потокам, что они могут прекратить ожидание и продолжить.

Во-первых, мы добавим переменную условия в реализацию и скажем, чтобы она передавалась всем ожидающим потокам, когда условие будет выполнено. Не забудьте также включить require "thread" :

 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 

Теперь, чтобы разобраться с другой стороной уравнения: заставить потоки приостановить выполнение и дождаться выполнения условия. Вот первая попытка ConditionVariable имеет метод под названием wait :

 module CountdownLatch class CountdownLatch def await @condition.wait end end end 

Однако это не работает, потому что ConditionVariable#wait требует аргумент, который принимает объект Mutex . Мьютекс — это, по сути, блокировка, которая защищает часть кода от ввода более чем одним потоком. Напомним, что я упоминал, что @count до сих пор не является поточно- @count . Мы собираемся это исправить.

В этом случае нам нужно предоставить мьютекс в условную переменную, чтобы только один поток мог одновременно читать / записывать переменную условия.

Сначала мы создадим мьютекс для защелки обратного отсчета:

 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 

Далее мы будем использовать @mutex.synchronize для разграничения критического раздела . Критическая секция — это область кода, в которую может войти только один поток. Во-первых, давайте обработаем метод count_down :

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

Наконец, у нас есть мьютекс для передачи в @condition.wait :

 module CountdownLatch class CountdownLatch def await @condition.wait(@mutex) end end end 

Однако, как и метод count_down , вам нужен критический раздел. Вот:

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

Пока мы добавляем его, давайте @count в методе count также мьютексом:

 module CountdownLatch class CountdownLatch def count @mutex.synchronize { @count } end end end 

Запустите тест снова, и мы должны быть зелеными! Woot!

Пробный прогон

Создайте папку в lib с именем sample . В этой папке я создаю файл с именем chucky.rb со следующим содержимым:

 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 

Этот класс извлекает шутки из стороннего API, анализирует ответ JSON и возвращает шутку в виде String . Мы можем использовать тест, чтобы вести Chucky и получить некоторые факты:

 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 

Как ожидается, испытания пройдут. Попробуйте снять защелку и посмотреть, что произойдет.

Ограничения, благодарности и где узнать больше

Эта реализация не обрабатывает ложные пробуждения , интересный феномен, когда потоки могут проснуться, даже если переменная условия еще не сигнализировала / не транслировала.

Если вы хотите более надежную и утонченную реализацию защелки обратного отсчета, взгляните на фантастическое хранилище Ruby Concurrency GitHub .

Спасибо за прочтение!

Я надеюсь, что вы узнали что-то новое о условных переменных, мьютексах и, конечно же, создании собственной защелки обратного отсчета! Что еще более важно, я надеюсь, что вы получили массу удовольствия. Вы можете получить полный источник здесь . Спасибо за прочтение!