Просто и на C++. Основы Userver — фреймворка для написания асинхронных микросервисов +47



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

Мы решили сделать свой фреймворк, с C++17 и корутинами. Вот так теперь выглядит типичный код микросервиса:

Response View::Handle(Request&& request, const Dependencies& dependencies) {
  auto cluster = dependencies.pg->GetCluster();
  auto trx = cluster->Begin(storages::postgres::ClusterHostType::kMaster);

  const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1";
  auto row = psql::Execute(trx, statement, request.id)[0];
  if (!row["ok"].As<bool>()) {
    LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();
    return Response400();
  }

  psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar);
  trx.Commit();

  return Response200{row["baz"].As<std::string>()};
}

А вот почему это крайне эффективно и быстро — мы расскажем под катом.

Userver — асинхронность


Наша команда состоит не только из матёрых C++ разработчиков: есть и стажёры, и младшие разработчики, и даже люди, не особо привыкшие писать на C++. Поэтому в основе дизайна userver — простота использования. Однако с нашими объёмами данных и нагрузкой мы так же не можем себе позволить неэффективно расходовать ресурсы железа.

Для микросервисов характерно ожидание ввода-вывода: зачастую ответ микросервиса формируется из нескольких ответов других микросервисов и баз данных. Задачу эффективного ожидания ввода-вывода решают через асинхронные методы и callback’и: при асинхронных операциях нет необходимости плодить потоки выполнения, а соответственно, нет и больших накладных расходов на переключение потоков… вот только код достаточно сложно писать и поддерживать:

void View::Handle(Request&& request, const Dependencies& dependencies, Response response) {
  auto cluster = dependencies.pg->GetCluster();

  cluster->Begin(storages::postgres::ClusterHostType::kMaster,
    [request = std::move(request), response](auto& trx)
  {
    const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1";
    psql::Execute(trx, statement, request.id,
      [request = std::move(request), response, trx = std::move(trx)](auto& res)
    {
      auto row = res[0];
      if (!row["ok"].As<bool>()) {
        if (LogDebug()) {
            GetSomeInfoFromDb([id = request.id](auto info) {
                LOG_DEBUG() << id << " is not OK of " << info;
            });
        }    
        *response = Response400{};
      }

      psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar, 
        [row = std::move(row), trx = std::move(trx), response]()
      {
        trx.Commit([row = std::move(row), response]() {
          *response = Response200{row["baz"].As<std::string>()};
        });
      });
    });
  });
}

И тут на помощь приходят stackfull-корутины. Пользователь фреймворка думает, что пишет обычный синхронный код:

  auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];

Однако под капотом происходит приблизительно следующее:

  1. формируются и отправляются TCP-пакеты с запросом к базе данных;
  2. приостанавливается выполнение корутины, в которой в данный момент работает функция View::Handle;
  3. ядру ОС мы говорим: "«Помести приостановленную корутину в очередь готовых к выполнению задач, как только от базы данных придёт достаточно TCP-пакетов»;
  4. не дожидаясь предыдущего шага, берём и запускаем другую готовую к выполнению корутину из очереди.

Другими словами, функция из первого примера работает асинхронно и близка к такому коду, использующему C++20 Coroutines:

Response View::Handle(Request&& request, const Dependencies& dependencies) {
  auto cluster = dependencies.pg->GetCluster();
  auto trx = co_await cluster->Begin(storages::postgres::ClusterHostType::kMaster);

  const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1";
  auto row = co_await psql::Execute(trx, statement, request.id)[0];
  if (!row["ok"].As<bool>()) {
    LOG_DEBUG() << request.id << " is not OK of " << co_await GetSomeInfoFromDb();
    co_return Response400{"NOT_OK", "Please provide different ID"};
  }

  co_await psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar);
  co_await trx.Commit();

  co_return Response200{row["baz"].As<std::string>()};
}

Вот только пользователю не надо задумываться о co_await и co_return, всё работает «само».

В нашем фреймворке переключение между корутинами происходит быстрее, чем вызов std::this_thread::yield(). Весь микросервис обходится очень малым количеством потоков.

