Что такое событийная модель
Что такое «асинхронная событийная модель», и почему сейчас она «в моде»
О читабельности статьи
Эта статья, со времени её появления здесь, подверглась множеству правок (в том числе концептуальных) и дополнений, благодаря обратной связи от читателей, упомянутых в конце статьи. Если Вам здесь сложен какой-то кусок для понимания, опишите это в комментариях, и мы распишем его в статье более понятным языком.
О производительности
Современные высоконагруженные сайты типа twitter’а, вконтакта и facebook’а работают на связках вида PHP + Apache + NoSQL или Ruby on Rails + Unicorn + NoSQL, и ничуть не тормозят. Во-первых, они используют NoSQL вместо SQL. Во-вторых, они распределяют запросы («балансируют») по множеству одинаковых рабочих серверов (это называется «горизонтальным масштабированием»). В-третьих, они кешируют всё, что можно: страницы целиком, куски страниц, данные в формате Json для Ajax’овых запросов, и т.п… Кешированные данные являются «статикой», и отдаются сразу серверами наподобие NginX’а, минуя само приложение.
Я лично не знаю, станет ли сайт быстрее, если его переписать с Apache + PHP на Node.js. В тематических интернетах можно встретить как тех, кто считает системные потоки медленнее «асинхронной событийной модели», так и тех, кто отстаивает противоположную точку зрения.
Думая о том, на чём писать очередной проект, следует исходить из его задач, и выбирать ту архитектуру, которая хорошо накладывается на задачи проекта.
Например, если ваша программа поддерживает множество одновременных подключений, и постоянно пишет в них, и считывает из них, то в таком случае вам определённо следует посмотреть в сторону «асинхронной событийной модели» (например, в сторону Node.js’а). Node.js отлично подойдёт, если вы хотите перевести какую-нибудь подсистему на протокол WebSocket.
Что такое «блокирующий» и «неблокирующий» вводы/выводы
Существует ещё «асинхронный» ввод/вывод. В нашей статье мы не будем его рассматривать, но вообще это когда мы вешаем на сокет «функцию обратного вызова» (callback) из нашего кода, которая будет вызываться операционной системой каждый раз, когда на этот сокет будет приходить очередная порция данных картинки. И дальше уже забываем о прослушивании этого сокета вообще, отправляясь делать другие дела. «Асинхронный» ввод/вывод, как и «синхронный», делится на «блокирующий» и «неблокирующий». Но в этой статье под словами «блокирующий» и «неблокирующий» мы будем иметь ввиду именно «синхронный» ввод / вывод.
И ещё, в этой статье мы будем рассматривать только «привычную» архитектуру, где приложение запущено непосредственно на операционной системе, с её системными потоками, а не на какой-нибудь «виртуальной машине» с её «зелёными потоками». Потому что внутри «виртуальной машины» с «зелёными потоками» можно творить разные чудеса, типа превращения якобы «синхронного» ввода/вывода в «асинхронный», о чём пойдёт речь ближе к концу статьи, в разделе «Альтернативный путь».
Предпосылки
Целая лавина экспериментов с новыми архитектурами приложений была вызвана тем, что традиционная архитектура решала нужды интернета на заре его развития, и, разумеется, не была рассчитана на удовлетворение эволюционировавших нужд «веб-два-нольного» интернета, в котором всё жужжит и движется.
Проверенная годами связка PHP + MySQL + Apache хорошо справлялась с «интернетом 1.0». Сервер запускал новый поток (или процесс, что почти одно и то же с точки зрения операционной системы) на каждый запрос пользователя. Этот поток шёл в PHP, оттуда – в базу данных, чего-нибудь там выбирал, и возвращался с ответом, который отсылал пользователю по HTTP, после чего самоуничтожался.
Однако, для приложений «реального времени» её стало не хватать. Допустим, у нас есть задача «поддерживать одновременно 10 000 соединений с пользователями». Можно было бы для этого создать 10 000 потоков. Как они будут уживаться друг с другом? Их будет уживать друг с другом системный «планировщик», задачей которого является выдавать каждому потоку его долю процессорного времени, и при этом никого не обделять. Действует он так. Когда один поток немного поработал, запускается планировщик, временно останавливает этот поток, и «подготавливает площадку» для запуска следующего потока (который уже ждёт в очереди).
Такая «подготовка площадки» называется «переключением контекста», и в неё входит сохранение «контекста» приостанавливаемого потока, и восстановление контекста потока, который будет запущен следующим. В «контекст» входят регистры процессора и данные о процессе в самой операционной системе (id’шники, права доступа, ресурсы и блокировки, выделенная память и т.д.).
Как часто запускается планировщик – это решает операционная система. Например, в Linux’е по умолчанию планировщик запускается где-то раз в сотую долю секунды. Планировщик также вызывается, когда процесс «блокируется» вручную (например, функцией sleep) или в ожидании «синхронного» и «блокирующего» (то есть, самого простого и обычного) ввода/вывода (например, запрос пользователя в потоке PHP ждёт, пока база данных выдаст ему отчёт по продажам за месяц).
В общем случае полагают, что «переключение контекста» между системными потоками не является таким уж дорогостоящим, и составляет порядка микросекунды.
Если потоки активно читают разные области оперативной памяти (и пишут в разные области оперативной памяти), то, при росте числа таких потоков, им станет не хватать «кеша второго уровня» (L2) процессора, составляющего порядка мегабайта. В этом случае им придётся каждый раз ожидать доставки данных по системной шине из оперативной памяти в процессор, и записи данных по системной шине из процессора в оперативную память. Такой доступ к оперативной памяти на порядки медленнее доступа к кешу процессора: для этого и был придуман этот кеш. В этих случаях, время «переключения контекста» может доходить до 50 микросекунд.
В интернете можно встретить мнение, что постоянное «переключение контекста» у большого количества одновременных потоков может существенно затормозить всю систему. Однако я не нашёл однозначных и подробных численных доказательств этой гипотезы.
Рассмотрим ещё, какой отпечаток накладывает многопоточная модель на потребление приложением оперативной памяти. С каждым системным потоком связан «стек». Если поток вызывает некую функцию с аргументами, то в «стек» кладутся аргументы этой функции, и текущий адрес в коде, называемый «адресом возврата» (потому что по нему мы вернёмся сюда обратно, когда вызванная функция закончит выполняться). Если эта функция вызывает ещё какую-то функцию внутри себя, то соответствующие данные опять пишутся в «стек», поверх тех, которые уже были туда записаны, создавая таким образом подобие клубка.
При создании системного потока, «стек» выделяется операционной системой в оперативной памяти не сразу целиком, а «кусочками», по мере его использования. Это называется «виртуальной памятью». То есть, каждому потоку выделяется сразу большой кусок «виртуальной памяти» под «стек», но на деле вся эта «виртуальная память» дробится на «кусочки», называемые «страницами памяти», и уже эти «страницы памяти» выделяются в «настоящей» оперативной памяти только тогда, когда в них возникает необходимость. Когда поток дотрагивается до «страницы памяти», ещё не выделенной в «настоящей» оперативной памяти (например, пытается отдать процессору команду записать туда что-нибудь), «блок управления памятью» процессора засекает это действие, и вызывает в операционной системе «исключение» «page fault», на которое она отвечает выделением данной «страницы памяти» в «настоящей» оперативной памяти.
В Linux’е размер стека по-умолчанию равен 8-ми мегабайтам, а размер «страницы памяти» — 4-рём килобайтам (под «стек» сразу выделяются одна-две «страницы памяти»). В пересчёте на 10 000 одновременно запущенных потоков мы получим требование около 80 мегабайтов «настоящей» оперативной памяти. Вроде как немного, и вроде как нет повода для беспокойства. Но размер требуемой памяти в этом случае растёт как O(n), что говорит о том, что с дальнейшим ростом нагрузки могут возникнуть сложности с «масштабируемостью»: что, если завтра ваш сайт будет обслуживать уже 100 000 одновременных пользователей, и потребует поддержания 100 000 одновременных соединений? А послезавтра — 1 000 000? А после-послезавтра — ещё неизвестно сколько…
Однонитевые серверы приложений лишены такого недостатка, и не требуют новой памяти с ростом количества одновременных подключений (это называется O(1)). Взгляните на этот график, сравнивающий потребление оперативной памяти Apache Web Server’ом и NginX’ом:
Современные web-серверы (включая современный Apache) построены не совсем на архитектуре «по потоку на запрос», а на более оптимизированной: имеется «пул» заранее заготовленных потоков, которые обслуживают все запросы по мере их поступления. Это можно сравнить с аттракционом, в котором имеется 10 лошадей, и 100 ездоков, которые хотят прокатиться: образуется очередь, и пока первые 10 ездоков не прокатятся «туда и обратно», следующие 10 ездоков будут стоять и ждать в очереди. В данном случае аттракцион — это сервер приложений, лошади — потоки из пула, а ездоки — пользователи сайта.
Если мы будем использовать такой «пул» системных потоков, то одновременно мы сможем обслуживать только то количество пользователей, сколько потоков у нас будет «в пуле», то есть никак не 10 000.
Описанные в этом разделе сложности, постоянно рождающие в умах вопрос о пригодности многопоточной архитектуры для обслуживания очень большого количества одновременных подключений, получили собирательное название «The C10K problem».
Асинхронная событийная модель
Нужна была новая архитектура для подобного класса приложений. И в такой ситуации, как нельзя кстати, подошла «асинхронная событийная модель». В основе её лежат «событийный цикл» и шаблон «reactor» (от слова «react» – реагировать).
«Событийный цикл» представляет собой бесконечный цикл, который опрашивает «источники событий» (дескрипторы) на предмет появления в них какого-нибудь «события». Опрос происходит с помощью библиотеки «синхронного» ввода/вывода, который, при этом будет являться «неблокирующим» (в системную функцию ввода/вывода передаётся флаг O_NONBLOCK).
То есть, во время очередного витка «событийного цикла», наша система проходит последовательно по всем дескрипторам, и пытается считать из них «события»: если таковые имеются, то они возвращаются функцией чтения в нашу систему; если же никаких новых событий у дескриптора нет, то он не станет «блокировать» и ждать появления «события», а сразу же возвратит ответ: «новых событий нет».
«Событием» может быть приход очередной порции данных на сетевой сокет («socket» – дословно «место соединения»), или считывание новой порции данных с жёсткого диска: в общем, любой ввод/вывод. Например, когда вы загружаете картинку на хостинг, данные туда приходят кусками, каждый раз вызывая событие «новая порция данных картинки получена».
«Источником событий» в данном случае будет являться «дескриптор» (указатель на поток данных) того самого TCP-сокета, через который вы соединились с сайтом по сети.
Второй компонент новой архитектуры, как уже было сказано, – это шаблон «reactor». И, для русского человека, это совсем не тот реактор, который стоит на атомной станции. Суть этого шаблона заключается в том, что код сервера пишется не одним большим куском, который исполняется последовательно, а небольшими блоками, каждый из которых вызывается («реагирует») тогда, когда происходит связанное с ним событие. Таким образом, код представляет собой набор множества блоков, задача которых состоит в том, чтобы «реагировать» на какие-то события.
Такая новая архитектура стала «мейнстримом» после появления Node.js’а. Node.js написан на C++, и основывает свой событийный цикл на Сишной библиотеке «libev». Однако Яваскрипт здесь не является каким-то избранным языком: при наличии у языка библиотеки «неблокирующего» ввода/вывода, для него тоже можно написать подобные «фреймворки»: у Питона есть Twisted и Tornado, у Перла – Perl Object Environment, у Руби – EventMachine (которой уже лет пять). На этих «фреймворках» можно писать свои серверы, подобные Node.js’у. Например, для Явы (на основе java.nio) написаны Netty и MINA, а для Руби (на основе EventMachine) – Goliath (который ещё и пользуется преимуществами Fibers).
Преимущества и недостатки
«Асинхронная событийная модель» хорошо подойдёт там, где много-много пользователей одновременно производят какие-нибудь действия, не нагружающие процессор. Например: получают температуру с датчиков в режиме «текущего времени», получают изображения с видеокамер, передают на сервер температуру, снятую с прикреплённых к ним градусников, пишут новые сообщения в чат, получают новые сообщения из чата, и т.п…
Требование действий, не нагружающих процессор, проясняется, когда мы вспоминаем о том, что весь этот бесконечный цикл запущен в одном единственном потоке, и если вы вклините в этот цикл какое-нибудь тяжеловесное вычисление (скажем, начнёте решать дифференциальное уравнение), то все остальные пользователи будут ждать в очереди, пока одно это вычисление не закончится.
То обстоятельство, что серверы по «асинхронной событийной модели» запущены в одном системном потоке, на практике порождает ещё два препятствия. Первое — утечки памяти. Если Apache создаёт по системному потоку на каждый новый запрос, то, после отправки ответа пользователю, этот системный поток самоуничтожается, и вся выделенная ему память просто высвобождается. В случае же со, скажем, Node.js’ом, разработчику следует быть осторожным, и не оставлять следов при обработке очередного запроса пользователя (унижчтожать из оперативной памяти все улики того, что такой запрос вообще приходил), иначе процесс будет пожирать больше и больше памяти с каждым новым запросом. Второе — это обработка ошибок программы. Если, опять же, обычный Apache создаст отдельный системный поток для обработки входящего запроса, и обрабатывающий код на PHP выбросит какое-нибудь «исключение», то этот системный поток просто тихо «умрёт», а пользователь получит в ответ страницу типа «500. Internal Server Error». В случае же того же Node.js’а, единственная ошибка, возникшая при обработке единственного запроса, «положит» весь сервер целиком, из-за чего его придётся мониторить и перезапускать вручную.
Ещё один возможный недостаток «асинхронной событийной модели» – иногда (не всегда, но бывает, особенно при использовании «асинхронной событийной модели» для того, для чего она не предназначена) код приложения может стать сложным для понимания из-за переплетения «обратных вызовов». Это называется проблемой «спагетти-кода», и описывается так: «коллбек на коллбеке, коллбеком погоняет». С этим пытаются бороться, и, например, для Node.js’а написана библиотека Seq.
Ещё один путь устранения «обратных вызовов» вообще — так называемые continuations (coroutines). Они введены, например, в Scala, начиная с версии 2.8 (coroutines), и в Руби, начиная с версии 1.9 (Fibers). Вот пример того, как помощью Fibers в Руби можно полностью устранить коллбеки, и писать код так, как будто бы всё происходит синхронно.
Для Node.js’а была написана аналогичная библиотека node-fibers. По производительности (в искусственных тестах, не в реальном приложении) node-fibers пока работают где-то в три-четыре раза медленнее обычного стиля с «обратными вызовами». Автор библиотеки утвреждает, что эта разница в производительности возникает там, где Яваскрипт стыкуется с C++’ным кодом движка V8 (на котором основан сам Node.js), и что замеры производительности нужно трактовать не как «node-fibers в три-четыре раза медленнее коллбеков», а как «по сравнению с остальными низкоуровневыми действиями в вашем коде (работа с байтовыми массивами, подключение к базе данных или к сервису в интернете), отпечаток производительности node-fibers совсем не будет заметен».
В дополнение к привычному стилю программирования, node-fibers возвращает нам ещё и привычный и удобный способ обработки ошибок try/catch’ами. Однако эта библиотека не будет внедрена в ядро Node.js’а, поскольку Райан Даль видит предназначение своего творения в том, чтобы оставаться низкоуровневым и не скрывать ничего от разработчика.
На этом основная часть этой статьи закончена, и напоследок мы вкратце рассмотрим альтернативный путь, и то, как «событийный цикл» опрашивает «источники событий» на предмет появления в них новых данных.
Альтернативный путь
В этой статье мы объяснили, почему приложение, использующее «синхронный» и «блокирующий» ввод/вывод, не выдерживает большого количества одновременных подключений. В качестве одного из решений мы предложили перевод этого приложения на «асинхронную событийную модель» (то есть, переписать приложение, скажем, на Node.js’е). Этим способом мы решим задачу фактически (закулисным) переходом с «синхронного» и «блокирующего» ввода/вывода на «синхронный» и «неблокирующий» ввод/вывод. Но это не единственное решение: мы также можем прибегнуть к «асинхронному» вводу/выводу.
А именно, мы можем использовать старый добрый «пул» системных потоков (описанный ранее в этой статье), эволюционировавший на новую ступень развития. Эта ступень развития называется «зелёные процессы» (соответственно, есть ещё и «зелёные потоки»). Это процессы, но не системные, а созданные виртуальной машиной того языка, на котором написан наш код. Виртуальная машина запускает внутри себя обычный «пул» системных потоков (скажем, по количеству ядер в процессоре), и уже на эти системные потоки отображает свои внутренние «зелёные процессы» (полностью скрывая это от разработчика).
«Зелёные процессы» — это именно «процессы», а не «потоки», так как они не имеют никаких общих переменных друг с другом, а общаются только посылкой управляющих «сообщений» друг другу. Такая модель обеспечивает защиту от разных «deadlock»’ов и избегает проблем с совместным доступом к данным, ибо всё, что имеет «зелёный процесс» — это его внутреннее состояние и «сообщение».
Каждый «объект» имеет свою очередь «сообщений» (для этого создаётся «зелёный процесс»). И любой вызов кода «объекта» — это посылка «сообщения» ему. Посылка «сообщений» от одного «объекта» другому «объекту» происходит асинхронно.
В дополнение к этому, виртуальная машина создаёт свою подсистему ввода/вывода, которая отображается на неблокирующий системный ввод/вывод (и снова разработчик ни о чём не подозревает).
И, конечно же, виртуальная машина ещё содержит свой внутренний планировщик.
В итоге, разработчик думает, что он пишет обычный код, с обычным вводом/выводом, а на деле выходит очень высокопроизводительная система. Примеры: Erlang, Actor’ы в Scala.
Как «событийный цикл» опрашивает «источники событий» на предмет появления в них новых данных
Поскольку доставка данных в регистры процессора из оперативной памяти, и отсылка данных из регистров процессора в оперативную память, не являются быстрыми операциями, то такое копирование массива туда-сюда сказывается на производительности системы (процессор простаивает, пока данные по системной шине уходят в оперативную память и приходят из неё).
При этом большинство (около 95%) полученного массива (для порядка 10 000 открытых сокетов) являются бесполезными, так как соответствующие сокеты не имеют новых данных.
А раз размер этого массива растёт пропорционально количеству дескрипторов, то получается, что алгоритм этот работает тем медленнее, чем больше сокетов открыто. То есть, чем больше одновременных посетителей на вашем сайте, тем больше «событийный цикл» начинает тормозить. В таком случае говорят: «алгоритм имеет сложность O(n)».
Можно ли написать более оптимальный алгоритм? Можно, и такие были написаны в основных серверных операционных системах: epoll в Linux’е и kqueue во FreeBSD. В Windows’е также имеется IO Completion Ports, которая является своего рода близким родственником epoll‘а, и была использована разработчиками Node.js’а при переносе его на Windows, для чего ими была написана библиотека libuv, предоставляющая единый интерфейс как для libev, так и для IO Completion Ports.
Пользователи, принявшие участие в правке статьи
Статья включает смысловые правки, предложенные пользователями: akzhan, erlyvideo, eyeofhell, MagaSoft, Mox, nuit, olegich, reddot, splav_asv, tanenn, Throwable.
А также синтаксические и стилистические правки, замеченные пользователями: Goder, @theelephant.
Блог сурового челябинского программиста
Are you aware how much time I’ve spent learning for details of Java? Thread management, dynamics, CORBA.
суббота, 29 августа 2009 г.
Событийная модель построения приложения
Большинство приложений в процессе своей работы постоянно реагируют на те или иные события. Это может быть щелчок пользователем по иконке, получение http-запроса, получение сигнала от датчика, завершение коммита транзакции и т.д. Реакция приложения проявляется в выполнении некоторых действий (отрисовка окна, отправка http-ответа, отправка сигнала исполнительному устройству, запуск BPEL-процесса и т.д.). Так вот, суть в том, что никто не мешает перенести такое поведение на уровень архитектуры приложения, т.е. организовав не только его внешнее поведение в соответствии с событийной моделью, но и внутреннее строение.
Таким образом алгоритм работы приложения, основанного на событийной модели, следующий:
1. Зарегистрировать обработчики событий в диспетчере событий.
2. В ответ на внешнее воздействие сгенерировать событие.
3. Послать событие диспетчеру событий.
4. Диспетчер событий принимает событие и ищет для него обработчик (если тот зарегистрирован).
5. Диспетчер событий вызывает обработчик.
6. Перейти на пункт 2.
Причем вот что интересно: посылать событие диспетчеру и принимать его в нем можно асинхронно. Это позволяет распараллеливать процессы генерации и обработки событий. Для обмена событиями между такими процессами используется очередь событий.
В чем же здесь профит? Если еще раз представить себе систему, построенную на базе событийной модели, то становится понятно следующее:
1. Весь код, который делает что-то полезное, вынесен в независимые друг от друга обработчики событий. Тем самым сильно увеличивается связность (cohesion) кода и уменьшается его связанность (сопряжение, coupling).
5. Приложение легко расширять путем регистрации новых обработчиков существующих событий и/или добавлением новых событий.
public class EventsQueue <
private static EventsQueue _instance = null ;
private Queue AbstractEvent > _queue = new ConcurrentLinkedQueue AbstractEvent > ( ) ;
public static EventsQueue getInstance ( ) <
if ( _instance == null )
_instance = new EventsQueue ( ) ;
public synchronized T extends AbstractEvent > T fetchEvent ( ) <
return ( T ) _queue. poll ( ) ;
public synchronized void putEvent ( AbstractEvent event ) <
Генерация событий реализована в классе EventsManager. EventsManager ищет в таблице зарегистрированных событий класс события по методу http-запроса и расширению запрашиваемого файла. Затем строит объект найденного класса с помощью reflection и помещает его в очередь событий:
public class EventsManager <
private static final EventsQueue EVENTS_QUEUE = EventsQueue. getInstance ( ) ;
private EventsTable _table = new EventsTable ( ) ;
public void generateEventAndPutInQueue ( Method method, String extname,
IHTTPRequest req, IHTTPResponse resp ) throws EventGenerationException <
Class clazz = _table. getEventClass ( method, extname ) ;
EVENTS_QUEUE. putEvent ( event ) ;
throw new EventGenerationException ( «Could not create event by class: » + clazz,
public abstract class AbstractEvent <
private IHTTPRequest request ;
private IHTTPResponse response ;
public AbstractEvent ( IHTTPRequest request, IHTTPResponse response ) <
public IHTTPRequest getRequest ( ) <
public IHTTPResponse getResponse ( ) <
Приведу небольшой фрагмент кода, демонстрирующий работу цикла генерации сообщений:
EventsManager manager = new EventsManager ( ) ;
Server server = new Server ( parsePort ( args [ 0 ] ) ) ;
Connection conn = server. accept ( ) ;
catch ( EventGenerationException e ) <
public class EventsDispatcher implements Runnable <
private static final EventsQueue EVENTS_QUEUE = EventsQueue. getInstance ( ) ;
private IHandlerRunner runner ;
private IHandlersRegistry registry ;
public EventsDispatcher ( IHandlersRegistry registry, IHandlerRunner runner ) <
AbstractEvent event = EVENTS_QUEUE. fetchEvent ( ) ;
private T extends AbstractEvent > void runHandlers ( List IHandler extends AbstractEvent >> handlers, T event ) <
for ( IHandler extends AbstractEvent > handler: handlers )
runner. run ( ( IHandler T > ) handler, event ) ;
Особых сложностей здесь нет. Как видим, класс представлят собой отдельный поток, в методе run которого и происходит обработка сообщений. Каждый обработчик запускается с помощью класса, реализующего интерфейс IHandlerRunner. Таким образом процедура запуска абстрагирована, что позволяет легко ее изменять. Код интерфейса IHandlerRunner следующий:
public interface IHandlerRunner <
public T extends AbstractEvent > void run ( IHandler T > handler, T event ) ;
Сам обработчик события представляет собой класс, реализующий интерфейс IHandler:
public interface IHandler T extends AbstractEvent > <
public void onEvent ( T event ) ;
Обработчики событий хранятся в соответствующем реестре. Фактически реестр представляет собой мэп, в котором каждому классу события соответствует список зарегитрированных для него обработчиков. Для удобства использования были написаны отдельные методы для регистрации и поиска обработчика.
Работа с реестром и запуск диспетчера событий могут выглядеть следующим образом:
IHandlersRegistry registry = new HandlersRegistry ( ) ;
EventsDispatcher dispatcher = new EventsDispatcher ( registry, new SingleThreadHandlerRunner ( ) ) ;
Конечно, можно вынести регистрацию обработчиков (как, собственно, и добавление новых событий) в отдельные сервисы и тем самым сделать приложение расширяемым с помощью, например, бандлов на OSGi-платформе. Но это уже тема для следующего разговора.