DRuby aka DRb — основа распределенных систем на Ruby. Принцип работы и обход подводных камней

в 0:03, , рубрики: ruby, Программирование, распределённые вычисления, метки: , ,

Недавно вышла книга The dRuby book — distributed and parallel computing with Ruby (перевод японской книги, написанной автором самой библиотеки). В этой статье я попытаюсь дать обзор глав книги, касающихся библиотеки DRb. Если вам захочется ознакомиться с темой более подробно, книгу можно купить или скачать. Сразу скажу, что я не буду говорить в этом посте не о синхронизации потоков, ни о библиотеке Rinda.

Предположим, что вы пишите систему, которая работает с более чем одним процессом. Например, у вас есть веб-сервер, который в фоновом режиме запускает задачи, работающие долгое время. Или вам просто нужно обеспечить пересылку данных из одного процесса в другой и координировать их. Для таких ситуаций и нужна библиотека DRb. Она написана целиком на Ruby и включена в стандартную библиотеку, поэтому начать работать с ней можно моментально. Для её подключения достаточно написать require 'drb'

Достоинства библиотеки DRb большей частью проистекают из динамичности самого языка Ruby.
Во-первых, при затрате минимальных усилий на подготовительном этапе, дальше вы работаете с объектами не задумываясь, где они расположены: в одном процессе или в другом. Библиотека полностью маскирует от вас все технические детали.
Во-вторых, вы не обязаны жестко прописывать интерфейс. Любой руби-объект может выставить свой интерфейс наружу — таким образом вы можете как воспользоваться функциональностью одного из стандартных классов типа Hash или Queue, а можете сделать свой класс с любым интерфейсом. Кроме того вам ничто не мешает менять интерфейс прямо в процессе исполнения, и даже использовать method_missing для обработки любых запросов. И уж разумеется, обновление интерфейса сервера вообще никак не влияет на клиента, если тот не вызывает методы, которые изменили сигнатуру или поведение. Таким образом сервер и клиент максимально независимы.
И наконец, клиент даже не обязан знать классы объектов, которые ему возвращает сервер, он может их использовать и без этого. Таким образом сервер волен скрыть столько много деталей, сколько ему угодно.
Но, конечно, есть и подводные камни, и их предостаточно. К счастью, dRuby несложен в понимании, ну а понимание его устройства позволяет большей части проблем просто не допускать. Документация к этой библиотеке, к сожалению, не проясняет множества моментов, поэтому статья будет интересна и новичкам, и людям уже поработавшим с библиотекой.

Для проверки того, что всё работает — откроем два irb терминала. Признаюсь, что не знаю, насколько велики отличия в ruby 1.8, так что давайте договоримся, что мы обсуждаем версию 1.9 (тем более, что 1.8 — скоро перестанет поддерживаться, ура!)

Условно эти два терминала — это сервер и клиент. Сервер должен предоставить front-объект, который будет принимать запросы. Этот объект может быть любым объектом: хоть объектом встроенного типа, хоть модулем со специально созданным интерфейсом. В свою очередь клиент, подключается к серверу и взаимодействует с этим объектом.
Давайте для примера в первом терминале запустим сервер и выставим наружу обыкновенный массив.

require 'drb'
front = []
DRb.start_service('druby://localhost:1234', front)
front << 'first'
# если вы запускаете сервер не в консоли, а отдельным скриптом - обязательно добавьте строку DRb.thread.join

Теперь подключим клиент. Узнаем первый элемент массива и запишем в массив ещё один элемент

require 'drb'
DRb.start_service
remote_obj = DRbObject.new_with_uri('druby://localhost:1234')
p remote_obj
p remote_obj[0]
remote_obj << 'second'

Теперь можно из первого терминала вызвать front[1] и увидеть, что там находится строка 'second'. А можно подключить ещё один клиент, и из него тоже оперировать тем же самым фронтальным объектом.

Как вы уже заметили, сервер запускается командой DRb.start_service (следите за регистром!). Метод принимает в качестве аргумента строку с адресом вида 'druby://hostname:port' и фронтальный объект. Фронтальный объект — это объект, который будет принимать запросы.
Когда сервер запускается в отдельном скрипте необходимо написать DRb.thread.join в конце скрипта. Дело в том, что DRb-сервер запускается отдельным потоком, а руби завершает работу программы, как только завершился главный поток. Поэтому если главный поток не подождет закрытия потока DRb-сервера, то досрочно завершатся оба потока, и сервер мгновенно станет недоступен. Будьте готовы к тому что выполнение метода DRb.Thread.join блокирует текущий поток до момента, пока сервер не будет выключен.

Для того, чтобы подключиться к серверу необходимо вызвать метод DRbObject.new_with_uri и передать ему в качестве аргумента адрес, по которому запускается сервер. Этот метод вернет прокси-объект remote_obj. Запросы (вызовы методов) к прокси-объекту автоматически передаются объекту на удаленном сервере, там вызванный метод выполняется, а затем результат возвращается вызвавшему метод клиенту. (Впрочем, не все методы вызываются на сервере. Например, судя по поведению, метод #class выполняется локально)
О смысле команды DRb.start_service у клиента речь пойдет чуть позднее.

Давайте всё-таки разберемся, каким образом выполняется выполнение метода удаленного объекта. Для этого вызов метода прокси-объекта выполняет сериализацию(маршализацию) имени метода и список аргументов, получившуюся строку по TCP-протоколу передаем серверу, который десериализует аргументы вызова, выполняет метод на фронтальном объекте, сериализует результат и передает его обратно клиенту. Всё выглядит просто. Фактически вы работаете с удаленным объектом так же как с обычным, а множество действий для удаленного выполнения метода прокси-объект и сервер от вас скрывают.

Но не всё так просто. Удаленный вызов метода — удовольствие дорогое. Представьте, что метод на сервере «дергает» множество методов аргумента. Это обернулось бы в то, что сервер и клиент вместо того, чтобы производить вычисления, львиную долю времени обращались бы друг к другу по сравнительно медленному протоколу (и хорошо ещё, если два процесса расположены на одной машине). Чтобы не допустить этого, аргументы и результат между процессами передаются по значению, а не по ссылке (маршализация объекта хранит в себе лишь внутреннее состояние объекта, и не знает его object_id — стало быть объект сначала сериализованный, а затем десериализованный будет лишь копией исходного объекта, но никак не тем же самым объектом, так что передача автоматически производится по копии). В Ruby обычно всё передается по ссылке, а в dRuby обычно — по значению. Таким образом, если вы выполните front[0].upcase! на сервере, то значение front[0] изменится, а если вы выполните remote_obj[0].upcase!, то вы получите первый элемент в верхнем регистре, но значение на сервере не поменяется, так как remote_obj.[](0) — это копия первого элемента. Можно считать этот вызов аналогичным методу front[0].dup.upcase!
Впрочем, всегда можно определить поведение dRuby так, чтобы передавать аргументы и результат по ссылке, но об этом позже.

Теперь настало время поговорить о первой проблеме. Не все объекты поддаются маршализации. Например, Proc- и IO-объекты, а также потоки (Thread-объекты) не могут быть маршализованы и переданы по копии. dRuby в этом случае поступает следующим образом: если маршализация не сработала, то объект передается по ссылке.
Итак, как же объект передается по ссылке? Вспомним Си. Там для этой цели используются указатели. В Руби роль указателя выполняет object_id. Для передачи объекта по ссылке используется объект класса DRbObject.
DRbObject — это, фактически, прокси-объект для передачи по ссылке. Экземпляр этого класса DRbObject.new(my_obj) содержит object_id объекта my_obj и URI-адрес сервера, откуда объект пришел. Это позволяет перехватить вызов метода и передать его именно тому объекту на удаленной машине (или в другом терминале), которому метод предназначался.

Сделаем нашему серверу метод

def front.[](ind)
  DRbObject.new(super)
end

И с клиента запустим код.

remote_obj.[0].upcase!

Новый метод #[] вернул не копию первого элемента, а ссылку, так что после выполнения метода upcase! фронтальный объект изменился, это легко проверить, выполнив, например команду puts remote_obj или puts front — с клиента и сервера соответственно.

Но каждый раз писать DRbObject.new — лень. К счастью, есть и другой способ передавать объект по ссылке, а не по значению. Для этого достаточно сделать объект немаршализуемым. Сделать это легко, достаточно включить в объект модуль DRbUndumped.

my_obj.extend DRbUndumped
class Foo; include DRbUndumped; end

Теперь объект my_obj и все объекты класса Foo будут автоматически передаваться по ссылке (а Marshal.dump(my_obj) будет выдавать TypeError 'can't dump').

Приведу пример, который встречался мне на практике. Сервер выставляет фронтальным объектом хэш, в котором значениями являются тикеты (изнутри тикет представляет из себя машину состояний). Тогда remote_obj[ticket_id] выдает копию тикета. Но это не позволяет нам изменить состояние тикета на сервере, только локально. Давайте заинклюдим DRbUndumped в класс Ticket. Теперь мы из хэша получаем не копию тикета, а ссылку на него — и любые действия с ним случаются теперь не на клиенте, а прямо на сервере.

А теперь пора вспомнить про обещание и рассказать, для чего нужно вызывать DRb.start_service у клиента. Представьте, что у вас на сервере указан фронтальным объектом массив, как в первом примере.
А теперь пусть клиент вызовет метод remote_obj.map{|x| x.upcase}
Фактически на фронтальном объекте вызывается метод map c аргументом-блоком. А его, как мы помним, маршализовать не получается. Значит этот блочный аргумент передается по ссылке. Метод map на сервере будет обращаться к нему инструкцией yield, а значит клиент является сервером! Но раз клиент должен время от времени быть сервером — значит он тоже должен запустить DRb-сервер методом start_service. При этом не обязательно указывать URI этого сервера. Как это работает изнутри я не знаю, но работает. И как вы уже заметили, отличия между клиентом и сервером меньше, чем может показаться.

Здесь есть риск наткнуться на новую неприятность. Допустим, метод вернул вам ссылку (не копию) на объект, сгенерированный прямо в методе. Если сервер этот объект нигде отдельно не сохранил (например, не поместил в специальный хэш), то сервер на него не имеет ссылки. Клиент на удаленной машине имеет, а сервер — нет! Поэтому поздно или рано тролль придет к тебе за почтой за этим объектом придет GC — сборщик мусора. Это означает, что через некоторое время ссылка типа DRbObject у клиента «протухнет» и станет указывать в никуда. Попытка обратиться к методам этого объекта вызовет ошибку.
Поэтому надо заботиться о том, чтобы сервер хранил ссылки на возвращаемые объекты по крайней мере до тех пор, пока они будут использоваться сервером. Для этого есть несколько решений:
1) сохранять все возвращаемые объекты, передаваемые по ссылке, в массив — тогда сборщик мусора их не будет собирать, ведь ссылка используются;
2) передавать клиенту ссылку в блок. Например:
Вместо такого кода:

Ticket.send :include DRbUndumped
def front.get_ticket
  Ticket.new
end

foo = remote_obj.get_ticket
foo.start
foo.closed? # Здесь ссылка foo может уже не иметь оригинала на сервере. Особенно, если метод start длится достаточно долго.

Следует писать так:

Ticket.send :include DRbUndumped
def front.get_ticket
  object_to_reference = Ticket.new
  yield object_to_reference
end

remote_obj.get_ticket do |foo|
  foo.start
  foo.closed?
end

Действующая локальная переменная на сервере не может быть собрана сборщиком мусора. Значит, внутри блока ссылка гарантировано будет работать.
3) В книге описан ещё один способ — надо вклиниться в процесс создания ссылки на этапе получения object_id объекта и попробовать в этот момент так или иначе оттянуть процесс сборки мусора. Можно автоматически добавлять элемент в хэш и хранить объект вечно (как вы можете догадаться, память рано или поздно кончится), можно хранить ссылку на объект и очищать её вручную, можно очищать этот хэш раз в несколько минут.
Последний способ можно реализовать, выполнив

require 'drb/timeridconv'
DRb.install_id_conv(DRb::TimerIdConv.new)

перед запуском сервера. За более подробной информацией обращайтесь к главе 11 книги — Handling Garbage Collection. Мне она кажется интересной и возможно у вас после её прочтения появятся новые способы использования манипулирования процессом сборки мусора. Но всё-таки я думаю, что на практике лучше использовать второй способ — и выдавать ссылки в блок. Надежнее и понятнее.

Осталось осветить, наверное, последний момент. Предположим, что вы передаете объект Foo как ссылку. Клиент знать не знает ни про какой класс Foo и тем не менее это не мешает ему работать с объектом. По сути клиент оперирует с объектом класса DRbObject. Всё, как обычно.
А теперь представьте, что вы передаете не ссылку, а копию. Сериализация на сервере сохранила состояние объекта и имя его класса. Клиент получил строку и пытается десериализовать её. У него это, конечно, не получается, ведь клиент не может создать объект несуществующего класса Foo. Тогда десериализация вернет объект типа DRbUnknown, который будет хранить буфер с маршализованным объектом. Этот объект можно передать дальше (например, в очередь задач). Также можно узнать имя класса, подгрузить соответствующую библиотеку с классом и вызвать метод reload — тогда будет предпринята ещё одна попытка произвести десериализацию. Это

Нет, всё же это не последний момент. Я обещал не писать про синхронизацию, но всё же пару слов скажу.
Для распределенного программирования синхронизация действий и атомарность операций — критически важные понятия. Сервер запускается в отдельном потоке. И на каждый запрос к серверу автоматически создается отдельный поток, в котором этот запрос обрабатывается. Так что просто необходимо запретить разным потокам обращаться к одной и той же информации одновременно. Так что, программируя распределенные и параллельные системы используйте:
1)конструкцию lock = Mutex.new; lock.synchronize{ do_smth }
2)модуль стандартной библиотеки MonitorMixin
3)классы стандартной библиотеки Queue, SizedQueue

Удачи в использовании DRb! Надеюсь, я предотвратил кому-нибудь долгие часы в попытках понять, почему объект не изменяется, хотя вы и применяете деструктивный метод, как заставить работать на клиенте метод, принимающий блок, и почему полученная ссылка работала-работала — и вдруг перестала.
Впрочем в книге вы найдете намного больше, особенно про библиотеку Rinda и её собратьев.

Автор: prijutme4ty

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js