WebServer как тестовое задание

в 19:23, , рубрики: c++, hiload, sockets, Программирование, разработка, метки: , ,

WebServer как тестовое задание

С чего все началось

Не смотря на то, что моя работа в данный момент связана с настольными приложениями, меня в последнее время заинтересовали «серверные технологии». Некоторый серфинг интернета, чтение man’ов и попытки написания чего-то сервероподобного для себя — это все, что было сделано за последнее время, так как нет четкой цели. Придумав себе интересную задачу можно не плохо поднять свой уровень навыков.

В один из моментов, когда мне стало окончательно скучно на работе от рутины, я поставил галку на одном из известных ресурсов поиска работы, что не против посмотреть на рынок, вдруг, что интересное попадется… Как результат некоторое количество предложений с вакансиями, на тему: «Возможно это Вас заинтересует». Среди таких предложений и пришло предложение с тестовым заданием. Тестовое задание – написать WebServer’а на C++ под Linux с реализацией HTTP–протокола; простенький…

Взяв фразу из тестового задания и вбив ее в Google, я нашел еще отзывов о таком не самом коротком тестовом задании на форуме RSDN. Задание было один в один лежащему в моем почтовике. Как задание выполнять его не стал. Принцип прост: тестовое задание если и стоит выполнять, то оно должно быть не более чем на 4 часа рабочего времени рассчитано. Но попробовать в деле все, о чем было прочитано и местами опробовано было интересно. Это и стало стимулом, т.е. постановкой интересной задачи. К какой конторе относится это задание, сказать не могу, так как оно пришло от кадрового агенства, но и не так это важно.

В этой статье будут рассмотрены подходы и соответствующие API, которые мною были найдены по данной тематике. Приведу несколько реализаций WebServer’а с использованием разных подходов и инструментов, проведено сравнительное тестирование полученных «поделок». Статья не рассчитана на «бородатых» серверописателей, но как обзор людям, сталкнувшимся с аналогичными задачами (не только в тестах) вполне может быть полезна. Буду рад конструктивным комментариям всех, в особенности от «бородатых» серверописателей, так как написание статьи — это не только поделиться опытом, но, вполне может быть, и пополнить его для себя…

Обзор API и библиотек

Результатом рассмотрения средств серверописания стали API *nix систем, API Windows (почему бы не посмотреть, хоть и нет этой платформы в целях данной задачи) и такие библиотеки, как boost.asio и libevent.

Сокеты Беркли хоть и универсальный, портируемый механизм, но не совсем он однозначно портируем. Так в некоторых платформах close для закрытия сокета, а в некоторых closesocket; в некоторых надо инициализировать библиотеку (Windows – WSAStartup/WSACleanup), в некоторых нет; где-то описателем сокета является тип int, а где-то SOCKET и прочие мелкие различия. Получается, если не применять всякие подходы кроссплатформенного программирования такие как pImpl и прочие, то один и тот же код не будет работать, а зачастую, и собираться на разных платформах одинаково. Все эти мелочи скрыты в библиотеках типа boost.asio, libevent и аналогичных. Кроме того подобные библиотеки используют и более специфичные методы API соответствующей платформы для реализации наиболее оптимальной работы с сокетами, предоставляя пользователю удобный интерфейс без намеков на платформу.

Если взять очень обобщенно работу сервера, то получается такая последовательность действий:

  1. Создать сокет
  2. Привязать сокет к сетевому интерфейсу
  3. Прослушивать сокет, привязанный к определенному сетевому интерфейсу
  4. Принимать входящие соединения
  5. Реагировать на события происходящие на сокетах

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

Если посмотреть на Windows, то можно увидеть следующие методы:

  1. Использование select. В основном для совместимости с кодом иных платформ, больше плюсов у него тут нет.
  2. WSAAsyncSelect – Предназначен для оконных приложений для отправки событий на сокете в оконную очередь. Не быстр и вряд ли будет интересен как механизм серверного кода.
  3. WSAEventSelect работа с объектом «событие» на сетевом интерфейсе. Уже более привлекательное средство. Т.е. если вы планируете сервер не более чем на сотни одновременно обслуживаемых соединений, то это самый оптимальный механизм по критерию быстродействие / скорость разработки.
  4. Перекрытый ввод-вывод – более быстродейственный механизм, чем WSAEventSelect, но и более трудозатратен при разработке.
  5. Порты завершения ввода-вывода – для высоконагруженных серверных приложений.

Есть отличная книга по разработке сетевого программного обеспечени под Windows – «Программирование в сетях Microsoft Windows».

