воскресенье, 19 февраля 2017 г.

[prog.thoughts] Несколько соображений на тему "А о чем же Модель Акторов?"

В конце своего доклада на C++ CoreHard Winter 2017 я сказал о том, что если кто-то ждет высокой производительности от универсальных акторных фреймворков, то это напрасно. И добавил, что SObjectizer -- это не про производительность, а про другое. Подразумевая при этом не только SObjectizer, но и вообще реализации Модели Акторов, включая Erlang и Akka. C CAF-ом ситуация, имхо, чутка другая -- они сами говорят про high performance, ну и флаг им в руки, ибо замеры говорят сами за себя ;)

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

На мой взгляд, сперва нужно определиться с тем, что понимается под high performance. Думаю, что речь может идти об одной из двух вещей:

  • Во-первых, сокращение суммарного времени выполнения какой-то операции или последовательности операций (обзовем это high throughput). Ну, например, у нас есть N многогигабайтных текстовых файлов, которые нужно распарсить и выделить из них какую-то полезную информацию. Чем быстрее мы сделаем это, тем лучше.

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

    Получается, что в стремлении сократить общее время решения задачи нам нужно:

    • уменьшить количество точек синхронизации между рабочими потоками, чем реже нити обмениваются информацией -- тем лучше;
    • очень тщательно подойти к тому, что и как будет перемещаться между независимыми потоками исполнения (в идеале чем меньше мы передаем информации, тем лучше);
    • тщательно следить за тем, на каких рабочих потоках какие операции выполняются и в каком порядке это происходит (нам не нужны ни простои рабочих потоков, ни частые их переключения).

    Все это, на мой взгляд, идет вразрез с принятым в Модели Акторов подходом fire-and-forget. Т.е. подходом, когда актор, закончив обработку своего сообщение пуляет куда-то новое сообщение и ему глубоко фиолетово куда именно сообщение уйдет, сколько оно будет находиться в дороге, кто и как его обработает.

    Конечно же, Модель Акторов и обмен сам по себе вполне может использоваться для координации действий, выполняемых на рабочих потоках. Ведь, если данные между потоками должны перемещаться (хотя бы в виде сигналов о том, что очередная порция данных готова к обработке), то message passing имеет такое же право на использование для этого, как и другие механизмы (будь то mutex/condition_variables или механизм rendez-vous). Только вот интенсивность обмена сообщениями будет небольшой (ведь каждый обмен сообщениями -- это кража вычислительных ресурсов у основной вычислительной задачи), да и забот о том, кто, когда, кому и как отсылает сообщения и во что это выливается, будет побольше.

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

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

    Головной болью у разработчика будут следующие моменты:

    • распределение задач по рабочим потокам. Например, если прием сигналов и их обработку можно разместить в одном рабочем потоке, то это гораздо лучше, чем если они будут жить в разных потоках. Так же разработчику нужно будет думать о том, сколько вообще ему нужно рабочих потоков. И насколько эффективно ОС будет их обслуживать. И, вероятно, о том, как потоки будут привязаны к ядрам процессора (т.е. где-то может потребоваться, чтобы два рабочих потока жили на одном ядре, дабы минимизировать затраты на передачу данных между ними);
    • типы очередей для обмена информацией. Наверняка очереди будут с преалоцированными хранилищами. Возможно, еще и с хитрыми lock-free реализациями, дабы свести взаимную блокировку читателей и писателей к минимуму. С большой вероятностью, придется уделять пристальное внимание тому, что из себя представляет само сообщение, как и где оно создается (выделяется ли новый блок данных в хипе (что вряд ли), используется ли регион памяти внутри самой очереди), как передается (копируется ли содержимое сообщение, передается ли указатель на сообщение), как сообщение утилизируется после завершения обработки;
    • как свободная рабочая нить, не имеющая ничего для текущей обработки, будет ожидать появления новых задач для себя. Будет ли это busy waiting на спинлоке (а так же какой именно backoff будет использоваться при этом), либо же это будет mutex/condition_variable, либо же это будет что-то еще.

    Тем не менее, здесь у нас больше места для fire-and-forget, чем в случае с чисто вычислительными задачами. Но этот самый fire-and-forget обрастает таким количеством низкоуровневых деталей и заморочек, что сложно говорить о чистом "fire" и о чистом "forget".

