Что такое сетевой сервис? Это программа, которая принимает входящие запросы по сети и обрабатывает их, возможно, возвращая ответы.


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


Выбор способа обработки запросов имеет далеко идущие последствия. Как сделать чат-сервис, выдерживающий 100.000 одновременных соединений? Какой подход выбрать для извлечения данных из потока слабоструктурированных файлов? Неправильный выбор приведет к пустой трате сил и времени.


В статье рассмотрены такие подходы как пул процессов/потоков, событийно-ориентированная обработка, half sync/half async паттерн и многие другие. Приводятся многочисленные примеры, рассматриваются плюсы и минусы подходов, их особенности и области применения.


Введение


Тема способов обработки запросов не нова, смотрите, например: один, два. Однако большинство статей рассматривают её лишь частично. Данная статья призвана заполнить пробелы и привести последовательное изложение вопроса.


Будут рассмотрены следующие подходы:


  • последовательная обработка
  • процесс на запрос
  • поток на запрос
  • пулл процессов/потоков
  • событийно-ориентированная обработка (reactor паттерн)
  • half sync/half async паттерн
  • конвейерная обработка

Нужно обратить внимание, что сервис, обрабатывающий запросы — это не обязательно сетевой сервис. Это может быть сервис, который получает новые задачи из БД или очереди задач. В данной статье подразумеваются именно сетевые сервисы, но нужно понимать, что рассматриваемые подходы имеют более широкую область применения.


TL;DR


В конце статьи приводится список с кратким описанием каждого из подходов.


Последовательная обработка


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


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


Процесс на запрос


Приложение состоит из основного процесса, который принимает входящие запросы, и рабочих процессов. На каждый новый запрос основной процесс создает рабочий процесс, который обрабатывает запрос. Масштабирование по количеству запросов простое: каждый запрос получает свой собственный процесс.


В этой архитектуре тоже нет ничего сложного, но у неё есть проблемы ограничения:


  • Процесс потребляет много ресурсов.
    Попробуйте создать 10.000 одновременных соединений с РСУБД PostgreSQL и посмотрите на результат.
  • Процессы не имеют общей памяти (по умолчанию). Если нужен доступ к общим данным или общий кэш, то придется мапить шаренную память (вызов linux mmap, munmap) или использовать внешнее хранилище (memcahed, redis)

Эти проблемы отнюдь не являются стоповыми. Ниже будет показано, как они обходятся в РСУБД PostgeSQL.


Плюсы этой архитектуры:


  • Падение одного из процессов не повлияет на остальные. Например, ошибка обработки редкого кейса не уронит все приложение, пострадает только обрабатываемый запрос
  • Разграничение прав доступа на уровне операционной системы. Так как процесс — сущность ОС, то можно использовать её стандартные механизмы для разграничения прав доступа к ресурсам ОС
  • Можно на лету менять запускаемый процесс. Например, если для обработки запроса используется отдельный скрипт, то для замены алгоритма обработки достаточно изменить скрипт. Ниже будет рассмотрен пример
  • Эффективно используются многоядерные машины

Примеры:


  • РСУБД PostgreSQL создает на каждое новое соединение новый процесс. Для работы с общими данными используется шаренная память. Проблему большого потребления ресурсов процессами в PostgreSQL можно решать по-разному. Если клиентов мало (выделенный стенд для аналитиков), то такой проблемы нет. Если есть единственное приложение, которое обращается к БД, можно создать пулл соединений к БД на уровне приложения. Если приложений много, можно использовать pgbouncer
  • sshd слушает входящие запросы на 22 порту и форкается при каждом коннекте. Каждое соединение по ssh — это форк sshd-демона, который принимает и выполняет команды пользователя последовательно. Благодаря такой архитектуре для разграничения прав доступа используются ресурсы самой ОС
  • Пример из собственной практики. Есть поток неструктурированных файлов, из которых нужно получать метаданные. Основной процесс сервиса распределяет файлы по процессам-обработчикам. Каждый процесс-обработчик — скрипт, принимающий путь к файлу как параметр. Обработка файла происходит в отдельном процессе, поэтому из-за ошибки обработки не падает весь сервис. Для обновления алгоритма обработки достаточно изменить скрипты обработки без остановки сервиса.

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


Поток на запрос


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


Плюсы:


  • Общая память "из коробки"
  • Простота реализации
  • Эффективное использование многоядерных CPU

Минусы:


  • Поток потребляет много ресурсов. На unix-подобных ОС поток потребляет почти столько же ресурсов, сколько и процесс

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


Пул процессов/потоков


Потоки (процессы) создавать дорого и долго. Чтобы не тратить ресурсы впустую, можно использовать один и тот же поток многократно. Ограничив дополнительно максимальное количество потоков, получим пул потоков (процессов). Теперь основной поток принимает входящие запросы и складывает их в очередь. Рабочие процессы берут запросы из очереди и обрабатывают. Этот подход можно воспринимать как естественное масштабирование последовательной обработки запросов: каждый рабочий поток может обрабатывать потоки только последовательно, объединение их в пул позволяет обрабатывать запросы параллельно. Если каждый поток может обрабатывать 1000 rps, то 5 потоков будут обрабатывать нагрузку близкую к 5000 rps (при условии минимальной конкуренции за общие ресурсы).


Пул может быть создан заранее при старте сервиса или формироваться постепенно. Использование пула потоков более распространено, т.к. позволяет применять общую память.


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


Плюсы:


  • использование многих ядер CPU
  • уменьшение издержек на создание потока/процесса

Минусы:


  • Ограниченная масштабируемость по количеству одновременных клиентов. Использование пула позволяет нам переиспользовать один и тот же поток многратно без дополнительных затрат ресурсов, однако он не решает принципиальную проблему большого количества ресурсов, расходуемых потоком/процессом. Создать чат-сервис, выдерживающий 100.000 одновременных соединений с помощью этого подхода не получится.
  • Масштабируемость ограничивается совместно используемыми ресурсами, например, если потоки используют общую память, регулируя доступ к ней с помощью семафоров/мьютексов. Это ограничение всех подходов, в которых используются общие ресурсы.

Примеры:


  1. Python-приложение, запускаемое с uWSGI и nginx. Основной процесс uWSGI получает входящие запросы от nginx'а и распределяет их между процессами Python интерпретатора, которые обрабатывают запросы. Приложение может быть написано на любом uWSGI-совместимом фреймворке — Django, Flask и т.д.
  2. MySQL использует пул потоков: каждое новое соединение обрабатывается одним из свободных потоков из пула. Если свободных потоков нет, то MySQL создает новый поток. Размер пула свободных потоков и максимальное количество потоков (соединений) ограничиваются настройками.

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


Событийно-ориентированная обработка (reactor паттерн)


Две парадигмы — синхронная и асинхронаая — вечные конкуренты друг друга. До сих пор речь шла только о синхронных подходах, но было бы неправильно игнорировать асинхронный подход. Событийно ориентированная или реактивная обработка запросов — это подход, в котором каждая IO операция выполняется ассинхронно, а по окончании выполнения операции вызывается обработчик (handler). Как правило, обработка каждого запроса состоит из множества асинхронных вызовов с последующим выполнением handler'ов. В каждый конкретный момент однопоточное приложение выполняет код только одного handler'а, но выполнение handler'ов различных запросов чередуется между собой, что позволяет одновременно (псевдопараллельно) обрабатывать множество параллельных запросов.


Полное рассмотрение этого подхода выходит за рамки этой статьи. Для более глубокого ознакомления можно порекомендовать Reactor (Реактор), В чем секрет скорости NodeJS?, Inside NGINX. Здесь ограничимся только рассмотрением плюсов и минусов данного подхода.


Плюсы:


  • Эффективное масштабирование по rps и количеству одновременных соединений. Реактивный сервис может одноверменно обрабатывать большое количество соединений (десятки тысяч), если большинство соединений ожидают завершения операций ввода/вывода

Минусы:


  • Сложность разработки. Программировать в асинхронном стиле сложнее, чем в синхронном. Логика обработки запросов более сложная, отлаживаться тоже сложнее, чем в синхронном коде.
  • Ошибки, приводящие к блокировке всего сервиса. Если язык или среда исполнения не рассчитаны изначально для асинхронной обработки, то единственная синхронная операция может заблокировать весь сервис, сведя на нет возможности по масштабированию.
  • Сложно масштабироваться по ядрам CPU. Этот подход предполагает наличие единственного потока в единственном процессе, поэтому нельзя использовать несколько ядер CPU одновременно. Нужно отметить, что есть способы обойти это ограничение.
  • Следствие предыдущего пункта: этот подход плохо масштабируется для запросов, требовательных к CPU. Количество rps для этого подхода обратно пропорционально количеству операций CPU, необходимых для обработки каждого запроса. Требовательные к CPU запросы сводят на нет преимущества данного подхода.

Примеры:


  1. Node.js использует паттерн reactor "из коробки". Подробнее смотрите В чем секрет скорости NodeJS?
  2. nginx: рабочие процессы (worker process) nginx'а используют reactor паттерн для параллельной обработки запросов. Подробнее смотрите Inside NGINX.
  3. C/C++ программа, напрямую использующая средства ОС (epoll на linux, IOCP на windows, kqueue на FreeBSD), или использующая фреймворк (libev, libevent, libuv и т.п.).

Half sync/half async


Название взято из книги POSA: Patterns for Concurrent and Networked Objects. В оригинале этот паттерн трактуется очень широко, однако для целей данной статьи я буду понимать этот паттерн несколько уже. Half sync/half async — это подход к обработке запросов, в котором для каждого запроса используется легковесный поток управления (зеленый поток). Программа состоит из одного или нескольких потоков уровня операционной системы, однако система исполнения программы поддерживает зеленые потоки, которые ОС не видит и которыми не может управлять.


Несколько примеров, чтобы сделать рассмотрение конкретнее:


  1. Сервис на языке Go. Язык Go поддерживает множество легковесных потоков исполнения — горутин. Программа использует один или несколько потоков ОС, но программист оперирует горутинами, которые прозрачным образом распределяются между потоками ОС, чтобы задействовать многоядерные CPU
  2. Сервис на Python с библиотекой gevent. Библиотека gevent позволяет программисту использовать зеленые потоки на уровне библиотеки. Вся программа при этом исполняется в единственном потоке ОС.

В сущности этот подход призван совместить высокую производительность асинхронного подхода с простотой программирования синхронного кода.


При использовании этого подхода, несмотря на иллюзию синхронности, программа будет работать асинхронно: система исполнения программы будет управлять event loop'ом, а каждая "синхронная" операция на самом деле будет асинхронной. При вызове такой операции система исполнения будет вызывать асинхронную операцию с помощью средств ОС и регистрировать handler завершения выполнения операции. Когда асинхронная операция будет завершена, система исполнения вызовет ранее зарегестрированный handler, который продолжит выполнение программы в точке вызова "синхронной" операции.


В результате подход half sync/half async содержит в себе как некоторые плюсы, так и некоторые минусы асинхронного подхода. Объем статьи не позволяет рассмотреть этот подход во всех деталях. Интересующимся советую прочесть одноименную главу в книге POSA: Patterns for Concurrent and Networked Objects.


Сам по себе подход half sync/half async вводит новую сущность "зеленый поток" — легковесный поток управления на уровне системы исполнения программы или библиотеки. Что делать с зелеными потоками — выбор программиста. Он может использовать пул зеленых потоков, может создавать новый зеленый поток на каждый новый запрос. Разница по сравнению с потоками/процессам ОС в том, что зеленые потоки намного дешевле: они расходуют гораздо меньше оперативной памяти и создаются намного быстрее. Это позволяет создавать огромное количество зеленых потоков, например, сотни тысяч в языке Go. Такое огромное количество делает оправданным использование подхода "зеленый поток на запрос".


Плюсы:


  • Хорошо масштабируется по rps и количеству одновременных соединений
  • Код легче писать и отлаживать по сравнению с асинхронным подходом

Минусы:


  • Так как выполнение операций на самом деле асинхронное, то возможны ошибки программирования, когда единственная синхронная операция блокирует весь процесс. Это особенно чувтствуется в языках, где этот подход реализуется средствами библиотеки, например Python.
  • Непрозрачность работы программы. При использовании потоков или процессов ОС алгоритм выполнения программы ясен: каждый поток/процесс выполняет операции в той последовательности, в которой они написаны в коде. При использовании подхода half sync/half async операции, которые написаны в коде последовательно, могут непредсказуемым образом чередоваться с операциями, обрабатывающими параллельные запросы.
  • Непригодность для real-time систем. Асинхронная обработка запросов значительно усложняет предоставление гарантий по времени обработки каждого отдельно взятого запроса. Это является следствием предыдущего пункта.

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


Конвейерная обработка


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


Плюсы:


  • Этот подход хорошо масштабируется по rps. Чем больше звеньев цепочки, тем болшее количество запросов обрабатывается в секунду.
  • Использование нескольких потоков позволяет хорошо масштабироваться по ядрам CPU.

Минусы:


  • Не для всех категорий запросов подойдет этот подход. Например, организовать long polling с помощью этого подхода будет сложно и неудобно.
  • Сложность реализации и отладки. Разбить последовательную обработку на стадии так, чтобы производительность была высокой, может быть непросто. Отлаживать программу, в которой каждый запрос поочередно обрабатывается в нескольких параллельно работающих потоках сложнее, чем последовательную обработку.

