Pull to refresh

Comments 61

UFO just landed and posted this here
Мне кажется, вы сейчас говорите о модели CSP (зеленые потоки и вот это вот все).

Ну и вопрос не в том, как именно будет выполняться работа в отдельном потоке. А в том, как потоки будут обмениваться информацией. Ведь поток существует не сам по себе, а чтобы взять что-то на себя, сделать это и отдать кому-то результат. И вопрос в том, как взаимодействие организовать, чтобы не получить гонок, дедлоков и, при этом, не тормозить.
UFO just landed and posted this here
Я немного радикальнее считаю.
Так это хорошо. Нет, серьезно. Когда вы уверены, что у вас есть хороший способ решать определенный класс задач, то это здорово.

Но целью статьи было не показать, что какой-то подход лучше для обслуживания сложных HTTP-запросов, а какой-то хуже. Целью было показать наличие разных подходов, которые могут применяться разработчиками помимо работы с threads, mutexes, condition_variables, barriers и т.д. А задача такая для иллюстрации была выбрана потому, что она простая, понятная многим и, что важно, все три показанных подхода вполне применимы для ее решения. Тогда как data flows и reactive programming, рассказ о которых в доклад бы не поместился, для решения такой задачи были бы вряд ли уместны.
Дедлоки отлаживать проще простого — аттач дебаггером к стоящему серверу, распечатка стеков всех тредов, и через полчаса все понятно.
Видимо, мне везло сильно меньше, чем вам.
UFO just landed and posted this here
На самом деле там можно идти и еще дальше. Приостанавливать на запись в CSP-шный канал можно только на время. Если даже после паузы место в канале не появилось, можно предпринимать какую-то попытку очистки канала: выбрасывать самое старое сообщение, например. Или игнорировать новое. У нас в SObjectizer-е есть возможность этим управлять.
Насколько понимаю, все зависит от реализации. Можно и у акторов ввести ограничение на размер ящика
Основной вопрос будет в том, что делать при попытке отсылки сообщения актору с полным ящиком.
Это вполне можно отнести к graceful degradation, а решение выносить на уровень выше. Т.е. что лучше для бизнеса стараться все обслужить, обслуживать не всех или обслуживать всех, но понизить качество предоставляемой услуги.
а решение выносить на уровень выше
А вы могли бы раскрыть мысль? Вот один актор делает send другому актору. И внутри send-а выясняется, что почтовый ящик получателя полон. Решение нужно принимать внутри send-а. О каком «уровне выше» вы говорите?
На уровень выше, это на уровень архитектуры. Мы можем притормозить писателя, а можем выкинуть ошибку и прекратить обработку входящих запросов на уровне сервиса, например для того, что бы другой инстанс сервиса его обработал.
Тем не менее, вопрос остается открытым. Актор A отсылает сообщение актору B и внутри send-а выясняется, что почтовый ящик актора B полон. Что делать send-у?
а можем выкинуть ошибку и прекратить обработку входящих запросов на уровне сервиса
Мне кажется, что вы немного путаете уровни. У вас некий сервис реализован как совокупность общающихся акторов. Чтобы прекратить обработку сервисом входящих запросов вам нужно, чтобы какие-то акторы из его реализации распознали и среагировали на переполнение почтового ящика одного (или не одного) актора. И вопрос реализации вашего сервиса будет в том, как вы это будете делать (точнее, это будет один из вопросов).

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


какие-то акторы из его реализации распознали и среагировали на переполнение почтового ящика одного

Разве? Если send вернул актору A ошибку, то актор А может отправить сообщение супервизору(если использовать терминологию из Erlang), а тот уже может приостановить подключение новых соединений.


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

Разве?
Да. И вы сами именно об этом и пишете:
Если send вернул актору A ошибку, то актор А может отправить сообщение супервизору
Это и есть распознавание проблемы и реакция на нее.

На практике, правда, есть важный нюанс: результат send-а, как правило, никто не контролирует.
Во всех actor-фреймворках результат send'а куда-нибудь, да валится. А значит, вопрос контроля — это вопрос волеизъявления/знаний разработчика. Если бы send'ы неконтролируемо могли бы потеряться, говорить о детерминированности actor-процессов не приходилось бы.
Во всех actor-фреймворках результат send'а куда-нибудь, да валится.
Простите, не понял этой фразы. Речь о том, что результат (успех/ошибка) доступен тому, кто вызвал send?

Если так, то да. Наверное, нет фреймворков, в которых нельзя узнать результат send-а.
А значит, вопрос контроля — это вопрос волеизъявления/знаний разработчика.
А это уже масло-масляное. Очевидно, что разработчик должен контролировать то, что он делает.
Если бы send'ы неконтролируемо могли бы потеряться, говорить о детерминированности actor-процессов не приходилось бы.
Здесь опять не понял.

