Асинхронность в Ruby
03 Apr 2025, Сергей УдаловПредставьте себе ситуацию, когда ваш веб-сервис получает тысячи запросов в секунду, и каждый из них обрабатывается последовательно. В результате пользователи сталкиваются с ощутимыми задержками, что негативно сказывается на их опыте работы с сервисом.
В этой статье мы рассмотрим, как использование асинхронных подходов позволяет эффективно параллелить выполнение запросов, минимизируя время ожидания и снижая нагрузку на сервер. Мы подробно разберем основные методы организации асинхронного кода в Ruby — от потоков и процессов до современных инструментов, таких как Fiber и Ractor, а также расскажем, как такие решения помогают обеспечить масштабируемость и стабильность приложения даже при высоких нагрузках.
Простой пример
Рассмотрим простой пример: если мы делаем 100 HTTP-запросов последовательно, это займет значительно больше времени, чем если мы сделаем их параллельно.
require 'net/http'
require 'uri'
# этим методом мы будем делать запрос в интернет
def response(url) = Net::HTTP.get_response(URI.parse(url))
# Создаем массив из 100 URL
urls = (1..100).map { |i| "https://example.com/#{i}" }
# Последовательная обработка занимает много времени
urls.each { |url| results << response(url) } # ~100 сек
Проблема в том, что мы начинаем выполнять следующий запрос только после окончания предыдущего. Однако, это вовсе не обязательно:
# асинхронно можно сильно быстрее
urls.each { |url| Thread.new { results << response(url) } } # ~1 сек
Почему это возможно?
Дело в том, что в реальных задачах процессор почти все время простаивает, потому что мы ждем ответа от устройств ввода/вывода, таких как сеть, диск, база данных (тоже сеть). А еще у нас у каждого уже давно не сервера, а “суперкомпьютеры”, на которых куча ядер. Так что тут есть большой простор для ускорения наших приложений.
Но разве мы для этого покупали дорогой CPU, чтобы он отдыхал? Отдыхать должен рубист, а компьютер должен работать. Пока процессор ждет ответ, почему бы его не занять чем-то полезным, например, обработкой следующей задачи. А как придет ответ от сети, можно переключиться обратно.
Забегая вперед, скажу, что в Ruby on Rails асинхронность уже присутствует: Puma использует потоки и процессы для параллельной обработки запросов, а Sidekiq выполняет задачи параллельно.
Для эффективной работы необходимо, чтобы одна задача не блокировала другие, и для этого нужна асинхронность.
Параллельность vs конкурентность
Часто возникают вопросы о параллельности и конкурентности. В чем же разница?
Параллельность подразумевает одновременное выполнение нескольких задач на разных ядрах процессора. Это “реальное” выполнение одновременно.
Конкурентность же – это способность системы обрабатывать несколько задач, создавая иллюзию одновременного выполнения. Это может достигаться путем быстрого переключения между задачами на одном ядре.
Параллельность похожа на работу нескольких поваров на кухне: каждый готовит своё блюдо одновременно. Конкурентность — это когда один повар быстро переключается между несколькими блюдами, создавая впечатление, что всё готовится одновременно.
Ключевое отличие: параллельность требует нескольких ядер, а конкурентность – нет. Конкурентность позволяет добиться прогресса, даже если у вас только одно ядро.
Тут можно вспомнить про GVL, который ограничивает параллельное выполнение в MRI. Из-за этого механизма программа на ruby не может задействовать больше 1 ядра CPU. Это серьезное ограничение, но его можно преодолеть. Увидим чуть позже.
Для простоты я буду использовать термин асинхронность, к этому понятию относится как конкурентность, так и параллелизм.
Какие механизмы ассинхронности у нас есть в Ruby?
Process
Процесс — это механизм операционной системы, который позволяет запустить несколько независимых программ.
В Ruby процессы представляют собой запуск отдельных экземпляров интерпретатора, где каждый экземпляр работает в своём изолированном адресном пространстве. Это позволяет обойти ограничение GVL (Global VM Lock), характерное для MRI, и использовать все доступные ядра.
Пример простого использования fork
:
pid = fork do
puts "Это выполняется в дочернем процессе"
end
Process.wait(pid)
puts "Дочерний процесс завершён"
Однако, такие процессы не могут иметь общих переменных. И координировать процессы становится сложно: нужно использовать сокеты, порты, внешние ресурсы (бд, редис, очереди).
Преимущества:
- используются все ядра
Недостатки:
- самый ресурсоемкий вариант
- нет простого способа коммуницировать между процессами
Thread
Потоки в Ruby позволяют выполнять несколько нитей внутри одного процесса, что особенно полезно для I/O-ориентированных задач. Нить (thread) — это единица исполнения внутри процесса операционной системы. Она позволяет выполнять несколько задач одновременно, используя ресурсы одного процесса. Хотя использование потоков позволяет добиться конкурентного выполнения, в MRI ограничение GVL (Global VM Lock) не позволяет выполнять CPU-ориентированные задачи параллельно в полном объёме. Тем не менее, для задач, где программа часто ожидает внешние события, потоки становятся удобным инструментом.
Минимальный пример использования потоков:
thread = Thread.new do
puts "Работа потока"
sleep 1
puts "Поток завершён"
end
thread.join
puts "Основной поток продолжил выполнение"
При использовании потоков важно уделять внимание синхронизации доступа к разделяемым данным, чтобы избежать гонок и дедлоков.
Преимущества:
- эффективнее, чем процессы
- могут иметь общие переменные в рамках одного процесса Недостатки:
- используется 1 ядро
- ограничение GVL - параллельно исполняется только 1 поток
- все же, высокое потребление ресурсов, если сравнивать с легковесными потоками (см Fiber)
Fiber
В современных языках программирования наряду с потоками существуют встроенные легковесные примитивы для организации асинхронного кода: горутины в Go, корутины в Kotlin, файберы в Ruby.
Файберы – это легковесные примитивы для реализации кооперативной многозадачности, позволяющие явно передавать управление между различными блоками кода. Они работают в рамках одного потока, что упрощает управление состоянием, однако ответственность за передачу управления полностью ложится на программиста. Файберы отлично подходят для реализации асинхронного ввода-вывода и нелинейного исполнения кода.
Пример использования файбера:
fiber = Fiber.new do
puts "Начало работы файбера"
Fiber.yield
puts "Возобновление работы файбера"
end
fiber.resume
puts "Между резюме"
fiber.resume
Преимущества:
- простота использования
- эффективность по ресурсам
Недостатки:
- используется только одно ядро (GVL)
Ractor
Мы подошли к самому интересному. Кажется, что ruby, один из немногих языков, который имеет несколько реализаций легковесных потоков. Кроме Fiber, в ruby 3.5 появляются Ractor.
Ractor = Ruby Actor
Причина, по которой нам потребовались ракторы - это GVL. Повторюсь, что механизм защиты конкурентного доступа к общим данным ограничивает интерпретатор от параллельного исполнения, даже если у нас запущено несколько потоков.
Ractor призван снять это ограничение. Использую ракторы и паттерн Actor можно добиться использования всех ядер процессора.
results = urls.map do |url|
Ractor.new(url) do |url|
response(url)
end
end
puts results.map(&:take)
Ractor — это параллельная модель выполнения в Ruby, вдохновленная Erlang. Она позволяет создавать независимые процессы (ractors), которые обмениваются сообщениями, избегая общих изменяемых данных и, следовательно, проблем с блокировками. Ractor подходит для задач, требующих высокой степени параллелизма и отказоустойчивости.
Есть существенного ограничение. Для того, чтобы избежать GVL потребовалось ввести ограничение на то, чем могут обмениваться такие процессы: только простые объекты и только копирование. То есть, передать объект своего класса с состоянием не получится.
Например:
class MyClass
attr_accessor :value
def initialize(value)
@value = value
end
end
ractor1 = Ractor.new do
obj = MyClass.new(42)
Ractor.yield(obj)
end
ractor2 = Ractor.new(ractor1) do |other_ractor|
received_obj = other_ractor.take
puts received_obj.value # Этот код вызовет ошибку
end
Этот пример демонстрирует попытку передать объект класса MyClass
между двумя ractor-ами, что приведёт к ошибке.
С одной стороны, мы получаем суперсилу, которая позволяет использовать процессор по полной, с другой - использовать это становится непривычно и неудобно.
Преимущества:
- позволяет использовать все ядра
- легковесные процессы
Недостатки:
- все еще не готовы для production
- производительность не превышает fiber, даже притом, что заняты все ядра
- неудобно писать код
Bonus Track
GVL: Глобальная блокировка VM
GVL (Global VM Lock) – это механизм, применяемый в MRI (Matz’s Ruby Interpreter), который ограничивает выполнение Ruby-кода так, что в один момент времени только один поток может исполнять интерпретируемый код. Это обеспечивает безопасность работы с общими объектами, но накладывает ограничения на настоящую параллельность при выполнении вычислительно интенсивных задач.
Важно отметить, что не все реализации Ruby используют GVL. Например, JRuby и TruffleRuby разработаны таким образом, чтобы позволять потокам выполняться параллельно на разных ядрах процессора. Это особенно актуально для задач, требующих интенсивного использования CPU, так как отсутствие GVL позволяет эффективно использовать все ресурсы системы.
Таким образом, при выборе реализации Ruby стоит учитывать, насколько критична для вашего приложения возможность настоящей параллельности. Если вы планируете решать вычислительно тяжелые задачи, лучше обратить внимание на альтернативные реализации, не имеющие ограничений, связанных с GVL.
Puma
А что насчет Puma? Вот пример конфигурационного файла Puma:
# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY") { 2 })
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS") { 5 })
threads threads_count, threads_count
preload_app!
rackup DefaultRackup
port ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "development" }
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end
Если workers больше 1, то puma запускается в кластерном режиме. Каждый воркер запускается в отдельном процессе. И в каждом этом воркере будет столько потоков, сколько указано в threads. Таким образом, даже если мы пишем синхронный код, на уровне сервера приложений, он будет обрабатывать конкурентно несколько запросов даже если запущен в одном процессе.
Sidekiq
Самый популярный (пока что) обработчик фоновых задач для Ruby on Rails тоже использует потоки.
# config/sidekiq.yml
:concurrency: 25
:queues:
- default
- mailers
# Пример для Redis на локальной машине
:redis:
url: redis://localhost:6379/0
# Опции для мониторинга Sidekiq (sidekiq-web)
:schedule:
cleanup:
cron: "0 * * * *" # Каждый час
class: CleanupJob
concurrency
- это число потоков, которое будет запущено в одном процессе.
Число потоков можно регулировать через параметры запуска:
bundle exec sidekiq -c 25
# или так
RAILS_MAX_THREADS=25 bundle exec sidekiq
А вот чтобы запустить несколько процессов, по примеру кластерного режима в Puma, уже понадобится немного DevOps:
services:
worker:
build: .
command: bundle exec sidekiq -C config/sidekiq.yml -c 25
deploy:
replicas: 2
Вот так мы сможем запустить 2 процесса, в каждом из которых по 25 потоков.
Итоги
В статье мы обсудили, что такое параллелизм и конкурентность, какие возможности для реализации ассинхронного кода есть в ruby, какие преимущества и недостатки у каждого из них.
Ресурсы | Изоляция | Параллельность | Ограничение | |
---|---|---|---|---|
Process | плохо | отлично | да | большое потребление ресурсов |
Thread | средне | средне | нет | GVL |
Fiber | хорошо | плохо | нет | GVL |
Ractor | хорошо | хорошо | да | не готово к production, не удобно |
Теперь ты знаешь, как асинхронность может ускорить твои приложения и улучшить пользовательский опыт. Попробуй внедрить асинхронные методы в свой проект и увидишь разницу!