Примеры:


  1. Интересный пример конвейерной обработки был описан в докладе highload 2018 Эволюция архитектуры торгово-клиринговой системы Московской биржи

Конвейерная обработка широко используется, однако чаще всего звеньями являются отдельные компоненты в независимых процессах, которые обмениваются сообщениями, например, через очередь сообщений или БД.


Резюме


Краткая сводка рассмотренных подходов:


  • Синхроная обработка.
    Простой подход, но сильно ограниченный в возможностях масштабирования, как по rps, так и по количеству одновременных соединений. Не позволяет использовать несколько ядер CPU одновременно.
  • Новый процесс на каждый запрос.
    Большие издержки на создание процессов. Подход не позволяет эффективно масштабироваться по количеству одновременных соединений, имеет сложности при использовании общей памяти. Вполне подходит для длительных по времени выполнения запросов при малом количестве одновременных соединений. Имеет полезные для некоторых приложений свойства (повышенная надежность, разграничение доступа на уровне ОС).
  • Новый поток на каждый запрос.
    Проблемы те же, что и у предыдущего подхода, но позволяет легко использовать общую память. Имеет схожую с предыдущим подходом область применения, однако лишен части его полезных свойств.
  • Пулл процессов/потоков.
    По сравнению с двумя предыдущими подходами позволяет избежать издержек на создание процессов/потоков. Наиболее часто используемый подход для построения сетевых сервисов. Хорошо масштабируется по rps и количеству используемых ядер. Хорошо подходит для обработки большого количества коротких запросов. Плохо масштабируется по количеству одновременных соединений.
  • Событийно-ориентированная обработка (reactor паттерн).
    Хорошо масштабируется по rps и количеству одновременных соединений. Сложнее использовать из-за асинхронного стиля программирования, возможны сложно отлавливаемые плавающие ошибки. Масштабирование по количеству используемых ядер CPU сопряжено с трудностями
  • Half sync/half async.
    Хорошо масштабируется по rps и количеству одновременных соединений. В зависимости от реализации хорошо масштабируется по ядрам CPU (Golang) или не масштабируется вовсе (Python). Расходует значительно меньше ресурсов на запрос, чем подход процесс(поток) на запрос. Программируется в синхронном стиле в отличие от reactor паттерна, однако возможны те же плавающие ошибки, что и у reactor паттерна.
  • Конвейерная обработка.
    Позволяет достичь высокой производительности, но сложный в реализации подход. Подойдет не для всех типов запросов (например, long polling будет сделать затруднительно).

Список выше не является исчерпывающим, но он содержит основные подходы к обработке запросов.


Обращаюсь к читателю: какие подходы используете Вы? Какие плюсы и минусы, особенности их работы Вы узнали на собственном опыте?


Ссылки


  1. Статьи по теме:
  2. Событийно-ориентированный подход:
  3. Сравнение подходов на основе потоков и событий:
  4. Half sync/half async:
  5. Зеленые потоки:
  6. Конвейерная обработка:

Комментарии (5)


  1. dimaaan
    11.12.2018 14:51

    А в какую категорию отнести такое?
    Пул потоков, но IO операции происходят асинхронно, возвращая поток в пул на время операции, а по завершении, продолжают обрабатывать запрос, снова взяв поток из пула.
    Похоже на сметь паттерна reactor и пула потоков, но при этом лишен недостатков обоих.


    1. vadimnt Автор
      11.12.2018 16:39

      По описанию похоже на разновидность half sync/half async. Операции выполняются в контексте зеленого потока, а каждая IO операция выполняет переключение на другой зеленый поток, который работает в том же потоке операционной системы. Минусы в появлении дополнительного уровня абстракции и возможности по ошибке заблокировать поток операционной системы, вызвав синхронную операцию.


      1. dimaaan
        11.12.2018 17:02

        Но там нет зеленых потоков. Используется самый обычный пул потоков ОС. Так что абстракции нет, а вот заблокировать поток вызвав IO операцию синхронно можно.


        Я интересуюсь, потому что вышеописанное — это стандартная модель которую предлагает .net'овский ASP.NET "из коробки".
        Продолжение выполнения после IO операции достигается за счет async/await, причем не обязательно в том потоке, в котором запрос начинался.


        Просто, ИМХО, это самая удачная модель для типичного веб сервиса, а у вас она, получается, не описана.


    1. Veikedo
      12.12.2018 12:56

      Тот же самый "reactor pattern", только с пулом потоков.


      Насколько знаю, у nodejs сейчас тоже пул потоков.


  1. vadimnt Автор
    11.12.2018 18:08

    Это интересно. Обязательно посмотрю на эту модель.