Как стать автором
Обновить

Многопоточное приложение под Tornado

Время на прочтение 5 мин
Количество просмотров 38K


В документации к неблокирующему вебсерверу Торнадо красиво расписано как здорово он справляется с нагрузкой, и вообще является венцом творения человечества в области неблокирующих серверов. Отчасти это верно. Но при построении сложных приложений за рамками «еще одного чата» выявляется много неочевидных и тонких моментов, о которых желательно знать до вояжа по граблям. Под «катом» разработчики клуба интелектуальных игр Трельяж готовы поделиться своими мыслями о подводных камнях.

Сразу оговоримся, что речь идет о второй ветке python, о последней версии tornado 1.2.1, и о postgresql, подключенном через psycopg2.

Application Instance


Многие программисты обожают использовать антипаттерн singleton для быстрого доступа
к инстансу класса приложения. Если мы думаем о дальнейшем горизонтальном масштабировании,
то этого делать крайне не рекомендуется. Объект 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 запрос обрабатывать в несколько этапов.

  1. Продекорировать вашу get/post функцию посредством tornado.web.asynchronous
  2. Принять запрос, проверить входные параметры если таковые есть и сохранить их в экземпляре запроса
  3. Запустить поток из функции-члена класса запроса
  4. Выполнить всю работу внутри этой функции аккуратно накладывая локи в момент изменения shared данных
  5. Вызвать 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. Пожалуйста используйте его всегда вместо штатных таймеров. Это позволит сохранить кучу времени и нервов по вышеупомянутым причинам.

Локализация и прочее


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

  1. Не используйте встроенный в tornado огрызок для локализации. Tornado прекрасно умеет биндиться к стандартному gettext и дает с ним гораздо лучшие результаты на больших объемах переводов.
  2. Кешируйте в памяти все что только можно. Забудьте про memcached & co. Вам он не нужен. Уже в процессе проектирования вы должны знать на какой аппаратной платформе будет запускаться ваше приложение. Лишние пару гигабайт памяти в сервере могут в корне изменить подход к той или иной стратегии кеширования.
  3. Если время генерации страницы целиком зависит от данных в системе и вы не можете заранее знать его пределы — всегда откладывайте новый поток для этого запроса
  4. Несмотря на то что Торнадо очень быстр, статику всегда отдавайте предназначенными для этого средствами. Например nginx. Вы просто не представляете на что способен сервер i7/16Gb/SAS с FreeBSD/amd64 и nginx на борту в вопросах раздачи статики. Быстрее ничего не может быть просто физически.


Результат


При нагрузочном тестировании, 5000 одновременных коннектов, которые активно играют на сайте (а это тысячи websocket сообщений в секунду) сервер тянет без проблем (LA~=0.2, памяти процесс сервера съедает около 400Mb при свободных 8Gb). 150 реальных игроков в онлайне, весело пишущих пули, сервер вообще не замечает (нулевая загрузка и огромный запас по мощности).

На фронте это выглядит примерно так:



И да пребудет с вами сила!
Теги:
Хабы:
+61
Комментарии 28
Комментарии Комментарии 28

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн