Мы решили сделать свой фреймворк, с 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];
Однако под капотом происходит приблизительно следующее:
- формируются и отправляются TCP-пакеты с запросом к базе данных;
- приостанавливается выполнение корутины, в которой в данный момент работает функция View::Handle;
- ядру ОС мы говорим: "«Помести приостановленную корутину в очередь готовых к выполнению задач, как только от базы данных придёт достаточно TCP-пакетов»;
- не дожидаясь предыдущего шага, берём и запускаем другую готовую к выполнению корутину из очереди.
Другими словами, функция из первого примера работает асинхронно и близка к такому коду, использующему 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)
antoshkka Автор
07.11.2019 13:00Макрос LOG_DEBUG() описан приблизитолько вот так:
#define LOG_DEBUG() if (GetCurrentLogLevel() >= debug_level) std::cout
Так что если уровень логирования недостаточный — в ветку if не заходим и никакие функции не выполняем.
* только вместо std::cout у нас пара наворотов, чтобы побыстрее формировать строчку (без динамических аллокаций, std::locale и прочего безобразия) и логировать её асинхронно. На C++ Piter я мельком показывал, как оно сделано под капотом.svr_91
07.11.2019 16:51Интересно, можно ли это когданибудь будет написать на чистом C++, без макросов?
antoshkka Автор
07.11.2019 17:16Да, сейчас думают добавить lazy evaluated arguments в C++. С ними можно будет писать нечто наподобие
и не вычислять GetSomeInfoFromDb() если логировать не нужно.log_debug("{} is not OK of {}", request.id, GetSomeInfoFromDb());
navrocky
08.11.2019 11:48Как-то так:
logger.debug([&](auto stream){ auto value = heavyCalculation(); stream << "Value is" << value; });
0xd34df00d
07.11.2019 19:54А ещё можно ммапить файл с логом и тупо делать memcpy туда. Быстро, асинхронно, устойчиво к падениям приложения (ядро ОС даёт все нужные гарантии).
antoshkka Автор
07.11.2019 20:34Это будет блокирующей операцией — в зависимости от флажков можно получать page faults при записи или блокирование в mmap. Так что memcpy надо будет запускать в специальном пуле/потоке с блокировкой которого мы готовы мириться.
0xd34df00d
07.11.2019 20:39+2Этого можно избежать вещами типа madvise, mlock и прочего подобного.
То есть, понятно, что блокировка возможна всегда, но если вы будете делать mmap заранее (возможно, в отдельном потоке) и какой-нибудь mlock заранее (тоже, возможно, в отдельном потоке), то вероятность этого стремится к нулю, и при возникновении такой ситуации у вас система, скорее всего, будет в таком состоянии, что вам не до логов вашего приложения.
ilammy
08.11.2019 01:22Как атомарно записать в конец файла в таком случае?
Ядро точно даёт гарантии без msync()?
0xd34df00d
08.11.2019 02:01Что значит «атомарно»? Вам точно для записи логов нужны гарантии, что вы не увидите partial write из другого потока (хотя я почти уверен, что ОС их даёт)?
ilammy
08.11.2019 03:06У меня в файле 1000 байтов логов. Поток А хочет залоггировать 100 байтов. Поток Б — 200 байтов. Я хочу увидеть в итоге файл на 1300 байтов, а в нём в конце две записи, в каком угодно порядке, но отдельно. А не так, что файл будет на 1100 байтов и в нём — половина лога Б.
open() с O_APPEND и write() атомарны при небольших записях (страница памяти). Как это можно сделать через mmap() и запись в память напрямую?
По идее, держать длину лога рядом в атомарной переменной и когда отображаемый кусок подходит в концу, то придётся блокировать запись в мьютексе, пока там файлу увеличивают размер и меняют маппинг в памяти.
Впрочем, на случай недостатка места в отображаемом куске можно условный memcpy() делать в дополнительный буфер-очередь (просто в памяти), а расширение файла вынести в отдельный поток, который после завершения работы подберёт всё из буфера. Лишь бы буфера хватило.
0xd34df00d
08.11.2019 03:16А, ну тут надо идти на компромисс в виде возможно неиспользуемого места. Например, сразу создавать файл на мегабайт (или на гигабайт) и писать в него, пока он не закончится, подменяя указатель на файл, когда он подходит к концу.
По идее, держать длину лога рядом в атомарной переменной и когда отображаемый кусок подходит в концу, то придётся блокировать запись в мьютексе, пока там файлу увеличивают размер и меняют маппинг в памяти.
Это можно сделать так, что дело обойдётся обычным CAS без всяких блокировок во всех реалистичных случаях.
ilammy
08.11.2019 03:24+1Блокировка всё равно понадобится (в коде), пусть и в оптимистичном случае мы на неё никогда не натолкнёмся. У меня есть маппинг на 1 МБ, куда сейчас пишут. Есть уже готовый маппинг ещё на 1 МБ, куда мы переключимся, когда первый закончится. И за то время, пока заполняется новый маппинг, надо успеть подготовить следующий ему на смену. Но если мы не успеем, то придётся идти и ждать.
Плюс должны быть гарантии, что старый маппинг на 1 МБ не освободят, пока в него не дозаписали все, кто увидел его до переключения. Но это, мне кажется, тоже атомиками можно как-то разрулить.
0xd34df00d
08.11.2019 03:25Да, если вы заполняете мегабайт логов быстрее, чем создаётся пустой файл на мегабайт, ему делается mmap и mlock, то да, придётся ждать. Я там рядом об этом писал.
ilammy
08.11.2019 03:44А насчёт падений, кстати, классно. Я почитал, что Линкус действительно даёт гарантию, что отображаемые страницы останутся в страничном кеше и — если только система не упадёт или там питание не отключится — то если поток записал что-то в эту память, то оно (со временем) дойдёт до диска. Даже если процесс пристрелит SIGKILL посреди memcpy().
Это всё предполагает, что страницы реально в физической памяти, для этого и всякие mlock() нужны.
siexo
07.11.2019 13:00А можно еще сравнение с pistache.io?
antoshkka Автор
07.11.2019 13:16Из того что я заметил, пробежавшись по документации и примерам:
* там только http сервер (нет баз данных, логирования и прочего)
* нет асинхронности, точнее её можно реализовать через цепочки фьючеров
response .send(Http::Code::Request_Timeout, "Timeout") .then([=](ssize_t) { }, PrintException());
Другими словами, pistache.io скорее просто библиотека, а не фреймворк. При этом с ней необходимо использовать callbacks. Вы не сможете писать высокопроизводительный код, который будет выглядеть как обычный синхронный код.
mayorovp
07.11.2019 13:27+1Сколько памяти выделяется на стек корутины?
antoshkka Автор
07.11.2019 13:51На порядки меньше чем на поток. По идее, при выкладывании в open source нам надо будет вынести размер в конфиг, чтобы можно было настраивать фреймворк под любые задачи.
DFooz
07.11.2019 13:34чем не подошли существующие опенсорсные решения? Наверняка, вы их тоже пробовали?
Какие получились накладные расходы по ЦПУ и памяти? Какой прирост с существующим опенсорсом?antoshkka Автор
07.11.2019 14:27Отсутствием асинхронных драйверов для всего подряд, разрозненностью интерфейсов, производительностью, отсутствием принятых у нас подходов и классов.
Для нормального фреймворка вам мало собрать популярные библиотеки в один проект. Вам важно подружить их, предоставить консистентные интерфейсы, наладить системы сборки, реализовать возможности которых нет у других. Корутиновый движок — малая часть фреймворка.
Если говорить только про корутиновый движок, то например в Boost.Fibers крайне простой шедулер. Нам он не подходит, а компоненты для построения более сложных шедулеров отсутствуют в Boost. И принести их в Boost нельзя, а значит и заапстримить изменения. В Boost так же нет части примитивов синхронизации, wait list примитивов плохо кастомизируются, что мешает их эффективной реализации и т.д. и т.п… В итоге, от изначального кода практически ничего не остаётся.
thatsme
07.11.2019 15:21Не нашёл в статъе ссылку на исходники и документацию.
Пример маловат, не ясно что под капотом.
На чём реализована работа с file handles (sockets) в плане мултиплексирования (epoll/poll/select)?
Можно-ли добавлять собственные классы (от чего наследоваться), для мултиплексирования?
EDIT:
HTTP/2 поддерживается?iroln
07.11.2019 16:04+2Сейчас мы раздумываем, выкладывать ли фреймворк в open source. Если решим, что да, подготовка фреймворка к открытию исходников потребует достаточно больших усилий.
Они просто похвастались, что у них есть такая штука. Ну OK.
AlexMal
07.11.2019 17:12Можно хотя бы, хоть немного увидеть минимальную версию фреймворка? или хотя бы просто, общую структуру проекта фреймворка? Заявлен достаточно хороший функциональный набор, который бы очень пригодился бы… как раз последнее время думаю, чтобы собрать нечто подобное в единое целое…
robert_ayrapetyan
07.11.2019 18:01Кто слышал про подобное на питоне?
NeoPhix
07.11.2019 19:19asyncio же?
iroln
07.11.2019 19:46asyncio — это совсем не то же самое, о чём идет речь в этой статье и о чём вопрос выше, как мне кажется. Это просто низоуровневая библиотека, механизм если хотите, для поддержки асинхронности и кооперативной многозадачности на уровне языка. В нём нет ни http-сервера/клиента, асинхронных драйверов к БД, быстрого event loop и всего остального о чём тут написано. Чтобы собрать подобный стек понадобится кучка сторонних библиотек поверх asyncio, вроде aiohttp и т.д. И надо заметить, asyncio далеко не идеален. Посмотрите в сторону trio. Это асинхронность с человеческим лицом. :)
arthuriantech
07.11.2019 20:17iroln
07.11.2019 20:21И...? Я знаю про uvloop и асинхронные драйверы для БД. :) И это никак не противоречит тому, что я написал выше. Всё это отдельные библиотеки, которые надо собирать в стек. Тут же представлен фреймворк (не библиотека), который может сразу всё это из коробки.
arthuriantech
07.11.2019 21:18Они предназначены для asyncio и отлично собираются в быстрый стек) Проблема асинхронности и кооперативной многозадачности для Питона в принципе уже решена — asyncio, gevent, stackless в PyPy, так что нужды в очередном асинхронном монофрейморке нет.
А моя реплика не в укор, а просто так. Эта ветка уже пустилась в пространные рассуждения)
GamePad64
08.11.2019 01:30+1Под такое описание может попасть Starlette вместе с FastAPI и databases. Их делают одни и те же люди, оно всё внутри хорошо друг с другом интегрировано.
raiSadam
07.11.2019 19:43- Очень интересно про распределенных блокировки.
- Pocoproject содержит ту же функциональность (кроме распределенных блокировок), но там c++ не современный, так что, если уж в комментариях спрашивали про сравнение с другими фреймворками, то спрошу и я, чем лучше userver по сравнению с poco?
ruzzz
07.11.2019 21:25+1А где в Poco асинхронность через корутины?
raiSadam
07.11.2019 21:35Через корутины асинхронности нет, но есть ActiveObject, ActiveMethod, ActiveResult pocoproject.org/slides/130-Threads.pdf
dr_begemot
08.11.2019 08:57А как корутина понимает, что данный ответ на тот самый tcp запрос?
antoshkka Автор
08.11.2019 10:21Чтобы отправить запрос прежде всего надо установить соединение с удалённой машиной. В итоге от ОС мы получаем сокет — нечто что связывает нас и удалённую машину. Теперь мы можем отправить через сокет байты, и сказать ОС «возобнови вот эту корутину, когда на вот этом сокете появятся данные».
dr_begemot
08.11.2019 10:43Это у вас получается по соединению на запрос — тогда да, вопросов нет…
Получается, что если мы хотим работать с короутинами в одном соединении с множеством запросов — нам придется городить некий дополнительный функционал в протоколе?antoshkka Автор
08.11.2019 11:05Если у вас одно соединение с множеством не связанных друг с другом запросов — читаете сразу N запросов из сокета, порождайте N независимых корутин, в них обрабатываете данные/делаете запросы к базам данных/делаете запросы к другим микросервисам/… в первоначальной корутине ждёте ответов и отправляете их по сокету обратно.
navrocky
08.11.2019 11:44Интересует инфраструктура проекта. У вас СMake?
Какой менеджер пакетов используете и используете ли? (Conan, vcpkg, qpm, что-то свое)
Корутины свои запилили или взяли из Boost?
Очень интересная для меня тема. Сейчас используем Kotlin/JVM в проде для микросервисов, но из-за JVM они не совсем «микро». Go идеален в плане минимальных системных требований, но как язык я его не приемлю. Посматриваю на плюсы в качестве базы для микросервисов, но видны следующие недостатки:
— нет интроспекции
— пакетные менеджеры не распространены и в тех что есть мало библиотек
— нет некоторых нужных библиотек (GraphQL например)antoshkka Автор
08.11.2019 12:15Да, CMake.
Используем скрипты CMake, которые при отсутствии нужных библиотек говорят какие системные команды надо выполнить, чтобы их поставить (например 'No compiler found. Please run `sudo apt install clang++`').
Корутины базируются на библиотеке Boost.Context + lock-free библиотеки + Boost библиотеки для умных указателей и интрузивных контейнеров.
С интроспекцией в C++17 и правда плохо, но для некоторых вещей хорошо подходит библиотека magic_get или constexpr функции с «рукописной помощью» для интроспекции.
navrocky
08.11.2019 12:10Если будете оперсорсить, уберите пожалуйста PascalCase :3
Лучше придерживаться стиля snake_case из std::
Кодстайл это, кажется, одна из основных болей всех проектов, которые хотят заопенсорситься.antoshkka Автор
08.11.2019 12:19PascalCase мы менять не будем потому что многие люди его используют, и недолюбливают snake_case. Тут либо одним не угодишь, либо другим :(
AlexMal
08.11.2019 13:01Проходил курс от Яндекса на Coursera и столкнулся с совершенно непривычным CodeStyle… Названия функций с больших букв… Ну, тут да, кто как привык… просто, как мне кажется snake_case, гораздо читабельнее для функций и названий внутренних переменных, а PascalCase для названий классов.
navrocky
08.11.2019 19:05Действительно всем не угодишь. snake_case мне тоже не нравится, я люблю camelCase, но мой опрос в плюсовых конфах показал, многие считают, что для публичных библиотек лучше придерживаться стиля стандартной библиотеки.
orcy
11.11.2019 11:56+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 Автор
11.11.2019 12:28У них разные характеристики:
* stackfull позволяют пользователям фреймворка не задумываться о внутренней реализации, о co_await, co_return
* stackless быстрее отменять и они расходуют меньше оперативной памяти
Возможно мы когда-нибудь сделаем возможность пользоваться во фреймворке сразу обоими разновидностями корутин. Но это не приоритетная задача.
kroilov
Расскажи, что за магия с отложенным вызовом функции при логировании