На данный момент userver содержит в себе асинхронные драйверы:
* для сокетов ОС;
* http и https (клиент и сервер);
* PostgreSQL;
* MongoDB;
* Redis;
* работы с файлами;
* таймеров;
* примитивов синхронизации и запуска новых корутин.

Приведённый выше асинхронный подход к решению I/O-bound задач должен быть знаком Go-разработчикам. Но, в отличие от Go, мы не получаем накладных расходов по памяти и CPU от сборщика мусора. Разработчики могут пользоваться более богатым языком, с различными контейнерами и высокопроизводительными библиотеками, не страдать от отсутствия константности, RAII или шаблонов.

Userver — компоненты


Разумеется, полноценный фреймворк — это не только корутины. Задачи у разработчиков в Такси крайне разнообразны, и для решения каждой из них требуется свой набор инструментов. Поэтому в userver есть всё необходимое:
* для логирования;
* кеширования;
* работы с различными форматами данных;
* работы с конфигами и обновлением конфигов без перезапуска сервиса;
* распределённых блокировок;
* тестирования;
* авторизации и аутентификации;
* создания и отправки метрик;
* написания REST handlers;
+ кодогенерации и поддержки зависимостей (вынесено в отдельную часть фреймворка).

Userver — кодогенерация


Вернёмся к первой строчке нашего примера и посмотрим, что скрывается за Response и Request:

Response Handle(Request&& request, const Dependencies& dependencies);

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

Например, для Handle из примера swagger-схема может выглядеть вот так:

paths:
    /some/sample/{bar}:
        post:
            description: |
                Ручка для статьи на Habr.
            summary: |
                Ручка, которая что-то делает с базой.
            parameters:
              - in: query
                name: id
                type: string
                required: true
              - in: header
                name: foo
                type: string
                enum:
                - foo1
                - foo2
                required: true
              - in: path
                name: bar
                type: string
                required: true
            responses:
                '200':
                    description: OK
                    schema:
                        type: object
                        additionalProperties: false
                        required:
                          - baz
                        properties:
                            baz:
                                type: string
                '400':
                    $ref: '#/responses/ResponseCommonError'

Ну а раз у разработчика уже есть схема с описанием запросов и ответов, то почему бы на её основе и не сгенерировать эти запросы и ответы? При этом в схеме можно указывать и ссылки на protobuf/flatbuffer/… файлы — кодогенерация из запроса сама всё достанет, провалидирует входные данные согласно схеме и разложит по полям структуры Response. Пользователю остаётся только написать функциональность в метод Handle, не отвлекаясь на boilerplate с разбором запросов и сериализацией ответа.

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

Request req;
req.id = id;
req.foo = foo;
req.bar = bar;
dependencies.sample_client.SomeSampleBarPost(req);

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

Userver — логирование


Мы любим писать логи. Если логировать лишь самую важную информацию, то будет набегать несколько терабайт логов в час. Поэтому неудивительно, что у нашего логирования есть свои хитрости:
* оно асинхронное (разумеется :-) );
* мы умеем логировать в обход медленных std::locale и std::ostream;
* мы умеем переключать уровень логирования на лету (без перезапуска сервиса);
* мы не выполняем пользовательский код, если он нужен только для логирования.

Например, при штатной работе микросервиса уровень логирования будет выставлен в INFO, и всё выражение

    LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();

не станет вычисляться. В том числе вызов ресурсоёмкой функции GetSomeInfoFromDb() не произойдёт.

Если же вдруг сервис начнёт «чудить», разработчик всегда может сказать работающему сервису: «Логируй в режиме DEBUG». И в этом случае записи «is not OK of» начнут появляться в логах, функция GetSomeInfoFromDb() будет выполняться.

Вместо итогов


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

