LINUX.ORG.RU

Организация асинхронного ввода/вывода на основе boost::asio и boost::context

 , ,


4

6

Прошу раскритиковать статью. Указание недочетов и каверзные вопросы категорически приветствуются :) http://log0div0.blogspot.ru/2015/06/boostasio-boostcontext.html

Написал бы на английском, я б коллеге отослал. Он как раз этим сейчас занимается.

UVV ★★★★★ ()

Ты слово сопрограммы не слышал?

Begemoth ★★★★★ ()
acceptor(SrvSkt) ->
  {ok, Clnt} = gen_tcp:accept(SrvSkt),
  spawn(fun() -> acceptor(SrvSkt) end),
  servant(Clnt).
anonymous ()
Ответ на: комментарий от UVV

Написал бы на английском

удваиваю - тяжело читать

fornlr ★★★★★ ()

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

Iron_Bug ★★★★ ()

познавательно, двигайтесь дальше

anonymous ()
Ответ на: комментарий от log0div0

к лучшему. очередной говеный вброс.

anonymous ()
Ответ на: комментарий от mix_mix

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

Iron_Bug ★★★★ ()
Ответ на: комментарий от mix_mix

Всегда были сопрограммы, а потом вдруг стали корутины. С чего вдруг?

anonymous ()

В своем сравнении с Synca вы пишете:

Не лучше ли распределять работу между потоками на более высоком уровне, на уровне бизнес логики. Это позволит локализовать те места, где потоки взаимодействуют между собой. Меньше проблемных мест - меньше ошибок и меньше синхронизации, а значит больше производительность.

Правильно ли я понимаю, что ваша библиотека вообще не берет на себя задачу мапинга короутин на рабочие нити? Вы отдаете это на откуп boost::asio?

Ну и еще забавно читать рассуждения об эффективности использования shared_ptr и трате тактов на операции с атомиками, а затем видеть в коде:

std::list<Coro*> _coros;

eao197 ★★★★★ ()

Ну фик знает. Про корутины прочитал. Интересно.

Но проблемы с асинхронным методом сильно надуманы, так что вначале возникало ощущение что писал неосилятор.

anonymous ()

Не. Зря ты взял акцент на то, что корутины это прям панацея. Это просто другой инструмент и он не заменит async_XXXXX.

Да и примеры кода с корутинами неудачные. Сначала говоришь, что асинхронный способ - ад и израиль, а потом создаешь еще большой ад.

anonymous ()

Я либо что-то не понял, либо реализация однопоточная что ли? Яндексовая поделка хоть и более сложная, но она хоть поддерживает кучу потоков, а тут если сверху прикручивать получится не проще яндексовой.

ReanGD ()

Не думаю, что с архитектурной точки зрения есть смысл переносить непосредственно реализацию протокола и работу с сокетами в корутины, а вот RPC на уровне приложения - вполне. Отличная статья, спасибо.

anonymous ()
Ответ на: комментарий от eao197

Правильно ли я понимаю, что ваша библиотека вообще не берет на себя задачу мапинга короутин на рабочие нити? Вы отдаете это на откуп boost::asio?

Я предлагаю маппить на рабочие нити не корутины, а задачи. Например, если я пишу сервер, то равномерно распределяю соединения по нитям.

Ну и еще забавно читать рассуждения об эффективности использования shared_ptr и трате тактов на операции с атомиками, а затем видеть в коде:
std::list<Coro*> _coros;

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

log0div0 ()
Ответ на: комментарий от anonymous

Но проблемы с асинхронным методом сильно надуманы, так что вначале возникало ощущение что писал неосилятор.

Да, я согласен, они действительно выглядят надуманными. Ровно до тех пор, пока не огребешь от них по полной.

Образцово-показательный пример: https://github.com/zaphoyd/websocketpp

Эта библиотека реализует протокол веб-сокетов поверх boost::asio. По моим оценкам, из 20000 строк - 18000 строк решают как раз перечисленные проблемы. И за полгода мне так и не удалось реализовать на этой библиотеке действительно стабильный сервер. Вечно вылезали проблемы с многопоточностью.