Теперь, если посмотреть на *nix состемы, то там тоже есть не малый набор селекторов событий:

  1. Тот же select. И опять его роль – это совместимость с иными платформами. Так же не быстр, так как срабатывает (возвращает управление) при событии на любом из сокетов, за которыми он наблюдает. После такого срабатывания нужно пробежать по всем и посмотреть на каком из сокетов произошло событие. Обобщая: одно срабатывание – это пробег по всему пулу наблюдаемых сокетов.
  2. poll – более быстродейственный механизм, но не расчитан на большое количество сокетов для наблюдения.
  3. epoll (Linux системы) и kqueue (FreeBSD) – примерно одинаковые механизмы, но яростные поклонники FreeBSD на некоторых форумах очень горячо твердят, что kqueue куда могучее. Не будем разжигать священные войны… Эти механизмы можно считать основными при написании высоконагруженных серверных приложений в *nix системах. Если описать кратко их принцип работы и он же достоинство – они возвращают некоторый объем информации, относящейся только к тем сокетам, на которых что-то произошло и не надо бегать по всем и проверять, что и где случилось. Так же эти механизмы расчитаны на большее количество одновременно обслуживаемых подключений.

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

  1. sendfile (Linux) и TransmitFile (Windows) позволяют скормить им пару описателей «откуда» и «куда» пересылать данные. Очень полезная вещь в HTTP серверах, когда надо передавать файлы, так как избавляет от выделения буфера и вызова функций чтения/записи, что положительно сказывается на производительности.
  2. aio – позволяет переложить некоторое количество работы на операционную систему, так как дает возможность проводить асинхронные операции на файловом описателе. Например, сказать системе вот тебе буфер, пиши его вот в этот файловый описатель, как закончишь посигналь (аналогично с чтением).
  3. Алгоритм Нейгла полезная штука при написании приложений, которым надо небольшими порциями и без задержек на буферизацию отправлять данные в сеть, но он не всегда полезен. В таких приложениях, как HTTP сервер лучше наоборот сказать системе, чтоб она буферизовала исходящие данные и отправляла максимально заполняя полезной информацией TCP фреймы (для этого можно использовать такую опцию сокета как TCP_CORK).
  4. Ну и конечно же неблоктрующие сокеты. Без комментариев...
  5. Так же есть такие функции, как writev (nix) (и аналогичных WSA функций Windows), которые позволяют отправлять несколько буферов сразу, что полезно когда нужно отправить заголовок HTTP пакета и прицепленные к нему данные и при этом сэкономить на количестве системных вызовов.

Про использование библиотек лучше сказать кодом для начала, что и будет сделано ниже на примерах boost.asio и libevent. boost.asio весьма сильно упрощает разработку сетевых приложений, libevent – это серверная классика.

Реализация на epoll

Какой бы механизм не был бы выбран для реагирования на сетевые события epoll, poll, select, еще много остается других нюансов.

Один из самых первых вопросов при реализации многопоточного сервера – это выбор количества потоков. Большинство из тех, кому когда-то приходилось по быстрому собрать свой «сервер на сокетах» в учебных или псевдобоевых целях, выбирал стратегию «Одно соединение – один поток». В этом подходе есть как свои плюсы, так и минусы. Самым большим плюсом является простота разработки. Минусов много: большое количество затрачиваемых системных ресурсов, много синхронизационных действий (кода, чего-то с чем-то синхронизирующего). Однако такой подход не плох для HTTP сервера с точки зрения синхронизации, так как особых пересечений между сессиями нет. Но, не смотря на простоту разработки, эту стратегию я не стал рассматривать для своей реализации. Имеются разные рекомендации по оптимальному выбору количества потоков – это по количеству процессоров / ядер процессора в системе, по тому же количеству, но с некоторым коэффициентом. В предлагаемой реализации количество рабочих потоков – параметр опциональный, задаваемый пользователем при запуске сервера. Для себя же было решено, что количество рабочих потоков равно количеству процессоров / ядер умноженному на два.
В текущем контексте под рабочим потоком стоит понимать поток, обрабатывающий запросы пользователя. Кроме этих потоков было задействовано еще два: слушающий поток и основной. Слушающий поток — слушает серверный сокет и принимает входящие соединения, далее они помещаются на обработку в очередь к рабочим потокам. Основной поток – запускает сервер и ждет определенного действия от пользователя для его остановки.

Второй вопрос, который меня интересовал при реализации данного примера – это в каких потоках и как обрабатывать сетевые события при использовании epoll’а. Первое, что мне пришло в голову – это в одном потоке реагировать на все события, отслеживаемые epoll’ом, а их обработку делать в других потоках (рабочих), передавая их туда посредством некоторой очереди. Т.е. один поток отслеживает и входящие события на слушающем сокете, и события о приходе данных на принятые соединения и события о закрытии соединения. Получил событие, положил в очередь, посигналил рабочим потокам, рабочие потоки вызывали accept для принятие нового соединения, добавляли в пул наблюдаемых сокетов в epoll, read, write и close для соединений. Решение ошибочное, так как пока идет обработка одного события, скажем чтение данных с сокета, на этот сокет уже в очереди может лежать событие о закрытия сокета. Конечно, чтение завершится с ошибкой, но вот до действий по очистке всех ресурсов, связанных с соединением, дело дойдет не сразу, а только при вычитывании данного события из очереди. Многие события по закрытию сокета просто терялись в моей реализации. Реализация становилась сложнее, количество мест синхронизации росло и при странных условиях были падения. Падения были по иной причине. С каждым сокетом в структуре epoll-события как пользовательские данные привязывался указатель на объект сессии, который и отвечал за всю работу с клиентом, пока он не закроется. Так как последовательность обработки событий становилась сложнее, отсюда и падения, так как объект, привязанный как пользовательские данные уже был удален (например, при закрытии сессии не по событию извне, а по логике самой сессии внутри нее), а в очереди было еще событие на обрабоку с уже битым указателем. Получив некоторый такой опыт «на граблях» от первой пришедшей идеи, была принята иная стратегия: основной слушающий поток посредством epoll’а реагирует только на события слушающего сокета, принимает входящие соединения и, если их число более чем разрешено для очереди ожидания, то закрывает их, в противном случае помещает принятые соединения в очередь для обработки; рабочие потоки вычитывают эту очередь помещают этот сокет уже в свой epoll набор, за которым и наблюдают. Получается, что рабочие потоки работают со своим описателем epoll’а и все делается в рамках одного потока: помещение в epoll, реакция на события прихода данных, чтение/запись, закрытие (удаление из epoll’а происходит автоматически на уровне системы при закрытии описателя). Как результат такой организации – всего один синхронизационный примитив для защиты очереди входящих соединений. С одной стороны, в эту очередь пишет только слушающий поток, а с другой, из нее выбирают принятые соединения рабочие потоки. Одной проблемой меньше. Осталось отказаться от связывания указателя на объект-сессию с пользовательскими данными структуры epoll’а. Решение: использовать ассоциативный массив; ключ – описатель сокета, данные – объект-сессия. Это позволяет работать с сессиями не только при приходе события, когда мы имеем возможность получить пользовательские данные из события epoll, но и когда по некоторой логике надо, например, закрыть некоторые соединения по таймауту (пул соединений доступен).

