Треды и выполнение кода в Rails

После прочтения этого руководства вы узнаете:

  • Какой код Rails будет автоматически выполняться конкурентно
  • Как интегрировать ручную конкурентность с внутренними компонентами Rails
  • Как обернуть весь код приложения
  • Как повлиять на перезагрузку приложения

1. Автоматическая конкурентность

Rails автоматически позволяет выполнять различные операции одновременно.

При использовании тредового веб-сервера, такого как дефолтный Puma, несколько HTTP-запросов будут обслуживаться одновременно, причем каждому запросу предоставляется свой собственный экземпляр контроллера.

Тредовые адаптеры Active Job, в том числе встроенный Async, будут также выполнять несколько заданий в то же время. Аналогичным образом управляются каналы Action Cable.

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

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

2. Executor

Executor в Rails отделяет код приложения от кода фреймворка: в любое время, когда фреймворк вызывает код, написанный в приложении, он будет обернут Executor.

Executor состоит из двух колбэков: to_run и to_complete. Колбэк to_run вызывается до кода приложения, а to_complete - после.

2.1. Дефолтные колбэки

В приложении Rails по умолчанию колбэки Executor используются для:

  • отслеживания, какие треды находятся в безопасных положениях для автозагрузки и перезагрузки
  • включения и отключения кэша запросов Active Record
  • возвращения приобретенного подключения Active Record к пулу
  • ограничения продолжительности жизни внутреннего кэша

До Rails 5.0 некоторые из них обрабатывались отдельными классами промежуточной программы Rack (такими, как ActiveRecord::ConnectionAdapters::ConnectionManagement) или напрямую оборачивали код с помощью таких методов, как ActiveRecord::Base.connection_pool.with_connection do. Executor заменяет их с помощью единого, более абстрактного интерфейса.

2.2. Оборачивание кода приложения

Если пишется библиотека или компонент, которые будут вызывать код приложения, необходимо обернуть их с помощью вызова Executor:

Rails.application.executor.wrap do
  # здесь вызывается код приложения
end

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

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

Thread.new do
  Rails.application.executor.wrap do
    # здесь какой-либо код
  end
end

Конкурентный Ruby использует ThreadPoolExecutor, который иногда настраивается с помощью опции executor. Несмотря на название, они не связаны.

Executor является безопасно реентерабельным; если он уже активен в текущем треде, wrap игнорируется.

Если нецелесообразно физически оборачивать код приложения в блок (например, Rack API делает это проблематично), можно также использовать пару run! / complete!:

Thread.new do
  execution_context = Rails.application.executor.run!
  # здесь какой-либо код
ensure
  execution_context.complete! if execution_context
end

2.3. Конкурентность (Concurrency)

Executor поместит текущий тред в режим running при Load Interlock. Эта операция временно будет блокироваться, если другой тред в настоящее время либо автозагружает константу, либо выгружает/перезагружает приложение.

3. Reloader

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

Rails.application.reloader.wrap do
  # здесь вызывается код приложения
end

Reloader подходит только тогда, когда долговременный процесс на уровне фреймворка повторно вызывается в коде приложения, например, для веб-сервера или очереди заданий. Rails автоматически оборачивает веб-запросы и воркеры Active Job, поэтому редко приходится ссылаться на Reloader. Всегда учитывайте, подходит ли Executor для конкретного случая.

3.1. Колбэки

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

Reloader также предоставляет колбэки to_run и to_complete; они вызываются в тех же точках, что и для Executor, но только когда текущее выполнение инициировало перезагрузку приложения. Когда нет необходимости в перезагрузке, Reloader будет вызывать обернутый блок без каких-либо других колбэков.

3.2. Выгрузка классов (Class Unload)

Наиболее значительная часть процесса перезагрузки - это выгрузка классов, в которой все автозагруженные классы удаляются, и их можно снова загружать. Это происходит непосредственно до колбэков to_run или to_complete, в зависимости от параметра reload_classes_only_on_change.

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

3.3. Конкурентность

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