log0div0 ()
Ответ на: комментарий от anonymous

Не. Зря ты взял акцент на то, что корутины это прям панацея. Это просто другой инструмент и он не заменит async_XXXXX.

Согласен с Вами полностью.

log0div0 ()
Ответ на: комментарий от log0div0

Я предлагаю маппить на рабочие нити не корутины, а задачи. Например, если я пишу сервер, то равномерно распределяю соединения по нитям.

А что у вас есть задача? В посте были Coro и CoroPool. Задач не заметил.

Вы предлагаете использовать очередь с приоритетами, основанную на куче?

Нет, этого не предлагаю. Предлагаю подумать, во что выливается хранение данных в std::list ;) Там же на каждый элемент отдельная аллокация делается. Т.е. вы будете выделять отдельный блок памяти под один-единственный указатель.

Если да, то действительно ли это будет эффективней на маленьких очередях?

Мерить нужно. Не удивлюсь, если std::deque или даже std::vector для хранения небольшого количества указателей будет выгоднее std::list. Даже при условии удаления элементов из середины.

eao197 ★★★★★ ()
Ответ на: комментарий от anonymous

Да и примеры кода с корутинами неудачные. Сначала говоришь, что асинхронный способ - ад и израиль, а потом создаешь еще большой ад.

Постараюсь найти более удачные примеры. Может быть у Вас есть что-то на примете?

log0div0 ()
Ответ на: комментарий от ReanGD

Я либо что-то не понял, либо реализация однопоточная что ли? Яндексовая поделка хоть и более сложная, но она хоть поддерживает кучу потоков, а тут если сверху прикручивать получится не проще яндексовой.

Совершенно верно, однопоточная. Кстати, Григорий Демченко отписался в комментариях к статье как раз по этому поводу. Свои плюсы, свои минусы.

log0div0 ()
Ответ на: комментарий от eao197

А что у вас есть задача?

В моём случае (написание сервера) задача - это прием и отправка данных для отдельно взятого клиента.

Там же на каждый элемент отдельная аллокация делается.

Да, я знаю. Эту проблему решают пулы памяти. И как Вы верно заметили - рассуждения по этому поводу бессмысленны до проведения стресс тестов. К сожалению, таким тестам я уделял мало времени. Мой косяк.

log0div0 ()
Ответ на: комментарий от log0div0

В моём случае (написание сервера) задача - это прием и отправка данных для отдельно взятого клиента.

Но как будут соотноситься задачи, короутины и пулы короутин?

Задача создает пул короутин, которые реализуют бизнес-логику этой задачи? И все это прибито гвоздями к одной рабочей нити?

eao197 ★★★★★ ()
Ответ на: комментарий от eao197

Задача создает пул короутин, которые реализуют бизнес-логику этой задачи? И все это прибито гвоздями к одной рабочей нити?

Да. Дело в том, что кол-во клиентов исчисляется десятками тысяч. А кол-во потоков равно кол-ву ядер, т.е. в пределах десятков. Поэтому обработка одного клиента на нескольких нитях - бессмысленна. Ресурсы процессора и так используются полностью.

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

log0div0 ()
Ответ на: комментарий от log0div0

Disclaimer. Нижеследующий текст не с целью осуждения, а токмо размышлений вслух для.

Просто если у вас короутины не мигрируют с одной рабочей нити на другую, то не очень понятно, зачем нужно было вообще свой огород на базе Boost.Context создавать. Почему не представлять обработчиков запросов в виде обычных объектов, привязанных к IO-операциям? Или чем плохи были короутины, которые уже есть в Boost.Asio из коробки?

Вы ссылаетесь выше на проект, в котором сложно разобраться из-за использования «голого» Boost.Asio. Но создавая свою библиотеку вы поверх не простой механики Boost.Asio навешиваете еще и непростую механику своих короутин. Вам, может быть, на таком наслоении писать будет удобно. Но насколько просто будет затем разбираться с этим всем?