Первый вариант, написанный полностью в одном файле и в стиле C# / Java разработчика (без разделения на объявления и определения) у меня оказался более 1800 строк кода. Многовато для тестового задания, несмотря на то, что реализация именно HTTP протокола минимальна, самый минимум на обработку GET/HEAD без чего либо еще и с минимумом обработки параметров HTTP-заголовка. Не в этом суть. Оговорюсь еще раз, что тестовое задание было всего-лишь «пинком» к тому, чтобы что-то попробовать. Основной интерес для меня представляло в данном решении не реализация HTTP-протокола, а реализация многопоточного сервера, управление соединениями и сессиями (под сессией можно понимать некоторую логическую структуру данных с алгоритмом обработки, связанную с соединением).
Разбив этот монструозный файл и местами причесав реализацию, вот что у меня получилось:

class TCPServer
  : private Common::NonCopyable
{
public:
  TCPServer(InetAddress const &locAddr, int backlog, int maxThreadsCount,
            int maxConnectionsCount, UserSessionCreator sessionCreator);
  
private:
  typedef std::tr1::shared_ptr<Common::IDisposable> IDisposablePtr;
  typedef std::vector<IDisposablePtr> IDisposablePool;
  Private::ClientItemQueuePtr AcceptedItems;
  IDisposablePool Threads;
};

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

Реализация

TCPServer::TCPServer(InetAddress const &locAddr, int backlog, int maxThreadsCount,
          int maxConnectionsCount, UserSessionCreator sessionCreator)
  : AcceptedItems(new Private::ClientItemQueue(backlog))
{
  int EventsCount = maxConnectionsCount / maxThreadsCount;
  for (int i = 0 ; i < maxThreadsCount ; ++i)
  {
    Threads.push_back(IDisposablePtr(new Private::WorkerThread(
        EventsCount + (i <= maxThreadsCount - 1 ? 0 : maxConnectionsCount % maxThreadsCount),
        AcceptedItems
      )));
  }
  Threads.push_back(IDisposablePtr(new Private::ListenThread(locAddr, backlog, AcceptedItems, sessionCreator)));
}

тоже не велика. Оба класса как

слушающий поток

class ListenThread
  : private TCPServerSocket
  , public Common::IDisposable
{
public:
  ListenThread(InetAddress const &locAddr, int backlog,
               ClientItemQueuePtr acceptedClients,
               UserSessionCreator sessionCreator)
    : TCPServerSocket(locAddr, backlog)
    , AcceptedClients(acceptedClients)
    , SessionCreator(sessionCreator)
    , Selector(1, WaitTimeout, std::tr1::bind(&ListenThread::OnSelect,
        this, std::tr1::placeholders::_1, std::tr1::placeholders::_2))
  {
    Selector.AddSocket(GetHandle(), Network::ISelector::stRead);
  }
  
private:
  enum { WaitTimeout = 100 };
  ClientItemQueuePtr AcceptedClients;
  UserSessionCreator SessionCreator;
  SelectorThread Selector;
  
  void OnSelect(SocketHandle handle, Network::ISelector::SelectType selectType)
  {
    //Принятие нового соединения, создание объекта-сессии и помещение его в очередь
  }
};

, так и

рабочие потоки

class WorkerThread
  : private Common::NonCopyable
  , public Common::IDisposable
{
public:
  WorkerThread(int maxEventsCount, ClientItemQueuePtr acceptedClients)
    : MaxConnections(maxEventsCount)
    , AcceptedClients(acceptedClients)
    , Selector(maxEventsCount, WaitTimeout, std::tr1::bind(&WorkerThread::OnSelect,
          this, std::tr1::placeholders::_1, std::tr1::placeholders::_2),
        SelectorThread::ThreadFunctionPtr(new SelectorThread::ThreadFunction(std::tr1::bind(
          &WorkerThread::OnIdle, this))))
  {
  }
  
private:
  enum { WaitTimeout = 100 };
  
  typedef std::map<SocketHandle, ClientItemPtr> ClientPool;
  unsigned MaxConnections;
  ClientItemQueuePtr AcceptedClients;
  ClientPool Clients;
  SelectorThread Selector;
  
  void OnSelect(SocketHandle handle, Network::ISelector::SelectType selectType)
  {
    //Реакция на события, происходящие на наблюдаемых описателях сокетов (чтение данных, их обработка, закрытие сокетов)
  }
  
  void OnIdle()
  {
    //Выполнение фоновых операций сессий. Вычитывание данных из очереди принятых соединений и помещение объектов-сессий в epoll.
  }
};

используют класс потока обработки событий

SelectorThread

