Наверх ▲

Паттерны серверных COMET-решений

Илья Кантор Илья Кантор В JavaScript-разработке около 8 лет. Интересуют сложные Frontend'ы, обучение в области JavaScript и связанных технологий.
View more presentations from rit2010.

Илья Кантор: Добрый день, меня зовут Илья Кантор. Сегодня я рассмотрю классические серверные паттерны - остановлюсь на проблемах, особенностях и попробую рассказать, чем нам грозит COMET. Начну с примера.

Допустим, у нас есть loop. Что происходит в лучшем случае? Сначала каждый процесс загружает фреймворк. Например, Mongrel загружает в себя Rails. Инициализируется и начинает в цикле принимать и обрабатывать запросы. При этом какая-то память уже «откушена» — у каждого процесса она своя (тот же Mongrel: 150 мегабайт — легко). Приходящие запросы распределяются каким-то прокси по таким вот процессам, которые их уже обрабатывают.

Как осуществляется обмен данными? Понятно: используется общая память (англ. shared memory). Это неудобно, поскольку данные — это только строки, есть какие-то очереди на IPC.

Следующий паттерн — потоки OS, это уже чуть-чуть интереснее. Есть пул потоков, применяется классическая архитектура Servlet. Я думаю, все серверные java-разработчики с ними знакомы.

Есть какой-то код, создается один объект класса MyServlet. В него помещаются все запросы. Один запрос во много потоков одной программы. Соответственно, переменные этого запроса общие. В цикле происходит обработка.

Здорово то, что здесь общее адресное пространство. Если у нас есть какие-то данные, то мы просто помещаем их в этот один объект. Они доступны всем потокам одновременно. Разумеется, при этом возникают проблемы.

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

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

Самая серьезная и единственная проблема поточного программирования — это разделяемый доступ.

Кратко сравним стандартные паттерны, которые не относятся к COMET (то, что есть и всегда было):

  • Управление. В обоих случаях применяется модуль pre-fork. Если это процесс, выполняется pre-fork процессов, если это потоки, используется пул потоков. В современных OS создание процесса не так сильно отличается от потока.
  • Переменные. В процессах все переменные независимые, у них разное адресное пространство. У потоков есть как общие, так и независимые переменные. Можно переменную привязать к потоку.
  • Передача информации. Чем мы можем обмениваться? Если это процесс, то понятно — можем кидать только строки между процессами. У них память разная, все разное: сериализация / десериализация.
    Если это потоки, то интереснее. Можно передавать вообще любые структуры данных. Можно таким же способом вызывать методы. Все это очень удобно и достаточно быстро.
  • Надежность. За что потоки не любят? Их не любят за проблемы с памятью. Память-то у всех общая. Если вдруг что-то поломалось, то оно может сломать вообще все.
  • Масштабируемость. Понятно, что сложно представить себе сервер, который будет нормально держать 1000 процессов и при этом не будет суперкомпьютером. 1000 процессов — все-таки серьезная нагрузка.
    Вот 1000 потоков NPTL современная архитектура позволяет легко выдерживать. Есть старый миф о том, что потоки — это очень плохо, они "тяжелые". Та же Java при 100 потоках будет плохо. Современные Linux 5000 потоков нормально выдерживают.
  • Сложность поддержки. Основная проблема потоков — это сложность поддержки. Нужно думать о том, что есть общие ресурсы. Нельзя, чтобы операции, которые не должны быть атомарными, стали атомарными.
    Синхронизация — то, с чем мы привыкли иметь дело. Даже если мы пишем простые процессы, в базах данных это есть. Мы должны следить, чтобы была нужная изоляция, учитывать состояния гонки (англ. race conditions).

Есть гибридные паттерны — это процессы плюс потоки. Тот же nginx, Apache, Worker. Есть несколько процессов, и в каждом — "пачка" потоков. Почему несколько процессов? Если вдруг кто-то испортит код и все "рухнет", и при этом останутся рабочие процессы, можно будет перезапускать их по отдельности для большей надежности.

Пара слов об Event MPM. Некоторые спрашивают: «Может быть, для COMET его стоит использовать?». Это обычный Worker. Единственная его особенность в том, что выделяется отдельный поток исключительно под обработку Keep-Alive запросов.

Обычно в Apache (также в Worker или Prefork) есть какой-то процесс сервера. Пришел запрос, он его обработал. Потом это процесс еще некоторое время занят. Он ожидает, не пришлет ли пользователь что-нибудь по Keep-Alive. Event MPM от этого освобождает. Только в этом плане он принципиально отличается от того же nginx.

Рассмотрим COMET. Там два основных паттерна: длительное HTTP-соединение (англ. Long Polling) и стриминг (англ. Streaming).

В первом случае приходит запрос, сервер его долго держит, потом выдает ответ и тут же получает следующий запрос со следующей "порцией" данных.

Второй (Streaming) — приходит запрос, и данные потихонечку пересылаются. Как правило, сделан фрейм, можно его в сокетах описать.

Какая при этом существует проблема? Обычный цикл запроса вот такой. Почему я сейчас делаю доклад? Почему нам эта тема интересна? Из-за чего возникла проблема?

COMET имеет другой цикл работы: принять запрос — кто-то подключился — и дальше он "висит". Какое-то время "висит" и при этом тратит ресурсы в классической архитектуре, потому что процесс или поток занят. Не хватает памяти под все эти потоки, которые должны "висеть" и ожидать чего-то.

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

Еще одна особенность, которая тоже часто имеет место — небольшой размер пакетов, которые передаются. Если в традиционной архитектуре пакеты большие (страница целиком), то в COMET-архитектуре, как правило, это 20 байт или50 байт. Разумеется, размер может быть и больше, но обычно это небольшие пакеты.

Мы разобрали классические проблемы серверных паттернов и COMET. Теперь мы можем перейти к основной теме доклада — к паттернам. Их всего три.

Первый паттерн называется «События в едином потоке». Из серверов могут использоваться Twisted, node.js, EventMachine, Perl server. Как видите, я взял по одному серверу для четырех языков. Разумеется, на "Си" тоже есть, да и на многих других. Почему? Этот паттерн — самый простой и, вместе с тем, эффективный.

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

Очень просто. В этот поток пришел запрос, вызвался render_GET. Понятно, что этот элемент языка кому-то близок, кому-то не очень. В любом случае, я все прокомментирую.

Пришел запрос и вызвался метод dbpool.runQuery. Понятно, что dbpool — это пул. Это разделяемый ресурс, если смотреть из поточного программирования. Здесь используются именно потоки.

Вызван запрос к базе данных. Этот запрос выполняется асинхронно. Сервер тут же продолжил выполнять код и вернул специальное значение NOT_DONE_YET. Запрос "висит". Все, этот сервер может принимать новые запросы.

Когда база данных закончила, вызывается функция обратного вызова (англ. Callback). Обычное синхронное программирование. Функция делает обработку. 

Таким образом можно обрабатывать большое количество запросов. При этом этот единственный поток не подвисает, он не блокируется. На практике это очень удобная парадигма программирования, ее активно используют.

Схематичное изображение. В цикле происходит render_GET. Дальше есть ряд операций, которые можно сделать асинхронно. Например, это сетевые операции, которые на уровне операционной системы могут быть реализованы асинхронно.

В этом случае Twisted (или другой сервер, основанный на этой технологии) делает специальный вызов. Дальше операционная система сама вызывает его, когда закончит обработку, поступят данные. Все. Никаких потоков в этом случае не используется. Это по-прежнему один поток.

Второе. Если нам вдруг нужно что-то, что требует вызова потока... Ну, никак без этого. Асинхронный вызов не предусмотрен, потому что асинхронный API где-то есть, где-то нет. Базы данных, как правило, нет. 

Текущая реализация просто использует пул потоков. То, что мы хотим в поток, уходит в поток. Я на Twisted писал приложение, оно работало с Django. Django ORM — как раз обычная ORM, база данных. Там многое не рассчитано на Twisted, рассчитано на процессы и потоки. Для этого есть достаточно удобная возможность, которая позволяет отложить выполнение в поток, название вызова именно такое - "отложенный поток" (англ. deferred thread).

Два способа: нативная асинхронность и пул потоков. Все работает.

За счет чего работает COMET-приложение? За счет того, что все есть в одном адресном пространстве. В этом ресурсе мы можем иметь массив клиентов (эти клиенты находятся в массиве). Поступил запрос — мы тут же можем найти в массиве нужного клиента и ему что-то передать. За это любят этот паттерн.

Какие проблемы? Первая проблема, которую хотелось бы упомянуть, — это блокировка GIL (Global Interpreter Lock). Он имеет место в большинстве языков, которые были здесь перечислены: Python, Ruby.

Обычно говорят: «Какой классный паттерн! Все здорово!», но не упоминают о недостатках. GIL — это такая штука, которая говорит, что в один момент времени может исполняться только один поток одновременно.

Вопрос:
— Не совсем верно.
Илья Кантор:
— Поправьте меня, пожалуйста, если я не прав.
Вопрос:
— В один момент времени может только один...
Илья Кантор:
— Может исполняться один фрагмент Python-кода, один фрагмент Ruby-кода. Я именно это имею в виду. Мы можем на "Си" написать расширение, которое будет многопоточное.
Вопрос:
— Что скажете про Iowait?
Илья Кантор:
— Iowait — асинхронный. Iowait в Python используется асинхронно в данном случае. Системный вызов работает нормально. Но фрагмент кода... Где это играет роль?

Например, если у вас сложные вычисления, и вы хотите использовать много потоков, то вам, скорее всего, придется писать это на "Си". Потому что расширения (англ. Extension) для Python пишутся на "Си" в основном. Только они могут удобно убирать эту блокировку и исполнять многопоточные вещи.

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

На самом деле, GIL — это хорошо. Эта блокировка позволяет избегать различных проблем, связанных с многопоточностью. Никакой синхронизации. Обычно есть два решения: первое — делать несколько процессов сервера, второе — использовать, например, jRuby. В JRuby GIL нет.

Вот характеристики метода. Мы это обсудили. Все достаточно удобно.

Следующий паттерн — Continuations. Предыдущий паттерн — самый распространенный, а Continuations — уже меньше. В Java он есть. Наверное, не только в Java.

В чем он заключается? Приходит запрос в наш Get. Запрос упаковывается в объект Continuations. 

Он куда-то упаковался — идем дальше. Когда запрос первый раз поступил, то тут будет "true". Затем мы "говорим" серверу: «Этот Continuations-запрос придержи у себя где-нибудь. Он еще понадобится в будущем. Примени Suspend». Я могу зарегистрировать этот объект на какого-нибудь клиент-менеджера: «У нас есть человек, который ожидает ответа». Например, ожидать ответа можно на сообщение из чата.

Разумеется, это все так или иначе построено на событиях (англ. Event). Это самая масштабируемая архитектура.

Итак, мы видим, что это сделали. Ждем. Запрос «повис».

Пришел второй запрос. Он приходит в тот же Servlet. Так или иначе он инициировал генерацию ответа. Например, Вася написал что-то в чат. Весь народ, который был "запакован", все запросы, которые были здесь, в этом массиве вдруг начали генерировать ответы. Как?

В данном случае мы в каждую Continuations пишем, что Вася указал такое сообщение. "Говорим" серверу: «Resume — продолжить обработку». Сервер "говорит": «Ok». Асинхронно мы идем дальше.

Второй запрос заканчивается. Сервер инициирует продолжение первого запроса, повторно закидывая его в doGet. Мы второй раз зашли в doGet.

В этот раз Continuations будет тот, который был привязан к изначальному запросу. IsInitial при этом вернет значение "false". Выполнится вторая ветка. Мы получим сообщение и его замечательно выдадим посетителям. Перезапуск запроса. Все окей.

Чем хорош этот метод? Он не то что хорош — он другой. Он основан на потоках, соответственно, используются все ядра ЦП для всего, все нормально. Нужно думать о всяких многопоточных проблемах: синхронизация, блокировка.

Чем он принципиально отличается, в частности, от предыдущего метода? Тем, что если просто продолжить обработку обычных объектов Continuations, то сервер заново раскидывает их по потокам. Если там какая-то сложная генерация ответов, то все будет замечательно.

Можно вместо повторной обработки этих Continuations попросить выдать ответ, чтобы каждый объект Continuations по запросу, который в нем запакован, что-то туда записал (сообщение) и вышел. Можно тут же позволить серверу все это заново "положить" в doGet.

Сейчас это метод в спецификации Servlet 3.0. Его реализует тот же Glassfish — компонент Grizzly. Для Java есть данные о том, как все будет дальше работать.

Вопрос:
— В чем отличие от Servlet 3.0?
Илья Кантор:
— В Servlet 3.0 немного другой синтаксис. То, что я рассматривал — это Jetty, потому что сейчас это стандарт де-факто для работы. Servlet 3.0. не так давно, в Glassfish проблемы есть.
Почему в названии моего доклада есть слово «паттерны»? Потому что я рассказываю, как это все устроено. Разумеется, в других серверах будет немного другой API. В том же Servlet спецификации 3.0 API немного другой. Немного другие слова будите там писать.

Я рекомендую спецификацию почитать. Там все очень доступно описано.

Третье — это микронити. Разумеется, это Erlang, о котором все столько говорят, и Stackless Python. На чем он основан?

Предыдущие подходы разделяли один общий момент — как мы вообще подходим к процессу обработки. У нас идет запрос, дальше мы его сохраняем где-нибудь в память. Когда он нам понадобится, мы его продолжим. Поток будет свободен, обрабатывает другие запросы.

Микронити — это другая идеология. Давайте мы вообще не будем освобождать потоки? Просто сделаем потоки легкими, чтобы 1000 и 5000 потоков память не «жрали», и все было хорошо. Вот такой принципиально другой подход, если заглянуть с другой стороны.

Это реализуется за счет так называемых "зеленых потоков" (англ. green threads). Этот термин обозначает отказ от стека и потоков OS. Мы не используем "родные" потоки OS. Интерпретатор как-то сам справляется с потоками, с переключением (есть некоторая виртуальная машина). Раз OS это умеет, то интерпретатор тоже может.

Вопрос:
— Java может это реализовать?
Илья Кантор:
— Java тоже так умеет. Можно какое-то название конкретное? Наверное, есть. На Java много чего есть. Вот про "зеленые потоки" статья на Wiki. Может быть, там это есть.

Самое известное решение здесь, конечно, на Erlang. Почему? Тут был доклад про Erlyvideo, где было достаточно много про Erlang и про базу.

Почему Erlang так хорошо масштабируется? Чем он принципиально отличается от обычных языков, обычных (тех, к которым мы привыкли) — "Си", Ruby, Perl, Python, Java, С++? Тем, что структуры данных никогда не меняются, поэтому коммуникация между процессами очень простая. Мы что-то сделали (какую-то структуру данных), дальше передаем ее другому процессу — просто пересылаем указатель. Мы можем себе это позволить, потому что мы знаем, что структура никогда не изменится. Это основа Erlang, функциональный язык все-таки.

Расскажу о примере некоторого максимально простого чата на Erlang. Синтаксис, может быть, мало знаком. Просто немного прокомментирую, как это все работает на Erlang.

