..

Асинхронность в 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 "Основной поток продолжил выполнение"

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

Преимущества:

Fiber

В современных языках программирования наряду с потоками существуют встроенные легковесные примитивы для организации асинхронного кода: горутины в Go, корутины в Kotlin, файберы в Ruby.

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

Пример использования файбера:

fiber = Fiber.new do
  puts "Начало работы файбера"
  Fiber.yield
  puts "Возобновление работы файбера"
end

fiber.resume
puts "Между резюме"
fiber.resume

Преимущества:

Недостатки:

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-ами, что приведёт к ошибке.

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

Преимущества:

Недостатки:

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,
не удобно

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

« DDD и проблема согласования словаря: почему это сложно, но необходимо