Алексей Махоткин

домашняя страница

Устройство сетевых серверов

UNIX-подобные операционные системы обеспечивают стандартный API для приема и обработки TCP-соединений, который называется Sockets API. Через него также можно отправлять и принимать UDP-пакеты, но в этой книге мы не будем касаться этого вопроса.

Для начала рассмотрим типичные сценарии, относящиеся к обработке TCP-соединений (поверх которых работает львиная доля протоколов, в первую очередь HTTP) на стороне сервера (inetd, Nginx, Apache и множестве других). В этой главе мы опишем простейший базовый случай (один процесс на соединение), pre-fork модель, а также FSM-модель.

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

Устройство сетевых серверов

Простейший базовый случай

В простейшем базовом случае каждому TCP-соединению соответствует один дочерний процесс. Эта модель используется в стандартном UNIX-сервере inetd.

Сокеты, как и открытые файлы, идентифицируются файловыми дескрипторами. Файловый дескриптор — это небольшое целое число.

Чтобы приготовиться к приему запросов, серверу нужно последовательно вызвать три стандартных функции: socket(), bind() и listen().

Первая функция, socket(), создает новый сокет, работающий по указанному протоколу (в нашем случае используется комбинация параметров AF_INET, SOCK_STREAM и IPPROTO_TCP). Эта функция возвращает целое число, соответствующее файловому дескриптору.

Вторая функция, bind(), задает сетевой адрес сокета — на нем будут приниматься соединения. Для TCP это означает, что устанавливается IP-адрес и номер порта.

Третья функция, listen(), перевод сокет в режим прослушивания. Теперь операционная система будет принимать TCP-соединения на указанном IP-адресе и номере порта, и передавать их для обработки в приложение.

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

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

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

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

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

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

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

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

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

Pre-fork модель

У базового сценария, описанного в предыдущей главе, есть очевидные проблемы. Например, если к серверу пришло 10 тысяч запросов, то он будет должен создать 10 тысяч дочерних процессов — это большая нагрузка на любую машину. Более того, при создании, а также завершении дочерних процессов тратятся определенные ресурсы. Часть этих проблем решается pre-fork-моделью, находящейся на следующем эволюционном уровне развития. Pre-fork модель используется в сервере Apache, а также, например, в сервере Unicorn.

В pre-fork модели родительский процесс (“master”) сразу же создает указанное в конфигурации количество дочерних процессов (“worker”, обычно их несколько десятков или сотен).

Затем родительский процесс вызывает стандартную последовательность функций socket(), bind() и listen(), затем accept(). Файловый дескриптор, соответствующий принятому соединению, родительский процесс пересылает одному из дочерних процессов с помощью IPC-функций socketpair() и sendmsg()). Вообще дочерние процессы постоянно общаются с родительским процессом, сообщая о своем текущем статусе — занятости, количестве уже обработанных запросов, размере занимаемой памяти и т. п. Поэтому мастер всегда знает, кому из дочерних процессов передать очередное соединение.

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

Преимущества pre-fork модели перед простейшим случаем очевидны: во-первых, не надо тратить время на создание и завершение дочерних процессов; во-вторых, мы можем контролировать загрузку системы, указывая в конфигурации максимальное количество дочерних процессов.

Архитектура frontend/backend

На подавляющем большинстве конфигураций в современном интернете используется стандартная модель frontend/backend. На фронтенде часто используется nginx (или haproxy), а на бэкенде — Apache, unicorn или другие application-сервера. Фронтенд принимает запросы из интернета и проксирует их на бэкенд-сервер, а затем проксирует его ответы обратно.

Почему именно так? Предположим, что мы выставили prefork-сервер, такой как Apache — прямо в интернет, указав в конфигурации 100 дочерних серверов. Предположим, что к нам пришло 100 запросов от разных клиентов, которые находятся на медленном канале (например, по 3G), или же вообще эти клиенты агрессивно тормозят. Теперь все наши 100 дочерних серверов будут висеть в памяти и ждать, пока клиенты не примут все свои ответы. Прием и обработка всех остальных запросов от других клиентов остановится, потому что у мастера не будет свободных дочерних процессов, которые могли бы выполнить очередной запрос.