Есть некоторая "комната" (room) — это обычный процесс, который выполняет получение данных "receive". Как только пришло сообщение (пришел запрос, команда), мы посылаем процессу, который ее прислал, сообщение о том, что мы писались. Дальше мы вызываем сами себя, но в "комнату" добавляем нового посетителя — "from". Соответственно, опять "receive". Вот такой цикл, классическая рекурсия.

У нас есть виртуальная "комната", куда приходят пользователи. Все это написано на фреймворке, который называется Mochiweb. Люди отправляют запросы HTTP. Если нужна подписка (англ. subscribe), то мы берем комнату и подписываемся в нее.

Я хочу еще раз перечислить три паттерна, которые мы рассмотрели:

  • События в едином потоке. Плюсы — это достаточно удобно. По возможности, асинхронность. Вот так оно работает: пул потоков плюс асинхронность. Удобство обмена данными. В основном, GIL есть.
  • Continuations — это стандарт на Java.
  • Микронити.

Относительно Erlang хочется еще заметить, что концепция микронитей никак не конкурирует с двумя предыдущими. Например, в том же Erlang есть такая штука, как команда гибернации (англ. hibernate). Да и в Stackless Python тоже можно тасклеты «замораживать». Эти потоки стоит все-таки не оставлять "висящими", ожидающими, а погружать их в "спячку".

Если скомбинировать вместе два эти подхода, то получается такой код, который может и миллион соединений держать. В частности, есть одна статья весьма интересная: «A Million-user Comet Application with Mochiweb». Там 60 байт на соединение человек получил, что ли.

Есть какие-то вопросы по паттернам, по Comet, по серверной части? Мы говорим именно о серверной части. Интересуют реализации? У меня есть четыре реализации: на Python, node.js, Erlang и Java.

