Руководство по многопроцессорным моделям сетевых серверов

Опубликовано: 2022-03-11

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

Какую модель сетевого сервера выбрать

Эта статья предназначена для «системных программистов», т. е. бэкенд-разработчиков, которые будут работать с низкоуровневыми деталями своих приложений, внедряя код сетевого сервера. Обычно это делается на C++ или C, хотя в настоящее время большинство современных языков и фреймворков предлагают достойную низкоуровневую функциональность с разным уровнем эффективности.

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

Я также буду считать само собой разумеющимся, что читатель знает, что «параллелизм» в основном означает «многозадачность», т.е. несколько экземпляров кода (один и тот же код или разные, это не имеет значения), которые активны в одно и то же время. Параллелизм может быть достигнут на одном ЦП, и до современной эпохи обычно так и было. В частности, параллелизм может быть достигнут за счет быстрого переключения между несколькими процессами или потоками на одном процессоре. Вот как старые однопроцессорные системы могли запускать множество приложений одновременно, таким образом, что пользователь воспринимал бы приложения, выполняющиеся одновременно, хотя на самом деле это не так. Параллелизм, с другой стороны, конкретно означает, что код выполняется одновременно, буквально, несколькими процессорами или ядрами ЦП.

Разделение приложения (на несколько процессов или потоков)

Для целей этого обсуждения в значительной степени не имеет значения, говорим ли мы о потоках или полных процессах. Современные операционные системы (за заметным исключением Windows) рассматривают процессы почти так же легковесно, как и потоки (или, в некоторых случаях, наоборот, потоки получили функции, которые делают их такими же весомыми, как процессы). В настоящее время основное различие между процессами и потоками заключается в возможностях межпроцессного или межпотокового взаимодействия и обмена данными. Там, где важно различие между процессами и потоками, я сделаю соответствующую пометку, в противном случае можно с уверенностью считать слова «поток» и «процесс» в этом разделе взаимозаменяемыми.

Общие задачи сетевых приложений и модели сетевых серверов

В данной статье рассматривается именно код сетевого сервера, который обязательно реализует следующие три задачи:

  • Задача №1: Установление (и разрыв) сетевых подключений
  • Задача №2: Сетевое взаимодействие (IO)
  • Задача №3: ​​Полезная работа; т. е. полезная нагрузка или причина существования приложения.

Существует несколько общих моделей сетевых серверов для разделения этих задач между процессами; а именно:

  • МП: многопроцессорность
  • СКОРОСТЬ: единый процесс, управляемый событиями
  • SEDA: поэтапная архитектура, управляемая событиями
  • AMPED: асимметричный многопроцессорный, управляемый событиями
  • SYMPED: SYmmetric Multi-Process, управляемый событиями

Это названия моделей сетевых серверов, используемые в академическом сообществе, и я помню, что нашел синонимы «в дикой природе», по крайней мере, для некоторых из них. (Сами имена, конечно, менее важны — реальная ценность заключается в том, как рассуждать о том, что происходит в коде.)

Каждая из этих моделей сетевых серверов подробно описана в следующих разделах.

Многопроцессная (МП) модель

Модель сетевого сервера MP — это та, которую все привыкли изучать в первую очередь, особенно при изучении многопоточности. В модели MP есть «главный» процесс, который принимает соединения (задача №1). Как только соединение установлено, главный процесс создает новый процесс и передает ему сокет соединения, поэтому на каждое соединение приходится один процесс. Затем этот новый процесс обычно работает с соединением простым, последовательным, поэтапным способом: он что-то читает из него (задача № 2), затем выполняет какие-то вычисления (задача № 3), затем что-то записывает в него (задача № 2). опять таки).

