В Rails 4.0 по умолчанию будет включена опция config.threadsafe! и в данном уроке вы узнаете о том, что же она все-таки делает, как влияет на production и как вообще стоит вести себя с потоками.
Cодержание цикла «Тонкости Rails 4»
- Cache digests
- Turbolinks
- Thread-Safety
Какое-то время назад Aaron Patterson (Tenderlove) опубликовал запись в своем блоге. В ней он рассказал об опции threadsafe в рельсах и упомянул о том, что скорее всего она будет по умолчанию включена в релизе Rails 4. Поэтому в этом эпизоде я расскажу именно о ней.
Для начала создадим простое приложение на Rails 3 под названием thready:
terminal
$ rails new thready
$ cd thready
Открыв и оглядев конфигурационный файл приложения для продакшена видно, что опция закомментирована:
/config/environments/production.rb
# Enable threaded mode
# config.threadsafe!
Необходимо понимать, что ее включение не означает волшебного превращения в мультипоточное приложение. Что же в таком случае она делает? При просмотре исходного кода метода threadsafe! видно, что происходит установка набора опций:
def threadsafe!
@preload_frameworks = true
@cache_classes = true
@dependency_loading = false
@allow_concurrency = true
self
end
Первые 3 опции отвечают за так называемую нетерпеливую загрузку приложения. Таким образом при запуске приложения оно загружается сразу же целиком, вместо загрузки по частям, как это происходит при отключенном threadsafe.
Четвертая опция, allow_concurrency отрубает использование промежуточного слоя Rack::Lock. Запустив в терминале, в режиме разработчика команду rake middleware видно, что Rack::Lock — это один из первых слоев, регистрирующий активность:
terminal
$ rake middleware
use ActionDispatch::Static
use Rack::Lock
# Остальная часть стека опущена.
Включив опцию threadsafe в продакшене и запустив:
$ rake middleware RAILS_ENV=production
Этого слоя больше не будет видно. Для ответа на вопрос «А что же делает Rack::Lock» стоит взглянуть в исходный код (внимание, файл успел претерпеть изменения):
/lib/rack/lock.rb
require 'thread'
require 'rack/body_proxy'
module Rack
class Lock
FLAG = 'rack.multithread'.freeze
def initialize(app, mutex = Mutex.new)
@app, @mutex = app, mutex
end
def call(env)
old, env[FLAG] = env[FLAG], false
@mutex.lock
response = @app.call(env)
response[2] = BodyProxy.new(response[2]) { @mutex.unlock }
response
rescue Exception
@mutex.unlock
raise
ensure
env[FLAG] = old
end
end
end
Когда в метод call приходит очередной запрос, то внутри него на объект mutex накладывается блокировка и только после этого происходит обработка запроса, после чего блок снимается. Такой подход гарантирует, что в каждый очередной момент времени будет обработан только один запрос. Для лучшего понимания процесса я покажу это наглядно. Для этого создадим новый контроллер с одним методом:
terminal
$ rails g controller foo bar
В созданном методе текущий поток будет засыпать на секунду, после чего будет отображаться некий загадочный текст:
/app/controllers/foo_controller.rb
class FooController < ApplicationController
def bar
sleep 1
render text: "foobar\n"
end
end
Теперь запустим сервер в режиме development. Заметьте, что используется WEBrick (по умолчанию это как раз он и есть). Теперь, в отдельной вкладке консоли сделаем запрос к сайту с помощью curl:
terminal
$ curl http://localhost:3000/foo/bar
foobar
Перед появлением сообщения возникла секундная задержка, как и ожидалось. Теперь сделаем 5 параллельных одновременных запросов. Для этого необходимо воспользоваться символом &, позволяющий сделать асинхронные запросы:
% repeat 5 (curl http://localhost:3000/foo/bar &)
% foobar
foobar
foobar
foobar
foobar
Здесь, конечно же, этого не видно, но эти 5 ответов пришли по очереди, друг за другом, заняв в сумме 5 секунд. Это происходит из-за вышеописанной последовательной обработки запросов. Теперь запустим сервер в режиме продакшена и снова сделаем к нему 5 параллельных запросов:
terminal
% rails s -e production
% repeat 5 (curl http://localhost:3000/foo/bar &)
Все 5 ответов от сервера придут одновременно, благодаря включенной опции threadsafe!, из-за которой слой Rack::Lock больше не используется. Так что теперь запросы обрабатываются асинхронно, ура!
Означает ли все это, что при включенной многопоточности теперь нужно начинать писать потоко-безопасный код? На самом деле, все зависит от настроек продакшена. Большинство из популярных rails-серверов, таких как Unicorn и Phusion Passenger в каждый момент времени пропускают только один запрос через одного worker’а. Другими словами, даже при включенной опции запросы будут обрабатываться по очереди.
Можно посмотреть как будет вести себя Unicorn. Для этого необходимо необходимо раскоментировать следующую строчку:
/Gemfile
# Use unicorn as the app server
gem 'unicorn'
После чего запустить bundle install. Теперь с помощью команды unicorn можно запустить сервер для Rails-приложение в режиме production:
terminal
$ unicorn -E production -p 3000
Запустив теперь вновь curl будет видно, что нет никакой многопоточности. Именно так работает Unicorn c одним worker’ом. Для Unicorn’а не требуется дополнительного mutex’а и слоя Rack::Lock. Именно по этой причине опция threadsafe! будет включена по умолчанию в продакшене: логика для обработки запросов и потоков отдается на откуп окружению в продакшене. Но не забывайте, что включение этой опции также включает нетерпеливую, принудительную загрузку всего приложения, что может вызвать некоторые побочные эффекты. Так что не забывайте хорошенько оттестировать свое приложение на тестовом сервере перед выносом на продакшен!
Еще одно небольшое замечание: опция threadsafe! может сменить свое название в релизе 4 рельс с целью лучше показать логику ее работы.
Итак, мы уже знаем, что Unicorn и Passenger не поддерживают многопоточность, а что же делать, если хочется сервер с ее поддержкой? На помощь приходит puma. Этот сервер основан на Mongrel, благодаря чему способен запускать любое Rack-приложение с использованием нескольких потоков. Puma поддерживает JRuby, Rubinius и даже MRI. Давайте попробуем:
/Gemfile
# Use unicorn as the app server
# gem 'unicorn'
gem 'puma'
Теперь запустим сервер в режиме продакшена:
terminal
$ rails s puma -e production
Запустив теперь curl будет видно, что ответы от сервера приходят почти мгновенно.
Puma не очень хорошо будет работать в MRI, из-за встроенного в эту версию руби механизма под названием Global Interpreter Lock. Но JRuby и Rubinius обладают более качественной поддержкой потоков, так что в них Пума будет работать лучше.
Разумеется, при использовании многопоточности нужно внимательно следить за кодом в своем приложении и его безопасностью. Вот небольшой пример небезопасного кода:
/app/controllers/foo_controller.rb
class FooController < ApplicationController
@@counter = 0
def bar
counter = @@counter
sleep 1
counter += 1
@@counter = counter
render text: "#{@@counter}\n"
end
end
В контроллере имеется переменная класса под названием @@counter, увеличивающуюся на 1 при каждом вызове метода bar. В самом методе мы сохраняем значение переменной класса, спим секунду, после чего возвращаем значение и отображаем его на экран. Посмотрим, как все будет работать в однопоточной среде:
terminal
$ rails s
Запустим curl 4 раза, после чего появятся 4 цифры, каждая — с секундной задержкой:
terminal
% repeat 4 (curl http://localhost:3000/foo/bar &)
% 1
2
3
4
Теперь остановим сервер, и запустим Puma в режиме продакшена:
terminal
$ rails s puma -e production
Ее вывод будет отличаться:
terminal
% repeat 4 (curl http://localhost:3000/foo/bar &)
% 1
1
1
1
Теперь запросы обрабатываются одновременно, в нескольких потоках. Таким образом последний запрос успел завершиться еще до того, как первый успел довести свою работу до конца. Поэтому следует быть очень аккуратным в работе с данными (@@counter, к примеру), к которым имеют доступ несколько потоков. Для разрешения возникшей проблемы необходимо воспользоваться объектом типа mutex:
/app/controllers/foo_controller.rb
class FooController < ApplicationController
@@counter = 0
@@mutex = Mutex.new
def bar
@@mutex.synchronize do
counter = @@counter
sleep 1
counter += 1
@@counter = counter
end
render text: "#{@@counter}"
end
end
Обрамленный код mutex'ом становится потоко-безопасным. После этого нужно перезапустить сервер приложения и вновь воспользоваться curl. Счетчик теперь работает корректно, поскольку код обрабатывает потоки последовательно друг за другом.
Такие вещи как переменные класса и объекта, глобальные переменные и константы необходимо использовать внутри mutex'а. Несмотря на предупреждения интерпетатора константы в руби можно изменить. Как и строки, впрочем. В случае со строками стоит воспользоваться методом freeze для запрета на их изменение. И не забудьте, пожалуйста, что код внутри класса является shared memory, поэтому не надо вставлять методы в сам класс динамически после загрузки приложения.
К счастью, преобразить свое приложение в потоко-безопасное не настолько уж и сложно, как может сперва показаться. Как правило, вам вряд ли придется часто расшаривать изменяемую информацию. А если и придется, то лучше поглядеть на известные способы это сделать. Вполне вероятно, что существует более элегантные методы.
Пожалуй, непростой задачей будет удостовериться, что все используемые гемы в вашем приложении потоко-безопасны. Почитайте README у используемых библиотек, если там нужной информации нет, то стоит, пожалуй, создать тикет в трекере.
Есть еще одна проблема, с которой можно столкнуться в мультипоточном приложении и связана она с пулом соединений базы данных. Этот пул задает количество одновременных соединений к бд и по умолчанию равен 5. Для демонстрации я закомментил mutex.
/app/controllers/foo_controller.rb
def bar
#@@mutex.synchronize do
counter = @@counter
sleep 1
counter += 1
@@counter = counter
#end
render text: "#{@@counter}\n"
end
Попробовав теперь сделать 12 одновременных запросов к приложению станет видно, что только 4 из них прошли, из-за ограничения количества соединений. Несмотря на то, что в данном методе не происходит запроса к бд, rails все равно резервирует соединения на протяжении каждого запроса. И если во время запроса будет не хватать соединений, то приложение будет ждать, пока не получит своего. Таким образом, если запрос затянется по времени, то начнут сыпаться timeout ошибки и другие запросы тоже отвалятся по причине нехватки доступных соединений из пула бд.
Увеличив значение в пуле до 15, можно повторить предыдущий запрос curl и все запросы пройдут успешно.
Спасибо за внимание!
На публикацию было получено разрешение от автора (поскольку это перевод платного скринкаста). Обо всех неточностях и ошибках просьба сообщать в личку.
Приложение
Подписывайтесь на мой блог!