Вопрос:
— Я хотел спросить не у докладчика, а у того, кто говорил, что в Java есть "зеленые потоки". Можно про это подробнее сказать?
Реплика:
— К сожалению, я точное название библиотек не помню. Можно в Google просто найти Actos для Java. Идея там следующая: в код каждого метода "инжектится" специальный пролог, который позволяет стек Java-потока сохранить в отдельный объект и произвести переключение стека. Грубо говоря, вы выбираете стек Java-потока, сохраняете его в отдельный объект и с другого объекта поднимаете стек.
Вопрос:
— У меня другой вопрос. Вы на потоках экономите. А на подключениях не убираете при этом какие-нибудь ограничения?
Илья Кантор:
— На подключениях?
Вопрос:
— Получается, сокеты же «висят» открытые?
Илья Кантор:
— Подключения, разумеется, «висят».
Вопрос:
— Здесь не просаживается именно в этом?
Илья Кантор:
— Все более или менее. Конечно, нужно "потюнить" OS. Например, просто так миллион посетителей на одном сервере не удержать. Нужен тюнинг. Там не так много настроек для этого.
Вопрос:
— А для этого FreeBSD можно использовать?
Илья Кантор:
— Я думаю, на FreeBSD это можно сделать. На практике не факт, что миллион посетителей на одной машине будете держать. Наверное, у вас сложная бизнес-логика какая-то. На самом деле вам миллиона не нужно, а нужны 10 000 какие-нибудь. 10 000 — это тоже много.
Вопрос:
— Из этих вариантов, которые вы описали, на ваш взгляд какой наиболее перспективно использовать? По вашему опыту, к чему вы пришли?
Илья Кантор:
— Честно говоря, лично мне больше нравится вариант «События в едином потоке», потому что он простой. Приходилось писать приложения на том же Twisted, который держал входящее соединение. С одной стороны, от посетителей веб. С другой стороны, он держал подключения к jabber. Чатоподобное приложение, когда из jabber можно было общаться с посетителями из веба, переводить их по сайту.
Twisted с этим замечательно справился. Это все было очень легко, понятно. Я просто хранил посетителей в массиве и по ним переходил. Все в одном потоке. Причем я совершенно не «заморачивался» с синхронизацией, с какими-то странными падениями, которые, как вы знаете, очень сложно в поточной архитектуре отлаживать.
У меня получился надежный сервис. Тот же GIL мне никаких негативных последствий не создал. У меня там не читались какие-то петабайты данных.
Вопрос:
— А были конкретные тесты производительности?
Илья Кантор:
— Были. Одновременно 10 000 оно держало. На тестах оно держало 100. Это то, что оно держало. В реальности оно держало... Честно, я не очень помню точную цифру. Порядка тысяч.
Аналогично я еще могу привести по цифрам. Jetty — 10 000 легко. Решение на Jetty — это опять же Java, кластеризация. На Jetty замечательно построен CometD, который я сейчас могу рекомендовать с чистой совестью, поскольку его довели до ума, наконец. Убрали основные ошибки и даже сделали документацию, без которой раньше было очень сложно. Если ты не следил за процессом разработки, то...
CometD — это "открытое" решение (CometD.org). На Java, Comet-сервер библиотеки на JQuery есть. Это опять же с клиентской частью связано. Мы здесь больше про серверную говорим.
Мне симпатичен Twisted, потому что я получил достаточно простое приложение, и оно работало. Это было не много кода и без ошибок, что самое главное. Node.js еще рекомендуют. Говорят, что оно совсем мало памяти потребляет.
Вопрос:
— А Tornado не пробовали применять?
Илья Кантор:
— Насчет Tornado — я его не использовал. Он не так давно появился. Я могу сказать, Twisted против Tornado.
Twisted — это громадная архитектура, на которой полно всего: начиная от SSH, FTP, Jabber, ICQ, IRC-модулей, работы с базой данных и кончая Livepage, Navel, DivMod. Это то, что под веб "заточено". Именно уже какие-то фреймворки для построения веб-приложений, которые интегрированы с Twisted. Насколько я знаю, под Tornado этого нет. С другой стороны, Tornado легче.
Вопрос:
— Я хочу сказать по своему опыту, что Tornado очень легкий. У него очень малый объем кода. Исходники довольно понятные.
Единственно, что меня очень сильно обломало — там нет отложенных потоков. Я с базой там асинхронно работать не смог. Это было плохо.
Все остальное было очень даже хорошо. Насколько я знаю, есть последняя версия на GitHub, там даже есть реализация вебсокетов.
Илья Кантор:
— С базой работать нельзя?
Вопрос:
— Асинхронно. Там нет отложенных потоков. Только поэтому.
Илья Кантор:
— Менее удобно.
Вопрос:
— Да, менее удобно. Но в моих целях помогло, и все было довольно просто.
Илья Кантор:
— Вы решили на Tornado сделать?
Вопрос:
— Да. Событийный цикл (англ. Event loop) обычный. То же самое, что и с Twisted.
Илья Кантор:
— Сложное приложение было?
Вопрос:
— Обычный чат с отслежкой статусов и подобных вещей (онлайн или офлайн), передача сообщений между пользователями.
Илья Кантор:
— Большое спасибо. Хороших вам приложений!

Комментарии

Нет ни одного комментария

Только пользователи могут оставлять комментарии

Возможно, вам будет интересно:

Елена Глухова

Елена Глухова

Руководитель группы общих интерфейсов в Симферополе, Яндекс.

Специалисты "Яндекса" о разработке сложной кнопки, полностью повторяющей свойства и функциональность нативного браузерного элемента управления.

Александр Кудымов

Александр Кудымов

Проектировщик интерфейсов, канбан-мастер.

Евгений Кобзев и Александр Кудымов (СКБ "Контур") объясняют, почему им стал тесен скрам и на какие грабли не стоит наступать.

Эсен Сагынов (Esen Sagynov)

Эсен Сагынов (Esen Sagynov)

Разработчик в NHN - крупнейшей IT-компании Южной Кореи.

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