среда, 17 мая 2017 г.

[prog.thoughts] По мотивам одного из докладов с C++ CoreHard Spring 2017

На прошедшей на днях конференции C++ CoreHard Spring C++ был один весьма неоднозначный доклад. Это доклад Василия Вяжевича "Модульность и управляемая многопоточность встраиваемых С++ приложений — трудности, проблемы, решения". Доклад, пожалуй, самый слабый из всех докладов этой конференции. Тем не менее, если тема многопоточности в C++ интересна всерьез, то с докладом ознакомиться имеет смысл (если смотреть на youtube, то имеет смысл увеличить скорость до 1.25 или даже до 1.5, иначе слушать будет совсем грустно).

У меня впечатления от доклада, хоть и неоднозначные, но в большей степени негативные.

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

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

Во-первых, многопоточность выбирается для реализации приложения не просто так. По крайнем мере, если мы говорим об опытных разработчиках, которые понимают, что делают, а не хватаются за std::thread просто потому, что только что прочитали об этом в документации. Как я уже неоднократно говорил, многопоточность используется либо для нужд parallel computing, либо для нужд concurrent computing. И в обоих случаях мы получаем сильно разную клиническую картину:

  • в случае parallel computing количество рабочих потоков у нас будет ограничиваться количеством вычислительных ядер. При этом мы заинтересованы в том, чтобы какой-либо обмен данными между рабочими потоками был сведен к самому минимуму. В идеале, рабочие потоки должно работать над совершенно независимыми и непересекающимися наборами данных. Поэтому отладка таких многопоточных приложений не так сложна, как это может показаться. Ведь каждая нить представляет из себя, по сути, небольшой самостоятельный однопоточный процесс, который очень мало взаимодействует с внешним миром. Следовательно, нас мало волнуют ситуации, когда параллельный поток убежал куда-то вперед или немного подотстал. Но самое главное другое: если у нас задача сократить общее время расчета (а для этого и нужен parallel computing), то мы можем использовать либо многопоточность, либо многопроцессность. И для одного, и для другого есть свои показания и протовопоказания. Но смысл в том, что для parallel computing в каких-то условиях выбор в пользу многопоточности будет единственно верным. И это не зависит о того, нравится ли нам многопоточность и умеем ли мы отлаживать многопоточный код;
  • в случае concurrent computing количество рабочих потоков ограничивается лишь степенью нашей распущенности :) Параллельные потоки в concurrent computing мы используем либо для того, чтобы снизить степень влияния одной независимой задачи на другую независимую задачу (например, запись в файл не должна блокировать запрос к БД), либо для того, чтобы упростить реализацию какой-то независимой задачи. Так, какую-то последовательность действий нам может быть проще записать в виде простой последовательности синхронных вызовов, чем делать то же самое посредством конечного-автомата с размазыванием логики между отдельными callback-ами. Проблем с многопоточностью в случае concurrent computing гораздо больше, поскольку:
    • нашим задачам, как правило, придется оперировать над какими-то общими разделяемыми данными. Соответственно, если это мутабельные данные, то у нас начинается головная боль по защите разделяемых мутабельных данных и по обеспечению их консистентности;
    • поскольку часть наших задач будет так или иначе зависеть друг от друга и взаимодействовать друг с другом, то нам важна будет синхронизация по времени в каких-то узловых точках. Т.е. если мы делаем задачу A, а параллельно выполняется задача B, результат которой когда-то потребуется в задаче A, то для нас может быть важно, чтобы задача B не затормозила и была выполнена до того момента, когда внутри A мы обратимся к результатам B.
    И вот все это в совокупности и приводит к тому, что отладка многопоточных приложений превращается в сплошной геморрой. Ведь нам приходится иметь дело и с разделяемыми мутабельными данными, которые могут поменяться в любой момент извне. И с "времянкой", когда параллельно выполняющиеся потоки либо сильно притормаживают, либо уходят далеко вперед.

Так вот проблема доклада Василия Вяжевича в том, что он не обозначил явным образом то, что речь будет идти только о специфике задач concurrent computing. И что представляемое слушателям решение возможно потому, что у задач из категории concurrent computing есть интересное свойство: зачастую им вообще не нужен параллелизм, можно обойтись квазипараллелизмом. Как раз то, что было в ранних версиях Windows 3.*, работавших в реальном режиме процессора.

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

  • использованием какого-то из вариантов объекта с коллбэками. Т.е. есть объект, у которого некий фреймворк дергает тот или иной коллбэк. И работа фреймворка не может продолжиться, пока объект не вернет управление назад. По этому принципу работают самые распространенные акторные фрейворки для C++ (QP/C++, CAF, SObjectizer) и не только для C++ (например, Akka);
  • использование сопрограмм, которые фреймворк может приостанавливать и возобновлять. Например, когда из сопрограммы дергается какой-то вызов самого фреймворка и фреймворк получает возможность заморозить текущую сопрограмму и разморозить какую-то другую сопрограмму. По этому принципу работают, например, Boost.Fiber и Synca для C++.

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

Во-вторых, то, что показывал Василий Вяжевич в своем докладе, на мой взгляд, является отличной иллюстрацией того, о чем я говорил в конце своего доклада "Шишки, набитые за 15 лет использования акторов в C++":

...реализовать в своем фреймворке какую-то идею и показать ее работоспособность — это не так уж и сложно. Можно потратить несколько месяцев труда и получить вполне себе работающий и интересный инструмент. Это все делается на чистом энтузиазме. Буквально: понравилась идея, захотел и сделал.
А вот оснащение того, что получилось, всякими вспомогательными средствами, вроде сбора статистики или трассировки сообщений — это уже скучная рутина, на которую не так то и просто найти время и желание.

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

У вас есть проблема. Вы думаете над ее решением и вам в голову приходит отличная мысль:
-- Да тут же можно использовать регулярные выражения!
Все. Теперь у вас есть две проблемы.

Вот так и с самодельными фреймворками для диспетчеризации задач. Вначале они решают ваши проблемы. Потом сами становятся вашими проблемами. Так что если вы сталкиваетесь с ситуацией, когда для concurrent computing вам нужно множество объектов-тасков с коллбэками, а так же какой-то диспетчер для них, посмотрите сперва по сторонам. Возможно, инструменты вроде QP/C++, CAF, SObjectizer или даже Intel TBB уже содержат то, что вам нужно. Уверяю, посмотреть по сторонам будет быстрее и дешевле, чем налабать на коленке что-то за пару дней, а потом саппортить это на протяжении нескольких лет, а то и дольше.

Кроме того, хоть я и сам без пиетета отношусь к формальному делению на какие-то модели, вроде Actor Model, CSP или что-то еще, но. Не могу не отметить, что когда признаки той или иной модели легко проявляются в некоем фреймворке, то гораздо проще понять, на что способен фреймворк и как этот фреймворк использовать. Так, если мне говорят про Actor Model, то это одни подходы к использованию. Если про CSP-шные каналы, то другие.

У Василия Вяжевича же получилась какая-то гремучая смесь, в которой таски (они же "модели" в его понимании) похожи одновременно и на акторов и на какое-то отдаленное подобие гороутин. А порты -- это что-то среднее между CSP-шным каналом и почтовым ящиком. Т.е. при желании разобраться можно будет. Но т.к. грани сильно размыты, то нужно будет приложить некоторые усилия для того, чтобы понять, как этим пользоваться.


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

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