Сейчас мы раздумываем, выкладывать ли фреймворк в open source. Если решим, что да, подготовка фреймворка к открытию исходников потребует достаточно больших усилий.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (53):

  1. kroilov
    /#20858296 / +2

    Расскажи, что за магия с отложенным вызовом функции при логировании

  2. antoshkka
    /#20858334

    Макрос LOG_DEBUG() описан приблизитолько вот так:

    #define LOG_DEBUG() if (GetCurrentLogLevel() >= debug_level) std::cout

    Так что если уровень логирования недостаточный — в ветку if не заходим и никакие функции не выполняем.

    * только вместо std::cout у нас пара наворотов, чтобы побыстрее формировать строчку (без динамических аллокаций, std::locale и прочего безобразия) и логировать её асинхронно. На C++ Piter я мельком показывал, как оно сделано под капотом.

    • svr_91
      /#20859708

      Интересно, можно ли это когданибудь будет написать на чистом C++, без макросов?

      • antoshkka
        /#20859826

        Да, сейчас думают добавить lazy evaluated arguments в C++. С ними можно будет писать нечто наподобие

        log_debug("{} is not OK of {}", request.id, GetSomeInfoFromDb());
        и не вычислять GetSomeInfoFromDb() если логировать не нужно.

      • navrocky
        /#20862616

        Как-то так:

        logger.debug([&](auto stream){ auto value = heavyCalculation(); stream << "Value is" << value; });

    • 0xd34df00d
      /#20860410

      А ещё можно ммапить файл с логом и тупо делать memcpy туда. Быстро, асинхронно, устойчиво к падениям приложения (ядро ОС даёт все нужные гарантии).

      • antoshkka
        /#20860590

        Это будет блокирующей операцией — в зависимости от флажков можно получать page faults при записи или блокирование в mmap. Так что memcpy надо будет запускать в специальном пуле/потоке с блокировкой которого мы готовы мириться.

        • 0xd34df00d
          /#20860614 / +2

          Этого можно избежать вещами типа madvise, mlock и прочего подобного.


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

      • ilammy
        /#20861380

        Как атомарно записать в конец файла в таком случае?


        Ядро точно даёт гарантии без msync()?

        • 0xd34df00d
          /#20861406

          Что значит «атомарно»? Вам точно для записи логов нужны гарантии, что вы не увидите partial write из другого потока (хотя я почти уверен, что ОС их даёт)?

          • ilammy
            /#20861474

            У меня в файле 1000 байтов логов. Поток А хочет залоггировать 100 байтов. Поток Б — 200 байтов. Я хочу увидеть в итоге файл на 1300 байтов, а в нём в конце две записи, в каком угодно порядке, но отдельно. А не так, что файл будет на 1100 байтов и в нём — половина лога Б.


            open() с O_APPEND и write() атомарны при небольших записях (страница памяти). Как это можно сделать через mmap() и запись в память напрямую?


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


            Впрочем, на случай недостатка места в отображаемом куске можно условный memcpy() делать в дополнительный буфер-очередь (просто в памяти), а расширение файла вынести в отдельный поток, который после завершения работы подберёт всё из буфера. Лишь бы буфера хватило.

            • 0xd34df00d
              /#20861486

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


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

              Это можно сделать так, что дело обойдётся обычным CAS без всяких блокировок во всех реалистичных случаях.

              • ilammy
                /#20861504 / +1

                Блокировка всё равно понадобится (в коде), пусть и в оптимистичном случае мы на неё никогда не натолкнёмся. У меня есть маппинг на 1 МБ, куда сейчас пишут. Есть уже готовый маппинг ещё на 1 МБ, куда мы переключимся, когда первый закончится. И за то время, пока заполняется новый маппинг, надо успеть подготовить следующий ему на смену. Но если мы не успеем, то придётся идти и ждать.


                Плюс должны быть гарантии, что старый маппинг на 1 МБ не освободят, пока в него не дозаписали все, кто увидел его до переключения. Но это, мне кажется, тоже атомиками можно как-то разрулить.

                • 0xd34df00d
                  /#20861510

                  Да, если вы заполняете мегабайт логов быстрее, чем создаётся пустой файл на мегабайт, ему делается mmap и mlock, то да, придётся ждать. Я там рядом об этом писал.

      • ilammy
        /#20861530

        А насчёт падений, кстати, классно. Я почитал, что Линкус действительно даёт гарантию, что отображаемые страницы останутся в страничном кеше и — если только система не упадёт или там питание не отключится — то если поток записал что-то в эту память, то оно (со временем) дойдёт до диска. Даже если процесс пристрелит SIGKILL посреди memcpy().


        Это всё предполагает, что страницы реально в физической памяти, для этого и всякие mlock() нужны.

  3. siexo
    /#20858336

    А можно еще сравнение с pistache.io?

    • antoshkka
      /#20858428

      Из того что я заметил, пробежавшись по документации и примерам:
      * там только http сервер (нет баз данных, логирования и прочего)
      * нет асинхронности, точнее её можно реализовать через цепочки фьючеров

          response
                  .send(Http::Code::Request_Timeout, "Timeout")
                  .then([=](ssize_t) { }, PrintException());
      


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

  4. mayorovp
    /#20858474 / +1

    Сколько памяти выделяется на стек корутины?

    • antoshkka
      /#20858620

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

      • mayorovp
        /#20859024

        Так всё-таки, сколько?

        • fougasse
          /#20860368

          Поддерживаю вопрос, очень интересно.
          Непонятно, что делать со всей информацией, как это всё использовать, если нет сорцов?

  5. DFooz
    /#20858524

    чем не подошли существующие опенсорсные решения? Наверняка, вы их тоже пробовали?
    Какие получились накладные расходы по ЦПУ и памяти? Какой прирост с существующим опенсорсом?

    • antoshkka
      /#20858892

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

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

      Если говорить только про корутиновый движок, то например в Boost.Fibers крайне простой шедулер. Нам он не подходит, а компоненты для построения более сложных шедулеров отсутствуют в Boost. И принести их в Boost нельзя, а значит и заапстримить изменения. В Boost так же нет части примитивов синхронизации, wait list примитивов плохо кастомизируются, что мешает их эффективной реализации и т.д. и т.п… В итоге, от изначального кода практически ничего не остаётся.

  6. thatsme
    /#20859218

    Не нашёл в статъе ссылку на исходники и документацию.

    Пример маловат, не ясно что под капотом.

    На чём реализована работа с file handles (sockets) в плане мултиплексирования (epoll/poll/select)?
    Можно-ли добавлять собственные классы (от чего наследоваться), для мултиплексирования?

    EDIT:

    HTTP/2 поддерживается?

    • iroln
      /#20859484 / +2

      Сейчас мы раздумываем, выкладывать ли фреймворк в open source. Если решим, что да, подготовка фреймворка к открытию исходников потребует достаточно больших усилий.

      Они просто похвастались, что у них есть такая штука. Ну OK.

  7. AlexMal
    /#20859812

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

  8. robert_ayrapetyan
    /#20860028

    Кто слышал про подобное на питоне?

    • NeoPhix
      /#20860302

      asyncio же?

      • iroln
        /#20860392

        asyncio — это совсем не то же самое, о чём идет речь в этой статье и о чём вопрос выше, как мне кажется. Это просто низоуровневая библиотека, механизм если хотите, для поддержки асинхронности и кооперативной многозадачности на уровне языка. В нём нет ни http-сервера/клиента, асинхронных драйверов к БД, быстрого event loop и всего остального о чём тут написано. Чтобы собрать подобный стек понадобится кучка сторонних библиотек поверх asyncio, вроде aiohttp и т.д. И надо заметить, asyncio далеко не идеален. Посмотрите в сторону trio. Это асинхронность с человеческим лицом. :)

          • iroln
            /#20860520

            И...? Я знаю про uvloop и асинхронные драйверы для БД. :) И это никак не противоречит тому, что я написал выше. Всё это отдельные библиотеки, которые надо собирать в стек. Тут же представлен фреймворк (не библиотека), который может сразу всё это из коробки.

            • arthuriantech
              /#20860744

              Они предназначены для asyncio и отлично собираются в быстрый стек) Проблема асинхронности и кооперативной многозадачности для Питона в принципе уже решена — asyncio, gevent, stackless в PyPy, так что нужды в очередном асинхронном монофрейморке нет.
              А моя реплика не в укор, а просто так. Эта ветка уже пустилась в пространные рассуждения)

            • GamePad64
              /#20861396 / +1

              Под такое описание может попасть Starlette вместе с FastAPI и databases. Их делают одни и те же люди, оно всё внутри хорошо друг с другом интегрировано.

  9. raiSadam
    /#20860390

    1. Очень интересно про распределенных блокировки.
    2. Pocoproject содержит ту же функциональность (кроме распределенных блокировок), но там c++ не современный, так что, если уж в комментариях спрашивали про сравнение с другими фреймворками, то спрошу и я, чем лучше userver по сравнению с poco?

  10. dr_begemot
    /#20861880

    А как корутина понимает, что данный ответ на тот самый tcp запрос?

    • antoshkka
      /#20862138

      Чтобы отправить запрос прежде всего надо установить соединение с удалённой машиной. В итоге от ОС мы получаем сокет — нечто что связывает нас и удалённую машину. Теперь мы можем отправить через сокет байты, и сказать ОС «возобнови вот эту корутину, когда на вот этом сокете появятся данные».

      • dr_begemot
        /#20862264

        Это у вас получается по соединению на запрос — тогда да, вопросов нет…
        Получается, что если мы хотим работать с короутинами в одном соединении с множеством запросов — нам придется городить некий дополнительный функционал в протоколе?

        • antoshkka
          /#20862394

          Если у вас одно соединение с множеством не связанных друг с другом запросов — читаете сразу N запросов из сокета, порождайте N независимых корутин, в них обрабатываете данные/делаете запросы к базам данных/делаете запросы к другим микросервисам/… в первоначальной корутине ждёте ответов и отправляете их по сокету обратно.

  11. navrocky
    /#20862584

    Интересует инфраструктура проекта. У вас СMake?
    Какой менеджер пакетов используете и используете ли? (Conan, vcpkg, qpm, что-то свое)
    Корутины свои запилили или взяли из Boost?

    Очень интересная для меня тема. Сейчас используем Kotlin/JVM в проде для микросервисов, но из-за JVM они не совсем «микро». Go идеален в плане минимальных системных требований, но как язык я его не приемлю. Посматриваю на плюсы в качестве базы для микросервисов, но видны следующие недостатки:
    — нет интроспекции
    — пакетные менеджеры не распространены и в тех что есть мало библиотек
    — нет некоторых нужных библиотек (GraphQL например)

    • antoshkka
      /#20862740

      Да, CMake.
      Используем скрипты CMake, которые при отсутствии нужных библиотек говорят какие системные команды надо выполнить, чтобы их поставить (например 'No compiler found. Please run `sudo apt install clang++`').
      Корутины базируются на библиотеке Boost.Context + lock-free библиотеки + Boost библиотеки для умных указателей и интрузивных контейнеров.

      С интроспекцией в C++17 и правда плохо, но для некоторых вещей хорошо подходит библиотека magic_get или constexpr функции с «рукописной помощью» для интроспекции.

      • navrocky
        /#20864694

        А почему не взяли Boost.Coroutine2, который тоже на Boost.Context основан, какие у него недостатки по вашему мнению?

  12. navrocky
    /#20862718

    Если будете оперсорсить, уберите пожалуйста PascalCase :3

    Лучше придерживаться стиля snake_case из std::

    Кодстайл это, кажется, одна из основных болей всех проектов, которые хотят заопенсорситься.

    • antoshkka
      /#20862760

      PascalCase мы менять не будем потому что многие люди его используют, и недолюбливают snake_case. Тут либо одним не угодишь, либо другим :(

      • AlexMal
        /#20862960

        Проходил курс от Яндекса на Coursera и столкнулся с совершенно непривычным CodeStyle… Названия функций с больших букв… Ну, тут да, кто как привык… просто, как мне кажется snake_case, гораздо читабельнее для функций и названий внутренних переменных, а PascalCase для названий классов.

      • navrocky
        /#20864656

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

  13. orcy
    /#20871022 / +1

    А какое у вас отношение к stackless coroutines? Не получится ли так что вот у вас есть фреймворк для stackfull, который надо будет переписать на stackless?


    Это наверное старые новости, но недавно видел упоминание на stackless vs stackfull у Raymond Chen https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=102989 в котором была ссылка на статью Gor Nishanov http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf где вкратце он топит за то что stackless считается более перспективным чем stackfull. В частности он там пишет что Facebook пытается уйти от stackfull по ряду причин и такую же штуке сделали в Rust.

    • antoshkka
      /#20871214

      У них разные характеристики:
      * stackfull позволяют пользователям фреймворка не задумываться о внутренней реализации, о co_await, co_return
      * stackless быстрее отменять и они расходуют меньше оперативной памяти

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