Яндекс.Метрика

    Песочница

    Что такое «асинхронная событийная модель», и почему сейчас она «в моде»

    Сейчас в интернетах очень модно слово «Node.js». В этой небольшой статье мы попробуем понять, откуда всё это взялось и чем такая архитектура отличается от привычной «по процессу на запрос».
    Новый «тренд» был вызван тем, что серверы с традиционной многопоточной архитектурой перестали справляться с возрастающей нагрузкой в «веб-два-нольном» интернете. Интернет превратился в подобие огромного пчелиного улья, в котором всё жужжит и движется. Проверенный годами Apache хорошо справлялся с «интернетом 1.0». Сервер запускал новый процесс (или поток, что почти одно и то же с точки зрения операционной системы) на каждый запрос пользователя. Этот поток шёл в PHP, оттуда – в базу данных, чего-нибудь там выбирал, и возвращался с ответом, который отсылал пользователю по HTTP, после чего самоуничтожался. Однако когда количество одновременно работающих запросов начало близиться к символическому порядку 10 000, начались тормоза. Допустим, у нас есть 10 000 потоков. Как они будут уживаться друг с другом? Их будет уживать друг с другом системный «планировщик», задачей которого является выдавать каждому потоку его долю процессорного времени, и при этом никого не обделять. Действует он так. Когда один поток немного поработал, запускается планировщик, временно останавливает этот поток, и «подготавливает площадку» для запуска следующего потока (который уже ждёт в очереди). Такая подготовка площадки называется «переключением контекста», и в неё входит сохранение «контекста» приостанавливаемого потока, и восстановление контекста потока, который будет запущен следующим. В «контекст» входят регистры процессора и данные о процессе в самой операционной системе (id’шники, права доступа, ресурсы и блокировки, выделенная память и т.д.). Как часто запускается планировщик – это решает операционная система. Например, в Linux’е «захардкожен» период запуска планировщика в сотую долю секунды. Планировщик также вызывается, когда процесс «блокируется» вручную или в ожидании ввода/вывода (например, запрос пользователя в потоке PHP ждёт, пока база данных выдаст ему отчёт по продажам за месяц). «Переключение контекста» не является таким уж дорогостоящим, если у вас дома на компьютере запущены несколько десятков процессов (системные процессы, музыка, браузер, торренты, скайп, фотошоп, все дела). Переключение контекста между потоками, когда в системе их несколько десятков, составляет порядка микросекунды. По мере того, как растёт или память, связанная с потоком, или количество потоков, начинает не хватать «кеша второго уровня» (L2) у процессора, который не может уже вместить в себе все эти «контексты» потоков, и эти «контексты» приходится начинать писать в обычную оперативную память (и потом доставать их оттуда), скорость доступа к которой из процессора на порядки ниже скорости доступа к кешу процессора (поэтому и был придуман этот кеш). Из-за этого время «переключения контекста» может доходить до десятой доли миллисекунды. При этом, напомню, у нас в «высоконагруженной» системе предполагается порядка 10 000 одновременно работающих потоков, которые постоянно что-то вводят/выводят, а, значит, «блокируются», и этим запускают планировщик ещё и ещё раз. И система начинает захлёбываться от такой нагрузки, тратя большинство процессорного времени на решение своих системных задач, а не на выполнение полезной работы. Эта загвоздка получила название «The C10K problem».

    Серверы интернета нуждались в новой архитектуре, и она была найдена – это «асинхронная событийная модель». В основе её лежат «событийный цикл» и шаблон «reactor» (от слова «react» – реагировать). «Событийный цикл» представляет собой бесконечный цикл, который опрашивает «источники событий» на предмет появления в них какого-нибудь «события». «Событием» может быть приход очередной порции данных на сетевой сокет («socket» – дословно «место соединения»), или считывание новой порции данных с жёсткого диска: в общем, любой ввод/вывод. Например, когда вы загружаете картинку на хостинг, данные туда приходят кусками, каждый раз вызывая событие «новая порция данных картинки получена». «Источником событий» в данном случае будет являться «дескриптор» (указатель на поток данных) того самого TCP-сокета, через который вы соединились с сайтом по сети. Второй компонент новой архитектуры, как уже было сказано, – это шаблон «reactor». И, для русского человека, это совсем не тот реактор, который стоит на атомной станции. Суть этого шаблона заключается в том, что код сервера пишется не одним большим куском, который исполняется последовательно, а небольшими блоками, каждый из которых вызывается («реагирует») тогда, когда происходит связанное с ним событие. Таким образом, код представляет собой набор множества блоков, задача которых состоит в том, чтобы «реагировать» на какие-то события.

    Такая новая архитектура стала «мейнстримом» после появления Node.js’а. Node.js написан на C++, и основывает свой событийный цикл на Сишной библиотеке «libev». Однако Яваскрипт здесь не является каким-то избранным языком: для множества других языков тоже написаны похожие серверы и «фреймворки». Всё, что требуется в этом случае от языка – это наличие библиотеки «асинхронного» («неблокирующего») ввода/вывода. При обычном, «синхронном», вводе/выводе вы пишете код типа «открыть файл; считать его в строку; вывести на экран», а при «асинхронном» вводе/выводе вы пишете «открыть файл, и когда он будет открыт, считать его в строку и вывести на экран; а пока, делать то-то и то-то». «Асинхронные» библиотеки ввода/вывода гораздо сложнее в написании (чем синхронные), поэтому не для каждого языка они написаны. На основе таких библиотек пишутся «фреймворки» для запуска приложений по «асинхронной событийной модели». Например, у Питона есть Twisted, у Перла – Perl Object Environment, у Руби – EventMachine (которой уже лет пять). На этих «фреймворках» можно писать свои серверы, подобные Node.js’у. Например, для Явы (на основе java.nio) написаны Netty и MINA, а для Руби (на основе EventMachine) – Goliath (который ещё и пользуется преимуществами Fibers).

    «Асинхронная событийная модель» хорошо подойдёт там, где много-много пользователей одновременно запрашивают какие-нибудь «легковесные» куски данных, или производят какие-нибудь «легковесные» действия. Например: проверить, есть ли новые «твиты» у Васи, загрузил ли Петя новые фотки, есть ли новые комментарии к моей статье или сообщения в моём личном ящике, плюсануть кого-нибудь, проставить рейтинг кому-нибудь, написать камент, отослать сообщение в чат, и т.п… Требование «легковесности» действий проясняется, когда мы вспоминаем о том, что весь этот бесконечный цикл запущен в одном единственном потоке, и если вы вклините в этот цикл какое-нибудь тяжеловесное вычисление, то все остальные пользователи будут ждать в очереди, пока одно это вычисление не закончится. Поэтому серверы, подобные Node.js’у, подходит только для выполнения «легковесных» задач, или как «фронтенд» для тяжеловесного «бекенда».

    Ещё один недостаток «асинхронной событийной модели» – код становится менее читаемым из-за переплетения множества «обратных вызовов». Например, подобный «спагетти-код» является бичом программирования на Node.js’е (коллбек на коллбеке, коллбеком погоняет). Об этом знают, и с этим пытаются бороться. Например, для Node.js’а написана библиотека Seq. А в Руби, начиная с версии 1.9, введены Fibers, с помощью которых можно полностью устранить коллбеки, и писать код так, как будто бы всё происходит синхронно.

    На этом основная часть этой статьи закончена, и напоследок мы рассмотрим то, как «событийный цикл» опрашивает «источники событий» на предмет появления в них новых данных. Самое простое решение, которое можно придумать – опрашивать все «дескрипторы» (открытые сетевые сокеты, считываемые или записываемые файлы, …) на предмет наличия в них новых данных. Такой алгоритм называется «poll». Выглядит это примерно так:
    • у вас есть два открытых сокета
    • вы создаёте массив из двух структур, которые описывают эти сокеты
    • каждому элементу этого массива вы проставляете, что и о каком сокете в него записать
    • затем вы передаёте этот массив системной функции poll, которая пишет туда описание текущего состояния этих сокетов
    • после этого вы проходитесь по этому массиву ещё раз, выясняя, есть ли там для этих сокетов новые данные
    • если есть – считываете их, и делаете с ними что-нибудь
    • всё это повторяется для нового витка бесконечного «событийного цикла»
    Причём упомянутый массив с данными передаётся не по ссылке, а именно копируется, по причине того, что операционная система состоит из «пространства ядра» и «пользовательского пространства», которые не могут иметь общие куски памяти (для обеспечения безопасности системы). Поскольку получение данных процессором из оперативной памяти, и отсылка данных процессором в оперативную память, не являются быстрыми операциями, то такое копирование массива туда-сюда сказывается на производительности системы (процессор простаивает, пока данные по системной шине уходят в оперативную память и приходят из неё). При этом около 95% полученного массива (для порядка 10 000 открытых сокетов) являются бесполезными, так как соответствующие сокеты не имеют новых данных. А раз размер этого массива растёт пропорционально количеству дескрипторов, то получается, что алгоритм этот работает тем медленнее, чем больше сокетов открыто. То есть, чем больше одновременных посетителей на вашем сайте, тем больше «событийный цикл» начинает тормозить. В таком случае говорят: «алгоритм имеет сложность O(n)». Можно ли написать более оптимальный алгоритм? Можно, и такие были написаны в основных операционных системах (epoll в Linux’е и kqueue во FreeBSD). Рассмотрим epoll. Он отличается от простого poll’а с двух сторон. Первое: программа не проверяет вообще все дескрипторы (и все виды событий), а подписывается только на те дескрипторы (и виды событий), которые ей нужны. Второе: ядро делает область памяти с данными дескрипторов видимой программе путём создания файла /dev/epoll, который программа может читать (и писать в него) с помощью функции mmap без какого-либо копирования вообще (наличие новых сообщений при этом проверяется системной функцией ioctl. И если обычный перебор занимал O(n) времени, то эти оптимизированные алгоритмы требуют O(1) времени, то есть не становятся медленнее с ростом количества одновременных посетителей на сайте.

    Ссылки по теме:

    Введение в EventMachine
    Scalable network programming
    Kqueue