company_banner

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

    В Яндекс.Такси придерживаются микросервисной архитектуры. С ростом количества микросервисов мы заметили, что разработчики много времени тратят на 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. Если решим, что да, подготовка фреймворка к открытию исходников потребует достаточно больших усилий.

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

    О каких ещё вещах из userver вы бы хотели узнать?

    • 55.5%Реализация примитивов синхронизации для корутин.90
    • 51.2%Особые хитрости C++, уникальные для нас.83
    • 48.1%Как писать асинхронные драйвера для баз данных.78
    • 30.2%Тестирование.49
    • 59.8%Больше информации о корутиновом движке — как работает на низком уровне и т. п.97
    • 2.4%Другое (укажу в комментариях).4

    Пригодился бы вам подобный фреймворк для написания своих проектов?

    • 77.2%да122
    • 22.7%нет36
    Яндекс
    641,20
    Как мы делаем Яндекс
    Поделиться публикацией

    Комментарии 53

      +3

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

        0
        Макрос LOG_DEBUG() описан приблизитолько вот так:
        #define LOG_DEBUG() if (GetCurrentLogLevel() >= debug_level) std::cout

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

        * только вместо std::cout у нас пара наворотов, чтобы побыстрее формировать строчку (без динамических аллокаций, std::locale и прочего безобразия) и логировать её асинхронно. На C++ Piter я мельком показывал, как оно сделано под капотом.
          0
          Интересно, можно ли это когданибудь будет написать на чистом C++, без макросов?
            +3
            Да, сейчас думают добавить lazy evaluated arguments в C++. С ними можно будет писать нечто наподобие
            log_debug("{} is not OK of {}", request.id, GetSomeInfoFromDb());
            и не вычислять GetSomeInfoFromDb() если логировать не нужно.
              0
              Как-то так:

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

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

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

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


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

                  0

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


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

                    0

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

                      0

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


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


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


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

                        0

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


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

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

                          +1

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


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

                            0

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

                    0

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


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

                  +1
                  А можно еще сравнение с pistache.io?
                    0
                    Из того что я заметил, пробежавшись по документации и примерам:
                    * там только http сервер (нет баз данных, логирования и прочего)
                    * нет асинхронности, точнее её можно реализовать через цепочки фьючеров
                        response
                                .send(Http::Code::Request_Timeout, "Timeout")
                                .then([=](ssize_t) { }, PrintException());
                    


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

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

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

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

                          0

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

                            0
                            ~250KB
                              0

                              Всё же сильно больше чем начальные 3к в горутинах...

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

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

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

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

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

                        EDIT:

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

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

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

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

                              0

                              asyncio же?

                                +1

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

                                    0

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

                                      0

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

                                        +2

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

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

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

                                  0
                                  Чтобы отправить запрос прежде всего надо установить соединение с удалённой машиной. В итоге от ОС мы получаем сокет — нечто что связывает нас и удалённую машину. Теперь мы можем отправить через сокет байты, и сказать ОС «возобнови вот эту корутину, когда на вот этом сокете появятся данные».
                                    0
                                    Это у вас получается по соединению на запрос — тогда да, вопросов нет…
                                    Получается, что если мы хотим работать с короутинами в одном соединении с множеством запросов — нам придется городить некий дополнительный функционал в протоколе?
                                      0
                                      Если у вас одно соединение с множеством не связанных друг с другом запросов — читаете сразу N запросов из сокета, порождайте N независимых корутин, в них обрабатываете данные/делаете запросы к базам данных/делаете запросы к другим микросервисам/… в первоначальной корутине ждёте ответов и отправляете их по сокету обратно.
                                  0
                                  Интересует инфраструктура проекта. У вас СMake?
                                  Какой менеджер пакетов используете и используете ли? (Conan, vcpkg, qpm, что-то свое)
                                  Корутины свои запилили или взяли из Boost?

                                  Очень интересная для меня тема. Сейчас используем Kotlin/JVM в проде для микросервисов, но из-за JVM они не совсем «микро». Go идеален в плане минимальных системных требований, но как язык я его не приемлю. Посматриваю на плюсы в качестве базы для микросервисов, но видны следующие недостатки:
                                  — нет интроспекции
                                  — пакетные менеджеры не распространены и в тех что есть мало библиотек
                                  — нет некоторых нужных библиотек (GraphQL например)
                                    0
                                    Да, CMake.
                                    Используем скрипты CMake, которые при отсутствии нужных библиотек говорят какие системные команды надо выполнить, чтобы их поставить (например 'No compiler found. Please run `sudo apt install clang++`').
                                    Корутины базируются на библиотеке Boost.Context + lock-free библиотеки + Boost библиотеки для умных указателей и интрузивных контейнеров.

                                    С интроспекцией в C++17 и правда плохо, но для некоторых вещей хорошо подходит библиотека magic_get или constexpr функции с «рукописной помощью» для интроспекции.
                                      0
                                      А почему не взяли Boost.Coroutine2, который тоже на Boost.Context основан, какие у него недостатки по вашему мнению?
                                        0
                                        Boost.Coroutine2 используем )
                                    +1
                                    Если будете оперсорсить, уберите пожалуйста PascalCase :3

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

                                    Кодстайл это, кажется, одна из основных болей всех проектов, которые хотят заопенсорситься.
                                      +1
                                      PascalCase мы менять не будем потому что многие люди его используют, и недолюбливают snake_case. Тут либо одним не угодишь, либо другим :(
                                        +1
                                        Проходил курс от Яндекса на Coursera и столкнулся с совершенно непривычным CodeStyle… Названия функций с больших букв… Ну, тут да, кто как привык… просто, как мне кажется snake_case, гораздо читабельнее для функций и названий внутренних переменных, а PascalCase для названий классов.
                                          +1
                                          Действительно всем не угодишь. snake_case мне тоже не нравится, я люблю camelCase, но мой опрос в плюсовых конфах показал, многие считают, что для публичных библиотек лучше придерживаться стиля стандартной библиотеки.
                                        +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.

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

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

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

                                        Самое читаемое