Я говорю о том, что на практике мало кто пишет код так, чтобы проверять результат каждого send-а и предпринимать соответствующую реакцию на каждый неудачный send.
Апологеты rx считают что необязательно для каждой сессии держать свой тред.
И их полку прибывает, в последнее время.

А дедлоки отлаживать действительно не сложно (когда есть физический доступ к оборудованию с дедлоком). Сложнее их фиксить, а еще сложнее добиться чтобы их не было.
Поэтому и расцвели теоретики по actors, immutables, pure functions — чтобы дедлоков в принципе не было.
все нижеследующие примеры не привязаны к какому-то конкретному фреймворку или библиотеке. Любые совпадения в именах API-ных вызовов являются случайными и непреднамеренными.

Шикарная формулировка, откровенно порадовали))))
Спасибо за статью, лет двадцать назад пришлось писать велосипед для модели, в этой классификации «актор». Последняя модель, таск, читается с трудом и не позволяет конфигурировать связи в runtime из внешнего конфига, хотя конечно ее можно адаптировать(написать на ней актор или CSP :). Модель «актор» позволяет конфигурировать связи в runtime.В моих приложениях это критично.
Спасибо за статью
Спасибо за отзыв!
Последняя модель, таск, читается с трудом и не позволяет конфигурировать связи в runtime из внешнего конфига
Имхо, Task-based подход имеет смысл там, где задачи формируются «по месту», в зависимости от того, что пришло на вход. И когда при обработке разных входящих воздействий можно строить разные наборы тасков.

То, о чем говорите вы — это уже из области task graph-ов, которые реализованы в Intel TBB. Но рассказ об этом подходе в доклад бы уже не поместился.
Таски просто находятся на слишком низком уровне чтобы там можно было что-то конфигурировать. Но их можно добавить к любому решению для конфигурирования связей в рантайме.
Спасибо за статью!
Спасибо за отзыв!
Ждём теперь статью по Data flow!
Не факт, не факт. Была мысль сделать продолжение доклада на следующем CoreHard-е, но не известно, хватит ли сил и времени.
Хотел бы добавить вот что по акторам и CSP — есть добротные книги, раскладывающие эти темы по полочкам, я их читал (не буду говорить, что эти книги — лучшие, но, по моему мнению, добротные). По акторам — книга автора QP (Miro Samek, «Practical UML Statecharts in C/C++»). По CSP — одноименная с названием подхода книга его изобретателя (Tony Hoar), она была переведена на русский году в 86-м примерно, «Взаимодействующие последовательные процессы».
По акторам — книга автора QP (Miro Samek, «Practical UML Statecharts in C/C++»).
Не читал.
«Взаимодействующие последовательные процессы»
ИМХО (здесь не зря ИМХО большими буквами, все очень и очень субъективно) эта книга хороша для студентов профильных ВУЗов, для исследователей в области computer science, для специалистов по верификации программ. Как эта книга может помочь при проектировании конкретного прикладного решения на базе CSP, для меня лично не понятно. И да, CSP-book — это и есть тот самый «матан», без которого хотелось в докладе обойтись в разговоре про CSP.
Хорошая статья, но к сожалению не упомянут такой замечательный инструмент, как boost::asio, а ведь он отлично подходит для решения такого рода задач.
Asio замечательный инструмент для асинхронного IO. Но для организации конкурентности в коде он слишком низкоуровневый.
И еще одна очень интересная особенность Task-based похода — это отмена задач если что-то пошло не так. В самом деле, допустим, мы создали 150 задач, выполнили первые 10 из них и поняли, что все, дальше работу продолжать нет смысла. Как нам отменить 140 оставшихся? Это очень и очень хороший вопрос :)

Еще один похожий вопрос — это как подружить задачи с таймерами и таймаутами.

Насколько я понимаю, Task-подход, это то, как работают современные rx-фреймворки.
А в современных rx-фреймворках все хорошо, с таймерами, таймаутами, отменами, контролем ошибок, и пр.

Следовательно, Actors/CSP и Tasks не столько противостоят друг другу, сколько дополняют друг друга. Actors/CSP могут использоваться для декомпозиции задачи и определения интерфейсов между компонентами. А Tasks затем могут использоваться в реализации конкретных компонентов.


