Как стать автором
Обновить

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

auto task = Async([socket = std::move(new_socket)]() -> coro_future {

Насколько я помню, в c++ корутинах есть глюки с захватом переменных в capture list.

Что делать, если нужен мьютекс с таймаутом?

Где-то я слышал (и согласен с этим), что мьютекс с таймаутом - это антипаттерн.

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

Насколько я помню, в c++ корутинах есть глюки с захватом переменных в capture list.

Можете подробнее рассказать, о каких именно глюках речь?

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

Что касается выделения памяти - то всё зависит от вашего аллокатора. Современные аллокаторы внутри как правило lock-free или имеют крайне низкий contention на мьютексах. А вот в пользовательском коде асинхронный мьютекс крайне полезен - особенно когда у пользователей фреймворка получается критическая секция приличных размеров. Проверено обстрелами :-)

Да, это место в корутинах аналогично поведению при использовании std::thread. Чтобы захватить объект по копии надо использовать [*this]

Однако с корутинами этих проблем наоборот становится сильно меньше, по сравнению с обычным асинхронным кодом, так как меньше callbacks.

Код с корутинами:

auto data = socket.receive();
this->process(data);
socket.send(data);
this->log_ok();


Аналогичный код без корутин:

socket.receive(
  [*this, socket](std::vector<unsigned char> data) {
    process(data);
    socket.send(data, [*this]() {
      this->log_ok();
    });
  });

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

Да, именно так

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

НЛО прилетело и опубликовало эту надпись здесь

С базами данных тоже можно работать асинхронно. Тогда ваш пример при использовании асинхронности с callback наподобие boost::asio будет выглядеть так:

socket.receive(
  [*this, socket](std::vector<unsigned char> data) {
    database->execute("SELECT hash FROM passwords WHERE id=$1", data,
      [*this](auto answer) {
        socket.send(answer[0][0] ? "OK" : "FAIL");
      });
  });

Поток не будет блокироваться на 200ms. Вместо этого, пока не получен ответ от базы данных, поток будет обрабатывать другие запросы. А когда ответ от базы данных будет получен, callback [*this](auto answer) будет перемещён в очередь готовых к выполнению задач и выполнен.

Если добавить корутины, то вся подкапотная магия с асинхронностью остаётся, но код начинает выглядеть как синхронный:

auto data = socket.receive();
auto answer = database->execute("SELECT hash FROM passwords WHERE id=$1", data);
socket.send(answer[0][0] ? "OK" : "FAIL");
НЛО прилетело и опубликовало эту надпись здесь

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

А процессор с 96 ядрами на какой стороне? На клиенте? или обрабатывает?
Если он на сервере обслуживает БД и эти 200мс почти полностью эффективно тратит на реальную работу - то да, ~400мс и выйдет (не считая нюансов, что эффективная работа = ~100% загрузка ядра, и если 96 ядер одновременно нагрузить на 100% - там уже может другая автоматика сработать. Например, сбросить частоту, или запаузить некоторые ядра. Или система электропитания хорошая и ей ничего не стоит справиться с таким пиком).

А если на клиенте, который отправляет параллельно 97 запросов, и через 200мс приходят ответы, а клиенту в это время делать нечего - там может быть хоть одно ядро первой "малинки". 97 потоков на нём разрулятся средствами ОС.

Смысл превращать потоки ОС в файберы (таски, корутины, контексты - как удобнее) возникает, когда речь не о 97 по 200мс, а, скажем, 97К по 200мкс.

Проблема возникает на порядок раньше. У неё даже название есть - C10k, так что уже при подходе к 10к RPS вам нужен асинхронный движок (с корутинами или без).

НЛО прилетело и опубликовало эту надпись здесь

Ну тут интересно помоделировать.

Если все по 10мс - то ничего необычного, всё переварится.

А если большой разброс - уже возникают интересности.

Например, прилетела пачка "тяжёлых" запросов, каждый из которых выполняется 500мс. Скажем, штук 200. Потом пачка мелких, по 1мс. Штук 500. Но в целом, в окне, скажем 3 секунд все укладываются в 12800rps.

Вот тут-то оно и всплывает. Если все ядра ждут на длинных запросах - в это время они не будут обрабатывать другие. И с этого момента модель "1 поток - 1 запрос" начинает сдавать. До определённого момента поможет простое увеличение числа потоков (и ваши 128 на 32 физических ядра - это уже шаг в этом направлении). Но тут уже примерно похоже на описанное в статье: рабочий код очень простой, однопоточный. А "под капотом" ОС видит, что потоку нечего делать и вытесняет его другим потоком, у которого появились данные и его надо пробудить. И вся "жесть" остаётся там, в чёрном ящике.

Однако с определённого числа потоков станут видны ограничения. Во-первых, в top станет видно, что процессор всё больше и больше занимается "какой-то фигнёй" (и помимо переключения потоков там будет ещё светиться "ныряние" в ядро и назад для чтения/ожидания кусочков данных). Во-вторых, у каждого потока есть стек. Вроде пустяки, 2мб (по умолчанию). Но вот 500 потоков просто "за будь здоров" уже съели гиг...

Переписывать программу на асинхронную - можно (но в наше время многие считают, что дешевле поставить ещё один сервер, чем платить за разработку). Логика станет сложнее. Например, запросы, потом мультиплексирование коннектов через какой-нибудь poll/epoll... впрочем, вам наверное хватит и старого доброго select. Потом надо позвать нужные колбэки, чтобы они продолжили работу... Линейность логики кода теряется, это уже не "спросили - дождались - отправили ответ", а паззл из кусочков "получили очередные 1300 байт, осталось ещё 4К, а пока дальше спать... И да, надо сохранить контекст перед сном!".

И вот тут как раз кастомный мультиплексор на корутинах позволяет вернуть логику назад, в исходные "1 поток - 1 запрос". Только это будет уже не честный поток, а "таск". А под капотом, в отдельной папке с исходниками будет весь трэш, раскрывающий новую суть "синхронно читаем ответ БД".

Код остаётся таким же простым, однопоточным и синхронным (но только на внешний взгляд).

Мы примерно так сделали у себя. Сперва заменили синхронное чтение простеньким циклом "пока не вычитали, сколько надо, читаем, сколько дали, потом спим в poll, пока ещё не прилетит". А потом poll заменили на самописный - с той же сигнатурой, что системный, но уже на корутинах. Вся линейность и простота логики осталась. Единственная особенность, которая появилась - задача после такого "синхронного" чтения скорее всего окажется в другом потоке, и это надо учитывать (например, не использовать системный tls. Не мешать корутины с системными примитивами синхронизации, потому что вроде простой паттерн "захватили мьютекс, сделали дело, отпустили мьютекс" начинает неиллюзорно доставлять, если в момент "сделали дело" мы ушли в другой поток...

НЛО прилетело и опубликовало эту надпись здесь
НЛО прилетело и опубликовало эту надпись здесь
НЛО прилетело и опубликовало эту надпись здесь

Всё так. Корутины и асинхронность - это не серебряная пуля для всех проблем, а лишь возможность экономить CPU на IO-bound приложениях.

В случае баз данных фреймворки зачастую предоставляют другие способы улучшить это место. Например userver использует бинарный протокол для общения с PostgreSQL (не тот, что по умолчанию в libpq), автоматическое превращение всех запросов в prepared statements, динамическое управление пулами; на подходе использование порталов и pipelining. Разумееется это всё полностью не убирает бутылочное горлышко, но позволяет его немного расширить

НЛО прилетело и опубликовало эту надпись здесь
А Exception влияют на работу коурутин?
Или там все без них только?

Исключения и корутины друг другу не мешают

Очень интересная статья.

Антон, а подскажите, пожалуйста, вот есть микросервис, обрабатывающий асинхронно вызовы rpc и выполняющий запросы к БД также асинхронно. Насколько целесообразно реализовывать многопоточность, пусть даже на корутинах, lock-free и т.д. по сравнению с пулом однопоточных микросервисов и балансировщиками перед ними? Есть ли профит с точки зрения производительности?

Это очень интересный вопрос.

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

На практике так жить зачастую не получается из-за несовершенства ОС (не для всего есть асинхронные вызовы) и из-за несовершенства драйверов к различным базам данных и протоколов передачи данных. В том же Python3 драйвер для gRPC порождает свой собственный поток.

С идеальным однопоточным режимом есть и недостаток - да, можно немного оптимальнее загрузить CPU, но страдает latency. Если к вам приходит запрос, обработку которого можно разложить на две несвязанные задачи по 400ms, то в однопоточном режиме вы вернёте ответ через 400ms+400ms. В многопоточном режиме задачи обработаются на разных потоках и ответ вы вернёте через 400ms.

Может быть стоит сделать backoff в вашем мьютексе? Глядишь и не все так плохо будет на 4 потоках и выше.

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

Допустим 50% времени поток проводит в критической секции. Соответственно на системе с 100 ядрами вы получите ускорение 1÷(0.5 +(0.5÷100)) ~= 2. Всего в 2 раза быстрее будет работать ваш код на 100 ядерной машине, чем на одноядерной.

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

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

Прошу прощения за наверное такой нубский вопрос, на С++ писал давно и немного, но почему тут lock_waiters_ написан без this->
void Mutex::lock() {
 ...
  impl::MutexWaitStrategy wait_manager(lock_waiters_, current);
...}

Это вроде поле структуры:
struct Mutex {
...
 private:
  std::atomic<Coroutine*> owner_{nullptr};
  WaitList lock_waiters_;
};

потому что this-> никогда не был обязательным

Долго не мог понять, при чём здесь userver. Потому что читал его как user-ver...

А насколько легко подружиться с gRPC, если не погружаться в потроха и не переписывать гугловый движок?

Вроде навскидку кажется, (если натягивать на такую модель мьютекса и делать по аналогии) - для корутины это как Sleep. В в AfterASleep запульнули данные в grpc и ждём ответа (как-то уже средствами grpc как "чёрного ящика", синхронно или асинхронно). А как получили ответ экспозим его в BeforeAwake и пробуждаем корутину. Кажется должно получиться...

Тут тоже, конечно, смущает факт, что "я тут оптимальное число потоков в тредпуле выделил, а какой-то гугл в своём чёрном ящике ещё чего-то откусил", но что поделать...

В целом приблизительно всё так и дружится.

Но помимо интеграции с корутиновым движком нужны ещё метрики, логи, плагин для кодогенерации gRPC C++ кода, документация... В общем, работы на пару-тройку месяцев

Зарегистрируйтесь на Хабре, чтобы оставить комментарий