eao197 ★★★★★ ()
Ответ на: комментарий от eao197

Почему не представлять обработчиков запросов в виде обычных объектов, привязанных к IO-операциям? Или чем плохи были короутины, которые уже есть в Boost.Asio из коробки?

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

Но насколько просто будет затем разбираться с этим всем?

Это покажет время. Прошло несколько месяцев после внедрения - полёт нормальный. Стало действительно приятно работать с кодом и, как я указал в статье, время его поддержки уменьшилось в разы. Если Вы имеете ввиду других разработчиков - то тут несомненный проигрыш. В моей компании корутины не использует никто. И, пока что, я не могу сказать, во сколько по времени обойдётся другим разработчикам изучение моего кода. НО! У меня есть сервер, написанный в асинхронной манере и тот же самый сервер, написанный на корутинах. Оба сервера содержат тысячи строк, но второй в разы короче и имеет заметно более читаемый код.

log0div0 ()
Ответ на: комментарий от log0div0

Я чувствую, что не раскрыл эту тему.

Да, из написанного тема иерархичности совершенно не ясна. Что это, зачем это, почему альтернативы хуже — не понятно.

Ну и осталось непонятно, чем вот это:

void HandleConnection(Http2Connection connection) {
  connection.doHandshake();
  CoroPool coroPool;
  coroPool.exec([&] { ReadLoop(connection) });
  coroPool.exec([&] { WriteLoop(connection) });
  Coro::current()->yield();
});
Отличается от обычных коллбэков?

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

eao197 ★★★★★ ()
Ответ на: комментарий от eao197

Отличается от обычных коллбэков?

Формально - ничем. Семантически - коллбэк это функция, вызываемая ПОСЛЕ выполнения какого-то действия. Здесь же представлены функции, которые как раз и содержат те самые действия, которые надо выполнить. Параллельно. Они используются даже в синхронном программировании - когда создается новый тред. Как ещё можно распараллелить код? На ум приходит только fork. Возможно, можно было бы замаскировать код выше под fork, но нужно ли?

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

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

log0div0 ()
Ответ на: комментарий от eao197

Хочу поблагодарить Вас за проявленный интерес и хорошие вопросы. Спасибо!

log0div0 ()
Ответ на: комментарий от log0div0

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

Чтобы выполнять эти функции параллельно, нужно иметь два разных рабочих контекста. Но у вас же один CoroPool будет работать только на одной рабочей нити. Т.е. в лучшем случае речь пойдет о квазипараллельности.

Вызов таких функций строго иерархически упорядочен, поэтому лапши не получается.

Ну это как посмотреть. Вам кажется, что лапши из коллбэков нет. Но это неудивительно, т.к. вы автор и вы делали инструмент под себя. А человек со стороны видит вот что:

void main() {
  Acceptor acceptor(endpoint);
  CoroPool coroPool;
  while (true) {
    auto socket = acceptor.accept();
    coroPool.exec([&] {...}); // callback #1
  }
}

void HandleConnection(Http2Connection connection) {
  connection.doHandshake();
  CoroPool coroPool;
  coroPool.exec([&] {...}); // callback #2
  coroPool.exec([&] {...}); // callback #3
  Coro::current()->yield();
});

void ReadLoop(Http2Connection& connection) {
  CoroPool coroPool;
  while (true) {
    auto stream = connection.accept();
    coroPool.exec([&] {...}); // callback #4
  }
}
Тут еще спасает, что нет миграции короутин с потока на поток. Если бы еще и это было, то вообще туши свет :)

Ну и непонятно, чем ваши короутины лучше короутин, которые идут в Boost.Asio из коробки.

eao197 ★★★★★ ()
Ответ на: комментарий от log0div0

Хочу поблагодарить Вас за проявленный интерес и хорошие вопросы.

Рано еще. Может вскоре вы будете пытаться прогнать меня из этой темы... :)

eao197 ★★★★★ ()

если бы не ограничение в использовании исключений, то годно