class SelectorThread
  : private EPollSelector
  , private System::ThreadLoop
{
public:
  
  using EPollSelector::AddSocket;
  
  typedef System::Thread::ThreadFunction ThreadFunction;
  typedef std::tr1::shared_ptr<ThreadFunction> ThreadFunctionPtr;
  
  SelectorThread(int maxEventsCount, unsigned waitTimeout, ISelector::SelectFunction onSelectFunc,
                 ThreadFunctionPtr idleFunc = ThreadFunctionPtr());
  virtual ~SelectorThread();
  
private:
  void SelectItems(ISelector::SelectFunction &func, unsigned waitTimeout, ThreadFunctionPtr idleFunc);
};

. Этот поток использует

EPollSelector

class EPollSelector
  : private Common::NonCopyable
  , public ISelector
{
public:
  EPollSelector(int maxSocketCount);
  ~EPollSelector();
  virtual void AddSocket(SocketHandle handle, int selectType);
  virtual void Select(SelectFunction *function, unsigned timeout);
  
private:
  typedef std::vector<epoll_event> EventPool;
  EventPool Events;
  int EPoll;
  
  static int GetSelectFlags(int selectType);
};

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

struct IUserSession
{
  virtual ~IUserSession() {}
  virtual void Init(IConnectionCtrl *ctrl) = 0;
  virtual void Done() = 0;
  virtual unsigned GetMaxBufSizeForRead() const = 0;
  virtual bool IsExpiredSession(std::time_t lastActionTime) const = 0;
  virtual void OnRecvData(void const *buf, unsigned bytes) = 0;
  virtual void OnIdle() = 0;
};

В зависимости от реализации данного интерфейса можно реализовывать разные протоколы. Методы Init и Done вызываются при начале сессии и при ее завершении соответственно. GetMaxBufSizeForRead должен возвращать максимальный размер буфера, который будет выделяться при операциях чтения данных. Прочитанные данные приходят в OnRecvData. Для того чтобы сессия могла сказать, что она истекла по времени, надо реализовывать IsExpiredSession соответствующим способом. OnIdle вызывается в промежутках между какими-то действиями, тут реализация сессии может выполнять какие-то фоновые действия и пометить себя как предназначенная для закрытия через интерфейс

struct IConnectionCtrl
{
  virtual ~IConnectionCtrl() { }
  virtual void MarkMeForClose() = 0;
  virtual void UpdateSessionTime() = 0;
  virtual bool SendData(void const *buf, unsigned *bytes) = 0;
  virtual bool SendFile(int fileHandle, unsigned offset, unsigned *bytes) = 0;
  virtual InetAddress const& GetAddress() const = 0;
  virtual SocketTuner GetSocketTuner() const = 0;
};

Интерфейс IConnectionCtrl передается для того, чтобы пользовательская сессия могла отправлять данные в сеть (методы SendData и SendFile), пометить себя как предназначенная для закрытия (метод MarkMeForClose), говорить о том что «она жива» (метод UpdateSessionTime; обновляет время, которое приходит в IsExpiredSession), так же сессия может получить адрес входящего соединения (метод GetAddress) и объект SocketTuner для настроек сокета — текущего соединения (метод GetSocketTuner).
Реализация HTTP-протокола находится в классе HttpUserSession. Как и говорил выше, реализация HTTP для меня была не самой интересной и приоритетной, поэтому над ней много не думал; думал ровно столько, сколько хватило для написания того, что получилось :)

Реализация на libevent

Реализация на libevent пока для меня фаворит. Эта библиотека дает возможность организовать асинхронный ввод-вывод и скрыть от разработчика многие тонкости сетевого программирования. Позволяет реализовать работу с сырыми данными, вешая функции обратного вызова на прием, передачу данных и иные события, отправлять асинхронно данные. Кроме низкоуровневой работы с данными есть и более высокоуровневые протоколы. libevent имеет встроенный HTTP-сервер, что дает возможность абстрагироваться от разбора заголовков запросов и формирования тех же заголовков ответов. Есть возможность реализовать RPC средствами библиотеки и прочие возможности.
Если реализовывать HTTP-сервер с использованием встроенного, то последовательность будет примерно такой:

  1. Создать некоторый базовый объект вызовом event_base_new (есть и упрощенный для более простых случаев — event_init). Парная функция для удаления объекта — event_base_free.
  2. Создать объект HTTP-движка вызовом evhttp_new. Парная функция для удаления объекта evhttp_free.
  3. Можно указать методы, которые сервер будет поддерживать, используя функцию evhttp_set_allowed_methods с комбинацией флагов. Так, например, для поддержки только метода GET это будет выглядеть примерно так: evhttp_set_allowed_methods(Http, EVHTTP_REQ_GET), где Http — это описатель созданный на этапе (2).
  4. Установить функцию обратного вызова для обработки входящих запросов вызовом функции evhttp_set_gencb.
  5. Связать слушающий сокет с экземпляром объекта HTTP-сервера вызовом функции evhttp_accept_socket. Слушающий сокет может быть создан и настроен через все те же socket/bind/listen.
  6. Запустить цикл обработки событий вызовом функции event_base_loop. Есть упрощенный вариант — event_base_dispatch. event_base_loop нужно вызывать в цикле. Эта функция либо делает что-то полезное в недрах библиотеки, откуда и приходят вызовы в установленные функции обратного вызова, либо когда делать нечего возвращает управление и можно что-то в этот момент полезное самим сделать; так же дает возможность более просто управлять временем жизни цикла обработки сообщений.
  7. В обработчике запросов можно отправлять некоторые текстовые данные вызовом функции evbuffer_add_printf или отдать библиотеке описатель файла и пусть она сама его отправляет, вызвав evbuffer_add_file. Данные функции работают с некоторым объектом буфера, который можно создать самостоятельно (и не забыть вовремя удалить) или использовать поле запроса: evhttp_request::output_buffer. Вся прелесть в том, что эти функции асинхронны, т.е. в примере с посылкой файла, вы можете отдать файловый дескриптор все той же evbuffer_add_file и она вернет сразу управление, а после окончания отправки файла сама и закроет файл.

