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



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

    Сразу оговоримся, что речь идет о второй ветке 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 реальных игроков в онлайне, весело пишущих пули, сервер вообще не замечает (нулевая загрузка и огромный запас по мощности).

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



    И да пребудет с вами сила!
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 28

    • НЛО прилетело и опубликовало эту надпись здесь
        +7
        На скриншоте, внезапно, Свидетель О_о
          +1
          ну да. это список наблюдателей за столом. приходит/уходит тихо, стоит в сторонке, никому не мешает.
          0
          Если в курсе про Cyclone, есть вопрос: сильно ли Cyclone отстает от Tornado по фичам и производительности? Не пробовал ни то, ни другое, хочу Cyclone мне нравится гораздо больше из-за использования отделимого event loop
            0
            — Циклон бегает на Twisted.
            — Нативная поддержка xmprpc и jsonrpc.
            — Встроенная поддержка редиса
            — Незначительно медленнее торнады.

            github.com/fiorix/cyclone#readme

            Торнада уже давно является стабильным продуктом, который используется в реальных проектах и я не вижу смысла начинать с циклона.
              +1
              Собственно, ваш первый аргумент решающий. В пользу циклона. И да, оно не «нативное» и не «встроенное», а обусловлено использованием twisted, для которого реализованы эти протоколы. Cyclone — просто реализация ещё одного протокола http. И даже при незначительном отставании в производительности я выберу его из-за интероперабельности и следования unix-way, если мне не подскажут других проблем с Cyclone :)
            0
            А зачем вам websockets если у вас как я понял flash клиент?
              +2
              нет. у нас целиком websockets приложение. никаких flash клиентов.
              невидимая flash-прокладка используется только в случае устаревшего браузера, не поддерживающего ws протокол для его (протокола) эмуляции.
              0
              В ff4 не могу карты увидеть, в хроме норм. Линукс.
                0
                или включите websockets в ff4 (что более предпочтительно), или установите свежий flash на него. галочка соединения слева сверху должна гореть зеленым. (в у нее в title можно увидеть используется ws native или flash emulation)
                  0
                  ага, и инструкции в помощи увидел… было бы здорово чтобы пользователям у кого это не сделано, выскакивало соответсвующее предупреждение, а то как-то не интуитивно получается.
                +2
                Даешь отдельный блог Tornado!
                  0
                  Шикарные показатели производительности!
                  Позвольте поинтересоваться, как вы производили нагрузочное тестирование?
                    0
                    эмуляцией типичного поведения пользователя внешними http/ws клиентами
                      0
                      спасибо
                        0
                        5000 параллельных клиентов стреляют запросами непрерывно или с паузами?
                        сколько выходит requests/second?
                      –3
                      Гроздья в твистед? Не умеете готовить — не хайте. Даже в jquery поняли, что deferred это круто, но создатели торнадо гордые олени.
                        0
                        В том то и дело что умеем. Опыт плотной работы с twisted около 5 лет. Если вы прочитаете статью внимательнее, вы поймете что серебрянной пули (кто бы сомневался) в очередной раз нет, и нужен выбор между несколькими моделями обработки событий. tornado этот выбор дает просто и эффективно. twisted в общем-то тоже дает, но он тянет за собой изрядный кус слоя совместимости, и все получается гораздо более громоздко и неочевидно, да еще и лишними зависимостями.
                          +1
                          А почему вы решили, что я читал ее невнимательно? И потом — что такое «лишние зависимости»? Gnome? KDE? Что такое «изрядный кус слоя совместимости»? В чем громоздкость?

                          Вы изложите конкретно, раз уж вы реверанс в сторону твистед сделали — хаете, хайте аргументировано.
                          А пока пяти лет опыта незаметно.
                        0
                        Пробовали tasklet/eventlet/gevent? Как они в сравнении с торнадо?
                          +1
                          Торнадо все таки web-фреймворк, пусть и не сильно раскачанный. А eventlen и gevent для работы с конкретно web средств практически не имеют. Поэтому сравнивать не вполне корректно, я считаю.
                          0
                          Насчет ThreadableMixin что-то пугает стартовать треды на каждый start_worker()… Может все-же пул завести и очередь?

                          Вот последние пару недель задумывался над следующим вопросом — как мне из main thread в которой крутится event_loop и слушаются сокеты передать задание воркеру (воркер в отдельном потоке) и получить обратно от него результат не выполняя callback в треде воркера?

                          Пока в голову приходит только 2 очереди. Одна очередь для передачи данных от main_thread к воркерам и вторая для передачи данных от воркеров к main_thread. Причем main_thread проверяет поступили ли к ней данные в очередь на каждом обороте EventLoop в неблокирующем режиме и если поступили — обрабатывает. Единственная проблема — проверять поступление данных в очередь нужно как можно чаще, соответственно придется делать холостые обороты в EventLoop…

                          Собственно вопрос — может ли worker_thread как-то добавлять свои события в EventLoop?
                            0
                            Вы совершенно правы. В реальной системе конечно же пул очередей. Здесь он опущен чтобы не добавлять в код громоздкости.

                            из worker_thread добавлять свои события в EventLoop теоретически можно, но лучше так не делать. Лучше добавить их уже в results() после отработки потока.
                            0
                            1. мне больше gevent нравиться.
                            2. А разве от websockets не отказались (из-за проблем с уязвимостью)? Они вроде сейчас включены по умолчанию только в Chrome.
                              0

                              if hasattr(self, 'res'):
                              self.finish(self.res)


                              Этот код при любом запросе ajax или get/post просто сделает редирект на страницу, к которой был сделан запрос, отобразив self.res в окне браузера, вместо того чтобы вернуть переменную инициатору запроса.
                                0
                                Извиняюсь, моя оплошность, забыл в форме указать метод js вместо action
                                +1
                                > Увы и ах. Всеми любимый nginx не умеет проксировать websocket протокол.
                                В новой версии уже может. Поправьте пожалуйста.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое