Пару лет назад мы опубликовали RESTinio — свой небольшой OpenSource C++фреймворк для встраивания HTTP-сервера в C++ приложения. Мегапопулярным за это время RESTinio не стал, но и не потерялся. Кто-то выбирает его за "родную" поддержку Windows, кто-то за какие-то отдельные фичи (вроде поддержки sendfile), кто-то за соотношение возможностей, простоты использования и настраиваемости. Но, думаю, изначально многих RESTinio привлекает вот этим лаконичным "Hello, World"-ом:


#include <restinio/all.hpp>
int main()
{
    restinio::run(
        restinio::on_this_thread()
        .port(8080)
        .address("localhost")
        .request_handler([](auto req) {
            return req->create_response().set_body("Hello, World!").done();
        }));
    return 0;
}

Это, действительно, все, что нужно чтобы запустить HTTP-сервер внутри C++ приложения.


И хотя мы всегда стараемся говорить, что ключевой фичей, ради которой мы вообще занялись RESTinio, была асинхронная обработка входящих запросов, все равно периодически сталкиваемся с вопросами о том, как быть, если внутри request_handler-а приходится выполнять длительные операции.


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


Небольшая отсылка к истокам


Мы решили сделать свой встраиваемый HTTP-сервер после того, как несколько раз подряд столкнулись с очень похожими задачами: нужно было организовать HTTP-вход для уже существующего C++ приложения или нужно было написать микросервис, в котором требовалось переиспользовать уже существующий "тяжелый" C++ный код. Общей чертой у этих задач было то, что прикладная обработка запроса могла растянуться на десятки секунд.


Грубо говоря, HTTP-сервер за миллисекунду разбирался с новым HTTP-запросом, но для выдачи HTTP-ответа требовалось обратиться к каким-то другим сервисам или же провести какие-то длительные вычисления. Если выполнять обработку HTTP-запросов в синхронном режиме, то HTTP-серверу потребуется пул из тысяч рабочих нитей, что вряд ли можно считать хорошей идеей даже в современных условиях.


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


Поскольку мы так и не нашли удовлетворяющего нас готового варианта, который был бы прост и удобен в использовании, был кроссплатформенным и поддерживал бы Windows как "родную" платформу, обеспечивал бы более-менее приличную производительность, и, главное, был бы заточен именно под асинхронную работу, то в начале 2017 года занялись разработкой RESTinio.


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


Итак, есть входящий запрос, требующий много времени на обработку. Что делать?


Рабочие нити RESTinio/Asio


Иногда пользователи RESTinio не задумываются о том, какие рабочие нити и как именно использует RESTinio. Например, кто-то может посчитать, что когда RESTinio запускается на одной рабочей нити (посредством run(on_this_thread(...)), как в примере выше), то на этой рабочей нити RESTinio только вызывает обработчики запросов. Тогда как для операций ввода-вывода RESTinio "под капотом" создает отдельную нить. И эта отдельная нить продолжает обслуживать новые подключения когда основная рабочая нить занята request_handler-ом.


На самом деле все нити, которые пользователь выделяет RESTinio, используются и для выполнения операций ввода-вывода, и для вызова request_handler-ов. Поэтому, если вы запустили RESTinio-сервер через run(on_this_thread(...)), то внутри run() на текущей нити будут выполняться и операции ввода-вывода, и обработчики запросов.


Грубо говоря, RESTinio запускает Asio-шный event-loop, внутри которого выполняется обработка новых подключений, чтение и парсинг данных из уже существующих подключений, запись готовых для отсылки данных, обработка закрытия соединений и т.п. Среди прочего, после того, как из очередного подключения вычитан и полностью разобран входящий запрос, для обработки этого запроса вызывается заданный пользователем request_handler.


Соответственно, если request_handler блокирует работу текущей нити, то блокируется и работающий на этой же нити Asio-шный event-loop. Все просто.


Если RESTinio запускается на пуле рабочих нитей (т.е. посредством run(on_thread_pool(...)), как вот в этом примере), то происходит практически тоже самое: на каждой нити из пула запускается Asio-шный event-loop. Поэтому если какой-то request_handler начнет перемножать большие матрицы, то это заблокирует рабочую нить в пуле и на этой нити перестанут обслуживаться операции ввода-вывода.