Модель MP очень проста в реализации и на самом деле работает очень хорошо, пока общее количество процессов остается довольно низким. Как низко? Ответ на самом деле зависит от того, что влекут за собой задачи № 2 и № 3. Как правило, количество процессов или потоков не должно превышать примерно вдвое количество ядер ЦП. Когда слишком много процессов активны одновременно, операционная система имеет тенденцию тратить слишком много времени на перегрузку (т. е. жонглирование процессами или потоками на доступных ядрах ЦП), и такие приложения обычно в конечном итоге тратят почти все свои ресурсы ЦП. время в коде «sys» (или ядра), выполняя мало действительно полезной работы.

Плюсы: очень просто реализовать, работает очень хорошо, пока количество подключений невелико.

Минусы: имеет тенденцию перегружать операционную систему, если количество процессов становится слишком большим, и может иметь дрожание задержки, поскольку сетевой ввод-вывод ожидает завершения фазы полезной нагрузки (вычислений).

Модель управления событиями одного процесса (SPED)

Модель сетевого сервера SPED стала известной благодаря некоторым относительно недавним высококлассным сетевым серверным приложениям, таким как Nginx. По сути, он выполняет все три задачи в одном процессе, мультиплексируя их. Чтобы быть эффективным, требуется довольно продвинутая функциональность ядра, такая как epoll и kqueue. В этой модели код управляется входящими подключениями и «событиями» данных и реализует «цикл событий», который выглядит следующим образом:

  • Спросите операционную систему, есть ли какие-либо новые сетевые «события» (например, новые подключения или входящие данные).
  • Если есть новые доступные соединения, установите их (Задание №1)
  • Если есть доступные данные, прочитайте их (Задание № 2) и действуйте в соответствии с ними (Задание № 3).
  • Повторяйте, пока сервер не выйдет

Все это делается в одном процессе, и это может быть сделано очень эффективно, потому что полностью исключается переключение контекста между процессами, которое обычно снижает производительность модели MP. Единственные переключения контекста здесь происходят из системных вызовов, и они сведены к минимуму, воздействуя только на определенные соединения, к которым привязаны некоторые события. Эта модель может обрабатывать десятки тысяч подключений одновременно, если работа с полезной нагрузкой (задача № 3) не является слишком сложной или ресурсоемкой.

Однако у такого подхода есть два существенных недостатка:

  1. Поскольку все три задачи выполняются последовательно в одной итерации цикла, работа с полезной нагрузкой (задача № 3) выполняется синхронно со всем остальным, а это означает, что если вычисление ответа на данные, полученные клиентом, занимает много времени, все остальное останавливается, пока это делается, вызывая потенциально огромные колебания задержки.
  2. Используется только одно ядро ​​процессора. Преимущество этого опять же состоит в абсолютном ограничении количества переключений контекста, требуемых от операционной системы, что увеличивает общую производительность, но имеет существенный недостаток, заключающийся в том, что любые другие доступные ядра ЦП вообще ничего не делают.

Именно по этим причинам требуются более совершенные модели.

Плюсы: Может быть высокопроизводительным и легким для операционной системы (т. е. требует минимального вмешательства ОС). Требуется только одно ядро ​​процессора.

Минусы: использует только один ЦП (независимо от их количества). Если работа полезной нагрузки неравномерна, это приводит к неравномерной задержке ответов.

Модель поэтапной архитектуры, управляемой событиями (SEDA)

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

  • Работа с полезной нагрузкой (задача № 3) разделена на максимально возможное количество этапов или модулей. Каждый модуль реализует одну конкретную функцию (например, «микросервисы» или «микроядра»), которая находится в отдельном процессе, и эти модули взаимодействуют друг с другом через очереди сообщений. Эту архитектуру можно представить в виде графа узлов, где каждый узел — это процесс, а ребра — это очереди сообщений.
  • Один процесс выполняет Задачу №1 (обычно по модели SPED), которая разгружает новые подключения к определенным узлам точек входа. Эти узлы могут быть либо чисто сетевыми узлами (задача № 2), которые передают данные другим узлам для вычислений, либо могут также выполнять обработку полезной нагрузки (задача № 3). Обычно не существует «главного» процесса (например, такого, который собирает и агрегирует ответы и отправляет их обратно по соединению), поскольку каждый узел может отвечать сам по себе.

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