Если это произойдет в "дочернем" треде, с ожидающим родителем внутри Executor, это вызовет неизбежный дедлок: перезагрузка должна произойти до того, как дочерний тред будет выполнен, но он не может быть безопасно выполнен, пока родительский тред находится в середине выполнения. Вместо этого дочерний тред должен использовать Executor.

4. Поведение фреймворка

Компоненты фреймворка Rails используют эти инструменты для управления своей собственной конкурентностью.

ActionDispatch::Executor и ActionDispatch::Reloader являются промежуточными программами Rack, которые обертывают запрос с помощью поставляемого Executor или Reloader, соответственно. Они автоматически включены в дефолтный стек приложений. Reloader гарантирует, что любой входящий HTTP-запрос будет обслуживаться последней копией приложения, если произойдут какие-либо изменения кода.

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

Action Cable использует вместо этого Executor: поскольку соединение Cable связано с конкретным экземпляром класса, невозможно перезагрузить для каждого прибывающего веб-сокета сообщение. Тем не менее, обрабатывается только обработчик сообщений; долговременное соединение Cable не предотвращает перезагрузку, вызванную новым входящим запросом или заданием. Вместо этого Action Cable использует колбэк before_class_unload Reloader для отключения всех его соединений. Когда клиент автоматически переподключается, он имеет дело с новой версией кода.

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

4.1. Конфигурация

Reloader проверяет только изменения файлов, когда cache_classes это false, а reload_classes_only_on_change это true (это значения по умолчанию в среде development).

Когда cache_classes это true (в production, по умолчанию), Reloader это всего лишь переходник к Executor.

У Executor всегда есть важная работа, например управление подключением к базе данных. Когда cache_classes и eager_load это true (production), автозагрузка или перезагрузка класса не будут происходить, поэтому Load Interlock не требуется. Если любой из них это false (development), то Executor будет использовать Load Interlock, гарантируя что константы загружаются только тогда, когда они безопасны.

5. Load Interlock

Load Interlock позволяет включить автозагрузку и перезагрузку в многотредовой среде выполнения.

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

Точно так же безопасно выполнять выгрузку/перезагрузку, когда код приложения не находится в середине выполнения: после перезагрузки константа User, например, может указывать на другой класс. Без этого правила несвоевременная перезагрузка будет означать, что User.new.class == User или даже User == User могут быть false.

Оба этих ограничения рассматриваются Load Interlock. Он отслеживает, какие треды в настоящее время запускают код приложения, загружают класс или выгружают автозагруженные константы.

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

5.1. permit_concurrent_loads

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

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

Например, если предположить, что User еще не загружен, следующее приведет к дедлоку:

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # внутренний тред ждет здесь; он не может
           # загружать User, пока выполняется другой тред
    end
  end

  th.join # внешний тред ждет здесь, удерживая блокировку 'running'
end

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

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # внутренний тред может приобрести блокировку 'load',
           # загрузить User и продолжить
    end
  end

  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    th.join # внешний тред ждет здесь, но не имеет блокировки
  end
end

Другой пример, с использованием конкурентного Ruby:

Rails.application.executor.wrap do
  futures = 3.times.collect do |i|
    Concurrent::Future.execute do
      Rails.application.executor.wrap do
        # здесь делаем работу
      end
    end
  end

  values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    futures.collect(&:value)
  end
end

5.2. ActionDispatch::DebugLocks

Если приложение попадает в дедлок, и вы думаете, что это из-за Load Interlock, можно временно добавить промежуточную программу ActionDispatch::DebugLocks в config/application.rb:

config.middleware.insert_before Rack::Sendfile,
                                  ActionDispatch::DebugLocks

Если затем перезапустить приложение и перезапустить условие дедлока, /rails/locks покажет сводку всех тредов, которые в настоящее время известны интерлоку, какой уровень блокировки они удерживают или ждут, и их текущий бэктрейс.

Как правило, дедлок будет вызван интерлоком, конфликтующим с какой-либо другой внешней блокировкой или блокирующим I/O вызовом. Как только он будет найден, можно обернуть его с помощью permit_concurrent_loads.