Все получается очень красиво в одном потоке, но, как оказалось, сделать многопоточный сервер тоже не сложно. Если использовать boost::thread или свой кроссплатформенный класс, инкапсулирующий работу потока, или что-то аналогичное, то можно получить полностью кроссплатформенное решение, так как libevent кроссплатформенная библиотека. В своей же реализации я возьму некоторую обертку только над потоками для Linux. Но это не так важно.
Основной поток для каждого рабочего потока должен создать свои описатели, т.е. выполнить шаги 1-5. Рабочие потоки должны только крутить циклы обработки сообщений – шаг 6. Шаг 7 будет выполняться в каждом рабочем потоке. Обобщая, можно сказать: создаем один слушающий сокет и навязываем его обработку нескольким рабочим потокам.
Так в своей реализации с учетом того, что у меня уже готовы некоторые примитивы для потоков, файлов и разбора командной строки у меня получился HTTP-сервер с поддержкой только GET метода всего примерно около 200 строк в стиле C#/Java. Такое сокращение работы по написанию кода с наличием полного контроля происходящего не может не радовать. К тому же субъективно, полученный сервер работает чуть быстрее, но посмотрим на тесты в конце…

Реализация HTTP-сервера на libevent

#include <event.h>
#include <evhttp.h>

#include <unistd.h>
#include <string.h>
#include <signal.h>

#include <vector>
#include <iostream>

#include <tr1/functional>
#include <tr1/memory>

#include "tcp_server_socket.h"
#include "inet_address_v4.h"
#include "thread.h"
#include "command_line.h"
#include "logger.h"
#include "file_holder.h"

namespace Network
{
  
  namespace Private
  {
    
    DECLARE_RUNTIME_EXCEPTION(EventBaseHolder)
    
    class EventBaseHolder
      : private Common::NonCopyable
    {
    public:
      EventBaseHolder()
        : EventBase(event_base_new())
      {
        if (!EventBase)
          throw EventBaseHolderException("Failed to create new event_base");
      }
      ~EventBaseHolder()
      {
        event_base_free(EventBase);
      }
      event_base* GetBase() const
      {
        return EventBase;
      }
      
    private:
      event_base *EventBase;
    };
    
    DECLARE_RUNTIME_EXCEPTION(HttpEventHolder)
                              
    class HttpEventHolder
      : public EventBaseHolder
    {
    public:
      typedef std::tr1::function<void (char const *, evbuffer *)> RequestHandler;
      HttpEventHolder(SocketHandle sock, RequestHandler const &handler)
        : Handler(handler)
        , Http(evhttp_new(GetBase()))
      {
        evhttp_set_allowed_methods(Http, EVHTTP_REQ_GET);
        evhttp_set_gencb(Http, &HttpEventHolder::RawHttpRequestHandler, this);        
        if (evhttp_accept_socket(Http, sock) == -1)
          throw HttpEventHolderException("Failed to accept socket for http");
      }
      ~HttpEventHolder()
      {
        evhttp_free(Http);
      }
      
    private:
      RequestHandler Handler;
      evhttp *Http;
      
      static void RawHttpRequestHandler(evhttp_request *request, void *prm)
      {
        reinterpret_cast<HttpEventHolder *>(prm)->ProcessRequest(request);
      }
      
      void ProcessRequest(evhttp_request *request)
      {
        try
        {
          Handler(request->uri, request->output_buffer);
          evhttp_send_reply(request, HTTP_OK, "OK", request->output_buffer);
        }
        catch (std::exception const &e)
        {
          evhttp_send_reply(request, HTTP_INTERNAL,
                            e.what() ? e.what() : "Internal server error.",
                            request->output_buffer);
        }
      }
    };
    
    class ServerThread
      : private HttpEventHolder
      , private System::Thread
    {
    public:
      ServerThread(SocketHandle sock, std::string const &rootDir, std::string const &defaultPage)
        : HttpEventHolder(sock, std::tr1::bind(&ServerThread::OnRequest, this,
                                               std::tr1::placeholders::_1,
                                               std::tr1::placeholders::_2))
        , Thread(std::tr1::bind(&ServerThread::DispatchProc, this))
        , RootDir(rootDir)
        , DefaultPage(defaultPage)
      {
      }
      ~ServerThread()
      {
        IsRun = false;
      }
      
    private:
      enum { WaitTimeout = 10000 };
      
      bool volatile IsRun;
      std::string RootDir;
      std::string DefaultPage;
      
      void DispatchProc()
      {
        IsRun = true;
        while(IsRun)
        {
          if (event_base_loop(GetBase(), EVLOOP_NONBLOCK))
          {
            Common::Log::GetLogInst() << "Failed to run dispatch events";
            break;
          }
          usleep(WaitTimeout);
        }
      }
      
      void OnRequest(char const *resource, evbuffer *outBuffer)
      {
        std::string FileName;
        GetFullFileName(resource, &FileName);
        try
        {
          System::FileHolder File(FileName);
          if (!File.GetSize())
          {
            evbuffer_add_printf(outBuffer, "Empty file");
            return;
          }
          evbuffer_add_file(outBuffer, File.GetHandle(), 0, File.GetSize());
          File.Detach();
        }
        catch (System::FileHolderException const &)
        {
          evbuffer_add_printf(outBuffer, "File not found");
        }
      }
      
      void GetFullFileName(char const *resource, std::string *fileName) const
      {
        fileName->append(RootDir);
        if (!resource || !strcmp(resource, "/"))
        {
          fileName->append("/");
          fileName->append(DefaultPage);
        }
        else
        {
          fileName->append(resource);
        }
      }
    };
    
  }
  