Поэтому при использовании RESTinio задача разработчика в том, чтобы его request_handler-ы завершались за разумное и, желательно, не очень большое время.


Нужен ли вам пул рабочих потоков для RESTinio/Asio?


Итак, когда заданный пользователем request_handler блокирует длительной операций рабочую нить, на которой он вызван, то эта нить теряет возможность обрабатывать операции ввода-вывода. Но что делать, если request_handler-у нужно много времени для формирования ответа? Допустим, он делает какую-то тяжелую вычислительную операцию, время которой в принципе нельзя ужать до нескольких миллисекунд?


Кто-то из пользователей может подумать, что раз RESTinio может работать на пуле рабочих нитей, то достаточно указать размер пула побольше и все.


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


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


Пример этой схемы мы подробно рассматривали когда рассказывали о своем демо-проекте Shrimp вот в этой статье: "Shrimp: масштабируем и раздаем по HTTP картинки на современном C++ посредством ImageMagic++, SObjectizer и RESTinio".


Примеры делегирования обработки запросов на отдельные рабочие нити


Выше я попытался объяснить, почему не стоит выполнять длительную обработку прямо внутри request_handler-а. Откуда проистекает очевидное следствие: длительная обработка запроса должна быть делегирована каким-то другим рабочим нитям. Давайте посмотрим на то, как это может выглядеть.


В двух примерах ниже нам потребуется единственная рабочая нить для запуска RESTinio и еще одна рабочая нить для имитации длительной обработки запросов. А также нам потребуется какая-то очередь сообщений для передачи запросов от нити RESTinio к отдельной рабочей нити.


Делать новую реализацию thread-safe message queue на коленке для этих двух примеров мне было не с руки, поэтому я воспользовался родным для меня SObjectizer-ом и его mchain-ами, которые суть CSP-шные каналы. Подробнее про mchain-ы можно прочитать здесь: "Обмен информацией между рабочими нитям без боли? CSP-шные каналы нам в помощь".


Сохранение объекта request_handle


Базовый прием, на котором строится делегирование обработки запросов, — это передача куда-то объекта request_handle_t.


Когда RESTinio для обработки входящего запроса вызывает заданный пользователем request_handler, в этот request_handler передается объект типа request_handle_t. Данный тип является ни чем иным, как умным указателем на параметры полученного запроса. Так что если кому-то удобно думать, что request_handle_t — это shared_ptr, то смело можно так думать. Это shared_ptr и есть.


А раз request_handle_t — это shared_ptr, то мы можем смело этот умный указатель куда-то передать. Что мы и будем делать в показанных ниже примерах.


Итак, нам потребуется отдельная рабочая нить и канал для связи с ней. Создадим это все:


