Всем привет. На связи Владислав Родин. В настоящее время я являюсь руководителем курса «Архитектор высоких нагрузок» в OTUS, а также преподаю на курсах, посвященных архитектуре ПО.
Помимо преподавания, как вы могли заметить, я занимаюсь написанием авторского материала для блога OTUS на хабре и сегодняшнюю статью хочу приурочить к запуску курса «Администратор Linux», на который прямо сейчас открыт набор.
Почему веб-приложение тормозит и не держит нагрузку? Разработчики, которые первыми столкнулись с таким вопросом и провели исследования некоторых систем, пришли к неутешительному выводу о том, что оптимизации одной бизнес-логики будет недостаточно. Ответ на поставленный вопрос кроется на более низком уровне — на уровне операционной системы. Чтобы ваше приложение держало нагрузку, необходимо пересмотреть его архитектурную концепцию таким образом, чтобы эффективно работать именно на этом уровне. Это привело к возникновению асинхронных веб-серверов.
К сожалению, мне не удалось найти ни одного материала, позволяющего восстановить разом все причинно-следственные связи в эволюции веб-серверов. Так возникла идея написания этой статьи, которая, как я надеюсь, станет таким материалом.
Перед тем, как говорить о моделях веб-серверов, я позволю себе напомнить некоторые особенности работы процессов и потоков в ОС Linux. Это понадобится нам при анализе преимуществ и недостатков вышеупомянутых моделей.
Скорее всего, у любого пользователя, знающего, что на одном процессорном ядре единовременно может выполняться только одна программа, возникает вопрос: «Почему на моем 4-хядерном процессоре одновременно может быть запущено 20 программ?».
На самом деле, это связано с тем, что имеет место вытесняющая многозадачность. Операционная система выделяет некоторый квант времени (~50 мкс) и ставит программу на выполнение на ядро в течении этого времени. После того, как время истекает, происходит прерывание и переключение контекста (context switch). То есть операционная система просто ставит на выполнение следующую программу. Поскольку переключения происходят часто, у нас создается впечатление, будто все программы работают одновременно. Обратите внимание на высокую частоту переключения, это будет важно для последующего изложения.
Выше было упомянуто переключение контекста. Что же оно в себя включает? При переключении контекста необходимо сохранить регистры процессора, очистить его конвейер команд, сохранить регионы памяти, выделенные процессу. В общем-то, операция достаточно дорогая. Она занимает ~0.5 мкс, тогда как выполнение простой строчки кода ~1 нс. Причем при увеличении числа процессов, приходящихся на одно процессорное ядро, overhead на переключение контекста увеличится.
На данный момент существуют следующие модели веб-серверов:
Давайте обсудим каждую из них отдельно.
Исторически с данных моделей все и начиналось. Суть очень простая: к нам приходит клиент, мы выделяем для него отдельный обработчик, который обрабатывает пришедшего клиента от начала и до конца. Обработчиком может быть как процесс (prefork), так и поток (worker). Примером такого веб-сервера может послужить известный Apache.
Сразу оговорюсь: создавать новый обработчик на каждого клиента — это дорого. Во-первых, при неизменном количестве ядер рост числа обработчиков приводит к увеличению latency (из-за context switch'ей). Во-вторых, необходимый объем памяти растет с увеличением клиентов линейно, потому что даже если вы используете разделяющие память потоки, стек у каждого потока свой. Таким образом, число обрабатываемых одновременно клиентов ограничено размером пула, который, в свою очередь, зависит от числа процессорных ядер. Проблема решается использованием методов вертикального масштабирования.
Еще один фундаментальный недостаток таких серверов заключается в неоптимальном использовании ресурсов. Процессы (или потоки) существенную часть времени простаивают. Представим себе следующую ситуацию: во время обработки клиента необходимо забрать какие-либо данные с жесткого диска, сделать запрос в базу данных или что-то записать в сеть. Поскольку в Linux'е чтение с жесткого диска является блокирующей операцией, процесс (или поток) зависнет в ожидании ответа, но при этом все равно будет участвовать в распределении процессорного времени.
Worker и prefork имеют достаточно мало принципиальных различий. Потоки несколько экономнее по памяти, потому что они ее разделяют. По этой же причине context switch между ними легче, чем между процессами. Однако в случае worker'а код становится многопоточным, ведь потоки необходимо синхронизировать. Как следствие, мы получаем все «прелести» многопоточного кода: его становится сложнее писать, читать, тестировать и дебажить.
Итак, worker и prefork не позволяют обрабатывать одновременно большое количество клиентов из-за ограниченности размера пула, а также не оптимально используют ресурсы из-за переключения контекста и блокирующих системных вызов. Как видите, проблема заключается в многопоточности и тяжелом планировщике ОС. Это приводит к следующей идее: давайте обрабатывать клиентов всего лишь в одном потоке, но пусть он будет загружен на все 100%.
Такие сервера основаны на событийном цикле и шаблоне reactor (машина событий). Клиентский код, инициируя операцию I/O, регистрирует callback в очереди с приоритетом (приоритет- время готовности). Цикл событий опрашивает дескрипторы, ожидающие I/O, а затем обновляет приоритет (в случае готовности). Помимо этого, цикл событий вытаскивает события из очереди с приоритетом, вызывая callback'и, возвращающие в конце управление циклу событий.
Такая модель позволяет обрабатывать большое количество клиентов, избегая overhead'а на переключение контекста. Эта модель не идеальна и обладает рядом недостатков. Во-первых, потребляется не более одного процессорного ядра, потому что процесс один. Это лечится применением комбинированной модели, о которой пойдет речь ниже. Во-вторых, клиенты связаны между собой этим процессом. Код необходимо писать аккуратно. Утечки памяти, ошибки приводят к тому, что отваливаются все клиенты сразу. К тому же, этот процесс не должен быть заблокирован чем либо, callback не должен заключаться в решнии каких-то тяжелых задач, потому что будут заблокированы все клиенты. В-третьих, асинхронный код существенно сложнее. Необходимо регистрировать дополнительный callback на то, что данные не придут, решать вопрос с тем, как правильно сделать ветвление и т.д.
Данная модель применяется в реальных серверах. В такой модели имеется пул процессов, каждый из которых имеет пул потоков, каждый из которых, в свою очередь, использует модель обработки на основе асинхронного ввода-вывода. Nginx представляет комбинированную модель.
Таким образом, мы, обратившись к основам работы операционной системы, рассмотрели концептуальные различия моделей веб-серверов, используемых в Apache и Nginx. Каждая из них обладает своими преимуществами и недостатками, поэтому на production'е часто используется их комбинация.
Идея асинхронной обработки развивалась и дальше: на уровне языковых платформ возникли понятия green threads / fibers / goroutines, которые позволяют «спрятать под капот» асинхронность ввода и вывода, оставив разработчика довольным красивым синхронным кодом. Впрочем, эта концепция заслуживает отдельной статьи.
Узнать подробнее о курсе.
Помимо преподавания, как вы могли заметить, я занимаюсь написанием авторского материала для блога OTUS на хабре и сегодняшнюю статью хочу приурочить к запуску курса «Администратор Linux», на который прямо сейчас открыт набор.
Введение
Почему веб-приложение тормозит и не держит нагрузку? Разработчики, которые первыми столкнулись с таким вопросом и провели исследования некоторых систем, пришли к неутешительному выводу о том, что оптимизации одной бизнес-логики будет недостаточно. Ответ на поставленный вопрос кроется на более низком уровне — на уровне операционной системы. Чтобы ваше приложение держало нагрузку, необходимо пересмотреть его архитектурную концепцию таким образом, чтобы эффективно работать именно на этом уровне. Это привело к возникновению асинхронных веб-серверов.
К сожалению, мне не удалось найти ни одного материала, позволяющего восстановить разом все причинно-следственные связи в эволюции веб-серверов. Так возникла идея написания этой статьи, которая, как я надеюсь, станет таким материалом.
Особенности работы ОС Linux
Перед тем, как говорить о моделях веб-серверов, я позволю себе напомнить некоторые особенности работы процессов и потоков в ОС Linux. Это понадобится нам при анализе преимуществ и недостатков вышеупомянутых моделей.
Переключение контекста
Скорее всего, у любого пользователя, знающего, что на одном процессорном ядре единовременно может выполняться только одна программа, возникает вопрос: «Почему на моем 4-хядерном процессоре одновременно может быть запущено 20 программ?».
На самом деле, это связано с тем, что имеет место вытесняющая многозадачность. Операционная система выделяет некоторый квант времени (~50 мкс) и ставит программу на выполнение на ядро в течении этого времени. После того, как время истекает, происходит прерывание и переключение контекста (context switch). То есть операционная система просто ставит на выполнение следующую программу. Поскольку переключения происходят часто, у нас создается впечатление, будто все программы работают одновременно. Обратите внимание на высокую частоту переключения, это будет важно для последующего изложения.
Выше было упомянуто переключение контекста. Что же оно в себя включает? При переключении контекста необходимо сохранить регистры процессора, очистить его конвейер команд, сохранить регионы памяти, выделенные процессу. В общем-то, операция достаточно дорогая. Она занимает ~0.5 мкс, тогда как выполнение простой строчки кода ~1 нс. Причем при увеличении числа процессов, приходящихся на одно процессорное ядро, overhead на переключение контекста увеличится.
Модели веб-серверов
На данный момент существуют следующие модели веб-серверов:
- worker
- prefork
- асинхронная
- комбинированная
Давайте обсудим каждую из них отдельно.
Worker и prefork
Исторически с данных моделей все и начиналось. Суть очень простая: к нам приходит клиент, мы выделяем для него отдельный обработчик, который обрабатывает пришедшего клиента от начала и до конца. Обработчиком может быть как процесс (prefork), так и поток (worker). Примером такого веб-сервера может послужить известный Apache.
Сразу оговорюсь: создавать новый обработчик на каждого клиента — это дорого. Во-первых, при неизменном количестве ядер рост числа обработчиков приводит к увеличению latency (из-за context switch'ей). Во-вторых, необходимый объем памяти растет с увеличением клиентов линейно, потому что даже если вы используете разделяющие память потоки, стек у каждого потока свой. Таким образом, число обрабатываемых одновременно клиентов ограничено размером пула, который, в свою очередь, зависит от числа процессорных ядер. Проблема решается использованием методов вертикального масштабирования.
Еще один фундаментальный недостаток таких серверов заключается в неоптимальном использовании ресурсов. Процессы (или потоки) существенную часть времени простаивают. Представим себе следующую ситуацию: во время обработки клиента необходимо забрать какие-либо данные с жесткого диска, сделать запрос в базу данных или что-то записать в сеть. Поскольку в Linux'е чтение с жесткого диска является блокирующей операцией, процесс (или поток) зависнет в ожидании ответа, но при этом все равно будет участвовать в распределении процессорного времени.
Worker vs prefork
Worker и prefork имеют достаточно мало принципиальных различий. Потоки несколько экономнее по памяти, потому что они ее разделяют. По этой же причине context switch между ними легче, чем между процессами. Однако в случае worker'а код становится многопоточным, ведь потоки необходимо синхронизировать. Как следствие, мы получаем все «прелести» многопоточного кода: его становится сложнее писать, читать, тестировать и дебажить.
Асинхронная модель
Итак, worker и prefork не позволяют обрабатывать одновременно большое количество клиентов из-за ограниченности размера пула, а также не оптимально используют ресурсы из-за переключения контекста и блокирующих системных вызов. Как видите, проблема заключается в многопоточности и тяжелом планировщике ОС. Это приводит к следующей идее: давайте обрабатывать клиентов всего лишь в одном потоке, но пусть он будет загружен на все 100%.
Такие сервера основаны на событийном цикле и шаблоне reactor (машина событий). Клиентский код, инициируя операцию I/O, регистрирует callback в очереди с приоритетом (приоритет- время готовности). Цикл событий опрашивает дескрипторы, ожидающие I/O, а затем обновляет приоритет (в случае готовности). Помимо этого, цикл событий вытаскивает события из очереди с приоритетом, вызывая callback'и, возвращающие в конце управление циклу событий.
Такая модель позволяет обрабатывать большое количество клиентов, избегая overhead'а на переключение контекста. Эта модель не идеальна и обладает рядом недостатков. Во-первых, потребляется не более одного процессорного ядра, потому что процесс один. Это лечится применением комбинированной модели, о которой пойдет речь ниже. Во-вторых, клиенты связаны между собой этим процессом. Код необходимо писать аккуратно. Утечки памяти, ошибки приводят к тому, что отваливаются все клиенты сразу. К тому же, этот процесс не должен быть заблокирован чем либо, callback не должен заключаться в решнии каких-то тяжелых задач, потому что будут заблокированы все клиенты. В-третьих, асинхронный код существенно сложнее. Необходимо регистрировать дополнительный callback на то, что данные не придут, решать вопрос с тем, как правильно сделать ветвление и т.д.
Комбинированная модель
Данная модель применяется в реальных серверах. В такой модели имеется пул процессов, каждый из которых имеет пул потоков, каждый из которых, в свою очередь, использует модель обработки на основе асинхронного ввода-вывода. Nginx представляет комбинированную модель.
Вывод
Таким образом, мы, обратившись к основам работы операционной системы, рассмотрели концептуальные различия моделей веб-серверов, используемых в Apache и Nginx. Каждая из них обладает своими преимуществами и недостатками, поэтому на production'е часто используется их комбинация.
Идея асинхронной обработки развивалась и дальше: на уровне языковых платформ возникли понятия green threads / fibers / goroutines, которые позволяют «спрятать под капот» асинхронность ввода и вывода, оставив разработчика довольным красивым синхронным кодом. Впрочем, эта концепция заслуживает отдельной статьи.
Узнать подробнее о курсе.