  class HTTPServer
    : private TCPServerSocket
  {
  public:
    HTTPServer(InetAddress const &locAddr, int backlog,
              int maxThreadsCount,
              std::string const &rootDir, std::string const &defaultPage)
      : TCPServerSocket(locAddr, backlog)
    {
      for (int i = 0 ; i < maxThreadsCount ; ++i)
      {
        ServerThreads.push_back(ServerThreadPtr(new Private::ServerThread(GetHandle(),
          rootDir, defaultPage)));
      }
    }
    
  private:
    typedef std::tr1::shared_ptr<Private::ServerThread> ServerThreadPtr;
    typedef std::vector<ServerThreadPtr> ServerThreadPool;
    ServerThreadPool ServerThreads;
  };
  
}

int main(int argc, char const **argv)
{
  if (signal(SIGPIPE, SIG_IGN) == SIG_ERR)
  {
    std::cerr << "Failed to call signal(SIGPIPE, SIG_IGN)" << std::endl;
    return 0;
  }
  try
  {
    char const ServerAddr[] = "Server";
    char const ServerPort[] = "Port";
    char const MaxBacklog[] = "Backlog";
    char const ThreadsCount[] = "Threads";
    char const RootDir[] = "Root";
    char const DefaultPage[] = "DefaultPage";
    
    // Server:127.0.0.1 Port:5555 Backlog:10 Threads:4 Root:./ DefaultPage:index.html
    Common::CommandLine CmdLine(argc, argv);
        
    Network::HTTPServer Srv(
        Network::InetAddressV4::CreateFromString(
          CmdLine.GetStrParameter(ServerAddr),
          CmdLine.GetParameter<unsigned short>(ServerPort)),
        CmdLine.GetParameter<unsigned>(MaxBacklog),
        CmdLine.GetParameter<unsigned>(ThreadsCount),
        CmdLine.GetStrParameter(RootDir),
        CmdLine.GetStrParameter(DefaultPage)
      );
    std::cin.get();
  }
  catch (std::exception const &e)
  {
    Common::Log::GetLogInst() << e.what();
  }
  return 0;
}

Реализация на boost.asio

boost.asio — это часть boost'а, которая может помочь очень сильно сократить разработку сетевых приложений и к тому же кроссплатформенных. Библиотека скрывает от разработчика много рутины.
Реализацию HTTP-сервера на boost я не стал писать. Взял готовую из примеров к boost.asio. Пример многопоточного HTTP-сервера.HTTP Server 3 Реализация данного примера вполне подойдет для тестирования в совокупности с примерами, приведенными выше.
Есть реализация HTTP-сервера для тестирования, но не плохо было бы немного об общих принципах… К сожалению, в отличии от libevent в boost.asio нет поддержки каких-то более высокоуровневых протоколов на подобиии HTTP и других. Библиотека скроет работу с сетью по TCP в данном случае, а вот реализацию HTTP придется делать разработчику самому: собирать и разбирать заголовки протокола.
Ниже приведу небольшой пример многопоточного эхо сервера с описанием, так как разбирать / собирать заголовки HTTP в свете данной темы мне было менее интересно. Последовательность шагов для создания многопоточного сервера с использованием boost.asio примерно такая:

  1. Создать объекты классов boost::asio::io_service и boost::asio::ip::tcp::acceptor.
  2. Используя boost::asio::ip::tcp::resolver и boost::asio::ip::tcp::endpoint перевести локальный адрес, на который будет осуществляться привязка слушающего сокета в структуру, используемую библиотекой.
  3. Вызвать bind и listen для объекта класса boost::asio::ip::tcp::acceptor.
  4. Создать некоторый класс «Соединение»; он же «Сессия», экземпляры которого и будут использоваться при приеме входящих соединений пользователя.
  5. Настроить соответствующие функции обратного вызова для приема входящих соединений, приема данных.
  6. Запустить цикл обработки сообщений вызовом boost::asio::io_service::run.

И как с примером на libevent многопоточный сервер весьма просто создать из однопоточного, используя описанный выше набор шагов. В данном случае разница однопоточного и многопоточного сервера заключается лишь в том, что метод boost::asio::io_service::run нужно вызвать в каждом потоке для в многопоточной реализации.

Реализация эхо-сервера на boost.asio

#include <boost/noncopyable.hpp>
#include <boost/asio.hpp>
#include <boost/shared_ptr.hpp>
#include <boost/thread.hpp>
#include <boost/make_shared.hpp>
#include <boost/bind.hpp>
#include <boost/enable_shared_from_this.hpp>
#include <boost/array.hpp>

namespace Network
{
  
  namespace Private
  {
    