anonymous ()
Ответ на: комментарий от eao197

Ну это как посмотреть. Вам кажется, что лапши из коллбэков нет. Но это неудивительно, т.к. вы автор и вы делали инструмент под себя. А человек со стороны видит вот что

Путаете понятие callback с лямбдами. Тут не происходит возврат управления, а происходит старт новой задачи.

Аналогия с std::thread. Для создания потока вызывается функция (лямбда) для выполнения работы.

Callback в данном контексте - это когда вместо

acceptor.accept()
зовется
acceptor.accept([] { /* handle on accept */ });

anonymous ()
Ответ на: комментарий от anonymous

Путаете понятие callback с лямбдами.

Это мощная фраза :)

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

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

Вот у него в main на каждый принятый клиентский сокет создается новая короутина (я подозреваю, что эта короутина будет корневой для обработки данного сокета). Далее внутри этой короутины создаются еще две новые короутины — одна для чтения, вторая для записи. С этими двумя новыми короутинами более-менее понятно. Но тут возникает вопрос: а что будет с корневой короутиной? Завершит ли она свою работу, т.к. по сути она уже не нужна? Или продолжит.

Если продолжит, то что она будет делать. И как будет взаимодействовать со своими дочерними короутинами.

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

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

eao197 ★★★★★ ()
Ответ на: комментарий от Pavval

замещение ввозимых товаров-с, господин анонимус.

anonymous ()

насчёт живыхпримеров для подобных систем.
мы писали софт для промышленной автоматизации. примеры из обработки объектов на конвейере как раз подходят под эту схему. каждый объект проходит по тракту, при этом он должен в строго определённые временные интервалы проходить под датчиками, его могут перенаправить на другую ветку конвейера (например, для обработки брака) и т.д. каждая ветка конвейера представляет собой конечный набор мелких отрезков между контрольными точками (разнообразными событиями с харда), по которым перемещается объект. в представленной схеме ветка конвейерв - это сопрограмма. мелкие события на ней - это аналог входов в сопрограмму, которые заносятся в стек. при этом, понятное дело, объектов на конвейере много и они все проходят одновременно по разным веткам и под разными датчиками, но на каждой ветке последовательность событий по каждому объекту одинакова. то есть, сопрограмма может выполняться в нескольких экземплярах, но они могут быть одновременно в разных состояниях.
мы писали конечные автоматы гораздо проще. наши программисты верхнего уровня называли этот метод циклограммами. я не знаю, откуда они взяли это название, но смысл был очень простой: каждая последовательность была представлена конечным автоматом (набор состояний), который элементарно реализовывался через замаскированный для удобства define'ами switch с множеством case'ов-состояний и нескольких макросов-функций для удобства указания прыжков от состояния к состоянию. получалась функция, поделенная на отрезки кода, разделённые этими макросами. отрезок выполняется, сопрограмма «засыпает» до сигнала от датчиков. таких функций было несколько (на каждую ветку тракта, пока объект не перенаправят на следующую ветку). когда новый объект попадал на ветку тракта, включалась соответствующая функция, ей устанавливалось нулевое состояние и обрабатывался её кусок до очередного макроса. в коде каждого куска было указано, куда должен прыгать обработчик после её завершения (за счёт макросов это было очень просто и наглядно, код был легко читаемый и дополнять и править его, при необходимости, было весьма просто). при вызовах в функции передавался указатель на отслеживаемый объект (так как каждый объект должен был быть тщательно отслеживаемым).
минусы, которые были: один поток. мы делали это на хардваре, поэтому со скоростью проблем не было. но такая схема не везде подходит. в бусте скорость далеко не всегда на высоте. они делают кроссплатформу и иногда жертвуют оптимальностью. не скажу наверняка, но подозреваю, что переключение по сигналам из boost::asio может сожрать всю оптимизацию от переключения стека сопрограмм. врочем, это надо проверять.
и да, ещё там где-то в тексте есть очепятка «мьюткес».

Iron_Bug ★★★★ ()
Ответ на: комментарий от log0div0

