В документации к неблокирующему вебсерверу Торнадо красиво расписано как здорово он справляется с нагрузкой, и вообще является венцом творения человечества в области неблокирующих серверов. Отчасти это верно. Но при построении сложных приложений за рамками «еще одного чата» выявляется много неочевидных и тонких моментов, о которых желательно знать до вояжа по граблям. Под «катом» разработчики клуба интелектуальных игр Трельяж готовы поделиться своими мыслями о подводных камнях.
Сразу оговоримся, что речь идет о второй ветке python, о последней версии tornado 1.2.1, и о postgresql, подключенном через psycopg2.
Application Instance
Многие программисты обожают использовать
к инстансу класса приложения. Если мы думаем о дальнейшем горизонтальном масштабировании,
то этого делать крайне не рекомендуется. Объект request принесет вам потокобезопасный
application на блюдечке, который можно использовать без риска отстрелить себе ногу в самом
неожиданном месте.
Websockets
Увы и ах. Всеми любимый nginx не умеет проксировать websocket протокол. Для поклонников «лайти», хороших новостей в этом отношении тоже немного. Многие хвалят ha-proxy, но в нашем случае удобнее оказалось вынести всю статику на другой узел с честным nginx, а весь динамический контент отдавать самим tornado-server. Полгода жизни под, иногда стрессовыми нагрузками, показали, что это решение вполне жизнеспособно. Если при этом используется flash-прокладка для эмуляции ws протокола, то ее нужно отдавать с того же домена во избежание перехода на insecure версию. Решение с прокладкой, также требует flash policy xml, который можно отдать с 843 порта тем же nginx.
Подключение к базе
Очевидно, что на нагруженных сервисах и речи быть не может об ужасно дорогих операциях соединения с базой на каждый чих. Вполне можно использовать простейший штатный пул подключений от psycopg2. Сразу берем потокобезопасный ThreadedConnectionPool из которого по мере необходимости выбираем соединения, и после окончания запросов не забываем возвращать обратно. Под «не забываем возвращать» подразумевается НИКОГДА не забываем. Какое бы исключение у нас не приключилось внутри. Использование python конструкции finally здесть более чем уместна.
Асинхронные запросы
В однопоточных неблокирующих серверах все выглядит красиво, ровно до тех пор, пока вам не придется выполнить какое-то, относительно долгое действие. Отправить письмо, сделать выборку из базы, послать запрос на внешний вебсервис и т. д. В этом случае все остальные подключенные клиенты будут покорно ждать когда до них дойдет очередь обработчика.
Если все что вам нужно в обработчике запроса, это просто дернуть базу и что-то вывести, то можно воспользоваться асинхронным враппером momoko. Тогда простейший запрос будет выглядеть примерно так:
class MainHandler(BaseHandler):
@tornado.web.asynchronous
def get(self):
self.db.execute('SELECT 4, 8, 15, 16, 23, 42;', callback=self._on_response)
def _on_response(self, cursor):
self.write('Query results: %s' % cursor.fetchall())
self.finish()
Безопасная многопоточность
Итак, для базы и для внешних вебсервисов есть асинхронные средства. Но что делать, если вам нужно выполнить большой кусок работы из многих sql запросов, что-то пересчитать громоздкое в недрах сервера, да еще и нагрузить дисковую i/o подсистему? Конечно мы можем состряпать гроздья из асинхронных callback функций в худших традициях twisted, но это как раз то, от чего хотелось бы уйти.
Здесь, казалось-бы, напрашивается использование стандартного threading. Но использование штатных python-потоков, приведет к просто чудовищным глюкам и катастрофическим результатам на production под нагрузкой. Да-да. На машинах разработчиков все будет при этом работать прекрасно. Обычно в таких случаях программисты начинают молиться на GIL, и судорожно обкладывать локами все что можно и что нельзя. Но проблема заключается в том, что далеко не вся часть tornado потокобезопасна. Для того чтобы это аккуратно обойти, нужно http запрос обрабатывать в несколько этапов.
- Продекорировать вашу get/post функцию посредством tornado.web.asynchronous
- Принять запрос, проверить входные параметры если таковые есть и сохранить их в экземпляре запроса
- Запустить поток из функции-члена класса запроса
- Выполнить всю работу внутри этой функции аккуратно накладывая локи в момент изменения shared данных
- Вызвать callback, который сделает окончательный _finish() процессу с уже подготовленными данными.
Для этих целей можно написать небольшой Mixin:
class ThreadableMixin:
def start_worker(self):
threading.Thread(target=self.worker).start()
def worker(self):
try:
self._worker()
except tornado.web.HTTPError, e:
self.set_status(e.status_code)
except:
logging.error("_worker problem", exc_info=True)
self.set_status(500)
tornado.ioloop.IOLoop.instance().add_callback(self.async_callback(self.results))
def results(self):
if self.get_status()!=200:
self.send_error(self.get_status())
return
if hasattr(self, 'res'):
self.finish(self.res)
return
if hasattr(self, 'redir'):
self.redirect(self.redir)
return
self.send_error(500)
И в этом случае безопасная многопоточная обработка запросов будет выглядеть просто и элегантно:
class Handler(tornado.web.RequestHandler, ThreadableMixin):
def _worker(self):
self.res = self.render_string("template.html",
title = _("Title"),
data = self.application.db.query("select ... where object_id=%s", self.object_id)
)
@tornado.web.asynchronous
def get(self, object_id):
self.object_id = object_id
self.start_worker()
Если нам нужен редирект, то в _worker() устанавливаем переменную self.redir в нужный url. Если нужен json для ajax запроса, то в self.res вместо сгенерированной страницы присваиваем сформированный dict с данными.
Еще один момент связан с C-расширениями для python. При использовании в потоках вызовов каких-либо внешних библиотек, обязательно убедитесь в их статусе thread-safe.
Периодические процессы
Часто бывает нужно запустить функцию через строго определенное время. Это и пользовательские таймауты, и реализация игровых процессов, и процедуры обслуживания системы, и многое другое. Для этих целей используются так называемые «периодические процессы».
Традиционно, для организации периодических процессов, в python используется штатный threading.Timer. Если мы попытаемся его использовать, то опять-же получим определенное количество трудноуловимых проблем. Для этих целей в tornado предусмотрен ioloop.PeriodicCallback. Пожалуйста используйте его всегда вместо штатных таймеров. Это позволит сохранить кучу времени и нервов по вышеупомянутым причинам.
Локализация и прочее
В заключении позвольте дать несколько советов, не относящихся к многопоточной обработке, но позволяющих иногда существенно поднять производительность развесистого приложения.
- Не используйте встроенный в tornado огрызок для локализации. Tornado прекрасно умеет биндиться к стандартному gettext и дает с ним гораздо лучшие результаты на больших объемах переводов.
- Кешируйте в памяти все что только можно. Забудьте про memcached & co. Вам он не нужен. Уже в процессе проектирования вы должны знать на какой аппаратной платформе будет запускаться ваше приложение. Лишние пару гигабайт памяти в сервере могут в корне изменить подход к той или иной стратегии кеширования.
- Если время генерации страницы целиком зависит от данных в системе и вы не можете заранее знать его пределы — всегда откладывайте новый поток для этого запроса
- Несмотря на то что Торнадо очень быстр, статику всегда отдавайте предназначенными для этого средствами. Например nginx. Вы просто не представляете на что способен сервер i7/16Gb/SAS с FreeBSD/amd64 и nginx на борту в вопросах раздачи статики. Быстрее ничего не может быть просто физически.
Результат
При нагрузочном тестировании, 5000 одновременных коннектов, которые активно играют на сайте (а это тысячи websocket сообщений в секунду) сервер тянет без проблем (LA~=0.2, памяти процесс сервера съедает около 400Mb при свободных 8Gb). 150 реальных игроков в онлайне, весело пишущих пули, сервер вообще не замечает (нулевая загрузка и огромный запас по мощности).
На фронте это выглядит примерно так:
И да пребудет с вами сила!