    class Connection
        : private boost::noncopyable
        , public boost::enable_shared_from_this<Connection>
    {
    public:
      Connection(boost::asio::io_service &ioService)
        : Strand(ioService)
        , Socket(ioService)
      {
      }
      boost::asio::ip::tcp::socket& GetSocket()
      {
        return Socket;
      }
      void Start()
      {
        Socket.async_read_some(boost::asio::buffer(Buffer),
                               Strand.wrap(
                                 boost::bind(&Connection::HandleRead, shared_from_this(),
                                             boost::asio::placeholders::error,
                                             boost::asio::placeholders::bytes_transferred)
                                 ));
      }
      void HandleRead(boost::system::error_code const &error, std::size_t bytes)
      {
        if (error)
          return;
        
        std::vector<boost::asio::const_buffer> Buffers;
        Buffers.push_back(boost::asio::const_buffer(Buffer.data(), bytes));
        boost::asio::async_write(Socket, Buffers,
                                 Strand.wrap(
                                   boost::bind(&Connection::HandleWrite, shared_from_this(),
                                               boost::asio::placeholders::error)
                                   ));
      }
      void HandleWrite(boost::system::error_code const &error)
      {
        if (error)
          return;
        
        boost::system::error_code Code;
        Socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, Code);
      }
      
    private:
      boost::array<char, 4096> Buffer;
      boost::asio::io_service::strand Strand;
      boost::asio::ip::tcp::socket Socket;
    };
    
  }
  
  class EchoServer
      : private boost::noncopyable
  {
  public:
    EchoServer(std::string const& locAddr, std::string const& port, unsigned threadsCount)
      : Acceptor(IoService)
      , Threads(threadsCount)
    {
      boost::asio::ip::tcp::resolver Resolver(IoService);
      boost::asio::ip::tcp::resolver::query Query(locAddr, port);
      boost::asio::ip::tcp::endpoint Endpoint = *Resolver.resolve(Query);
      Acceptor.open(Endpoint.protocol());
      Acceptor.set_option(boost::asio::ip::tcp::acceptor::reuse_address(true));
      Acceptor.bind(Endpoint);
      Acceptor.listen();
      
      StartAccept();
      
      std::generate(Threads.begin(), Threads.end(),
                    boost::bind(
                      &boost::make_shared<boost::thread, boost::function<void ()> const &>,
                      boost::function<void ()>(boost::bind(&boost::asio::io_service::run, &IoService))
                      ));
    }
    ~EchoServer()
    {
      std::for_each(Threads.begin(), Threads.end(),
                    boost::bind(&boost::asio::io_service::stop, &IoService));
      std::for_each(Threads.begin(), Threads.end(),
                    boost::bind(&boost::thread::join, _1));
    }
    
  private:
    boost::asio::io_service IoService;
    boost::asio::ip::tcp::acceptor Acceptor;
    
    typedef boost::shared_ptr<Private::Connection> ConnectionPtr;
    
    ConnectionPtr NewConnection;
    
    typedef boost::shared_ptr<boost::thread> ThreadPtr;
    typedef std::vector<ThreadPtr> ThreadPool;
    
    ThreadPool Threads;
    
    void StartAccept()
    {
      NewConnection = boost::make_shared<Private::Connection, boost::asio::io_service &>(IoService);
      Acceptor.async_accept(NewConnection->GetSocket(),
                            boost::bind(&EchoServer::HandleAccept, this,
                                        boost::asio::placeholders::error));
    }
    void HandleAccept(boost::system::error_code const &error)
    {
      if (!error)
        NewConnection->Start();
      StartAccept();
    }
  };
  
}

int main()
{
  try
  {
    Network::EchoServer Srv("127.0.0.1", "5555", 4);
    std::cin.get();
  }
  catch (std::exception const &e)
  {
    std::cerr << e.what() << std::endl;
  }
  return 0;
}

Тестирование

Пришло время сравнить полученные поделки…
Платформа, на которой все разрабатывалось и тестировалось — обычный ноутбук с 4Гб оперативной памяти и 2х ядерным процессором под управлением Ubuntu 12.04 desktop.
Первым делом ставлю утилиту для тестирования:

sudo apt-get install apache2-utils

и тестирую полученое таким образом:

ab -c 100 -k -r -t 5 "http://127.0.0.1:5555/test.jpg"

Для всех серверов было задано по 4 рабочих потока, 100 параллельных соединений, файл для передачи в 2496629 байт и оценочный интервал времени 5 секунд.
Результаты:

Реализация на epoll

Benchmarking 127.0.0.1 (be patient)
Finished 2150 requests

Server Software: MyTestHttpServer
Server Hostname: 127.0.0.1
Server Port: 5555

Document Path: /test.jpg
Document Length: 2496629 bytes