Они действительно взаимодополняют друг друга.
Стоит только отметить, что Actors/CSP могут физически быть на разном железе. Тогда как описанный Task-подход, это то как устроена утилизация многоядерности на одной железке. (шарить треды в общем то некоторые ОС умеют, но массового применения это не нашло)
Нет, rx использует абстракцию потока событий, а не асинхронной задачи. Выглядит оно, конечно, похоже: и там, и там колбек, который кто-то дернет когда придет время — но разница между потоком и задачей принципиальная. Потоки нужно явно останавливать, задача остановится сама. Для потока нельзя сделать co_await, для задачи — можно.
Что есть поток в конкретной среде разработки — вещь вполне конкретная.
А что есть задача? Чем она имплементируется? Наверно некоторая субстанция, которая что-то делает в отдельном потоке. Ой, опять вернулись к потоку.

При этом я понимая, что async/await это языковые конструкции. А rx-фреймворки — набор библиотек, возможно даже без async/await (ну нет их в конкретной языковой платформе, что поделать) Но чем они принципиально отличаются с точки зрения общей механики. И там, и там отдельные потоки, и там, и там, присутствует в том или ином виде event loop, даже если он не называется event loop.
И чем отличается асинхронная задача, от потока событий, который порождает ровно одно событие?
потока событий, который порождает ровно одно событие?
Тем, что это оксюморон. Ну вроде «живой мертвец».

Лишено практического смысла. Нет, конечно в камментах на Хабре об этом можно потрепаться, но зачем это использовать при разработке софта?
Ну что же вы так категорично.
Вот есть у вас обращение к удаленному сервису. Обернули вы вызов в Observable, и так как удаленный сервис возвращает один результат, ваш Observable тоже возвращает один результат — одно событие. И такой подход не является biased, а вполне практичный, никаких живых мертвецов.
Обернули вы вызов в Observable
Обернули что именно? Кто, где и как будет вызов осуществлять?
Обернули что именно? — > вызов внешнего удаленного сервиса
Кто, где и как будет вызов осуществлять? -> вызов дергает какойнибудь subscriber
Простите, но либо я не понимаю того, что вы предлагаете, либо вы предлагаете следующее:

Когда мне нужно сделать какую-то операцию (например, загрузить изображение), то я создаю некий observable объект. Скажем, ImageDownloadTask.

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

Этот ImageDownloadTaskPerformer получает ImageDownloadTask как входящее событие и выполняет загрузку изображения.

После чего, как я понимаю, должен возникнуть еще один объект, скажем, DownloadImageInstance, на возникновение которого должен еще кто-то среагировать.

Правильно? Если нет, то раскройте свою мысли пошире, пожалуйста.

Задача — это абстракция некоторого значения, которое будет доступно в будущем. Видите, я обошелся без использования слова "поток".


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

Набором гарантий. Если вам дан на вход произвольный поток событий — вы не можете сказать сколько их там будет. Соответственно, нужно или обрабатывать общий случай — или писать хрупкий код. Если же вы на вход получили задачу — вы всегда можете считать что событие будет ровно одно.

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

Вот вам реализация задачи на javascript: github.com/taylorhakes/promise-polyfill/blob/master/src/index.js
Попробуйте найти тут хоть что-нибудь про потоки…
Приписка «или какой-то его аналог. если в платформе нет честных threads.», как раз и была для таких случаев. ;)
Тогда найдите там какой-нибудь аналог потоков.
Насколько я понимаю, Task-подход, это то, как работают современные rx-фреймворки.
Я понимаю совсем по другому. RX-подход — это когда у нас есть поток событий и этот поток валится в одну точку входа (будь то задача, нить, сопрограмма или еще что-то). И уже в этой точке происходит обработка очередного события. Возможно, с использованием знаний о предыдущих событиях (т.е. с использованием состояния обработчика).

Поскольку есть поток событий, то в этот поток элементарно добавляются и события от таймера.

В случае с task-based подходом у нас нет потока событий. Есть конкретный таск, который должен выполниться на конкретном контексте при возникновении конкретных условий (в простейшем случае когда будет готов future, на который таск «повесили»). И, что важно, таски создаются «по месту», для обработки конкретной операции. Количество, типы и взаимосвязи между тасками определяются в зависимости от этой самой конкретной операции.
Стоит только отметить, что Actors/CSP могут физически быть на разном железе. Тогда как описанный Task-подход, это то как устроена утилизация многоядерности на одной железке.
Речь шла только об использовании Actors/CSP для упрощения многопоточности. Возможность создавать распределенные приложения на базе Actors/CSP выходит за рамки этого разговора.

Отличная статья, я, вроде бы, наконец понял, что такое каналы в Го :)