Естественно, что в универсальных акторных фреймворках сложно предоставить набор инструментов, который хорошо бы ложился на разные типы задач. Подозреваю, что это же справедливо не только для акторных фреймворков. Если фреймворк специализируется на каком-то другом механизме message-passing-а (будь то CSP или Pub/Sub), там будут такие же проблемы. Ибо, если фреймворк по-умолчанию использует lock-free очереди и busy-waiting, то это может быть хорошо для нагруженных приложений, от которых требуется высокая отзывчивость (т.е. маленькая латентность). Но недопустимо для тяжелых вычислительных задач (т.к. там нельзя допускать, чтобы какая-то освободившаяся на время рабочая нить загружала на 100% вычислительное ядро внутри busy waiting-а).

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

Поэтому акторные фреймворки, можно сказать, обречены на то, чтобы иметь весьма средненькие показатели по сравнению со специализированными инструментами, заточенными под high throughput или low latency. А когда авторы универсальных фреймворков начинают говорить о высокой производительности, то речь идет о том, что в самом фреймворке авторы постарались избавиться от большинства узких мест. И, скажем, диспетчеризация сообщений, в рамках выбранной авторами модели, происходит максимально эффективно. Речь вовсе не о том, что этой производительности хватит для суровых задач из области low latency.


Может показаться, что я только что просто-напросто закопал универсальные акторные фреймворки вообще. Мол, если они не годятся ни для high throughput, ни для low latency, то грош им цена, никому они не нужны, и должны отправиться "на помоечку" (c).

Думаю, однако, что это совершенно не так. Мир не черно-белый, и задачи в нем не делятся по такому простому принципу: либо high throughput, либо low latency. Можно посмотреть, например, на замечательный GUI-фреймворк Qt (именно как GUI это самый лучший фреймворк, с которым мне приходилось иметь дело). Qt отнюдь не рекордсмен по скорости работы. Делать на Qt что-то с очень шустрым отображением -- это то еще занятие, лучше взять что-то полегче, типа FLTK. Но для большого класса задач, связанных с GUI, особенно со сложным GUI, Qt отлично подходит. Поскольку на первое место выходит не скорость работы Qt, а его средства борьбы со сложностью.

Аналогично универсальные акторные фреймворки предназначены для того, чтобы упростить разработчикиу решение сложных задач, в которых high throughput и low latency не являются основными приоритетами.

Грубо говоря, все разговоры о "тормознутости" Erlang-а или Akka окажутся пустыми разговорами, если выяснится, что обработка прикладных сообщений отнимает на порядки больше времени, чем сама передача сообщения. Т.е. фреймворк может обеспечить вам 1M msg/sec при передаче сообщений между двумя нитями и это окажется просто-напросто запредельной производительностью, если обработка сообщения занимает 3.5ms. Что уж говорить о ситуациях, когда обработка сообщений обходится в 35ms. И уж совсем все эти разговоры оказываются смешными, если прикладной обработчик тратит на сообщение 350ms. Что, вообще-то говоря, далеко не редкость.

Огромное преимущество Модели Акторов в том, что она позволяет выделять отдельные сущности, обладающие собственным поведением и общающиеся с внешним миром посредством четких асинхронных интерфейсов. Очевидно, что в run-time у этих сущностей будут далеко ненулевые накладные расходы. Но преимущества от их использования мы получаем не за счет быстро работающего кода, а за счет того, что у нас вообще появляется возможность написать и отдалить свой код в приемлемые сроки и с приемлемой стоимостью. Ибо при проектирование прикладных систем с использованием акторов мы начинаем работать на более высоком уровне абстракции, отвлекаясь от таких непростых на практике вещей, как управление рабочими нитями, синхронизация посредством mutex-ов или spinlock-ов и т.д.

Плюс к тому, интерфейс на основе сообщений -- это своего рода контракт. Причем контракт, который обладает несколькими интересными достоинствами:

  • для контрактов на базе асинхронных сообщений довольно просто строить тестовые окружения. Т.е., если у нас есть актор A, который общается с внешним миром посредством сообщений, и мы хотим его протестировать, то сделать это проще, чем если у нас есть класс A, который связан синхронными вызовами с классами B, C и D. Ведь для тестов мы должны будет сделать так, чтобы B, C и D были интерфесами, за которые нужно спрятать разные реализации, а потом еще и предусмотреть возможность подсунуть A наши тестовые реализации интерфейсов B, C и D. Если же A -- это актор, который отсылает сообщения m1, m2 и m3, а получает сообщения m4 и m5, то кто именно сообщения получает и кто именно отсылает не суть важно;
  • контракт на базе сообщений может очень гибко расширяться со временем. Скажем, мы запросто можем добавить в какое-то сообщение дополнительные поля. Например, было сообщение m1 с тремя полями, стало m1 с четырьмя полями. А вот если класс A дергает у класса B метод m, в котором три формальных параметра, то изменить сигнатуру метода B::m и добавить туда еще один параметр может быть намного сложнее (если вообще возможно). Можно и расширять список сообщений в контракте. Так, со временем актор A может начать отсылать дополнительное сообщение m6 и получать дополнительное сообщение m7. Такое расширение не всегда возможно в случае традиционных классов и интерфейсов.