int main()
{
    // Запускаем SObjectizer.
    so_5::wrapped_env_t sobj;

    // Объект std::thread для нити обработки запросов.
    std::thread processing_thread;
    // При выходе из main для этой нити нужно вызвать join.
    // Делаем это через RAII.
    auto processing_thread_joiner = so_5::auto_join(processing_thread);

    // Канал для передачи запросов на обработку.
    auto req_ch = so_5::create_mchain(sobj);
    // Канал нужно закрыть при выходе из main.
    // Делаем это через RAII.
    auto ch_closer = so_5::auto_close_drop_content(req_ch);

    // Теперь можем запустить отдельную нить.
    // Если далее произойдет выход из main() по какой-то причине,
    // то канал принудительно будет закрыт, а для нити будет вызван join().
    processing_thread = std::thread{
            processing_thread_func, req_ch
    };

Тело самой рабочей нити находится внутри функции processing_thread_func(), которую мы рассмотрим чуть позже.


Сейчас у нас уже есть отдельная рабочая нить и канал для связи с ней. Можно запустить RESTinio-сервер:


    // Свойства, которыми должен обладать наш сервер.
    struct traits_t : public restinio::default_traits_t
    {
        using logger_t = restinio::shared_ostream_logger_t;
    };

    restinio::run(
        restinio::on_this_thread<traits_t>()
            .port(8080)
            .address("localhost")
            .request_handler([req_ch](auto req) {
                // Обрабатываем только GET-запросы для корневого каталога.
                if(restinio::http_method_t::http_get == req->header().method() &&
                        "/" == req->header().path())
                {
                    // Делегируем обработку отдельной нити.
                    so_5::send<handle_request>(req_ch, req);
                    return restinio::request_accepted();
                }
                else
                    return restinio::request_rejected();
            })
            .cleanup_func([&] {
                // Закрываем канал запросов для отдельной нити.
                // Лучше сделать это вручную, т.к. внутри req_ch
                // могут быть ждущие своей очереди запросы и их следует
                // уничтожить пока сервер еще существует.
                so_5::close_drop_content(req_ch);
            }));

Логика у этого сервера очень простая. Если пришел GET-запрос для '/', то мы делегируем обработку запроса отдельной нити. Для этого выполняем две важных операции:


  • отсылаем объект request_handle_t в CSP-шный канал. Пока этот объект хранится внутри CSP-шного канала или где-то еще, то RESTinio знает, что запрос еще жив;
  • возвращаем значение restinio::request_accepted() из обработчика запроса. Это дает RESTinio понять, что запрос принят к обработке и нельзя закрывать соединение с клиентом.

Тот факт, что request_handler сразу не сформировал ответ RESTinio нисколько не смущает. Раз вернули restinio::request_accepted(), значит пользователь взял на себя ответственность за обработку запроса и когда-нибудь ответ на запрос будет сформирован.


Если же обработчик запроса вернул restinio::request_rejected(), то RESTinio понимает, что запрос обработан не будет и вернет клиенту ошибку 501.


Итак, зафиксируем предварительный итог: экземпляр request_handle_t может быть передан куда-то, поскольку это, по сути, std::shared_ptr. Пока этот экземпляр жив, RESTinio считает, что запрос находится в обработке. Если обработчик запроса возвратил restinio::request_accepted(), то RESTinio не будет волноваться о том, что ответ на запрос не был сформирован вот прямо сейчас.


Теперь мы можем посмотреть на реализацию этой самой отдельной нити:


void processing_thread_func(so_5::mchain_t req_ch)
{
    // Генератор случайных чисел для определения задержки
    // при имитации обработки запроса.
    std::random_device rd;
    std::mt19937 generator{rd()};
    std::uniform_int_distribution<> pause_generator{350, 3500};

    // Отдельный канал для отложенных сообщений timeout_elapsed.
    auto delayed_ch = so_5::create_mchain(req_ch->environment());

    // Выставляем этот флаг если какой-то канал закрывается.
    bool stop = false;
    select(
        so_5::from_all()
            // Реакция на закрытие любого из каналов.
            .on_close([&stop](const auto &) { stop = true; })
            // Предикат для выхода из select().
            // Завершаем select() как только канал оказывается закрыт.
            .stop_on([&stop]{ return stop; }),

        // Читаем сообщения handle_request из канала общения с RESTinio.
        case_(req_ch,
            [&](handle_request cmd) {
                // Случайная задержка для обработки запроса.
                const std::chrono::milliseconds pause{pause_generator(generator)};

                // Отсылаем самим себе отложенное сообщение.
                so_5::send_delayed<timeout_elapsed>(delayed_ch,
                        // Это задержка для timeout_elapsed.
                        pause,
                        // Все остальное идет в конструктор timeout_elapsed.
                        cmd.m_req,
                        pause);
            }),

        // Читаем сообщения timeout_elapsed.
        case_(delayed_ch,
            [](timeout_elapsed cmd) {
                // Формируем актуальный ответ на запрос.
                cmd.m_req->create_response()
                        .set_body("Hello, World! (pause:"
                                + std::to_string(cmd.m_pause.count())
                                + "ms)")
                        .done();
            })
    );
}

Здесь очень простая логика: мы получаем исходный запрос в виде сообщения handle_request и пересылаем его сами себе в виде отложенного на некоторое случайное время сообщения timeout_elapsed. Реальную обработку запроса делаем лишь при получении timeout_elapsed.


Upd. Когда на отдельной рабочей нити вызывается метод done(), то RESTinio уведомляется о том, что появился готовый ответ, который нужно записать в TCP-соединение. RESTinio инициирует операцию записи, но сама I/O-операция будет выполнена не там, где вызван done(), а там, где RESTinio выполняет ввод-вывод и вызывает request_handler-ы. Т.е. в данном примере done() вызывается на отдельной рабочей нити, а операция записи будет выполнена на основной нити, там, где работает restinio::run().


Сами упомянутые сообщения имеют следующий вид:


struct handle_request
{
    restinio::request_handle_t m_req;
};

struct timeout_elapsed
{
    restinio::request_handle_t m_req;
    std::chrono::milliseconds m_pause;
};

Т.е. отдельная рабочая нить берет request_handle_t и сохраняет его до того времени, пока не появится возможность сформировать полный ответ. А когда такая возможность появляется, у сохраненного объекта-запроса вызывается create_response() и ответ отдается RESTinio. После чего RESTinio уже на своем рабочем контексте записывает ответ в соединение с соответствующим клиентом.


Здесь экземпляр request_handle_t хранится в отложенном сообщении timeout_elapsed, поскольку никакой реальной обработки в этом примитивном примере нет. В реальном приложении request_handle_t может храниться в какой-то очереди или внутри какого-то объекта, созданного для обработки запроса.


Полный код этого примера можно найти среди штатных примеров RESTinio.


Несколько небольших пояснений по коду


Вот эта конструкция задает RESTinio свойства, которыми должен обладать RESTinio-сервер:


    // Свойства, которыми должен обладать наш сервер.
    struct traits_t : public restinio::default_traits_t
    {
        using logger_t = restinio::shared_ostream_logger_t;
    };

    restinio::run(
        restinio::on_this_thread<traits_t>()

Для данного примера мне нужно, чтобы RESTinio логировал свои действия по обработке запросов. Поэтому я задаю logger_t, отличный от используемого по умолчанию null_logger_t. Но т.к. RESTinio будет работать, фактически, на нескольких нитях (входящие запросы RESTinio обрабатывает на основной нити, а вот ответы к нему приходят с отдельной рабочей нити), то нужен thread-safe logger, коим и является shared_ostream_logger_t.


Внутри processing_thread_func() используется SObjectizer-овская функция select(), в чем-то аналогичная Go-шной конструкции select: можно читать и обрабатывать сообщения сразу из нескольких каналов. Функция select() работает до тех пор, пока не будут закрыты все переданные ей каналы. Или пока ей принудительно не скажут, что пора завершаться.


При этом если закрывается канал для связи с RESTinio-сервером, то продолжать работу смысла нет. Поэтому в select() определяется реакция на закрытие любого из каналов: как только какой-то канал закрывается, взводится флаг stop. А это приведет к завершению работы select() и выходу из processing_thread_func().


Сохранение объекта response_builder


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


Но могут быть и более сложные сценарии, когда, например, нужно отдавать ответ по частям. Т.е., мы получаем запрос, сразу можем сформировать лишь первую часть ответа. Формируем ее. Потом, спустя какое-то время, у нас появляется возможность сформировать вторую часть ответа. Потом, спустя еще какое-то время, мы можем сформировать следующую часть и т.д.


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


RESTinio позволяет сделать это за счет responce_builder-ов разного типа. В частности, таких типов, как user_controlled_output и chunked_output.


В этом случае нем недостаточно сохранить request_handle_t, ведь request_handle_t будет полезен только до первого вызова create_reponse(). Далее нам нужно работать с response_builder-ом. Ну и...


Ну и ничего страшного. Response_builder — это moveable тип, чем-то похожий на unique_ptr. Так что мы его так же можем сохранить до тех пор, пока он нам потребуется. А чтобы показать, как это выглядит, немного переделаем рассмотренный выше пример. Сделаем так, чтобы функция processing_thread_func() формировала ответ частями.


Это совсем не сложно.


Сперва нам нужно определиться с типами, которые потребуются новой processing_thread_func():


struct handle_request
{
   restinio::request_handle_t m_req;
};

// Тип нашего ответа на запрос.
using output_t = restinio::chunked_output_t;

// И типа reponse_builder-а для нашего запроса.
using response_t = restinio::response_builder_t<output_t>;

// Отложенное сообщение для формирования ответа.
struct timeout_elapsed
{
   response_t m_resp;
   int m_counter;
};

Сообщение handle_request остается без изменений. А вот в сообщении timeout_elapsed мы теперь храним не request_handle_t, а response_builder нужного нам типа. Плюс счетчик оставшихся частей. Как только этот счетчик обнуляется, обслуживание запроса завершается.


Теперь мы можем посмотреть на новый вариант функции processing_thread_func():


void processing_thread_func(so_5::mchain_t req_ch)
{
   std::random_device rd;
   std::mt19937 generator{rd()};
   std::uniform_int_distribution<> pause_generator{350, 3500};

   auto delayed_ch = so_5::create_mchain(req_ch->environment());

   bool stop = false;
   select(
      so_5::from_all()
         .on_close([&stop](const auto &) { stop = true; })
         .stop_on([&stop]{ return stop; }),

      case_(req_ch,
         [&](handle_request cmd) {
            // Начинаем обработку запроса сразу, как только получаем его.
            auto resp = cmd.m_req->create_response<output_t>();

            resp.append_header( restinio::http_field::server, "RESTinio" )
               .append_header_date_field()
               .append_header( restinio::http_field::content_type,
                     "text/plain; charset=utf-8" );

            // Первая часть ответа сформирована, заставляем RESTinio
            // отослать ее клиенту.
            resp.flush();

            // Обработку остальных частей откладываем на случайное время.
            so_5::send_delayed<so_5::mutable_msg<timeout_elapsed>>(delayed_ch,
                  // Пауза перед отсылкой следующей части.
                  std::chrono::milliseconds{pause_generator(generator)},
                  // Аргументы для конструктора timeout_elapsed.
                  // Обращаем внимание на перемещение response_builder-а внутрь сообщения.
                  std::move(resp),
                  3);
         }),

      case_(delayed_ch,
         [&](so_5::mutable_mhood_t<timeout_elapsed> cmd) {
            // Пришло время сформировать следующую часть ответа.
            cmd->m_resp.append_chunk( "this is the next part of the response\n" );
            // Заставляем RESTinio отослать ее клиенту.
            cmd->m_resp.flush();

            cmd->m_counter -= 1;

            if( 0 != cmd->m_counter )
            {
               // Нужно продолжать отсылку частей ответа через случайный интервал.
               so_5::send_delayed(
                     delayed_ch,
                     std::chrono::milliseconds{pause_generator(generator)},
                     std::move(cmd));
            }
            else
               // Все, ответ полностью сформирован.
               cmd->m_resp.done();
         })
   );
}

Т.е. здесь ответ начинает формироваться сразу, как только запрос приходит на отдельную рабочую нить. И первая часть сразу же отсылается клиенту. А вот оставшиеся части формируются через случайные интервалы времени.


Upd. С методом flush() такая же ситуация, как и с методом done(): RESTinio инициирует операцию записи, но сама I/O-операция будет выполнена не там, где вызван flush(), а там, где RESTinio выполняет ввод-вывод и вызывает request_handler-ы. Т.е. в данном примере flush() вызывается на отдельной рабочей нити, а операция записи будет выполнена на основной нити, там, где работает restinio::run().


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


[2019-05-13 15:02:35.106] TRACE: starting server on 127.0.0.1:8080
[2019-05-13 15:02:35.106]  INFO: init accept #0
[2019-05-13 15:02:35.106]  INFO: server started on 127.0.0.1:8080
[2019-05-13 15:02:39.050] TRACE: accept connection from 127.0.0.1:49280 on socket #0
[2019-05-13 15:02:39.050] TRACE: [connection:1] start connection with 127.0.0.1:49280
[2019-05-13 15:02:39.050] TRACE: [connection:1] start waiting for request
[2019-05-13 15:02:39.050] TRACE: [connection:1] continue reading request
[2019-05-13 15:02:39.050] TRACE: [connection:1] received 78 bytes
[2019-05-13 15:02:39.050] TRACE: [connection:1] request received (#0): GET /
[2019-05-13 15:02:39.050] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 1
[2019-05-13 15:02:39.050] TRACE: [connection:1] start next write group for response (#0), size: 1
[2019-05-13 15:02:39.050] TRACE: [connection:1] start response (#0): HTTP/1.1 200 OK
[2019-05-13 15:02:39.050] TRACE: [connection:1] sending resp data, buf count: 1, total size: 167
[2019-05-13 15:02:39.050] TRACE: [connection:1] outgoing data was sent: 167 bytes
[2019-05-13 15:02:39.050] TRACE: [connection:1] finishing current write group
[2019-05-13 15:02:39.050] TRACE: [connection:1] should keep alive
[2019-05-13 15:02:40.190] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 3
[2019-05-13 15:02:40.190] TRACE: [connection:1] start next write group for response (#0), size: 3
[2019-05-13 15:02:40.190] TRACE: [connection:1] sending resp data, buf count: 3, total size: 42
[2019-05-13 15:02:40.190] TRACE: [connection:1] outgoing data was sent: 42 bytes
[2019-05-13 15:02:40.190] TRACE: [connection:1] finishing current write group
[2019-05-13 15:02:40.190] TRACE: [connection:1] should keep alive
[2019-05-13 15:02:43.542] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 3
[2019-05-13 15:02:43.542] TRACE: [connection:1] start next write group for response (#0), size: 3
[2019-05-13 15:02:43.542] TRACE: [connection:1] sending resp data, buf count: 3, total size: 42
[2019-05-13 15:02:43.542] TRACE: [connection:1] outgoing data was sent: 42 bytes
[2019-05-13 15:02:43.542] TRACE: [connection:1] finishing current write group
[2019-05-13 15:02:43.542] TRACE: [connection:1] should keep alive
[2019-05-13 15:02:46.297] TRACE: [connection:1] append response (#0), flags: { not_final_parts, connection_keepalive }, write group size: 3
[2019-05-13 15:02:46.297] TRACE: [connection:1] start next write group for response (#0), size: 3
[2019-05-13 15:02:46.297] TRACE: [connection:1] sending resp data, buf count: 3, total size: 42
[2019-05-13 15:02:46.297] TRACE: [connection:1] append response (#0), flags: { final_parts, connection_keepalive }, write group size: 1
[2019-05-13 15:02:46.297] TRACE: [connection:1] outgoing data was sent: 42 bytes
[2019-05-13 15:02:46.298] TRACE: [connection:1] finishing current write group
[2019-05-13 15:02:46.298] TRACE: [connection:1] should keep alive
[2019-05-13 15:02:46.298] TRACE: [connection:1] start next write group for response (#0), size: 1
[2019-05-13 15:02:46.298] TRACE: [connection:1] sending resp data, buf count: 1, total size: 5
[2019-05-13 15:02:46.298] TRACE: [connection:1] outgoing data was sent: 5 bytes
[2019-05-13 15:02:46.298] TRACE: [connection:1] finishing current write group
[2019-05-13 15:02:46.298] TRACE: [connection:1] should keep alive
[2019-05-13 15:02:46.298] TRACE: [connection:1] start waiting for request
[2019-05-13 15:02:46.298] TRACE: [connection:1] continue reading request
[2019-05-13 15:02:46.298] TRACE: [connection:1] EOF and no request, close connection
[2019-05-13 15:02:46.298] TRACE: [connection:1] close
[2019-05-13 15:02:46.298] TRACE: [connection:1] close: close socket
[2019-05-13 15:02:46.298] TRACE: [connection:1] close: timer canceled
[2019-05-13 15:02:46.298] TRACE: [connection:1] close: reset responses data
[2019-05-13 15:02:46.298] TRACE: [connection:1] destructor called

Здесь можно увидеть, как RESTinio принимает запрос и сразу же отсылает первую часть ответа размером 167 байт. Затем через разные промежутки времени отсылаются остальные части ответа и, когда клиент получает весь ответ и закрывает соединение со своей стороны, RESTinio закрывает соединение у себя и освобождает связанные с ним ресурсы.


Данный пример показывает, что при использовании RESTinio можно сохранить где-то в прикладном коде объект response_builder и задействовать этот объект тогда, когда появляется возможность сформировать очередную часть ответа клиенту.


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


Полный код примера можно увидеть здесь.


Что будет, если обработка запроса займет слишком много времени?


Допустим, в своем request_handler-е мы делегировали обработку запроса какой-то другой рабочей нити. Что произойдет, если эта нить будет настолько занята, что сможет приступить к обработке запроса только через час или два?


Когда RESTinio отдает запрос на обработку, начинается отсчет тайм-аута для request_handler-а. Как только этот тайм-аут истечет, а ответ не будет сформирован, RESTinio просто напросто закроет соединение на своей стороне. То, что затем будет сформировано в качестве ответа будет проигнорировано. Вот, например:


[2019-05-13 15:32:23.618] TRACE: starting server on 127.0.0.1:8080
[2019-05-13 15:32:23.618]  INFO: init accept #0
[2019-05-13 15:32:23.618]  INFO: server started on 127.0.0.1:8080
[2019-05-13 15:32:26.768] TRACE: accept connection from 127.0.0.1:49502 on socket #0
[2019-05-13 15:32:26.768] TRACE: [connection:1] start connection with 127.0.0.1:49502
[2019-05-13 15:32:26.768] TRACE: [connection:1] start waiting for request
[2019-05-13 15:32:26.768] TRACE: [connection:1] continue reading request
[2019-05-13 15:32:26.768] TRACE: [connection:1] received 78 bytes
[2019-05-13 15:32:26.768] TRACE: [connection:1] request received (#0): GET /
[2019-05-13 15:32:30.768] TRACE: [connection:1] handle request timed out
[2019-05-13 15:32:30.768] TRACE: [connection:1] close
[2019-05-13 15:32:30.768] TRACE: [connection:1] close: close socket
[2019-05-13 15:32:30.768] TRACE: [connection:1] close: timer canceled
[2019-05-13 15:32:30.768] TRACE: [connection:1] close: reset responses data
[2019-05-13 15:32:31.768]  WARN: [connection:1] try to write response, while socket is closed
[2019-05-13 15:32:31.768] TRACE: [connection:1] destructor called

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


Управлять величиной тайм-аута можно посредством параметра handle_request_timeout, который задается в параметрах RESTinio-сервера (подробнее здесь).


Заключение


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


Ну а если вы смотрели RESTinio и не решились взять наш фреймворк в работу, то поделитесь, пожалуйста, своими соображениями: почему отказались? Чего-то не хватило? Что-то не понравилось? Где-то что-то сделано лучше?


PS. На Хабре про RESTinio мы рассказываем пока сильно меньше, чем про SObjectizer, но несколько публикаций было. Так что, если кто-то узнал про RESTinio впервые, то вот некоторые из них: "Трехэтажные C++ные шаблоны в реализации встраиваемого асинхронного HTTP-сервера с человеческим лицом", "Асинхронные HTTP-запросы на C++: входящие через RESTinio, исходящие через libcurl. Часть 1", "Shrimp: масштабируем и раздаем по HTTP картинки на современном C++ посредством ImageMagic++, SObjectizer и RESTinio"

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


  1. vyo
    15.05.2019 15:39

    А можно ли ставить хандлер таймаутов/закрытий сокетов? А то имеет полный смысл освободить ресурсы, если ответ уйдёт в никуда.


    1. eao197 Автор
      15.05.2019 16:51

      На данный момент можно повесить только after-write-нотификаторы. Т.е. в методы flush и done можно передать лямбду, которая будет вызвана при завершении (успешном или нет) операции записи. В этой лямбде можно проанализировать результат и, если результат записи очередного chunk-а отрицательный, предпринять какие-то действия по прекращению текущей операции.


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


      Да и само соединение, в принципе, от пользователя спрятано. Через request_handle можно получить connection_id, но это и все. При этом с один соединением может быть ассоциировано сразу несколько request-ов (в результате использования pipelining-а клиентом, к примеру).


  1. A1ien
    15.05.2019 17:03

    Единствнное, что не понятно, как в поток обработки ввода-вывода попадает сформированный ответ, или запись ответа в сокет происходит в потоке обработчика? По сути вопрос можно перефразировать так — что делает cmd.m_req->create_response()…


    1. eao197 Автор
      15.05.2019 17:08

      Да, вероятно, нужно вставить update в текст статьи. Когда на отдельной рабочей нити вызывается flush или done, то RESTinio инициирует операцию записи в канал. Но запись будет сделана на той нити, где работает сам RESTinio (т.е. там, где выполняется весь I/O и где вызываются request_handler-ы). А пока RESTinio где-то делает запись в канал, эта самая отдельная рабочая нить может делать следующую часть ответа, а затем снова вызвать flush или done.


      Вызов create_response() сам по себе ничего не записывает. Он только создает response_builder. А вот методы flush и done у response_builder-а уже инициируют запись.


      1. A1ien
        15.05.2019 17:51

        Как flush и done переносят данные в поток ввода-вывода? Через внутреннюю очередь?


        1. eao197 Автор
          15.05.2019 17:58

          ЕМНИП, через Asio-шный io_context::post, а там внутри уже какая-то очередь, наверное.


          1. A1ien
            15.05.2019 18:53

            Понятно. Спасибо.