В модели frontend/backend происходит следующее: так как на фронтенде работает сервер, выполненный по FSM-модели (подробно описанной в следующей секции), то ни один, сколь угодно медленный клиент, не может заблокировать его работу. Запрос целиком принимается на фронтенде, и уже готовым быстро пересылается бэкенд-серверу. Бэкенд-сервер обрабатывает его и присылает ответ. Фронтенд-сервер быстро принимает ответ целиком от бэкенд-сервера, кладет его в промежуточный буфер, например, на файловой системе, и начинает отдавать его (медленному) клиенту. Бэкенд-сервер при этом моментально становится свободным и готов принимать новые запросы. Фронтендовый сервер также обычно обрабатывает запросы на статические файлы (в высоконагруженных случаях для статических файлов используются выделенные сервера, работающий по FSM-модели).

FSM-модель

FSM-модель использует конечные автоматы и асинхронный ввод-вывод и реализована в сервере nginx. В ней используется минимальное количество процессов — один мастер-процесс и считанные единицы worker-процессов (для многих сайтов прекрасно хватает ровно одного worker-процесса). Каждый воркер-процесс при этом может обрабатывать десятки тысяч одновременных соединений.

FSM-модель идеально заточена под распространенный случай frontend-сервера и сервера статических файлов и позволяет добиться предельной производительности для этого случая. Prefork-модель, однако, сохраняет свою актуальность, потому что практически только в ней могут работать стандартные интерпретаторы web-языков программирования — Perl, Ruby, PHP, Python и др. В FSM-модель уложить их практически невозможно.

FSM-модель работает с помощью одного из механизмов асинхронного ввода вывода: select(), poll(), epoll и kqueue. В реальности в современном Linux используется epoll, а в современном FreeBSD — kqueue. В последующем тексте мы будем обсуждать вариант c epoll.

При описании базового случая мы упоминали, что системные вызовы accept(), read() и write() работают в блокирующем режиме — при их вызове процесс останавливается и ожидает, соответственно, 1) пока не придет соединение, 2) пока не придут новые данные или 3) пока клиент не подтвердит получение уже отправленных данных. Оказывается, что эти функции могут также работать в неблокирующем режиме: если ничего нового прямо сейчас нет — то они немедленно возвращают специальный код ошибки EAGAIN, не останавливая выполнение процесса. Тот или иной сокет можно перевести в неблокирующий режим с помощью функции fcntl().

FSM-системы всегда работают c сокетами в неблокирующем режиме. Мастер-процесс создает указанное в конфигурации количество дочерних процессов. Затем мастер-процесс создает слушающий сокет с помощью стандартной последовательности функций socket(), bind() и listen(). Затем мастер-процесс раздает слушающий сокет сразу всем дочерним процессам — в каждый момент времени один из них будет некоторое время принимать новые соединения, а затем передавать эстафету приема новых соединений следующему. Для простоты допустим пока, что дочерний процесс ровно один.

Итак, дочерний процесс получил в свое распоряжение слушающий сокет. Для начала он переводит его в неблокирующий режим. Затем с помощью функции epoll_create() он инициализирует пустой набор файловых дескрипторов, с которыми будет вести работу. Наконец, он добавляет слушающий сокет в этот набор с помощью функции epoll_ctl().

Теперь дочерний процесс переходит в основной цикл работы. На каждой итерации цикла он сначала вызывает функцию epoll_wait(). Эта функция возвращает один или несколько “готовых” файловых дескрипторов, то есть 1) таких, на которых есть либо новое соединение, либо 2) таких, на которых есть новые данные, либо 3) таких, в которые можно писать следующую порцию данных. Пока что в нашем наборе есть только один слушающий сокет, поэтому когда придет соединение, то нам будет возвращен именно слушающий сокет с указанием, что на нем есть новое соединение.

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

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

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

Наконец, хотя бы один запрос дочитан до конца (например, это запрос на получение средних размеров файла с диска). Теперь мы можем обработать его и начать отправлять ответ. У нас есть очень немного времени на обработку очередного набора готовых дескрипторов от epoll_wait() — мы находимся внутри цикла обработки epoll, у нас есть еще множество параллельных соединений, а также есть слушающий сокет. Практически, у нас есть несколько миллисекунд, а то и меньше. Поэтому все, что мы делаем — это открываем запрошенный файл, и указываем, что теперь нас интересует возможность записи на файловом дескрипторе, соответствующем соединению. После этого epoll_wait() будет сообщать нам, что теперь можно прочитать немного данных из запрошенного файла и записать их в файловый дескриптор соединения (в неблокирующем режиме).

Закончив обработку запроса, мы должны убрать соответствующий файловый дескриптор из набора (с помощью epoll_ctl()), и закрыть его с помощью close().

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

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

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

Comments