даже если это состояние включает в себя состояние тысяч клиентов

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

anonymous ()
Ответ на: комментарий от Iron_Bug

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

Pavval ★★★★★ ()

Прошу раскритиковать статью.

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

В каждой статье вижу это утверждение и ни разу не были предоставлены тесты. Хочу увидеть код и результаты замеров (таймингов) 4-х тестов:

1. Первый вызов (код не в кеше процессора) любого достаточно легкого системного вызова.

2. Повторный вызов того же syscall (код в кэше),

3, 4. То же для обычной функции.

Функцию можно взять типа такой:

void func() __attribute__ ((noinline)){
  asm volatile("nop" ::: "memory");
}
А для системного вызова нужно подобрать что-то достаточно быстрое, но такое, чтобы было переключение в ядро (бывают syscall без переключения контекста) и при этом, чтоб glibc не кешировало результат и второй запрос тоже шел с переключением контекстов. Время нужно измерять с помощью rdtsc (нулевым тестом полезно сделать два вызова rdtsc и вывести разницу).
extern "C" {
__inline__ uint64_t rdtsc()
{
    uint32_t tickl, tickh;
    __asm__ __volatile__("rdtsc":"=a"(tickl),"=d"(tickh));
    return ((uint64_t)tickh << 32)|tickl;
}
}
Все измерения нужно проводить на голом железе без виртуализации. Если сможешь еще провести измерения скорости переключения корутин, то будет совсем отлично.

Результаты измерений будут сильно расходиться с утверждением «Переключение корутин осуществляется значительно быстрее переключения тредов».

Код и результаты измерений в студию!

anonymous ()
Ответ на: комментарий от eao197

А че, прикольно - колбеки, но каждый с собственным стеком.

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

anonymous ()
Ответ на: комментарий от anonymous

Результаты измерений будут сильно расходиться с утверждением «Переключение корутин осуществляется значительно быстрее переключения тредов».

Условия для тестов не до конца определены. Нужно сравнивать переключение хотя бы 5K короутин и стольких же тредов. А потом идти по нарастающей — 10K, 15K и т.д.

Выгода же от использования короутин в том, что они позволяют записать бизнес-логику обработки одного конекшена (что актуально для ТС) в виде простого последовательного кода. Код этот автоматически прерывается при обращении к IO и автоматически возобновляется при получении результатов IO. При этом короутины легче тредов, что и позволяет создавать их в огромных количествах.

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

Посему остается, как минимум, два актуальных вопроса к ТС:

1. Чем же его не устроили родные короутины из Boost.Asio?

2. Оправданным ли будет создание нескольких короутин (связанных отношениями родитель-потомок) для обработки каждого конекшена? Ведь одно дело иметь 10K коннектов и 10K обслуживающих их короутин. Другое дело — 10K коннектов и N*10K короутин (где N>2).

eao197 ★★★★★ ()
Ответ на: комментарий от anonymous

Ты, конечно в курсе

Да, в курсе Согласен - недостаток. Но серебрянных пуль не бывает.

log0div0 ()
Ответ на: комментарий от eao197

Условия для тестов не до конца определены. Нужно сравнивать переключение хотя бы 5K короутин и стольких же тредов. А потом идти по нарастающей — 10K, 15K и т.д.

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

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

А чуть выше ты писал, что там обычные (не слегка не обычные) callback :-)

При этом короутины легче тредов

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

Другое дело — 10K коннектов и N*10K короутин (где N>2).

А вот про это я сам написал. Организация асинхронного ввода/вывода на основе boost::asio и boost::context (комментарий)

1. Чем же его не устроили родные короутины из Boost.Asio?

Чем его не устроила Synca? Там корутины более похожи на синхронный код.

anonymous ()
Ответ на: комментарий от eao197

Ключевая фишка подхода ТС — это иерархия короутин.

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

void DoSomeAsyncOperation() {
  auto coro = Coro::current();
  ioService.post([=] {
    coro->resume(); 
  });
  coro->yield();
}

anonymous ()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.