Плюсы: Абсолютная мечта архитектора программного обеспечения: все разделено на аккуратные независимые модули.

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

Модель асимметричного многопроцессорного управления событиями (AMPED)

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

  • Реализуйте Задачи №1 и №2 в одном «мастер-процессе» в стиле SPED. Это единственный процесс, выполняющий сетевой ввод-вывод.
  • Реализуйте задачу № 3 в отдельном «рабочем» процессе (возможно, запущенном в нескольких экземплярах), подключенном к главному процессу с помощью очереди (одна очередь на процесс).
  • Когда данные получены в «главном» процессе, найдите малоиспользуемый (или простаивающий) рабочий процесс и передайте данные в его очередь сообщений. Когда ответ готов, главный процесс сообщает ему, и в этот момент он передает ответ соединению.

Здесь важно то, что работа с полезной нагрузкой выполняется в фиксированном (обычно настраиваемом) количестве процессов, которое не зависит от количества подключений. Преимущество здесь в том, что полезная нагрузка может быть произвольно сложной и не повлияет на сетевой ввод-вывод (что хорошо для задержки). Существует также возможность повышения безопасности, поскольку только один процесс выполняет сетевой ввод-вывод.

Плюсы: очень четкое разделение сетевого ввода-вывода и работы с полезной нагрузкой.

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

Модель SYmmetric Multi-Process Event-Driven (SYMPED)

Модель сетевого сервера SYMPED во многих отношениях является «Святым Граалем» моделей сетевых серверов, потому что это похоже на наличие нескольких экземпляров независимых «рабочих» процессов SPED. Это реализовано за счет того, что один процесс принимает соединения в цикле, а затем передает их рабочим процессам, каждый из которых имеет цикл обработки событий, подобный SPED. Это имеет несколько очень благоприятных последствий:

  • ЦП загружаются ровно для того количества порожденных процессов, которые в каждый момент времени либо выполняют сетевой ввод-вывод, либо обрабатывают полезную нагрузку. Дальнейшее увеличение загрузки ЦП невозможно.
  • Если соединения независимы (например, с HTTP), межпроцессное взаимодействие между рабочими процессами отсутствует.

Фактически это то, что делают более новые версии Nginx; они порождают небольшое количество рабочих процессов, каждый из которых запускает цикл обработки событий. Чтобы сделать ситуацию еще лучше, большинство операционных систем предоставляют функцию, с помощью которой несколько процессов могут независимо прослушивать входящие соединения на порту TCP, что устраняет необходимость в специальном процессе, предназначенном для работы с сетевыми соединениями. Если приложение, над которым вы работаете, может быть реализовано таким образом, я рекомендую это сделать.

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

Минусы: поскольку каждый из процессов имеет петлю, подобную SPED, если работа с полезной нагрузкой неравномерна, задержка снова может варьироваться, как и в обычной модели SPED.

Некоторые трюки низкого уровня

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

  1. Избегайте динамического выделения памяти. В качестве объяснения просто взгляните на код популярных распределителей памяти — они используют сложные структуры данных, мьютексы и в них просто так много кода (например, jemalloc занимает около 450 КиБ кода на C!). Большинство описанных выше моделей можно реализовать с полностью статической (или предварительно выделенной) сетью и/или буферами, которые меняют владельца между потоками только там, где это необходимо.
  2. Используйте максимум того, что может предоставить ОС. Большинство операционных систем позволяют нескольким процессам прослушивать один сокет и реализуют функции, при которых соединение не будет принято до тех пор, пока на сокете не будет получен первый байт (или даже первый полный запрос!) Используйте sendfile(), если можете.
  3. Поймите сетевой протокол, который вы используете! Например, обычно имеет смысл отключить алгоритм Нэгла, и может иметь смысл отключить задержку, если скорость (повторного) соединения высока. Узнайте об алгоритмах управления перегрузкой TCP и посмотрите, есть ли смысл попробовать один из более новых.

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