Concurrency Level: 100
Time taken for tests: 5.017 seconds
Complete requests: 2150
Failed requests: 0
Write errors: 0
Keep-Alive requests: 0
Total transferred: 5389312814 bytes
HTML transferred: 5388981758 bytes
Requests per second: 428.54 [#/sec] (mean)
Time per request: 233.348 [ms] (mean)
Time per request: 2.333 [ms] (mean, across all concurrent requests)
Transfer rate: 1049037.42 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 0 0.5 0 3
Processing: 74 226 58.2 229 364
Waiting: 2 133 64.8 141 264
Total: 77 226 58.1 229 364

Реализация на libevent

Benchmarking 127.0.0.1 (be patient)
Finished 1653 requests

Server Software:
Server Hostname: 127.0.0.1
Server Port: 5555

Document Path: /test.jpg
Document Length: 2496629 bytes

Concurrency Level: 100
Time taken for tests: 5.008 seconds
Complete requests: 1653
Failed requests: 0
Write errors: 0
Keep-Alive requests: 1653
Total transferred: 4263404830 bytes
HTML transferred: 4263207306 bytes
Requests per second: 330.05 [#/sec] (mean)
Time per request: 302.987 [ms] (mean)
Time per request: 3.030 [ms] (mean, across all concurrent requests)
Transfer rate: 831304.15 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 53 223.3 0 1000
Processing: 3 228 275.5 62 904
Waiting: 0 11 42.5 5 639
Total: 3 280 417.9 62 1864

Реализация на boost.asio

Benchmarking 127.0.0.1 (be patient)
Finished 639 requests

Server Software:
Server Hostname: 127.0.0.1
Server Port: 5555

Document Path: /test.jpg
Document Length: 2496629 bytes

Concurrency Level: 100
Time taken for tests: 5.001 seconds
Complete requests: 639
Failed requests: 0
Write errors: 0
Keep-Alive requests: 0
Total transferred: 1655047414 bytes
HTML transferred: 1654999464 bytes
Requests per second: 127.78 [#/sec] (mean)
Time per request: 782.584 [ms] (mean)
Time per request: 7.826 [ms] (mean, across all concurrent requests)
Transfer rate: 323205.36 [Kbytes/sec] received

Connection Times (ms)
min mean[±sd] median max
Connect: 0 0 1.1 0 4
Processing: 286 724 120.0 689 1106
Waiting: 12 364 101.0 394 532
Total: 286 724 120.0 689 1106

Результаты сведены в таблице

epoll libevent boost.asio
Complete requests 2150 1653 639
Total transferred (bytes) 5389312814 4263404830 1655047414
HTML transferred (bytes) 5388981758 4263207306 1654999464
Requests per second [sec] (mean) 428.54 330.05 127.78
Time per request [ms] (mean) 233.348 302.987 782.584
Transfer rate [Kbytes/sec] received 1049037.42 831304.15 323205.36

Есть три вида лжи: ложь, наглая ложь и статистика. Хотя, надо признать, результат меня не может не радовать. Думаю не стоит уделять особое внимание полученным результатам, а можно на них оглядываться как на некоторую опорную информацию, которая может будет полезна при принятии решения в выборе инструмента разработки своего серверного программного обеспечения. Для верности результатов желательно устраивать многократный прогон на серверном железе, клиентами, запущенными на других машинах сети и т.д.
100 параллельных запросов — это казалось бы мало, но вполне достаточно для проведения тестирования в таких скромных условиях. Конечно, хотелось бы проверять результаты на тысячах параллельных запросах, но тут есть уже иные факторы. Один из таких факторов — это количество одновременно открытых файловых описателей процесс. Узнать и задать некоторые параметры процесса можно вызовами функций getrlimit и setrlimit. Для того, чтобы узнать сколько на процесс выделяется файловых описателей можно вызвать getrlimit с флагом RLIMIT_NOFILE структуры rlimit. Для своей операционной системы — это 1024 файловых дескриптора на процесс по умолчанию и 4096 максимум, который можно установить на процесс. По этому критерию сильно не разгонишься… Как вариант, для того, чтобы ваш сервер в одном процессе мог работать с большим количеством файловых дескрипторов можно настроить систему соответствующим образом. Есть хорошее описание в статье Увеличение производительности сокета в Linux

Выводы и заключение

Писать свой WebServer на «голых» сокетах однозначно интересно, но не надо забывать, что дьявол кроется в деталях. По мере реализации эти самые детали как снежный ком все больше накатывают и накатывают. Не смотря на мою огромную любовь к велосипедостроительной промышленности в области информационных технологий, я бы все-таки этот подход оставил на случай, когда нужно написать что-то очень специфичное, пожертвовать какими-то обобщениями и чем-то более высокоуровневым в пользу достижения максимального быстродействия сервера. Код, приложенный к статье с реализацией на epoll еще можно совершенствовать и совершенствовать. Доведение до промышленной реализации и поддержка такой разработки будет весьма дорога, соответственно, планируемая отдача от такой системы должна быть тоже не малой. Но «наглая ложь», приведенная в сводной таблице выше не может не радовать, не смотря на то, что сервер был написан на C++, а не на чистом C, не брезговал в его реализации использованием stl, исключениями и писался он без больших обдумываний каждого из шагов.
Как уже и было отмечено выше, фаворитом для меня оказалась библиотека libevent, весьма проста для быстрого старта, дает очень хорошие результаты в производительности, кроссплатформенная, много скрывает рутины. Для большинства проектов я бы ее рассматривал в первую очередь.
По моему субъективному мнению, boost весьма своеобразен. Он дает много плюсов в разработке различного программного обеспечения и местами очень привлекателен. boost.asio дает достаточно высокий уровень абстракции от большинства необходимых вещей при разработке сетевого программного обеспечения. Очень хотелось бы услышать мнения «бывалых» о применении данной библиотеки при разработке именно серверного программного обеспечения и, желательно, с высокой нагрузкой.
Есть еще интересный механизм асинхронного ввода вывода в Linux (aio), но пока не хватило времени сделать на нем реализацию на все тех же «голых» сокетах для сравнения с иными реализациями.

Весь код с минимальными сборочными файлами доступен в SVN. Код, конечно можно еще совершенствовать и совершенствовать. Но! Обострившийся перфекционизм может или сильно затянуть реализацию чего либо, или сделать ее вообще не достижимой. Согласно статье «Разработка через страдание», первое, что нужно сделать — сделать чтобы работало, второе — было красиво, третье — работало быстро. Приведенный код прошел первую стадию и понемногу задел вторую и третью :)

Так «небольшое» тестовое задание стало для меня интересным стимулом к некоторому обзору API операционных систем и библиотек.

Интересные материалы

Спасибо за внимание!

Автор: NYM

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js