Параллелизм и параллелизм в Ruby: практическое руководство

Опубликовано: 2022-03-11

Начнем с того, что проясним слишком распространенную путаницу среди разработчиков Ruby; а именно: параллелизм и параллелизм - это не одно и то же (т. е. параллельный! = параллельный).

В частности, параллелизм Ruby — это когда две задачи могут запускаться, выполняться и завершаться в перекрывающиеся периоды времени. Однако это не обязательно означает, что они оба будут выполняться одновременно (например, несколько потоков на одноядерной машине). Напротив, параллелизм — это когда две задачи буквально выполняются одновременно (например, несколько потоков на многоядерном процессоре).

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

В этом учебнике представлено практическое (а не теоретическое) рассмотрение различных методов и подходов, доступных для параллелизма и параллелизма в Ruby.

Дополнительные примеры Ruby из реального мира см. в нашей статье об интерпретаторах Ruby и средах выполнения.

Наш тестовый пример

В качестве простого тестового примера я создам класс Mailer и добавлю функцию Фибоначчи (вместо метода sleep() ), чтобы сделать каждый запрос более ресурсоемким, как показано ниже:

 class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end

Затем мы можем вызвать этот класс Mailer для отправки почты следующим образом:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(Примечание. Исходный код этого тестового примера доступен здесь, на github.)

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

 puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }

Это дало следующие результаты на четырехъядерном процессоре с MRI Ruby 2.0.0p353:

 15.250000 0.020000 15.270000 ( 15.304447)

Несколько процессов против многопоточности

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

Процессы Потоки
Использует больше памяти Использует меньше памяти
Если родитель умирает до выхода дочерних процессов, дочерние процессы могут стать процессами-зомби. Все потоки умирают, когда процесс умирает (без шансов на зомби)
Переключение контекста для разветвленных процессов дороже, поскольку ОС должна сохранять и перезагружать все Потоки имеют значительно меньшие накладные расходы, поскольку они используют совместное адресное пространство и память.
Разветвленные процессы получают новое пространство виртуальной памяти (изоляция процессов). Потоки совместно используют одну и ту же память, поэтому необходимо контролировать и решать проблемы с параллельной памятью.
Требуется межпроцессное взаимодействие Может «общаться» через очереди и разделяемую память
Медленнее создавать и разрушать Быстрее создавать и уничтожать
Легче кодировать и отлаживать Может быть значительно сложнее кодировать и отлаживать

Примеры решений Ruby, использующих несколько процессов:

  • Resque: поддерживаемая Redis библиотека Ruby для создания фоновых заданий, размещения их в нескольких очередях и последующей обработки.
  • Unicorn: HTTP-сервер для стоечных приложений, предназначенный только для обслуживания быстрых клиентов на соединениях с малой задержкой и высокой пропускной способностью и использующий преимущества функций Unix/Unix-подобных ядер.

Примеры решений Ruby, использующих многопоточность:

  • Sidekiq: полнофункциональная среда фоновой обработки для Ruby. Он нацелен на простоту интеграции с любым современным приложением Rails и гораздо более высокую производительность, чем другие существующие решения.
  • Puma: веб-сервер Ruby, созданный для параллелизма.
  • Thin: очень быстрый и простой веб-сервер Ruby.

Несколько процессов

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

В Ruby системный вызов fork() используется для создания «копии» текущего процесса. Этот новый процесс запланирован на уровне операционной системы, поэтому он может работать одновременно с исходным процессом, как и любой другой независимый процесс. ( Примечание: fork() — это системный вызов POSIX, поэтому он недоступен, если вы используете Ruby на платформе Windows.)

Итак, давайте запустим наш тестовый пример, но на этот раз с помощью fork() для использования нескольких процессов:

 puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }

( Process.waitall ожидает завершения всех дочерних процессов и возвращает массив статусов процессов.)