В целом, callback hell решается средствами языка (async/await в C#, TypeScript, Javascript).

Отличная статья
Спасибо!
callback hell решается средствами языка (async/await в C#, TypeScript, Javascript).
У меня, к сожалению, нет опыта работы с async/await и этими ЯП, не могу прокомментировать.

Примерно так это выглядит (код нельзя использовать в продакшене).


var task1 = new Task(() => DoStuff());
var task2 = new Task(x => DoStuff2(x));
task1.ContinueWith(x => DoStuff2(x.Result)).Wait();

А вот на асинк-авейт


var x = await DoStuff();
await DoStuff(x);

Компилятор за тебя строит стейт-машину и прочее. Это имеет свои минусы, но в целом ок.

Идею я, в общих чертах, понимаю. Но не имея опыта не берусь утверждать, что async/await действительно спасает от Callback Hell в сложных случаях.

Спасает. В C# не надо юзать ContinueWith и Wait совместно с await, велика вероятность словить дедлок на SyncronizationContext. Поэтому, если ты используешь await, у тебя везде await и код выглядит как синхронный.

Возможно, мы по-разному понимаем callback hell. Я говорю о ситуациях, когда образуется вложенность лямбд глубиной в 3-4-5 и более уровней. Т.е. когда есть ситуации вроде:
async([]{
   ...
   return async(do_something([=]{
      ...
      return async(do_something_else([=](auto x) {
         if(x) return async(do_another_thing([=]{ ... }));
         else return async(do_different_thing([=]{ ... }));
      }));
   }));
});

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

Т.е. можно получить ту же лапшу, но намотанную в другом направлении.

Ну можно, конечно. Но лапшу можно получить на ровном месте просто так. Можно и на коллбэках ведь писать аккуратный код =)

На коллбэках лапшу — это проще простого. Поинт в рассказе и был как раз в том, что когда мы ударяемся слишком сильно в task-based подход, то в существующем варианте C++ мы попадаем в тот самый callback hell. И ссылка на статью Полухина была дана как раз для того, чтобы показать, как добавление в язык async-ов (пусть даже в виде stackless coroutines) может изменить ситуацию в лучшую сторону.

Но проблема в том, что в С++ здесь еще непаханное поле. И как оно все будет нужно еще посмотреть, попробовать, набить шишек.

Откуда возьмется обратный порядок? приведенный вами код будет выглядеть как-то так:


async([] -> void {
    ...
    co_await do_something();
    ...
    auto x = co_await do_something_else();
    if (x) {
        co_await do_another_thing();
        ...
    } else {
        co_await do_different_thing();
        ...
    }
});
auto x = co_await wrap_async(do_something_else);
Вы решили, что x — это результат вычисления do_something_else. У меня в коде в do_something_else передается лямбда, которой кто-то отдаст x в качестве параметра.
Инструкция co_await как раз и превращает продолжение текущего метода в подобную лямбду.
Я не разбирался еще с короутинами из грядущего C++20, но мне кажется, что здесь есть какое-то непонимание. Представьте, что do_somethig_else получает лямбду как аргумент. Делает что-то, например, подготавливает http-запрос, вызывает выполнение этого запроса, а переданную аргументом лямбду вешает в качестве обработчика результата запроса. Возвращать do_something_else будет результат инициации http-запроса (ID запроса или что-то в этом духе).
Соответственно, результат wrap_async-а для do_something_else не может использоваться для выбора между do_another_thing и do_different_thing.

А зачем do_something_else будет возвращать ID запроса, если мы пишем код на задачах, а не на колбеках? Пусть возвращает задачу!


Ну а если это какая-то внешняя функция, то написать для нее обертку будет нетрудно:


decltype(auto) do_something_else_wrapped() {
    struct awaiter {
        bool await_ready() { return false; }
        void await_suspend(coroutine_handle<void> handle) {
            do_something_else([=] (bool x) { 
                this.result = x;
                handle.resume();
            });
        }
        bool await_result() { return result; }

        bool result;
    };
    return awaiter{};
}
А зачем do_something_else будет возвращать ID запроса
Чтобы этот ID можно было куда-то сохранить, например, в список активных в данный момент запросов?
Так вы ж его никуда не сохраняете…

Кстати, а что у вас за волшебная функция async, перегрузки которой принимают то лямбду, то ID запроса?
Кстати, а что у вас за волшебная функция async, которая принимает ID запроса?
Это следствие того, что писался абстрактный пример в вакууме, да еще после переключения с совершенно другой темы. Там следовало бы написать что-то вроде:
async(do_something_else, [=](auto x){...});
чтобы лямбда шла аргументом в do_something_else.
Ну, тогда ничего не меняется: вы никуда не сохраняете ID запроса, а значит и возвращать его нет необходимости.

Значит, нужно либо переписать do_something_else, либо обернуть в do_something_else_wrapped как я написал выше, после чего спокойно вызывать через co_await.
Жаль, что столько времени тратится на неудачный пример. За ваши примеры с использованием co_await спасибо. Надо будет найти время и поразбираться с темой плюсовых stackless coroutines подробнее.
А вообще, возможно, вы правы и это я напутал.
Sign up to leave a comment.

Articles