Ну и не забываем такое достоинство messsage-passing-а вообще и Модели Акторов в частности: отсылка сообщения подразумевает, что обработка сообщения будет происходить на каком-то другом контексте, который не волнует отправителя сообщения. Что дает нам возможность передавать сообщение как другой рабочей нити в этом же процессе, так и другому процессу на этом же узле. Так и процессу на другом узле. И такая возможность "расщепить" один монолитный процесс на группу мелких процессов или даже на группу процессов на разных узлах может оказаться очень важной, если мы по тем или иным причинам сталкиваемся, например, с проблемами надежности. Понятно, что каждый из нас пишет абсолютно надежный, никогда не падающий и не зависающий код. Но, блин, нас окружают просто толпы криворуких "индусов" (причем "индус" -- это уровень умственного развития, а не национальность), массово клепающих ненадежные, зависающие, текущие и падающие third-party библиотеки, которые нам приходится встраивать в свои идеально написанные приложения ;) С эти приходиться как-то жить. И выясняется, что message-passing позволяет жить проще, дешевле и дольше.

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

На мой взгляд, универсальные акторные фреймворки просто замечательно подходят под задачи быстрого прототипирования. Эффективность результирующего кода никого не волнует, а вот скорость появления работающей (или не работающей) модели, простота и скорость ее модификации -- вот это архиважно. И тут взять что-то готовое, вроде Akka или SO-5, набросать прототип, посмотреть, работают ли идеи разработчика или нет, нормальное ли получилось приближение к предметной области или нужно еще покурить бамбук, прогнать какие-то эксперименты и получить результаты для дальнейшего анализа -- все это можно просто брать и делать, не тратя время и ресурсы на выбор подходящей схемы распределения рабочих нитей по ядрам процессора и не колупаясь с lock-free очередями сообщений.

Так что если у вас есть большая и/или сложная задача, борьба со сложностью которой для вас сейчас важнее итоговой скорости работы самой первой реализации... Или же основную сложность представляют очень сжатые сроки появления первой реализации (см.быстрое прототипирование)... В этих случаях я не вижу ничего плохого в использовании универсальных акторных фреймворков. Пусть даже они проигрывают специализированным решениям для областей high throughput или low latency.

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


Резюмирю. Не стоит ждать от Erlang/Akka/SO-5/CAF и пр. столь же высоких показателей производительности, как у специализированных решений, заточенных под области high throughput или low latency. При этом не стоит отказываться от использования Erlang/Akka/SO-5/CAF/etc в других областях, т.к. вы получаете преимущества от работы на более высоком уровне абстракции, а реальной производительности этих инструментов вам может хватить даже с избытком.


PS. Можно обратить внимание, что когда я говорил о специфике задач high throughput и low latency, то в обоих случаях речь шла о том, что разработчику придется заморачиваться рабочими контекстами, на которых выполняется прикладная работа. Это ж не спроста :) Именно потому, что эта часть работы при проектировании прикладных систем на базе подхода message-passing очень важна, мы в SObjectizer предоставляем возможность программисту гибко управлять рабочими контекстами посредством выбора нужных ему диспетчеров.

PPS. Отдельно пять копеек по поводу важности наличия акторных фреймворков для современного C++. Современный C++ дает возможность писать код быстро. До скорости разработки на динамических языках, вроде Ruby или Python, конечно, еще далеко. Но разница в сравнении с C++98 очень значительная. При этом, за счет того, что это C++, скорость даже наспех написанных приложений оказывается весьма высокой. Т.е. собрать сейчас что-то на C++ из "говна и палок" можно если не за пять минут, то за десять. Ну, ладно, за пятнадцать :). Почти как на Python-е или на Java. Но ведь это еще и шустро будет работать :))

Комментариев нет: