Статьи

Агент: Go-Like Concurrency в Ruby

рубиново-го

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

К счастью, все это добро больше не заперто в Го; теперь есть библиотека для Ruby! Он называется «Агент» и реализует магию параллелизма Голанга в Ruby.

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

основы

Модель параллелизма Go основана на теориях пи-исчисления и последовательных процессов связи (CSP) . Хотя математика, лежащая в основе conepts, довольно интересна, сжатая версия поможет нам быстрее ускориться.

В старые времена у нас были потоки, которые общались друг с другом, делясь битами памяти. Это вызвало все виды проблем. Бывали моменты, когда два потока пытались писать в одно и то же одновременно, или один поток пытался читать из того, что было записано другим. Гениальные, как они есть, компьютерные ученые придумали концепции блокировки, семафоров и т. Д., Чтобы решить эти проблемы.

Go / CSP использует другой подход. Вместо совместного использования памяти параллельные «процессы» (здесь я не имею в виду процессы как термин * nix) связываются с сообщениями. Go реализует эти «процессы» как «goroutines», которые похожи на потоки, но гораздо более «легки» в потребляемых ими ресурсах. Первоначально концепция «обмена сообщениями» может показаться очень ограниченной по сравнению с тем, чтобы позволить потокам обращаться к одному и тому же набору переменных. Но оказывается, что боксирование себя немного дает фантастические результаты с точки зрения «правильности» параллельного кода.

Агент берет большую часть параллелизма Go и реализует его в Ruby. Однако при использовании агента (или любой среды параллелизма на основе Ruby) следует помнить одну вещь: стандартный интерпретатор ruby ​​реализует зеленую многопоточность . Это означает, что потоки фактически не работают на отдельных процессорах.

Агент, под абстракциями, с которыми мы сталкиваемся, использует зеленые потоки. Таким образом, он может обеспечить параллелизм (две операции, выполняющиеся за один и тот же период времени, но не обязательно обе выполняющие операцию в один и тот же момент), но не параллелизм (две операции, выполняющиеся в один и тот же момент). У этой схемы есть несколько преимуществ, но обратная сторона очевидна: если вы хотите распределить рабочую нагрузку по нескольким ядрам, агент (и, вероятно, стандартный интерпретатор Ruby) — не лучший выбор.

Теперь, когда у нас есть общие идеи, давайте перейдем к коду.

Goroutines

Прежде всего, получите себе копию агента:

gem install agent 

Вы можете найти весь код, связанный с этой статьей здесь .

Базовая единица параллелизма в Agent / Golang — это «goroutine», которая является своего рода легким потоком. Агент позволяет легко создать и запустить один из них:

 require 'agent' #go routine go! do puts 'hello, world!' end #wait around loop do end 

Давайте быстро пройдемся по тому, что мы здесь делаем. Мы передаем блок на go! функция (предоставляется агентом), таким образом, запускает программу, которая выполняет этот блок. Обратите внимание, что у нас есть бесконечный цикл в конце этого фрагмента. Если бы не этот кусок кода, у программы никогда не было бы времени для завершения, так как программа закрывалась до завершения программы! Очевидно, что этот подход «ждать вечно» не практичен и не элегантен. Но, чтобы придумать что-то лучшее, нам нужен какой-то механизм для общения между подпрограммами между собой и с основным потоком выполнения.

каналы

Каналы служат коммуникационными механизмами между программами. Вы можете добавить вещи
на канал и вынести вещи.

Если концепция еще не ясна, поможет некоторый код:

 require 'agent' chan = channel!(Integer) go! do #the program should only end #when this goroutine ends sleep 10 puts "Hello, world!" chan << 1 end puts chan.receive.first 

Этот фрагмент кода ожидает, пока не будет напечатано сообщение «Здравствуй, мир!», А затем завершится. Во главе программы мы создаем channel! используя channel! , Первый аргумент для channel! это имя класса, которое сообщает агенту, что chan должен принимать только сообщения типа Integer . Заглядывая внутрь блока горутин, мы имеем утверждение chan . This means: "take the chan . This means: "take the Integer 1 и добавьте его в канал с именем chan . Затем за пределами процедуры мы читаем значение из канала.

Здесь очень важно отметить, что чтение канала обычно блокируется, что означает, что они не возвращаются немедленно, если в канале нечего читать. Вместо этого они ждут, пока есть что-то доступное для чтения. Это очень важно для выполнения приведенного выше фрагмента. Если chan.receive.first возвращается немедленно, программа завершает выполнение перед завершением процедуры.

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

 go! do chan << 1 end 

Мы также рассмотрим буферизованные каналы позже, что позволит вам сделать примерно то же самое.

Теперь давайте применим некоторые знания нашего агента.

Эхо-сервер: Пулы потоков против Goroutines

Нам часто нужно использовать параллелизм при написании серверов, потому что они должны одновременно обслуживать множество клиентов. Использование пулов потоков в качестве метода параллелизма является традиционным решением этой проблемы. По сути, при инициализации сервера создается несколько потоков, и каждый поток обслуживает по одному клиенту за раз. Затем, как только поток завершает работу с клиентом, он готов обслуживать другой.

Но есть все виды проблем с этим подходом. Прежде всего, это трудно реализовать эффективным способом, потому что алгоритм, который решает, когда выделять больше потоков (в случае, если существующий пул потоков недостаточен), может привести к значительным накладным расходам либо с созданием слишком большого количества потоков, либо с ненужным их уничтожением.

Вы можете спросить: «Почему бы нам просто не создать поток для каждого отдельного клиента?» Создание потока связано со значительными накладными расходами. Таким образом, для серверов, которые должны работать с тысячами клиентов (довольно часто в наши дни), такой подход не практичен. С другой стороны, подпрограммы менее ресурсоемки, чем потоки. Разница настолько существенная, что создание программы для каждого клиента вполне разумно. Это может значительно ускорить внедрение быстрых параллельных серверов.

Давайте посмотрим, как мы можем сделать это в агенте:

 require 'agent' require 'socket' class EchoServer attr_accessor :host, :port def initialize(port) @port = port @server = TCPServer.new @port end def handle_client(client) loop do line = client.gets client.puts line end end def start loop do client = @server.accept puts 'accepted' go! do handle_client(client) #could do other stuff here end end end end server = EchoServer.new(1337) server.start() 

Большая часть кода просто настраивает структуру. Основные идеи содержатся в EchoServer.handle_client и EchoServer.start . В последнем у нас есть основной цикл приложения, который ожидает, пока клиент не будет принят. Затем он вызывает handle_client как программу, которая устанавливает «эхо» (т.е. повторяет все, что говорит клиент) с клиентом. Обратите внимание, что каждому клиенту требуется новая программа. Если что-то подобное используется в производстве, очевидно, что нужно будет проверить наличие ошибок в handle_client и выйти из программы по мере необходимости.

Агент и Голанг Goroutines Различия

Как уже говорилось, подпрограммы потребляют меньше ресурсов, чем потоки. На самом деле, достаточно простой сервер может выполнять до 100 000 одновременных подпрограмм. Тем не менее, агент запускает зеленые рубиновые потоки внизу. Эти реализации отличаются (распределение стека, переключение контекста и т. Д.), Что означает, что выигрыш в производительности Go может не полностью перевести в Agent. Таким образом, вы должны знать (например, тестировать) производительность приложений, основанных на агентах (точно так же, как при использовании любой новой технологии). Независимо от этого, агент предоставляет фантастический способ рассуждать о параллелизме в Ruby.

Выбор каналов

Как только вы начнете писать большие куски кода с помощью агента, вы поймете, что одного канала часто недостаточно. Большую часть времени вы будете иметь дело с несколькими каналами. Обычно есть часть кода, которая должна решить, что делать, основываясь на том, какие каналы готовы для чтения или записи. Вот тут и приходит вызов «выбрать».

select! в агенте, похожем по духу на вызов POSIX ,
позволяет нам действовать в соответствии с тем, какой канал готов для чтения / записи.

Давайте посмотрим на пример:

 require 'agent' require 'socket' #this example involves selecting between #two channels. $string_chan = channel!(String, 1) $int_chan = channel!(Integer, 1) def process_chan loop do select! do |s| s.case($string_chan, :receive) do |value| puts "Received string: ", value end s.case($int_chan, :receive) do |value| puts "Received integer: ", value end end end end go! do process_chan end $string_chan << "hello" $int_chan << 18 $string_chan << "world" #in a typical design, this would not actually #be here; more or less specific to this example loop do end 

Хотя поначалу код может показаться немного сложным, на самом деле он довольно прост. Суть этого лежит в методе process_chan ; в нем проживает select! заявление. Это «выбирает» между двумя каналами, $string_chan и $int_chan . Используя s.case($string_chan, :receive) , проверьте, можно ли считывать (« s.case($string_chan, :receive) $string_chan . Используя блочную нотацию, сохраните значение, считанное из $string_chan в value и затем напечатайте его. Мы делаем то же самое для $int_chan . В некотором смысле это можно считать стилем программирования, управляемым событиями: мы ожидаем данное событие (один из каналов становится читаемым).

Буферизованные каналы

До сих пор мы в основном использовали небуферизованные каналы, хотя я и пробовал буферизованные каналы в последнем примере. Для них и отправка и получение блокируются, подразумевая, что делать это:

 unbuffered_chan << 1 

ждет, пока unbuffered_chan фактически не будет прочитан.

С другой стороны, буферизованные каналы позволяют вам делать это, не дожидаясь, пока канал не «заполнится». Буферизованный канал объявляется с размером, и отправления не блокируются, пока количество сообщений, «ожидающих» (то есть не прочитанных) на канале, не достигнет размера. Если вы знакомы со структурой данных очереди, идея буферизованного канала почти такая же: сначала во-первых, то и во-первых. Это означает, что если вы поместите в канал 50 сообщений, то первое, которое вы вставите, будет прочитано первым. И если размер канала равен 50, только при попытке отправить 51-е сообщение в канал (в то время как 50 еще не прочитано) вызов будет заблокирован.

Давайте посмотрим на простой пример:

 require 'agent' buffered_chan = channel!(Integer, 2) #this goroutine makes Ruby think #there isn't a deadlock go! do loop do end end #should not block buffered_chan << 1 puts 'added one message to buffered_chan' #should not block buffered_chan << 1 puts 'added one message to buffered_chan' #will block buffered_chan << 1 puts 'this should never print' 

Основная идея кода проста. Мы объявляем буферизованный канал с размером буфера, равным 2. Затем попробуйте вставить третье сообщение в буферизованный канал без предварительного чтения из него, которое будет блокироваться навсегда. Нам нужно немного поработать, чтобы убедиться, что Ruby не думает, что есть тупик (то есть потоки просто ждут без всякой причины).

Итак, какой смысл? Вместо того, чтобы одна подпрограмма ожидала, пока другая (вероятно, выполняющая длительную операцию) считывает и обрабатывает данные из канала, процесс может продвигаться быстрее, если сообщения в канале буферизуются, то есть удерживается до трудоемкой подпрограммы. добрался до них. Конструкция этого типа интенсивно используется в многоядерной обработке (что, как уже упоминалось, в зеленых потоках агента / Ruby невозможно), но также может применяться к другим областям, требующим параллелизма.

Завершение

Надеюсь, вам понравился тур по Агенту, библиотеке, где сталкиваются Ruby и лучшие из Go. За прошедший год я много занимался разработкой Go и часто искал возможности параллелизма Go в Ruby: агент — очень позитивный шаг для утоления этой жажды.