Этот код теперь дает следующие результаты (опять же, на четырехъядерном процессоре с MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

Не слишком потрепанный! Мы сделали почтовую программу примерно в 5 раз быстрее, всего лишь изменив пару строк кода (например, используя fork() ).

Однако не стоит слишком волноваться. Хотя использование разветвления может показаться заманчивым, так как это простое решение для параллелизма в Ruby, у него есть серьезный недостаток — объем памяти, который он будет потреблять. Разветвление несколько дорого, особенно если Copy-on-Write (CoW) не используется интерпретатором Ruby, который вы используете. Например, если ваше приложение использует 20 МБ памяти, 100 разветвлений потенциально могут потреблять до 2 ГБ памяти!

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

Рубиновая многопоточность

Итак, теперь давайте попробуем сделать ту же программу быстрее, используя методы многопоточности Ruby.

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

Имея это в виду, давайте вернемся к нашему тестовому примеру, но на этот раз с использованием класса Thread Ruby:

 threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Этот код теперь дает следующие результаты (опять же, на четырехъядерном процессоре с MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

облом. Это конечно не очень впечатляет! Итак, что происходит? Почему это дает почти те же результаты, что и при синхронном запуске кода?

Ответ, который является проклятием существования многих Ruby-программистов, — глобальная блокировка интерпретатора (GIL) . Благодаря GIL CRuby (реализация MRI) на самом деле не поддерживает многопоточность.

Глобальная блокировка интерпретатора — это механизм, используемый в интерпретаторах компьютерных языков для синхронизации выполнения потоков, чтобы одновременно мог выполняться только один поток. Интерпретатор, использующий GIL, всегда разрешает выполнение только одного потока и только одного потока за раз , даже если он выполняется на многоядерном процессоре. Ruby MRI и CPython — два наиболее распространенных примера популярных интерпретаторов с GIL.

Итак, вернемся к нашей проблеме: как мы можем использовать многопоточность в Ruby для повышения производительности в свете GIL?

Что ж, в МРТ (CRuby) неудачный ответ заключается в том, что вы в основном застряли, и многопоточность мало что может для вас сделать.

Тем не менее параллелизм Ruby без параллелизма может быть очень полезен для задач с большим объемом операций ввода-вывода (например, задач, которые должны часто ожидать в сети). Таким образом, потоки все еще могут быть полезны в МРТ для задач с большим объемом ввода-вывода. В конце концов, есть причина, по которой потоки были изобретены и использовались еще до того, как многоядерные серверы стали обычным явлением.

Но при этом, если у вас есть возможность использовать версию, отличную от CRuby, вы можете использовать альтернативную реализацию Ruby, такую ​​как JRuby или Rubinius, поскольку у них нет GIL, и они поддерживают настоящую параллельную многопоточность Ruby.

с JRuby

Чтобы доказать это, вот результаты, которые мы получаем, когда запускаем точно такую ​​же многопоточную версию кода, что и раньше, но на этот раз запускаем ее на JRuby (вместо CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

Теперь мы говорим!

Но…

Темы не бесплатны

Улучшенная производительность с несколькими потоками может привести к мысли, что мы можем просто добавлять больше потоков — практически бесконечно — чтобы наш код работал все быстрее и быстрее. Это действительно было бы хорошо, если бы это было правдой, но реальность такова, что потоки не бесплатны, и поэтому рано или поздно у вас закончатся ресурсы.

Допустим, например, что мы хотим запустить наш образец почтовой программы не 100 раз, а 10 000 раз. Давай посмотрим что происходит:

 threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

Бум! Я получил ошибку с моей OS X 10.8 после создания около 2000 потоков:

 can't create Thread: Resource temporarily unavailable (ThreadError)

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

Объединение потоков

К счастью, есть лучший способ; а именно, объединение потоков.

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

Ключевым параметром конфигурации пула потоков обычно является количество потоков в пуле. Эти потоки могут создаваться либо сразу (т. е. при создании пула), либо лениво (т. е. по мере необходимости, пока не будет создано максимальное количество потоков в пуле).

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

Объединение потоков

Итак, возвращаясь к нашему примеру, мы начнем с использования Queue (поскольку это потокобезопасный тип данных) и воспользуемся простой реализацией пула потоков:

требуют «./lib/mailer» требуют «тест» требуют «поток»

 POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)

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

Затем мы отправили идентификаторы почтовых программ в очередь заданий и создали пул из 10 рабочих потоков.

В каждом рабочем потоке мы извлекаем элементы из очереди заданий.

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

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

Целлулоид

Благодаря экосистеме Ruby Gem большая часть сложности многопоточности аккуратно инкапсулирована в ряд простых в использовании готовых Ruby Gems.

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

В контексте нашего обсуждения в этом посте я специально сосредоточился на функции пулов, но сделайте себе одолжение и ознакомьтесь с ней более подробно. Используя Celluloid, вы сможете создавать многопоточные программы на Ruby, не беспокоясь о таких неприятных проблемах, как тупиковые блокировки, и вы обнаружите, что использование других более сложных функций, таких как Futures и Promises, является тривиальным.

Вот насколько проста многопоточная версия нашей почтовой программы с использованием Celluloid:

 require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end

Чистый, простой, масштабируемый и надежный. О чем вы еще хотите попросить?

Фоновые задания

Конечно, другой потенциально жизнеспособной альтернативой, в зависимости от ваших операционных требований и ограничений, может быть использование фоновых заданий. Существует ряд Ruby Gems для поддержки фоновой обработки (т. е. сохранения заданий в очереди и их последующей обработки без блокировки текущего потока). Известные примеры включают Sidekiq, Resque, Delayed Job и Beanstalkd.

В этом посте я буду использовать Sidekiq и Redis (кэш и хранилище с открытым исходным кодом).

Во-первых, давайте установим Redis и запустим его локально:

 brew install redis redis-server /usr/local/etc/redis.conf

Запустив наш локальный экземпляр Redis, давайте взглянем на версию нашего примера почтовой программы ( mail_worker.rb ) с использованием Sidekiq:

 require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end

Мы можем запустить Sidekiq с помощью файла mail_worker.rb :

 sidekiq -r ./mail_worker.rb

А потом из ИРБ:

 ⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100

Потрясающе просто. И его можно легко масштабировать, просто изменив количество рабочих.

Другой вариант — использовать Sucker Punch, одну из моих любимых библиотек асинхронной обработки RoR. Реализация с использованием Sucker Punch будет очень похожей. Нам просто нужно включить SuckerPunch::Job , а не Sidekiq::Worker , и MailWorker.new.async.perform() , а не MailWorker.perform_async() .

Заключение

Высокий параллелизм не только достижим в Ruby, но и проще, чем вы думаете.

Один из жизнеспособных подходов — просто разветвить запущенный процесс, чтобы увеличить его вычислительную мощность. Другой метод заключается в использовании преимуществ многопоточности. Хотя потоки легче процессов и требуют меньших накладных расходов, вы все равно можете исчерпать ресурсы, если одновременно запустите слишком много потоков. В какой-то момент вам может понадобиться использовать пул потоков. К счастью, многие сложности многопоточности упрощаются за счет использования любого из множества доступных драгоценных камней, таких как Celluloid и его модель Актера.

Еще один способ справиться с трудоемкими процессами — использовать фоновую обработку. Существует множество библиотек и сервисов, позволяющих реализовать фоновые задания в ваших приложениях. Некоторые популярные инструменты включают платформы заданий на основе базы данных и очереди сообщений.

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