12. Кодирование параллельных вычислений
Если ваше приложение использует несколько потоков или процессов, следует минимизировать количество совместно используемых объектов, где это только можно (см. рекомендацию 10), и аккуратно работать с оставшимися.
Работа с потоками — отдельная большая тема. Данная рекомендация оказалась в книге, потому что эта тема очень важна и требует рассмотрения. К сожалению, одна рекомендация не в силах сделать это полно и корректно, поэтому мы только резюмируем несколько наиболее важных положений и посоветуем обратиться к указанным ниже ссылкам за дополнительной информацией. Среди наиболее важных вопросов, касающихся параллельных вычислений, такие как избежание взаимоблокировок (deadlock), неустойчивых взаимоблокировок (livelock) и условий гонки (race conditions).
Стандарт С++ ничего не говорит о потоках. Тем не менее, С++ постоянно и широко применяется для написания кода с интенсивным использованием многопоточности. Если в вашем приложении потоки совместно используют данные, на это следует обратить особое внимание.
•
•
•
•
•
Заметим, что сказанное выше применимо независимо от того, является ли тип некоторым строковым типом, контейнером STL наподобие vector
или некоторым иным типом. (Мы заметили, что ряд авторов дают советы, из которых вытекает, что стандартные контейнеры представляют собой нечто отдельное. Это не так — контейнер представляет собой просто объект другого типа.) В частности, если вы хотите использовать компоненты стандартной библиотеки (например, string
или контейнеры) в многопоточной программе, проконсультируйтесь сначала с документацией разработчика используемой вами стандартной библиотеки, чтобы узнать, как именно следует пользоваться ею в многопоточном приложении.
При разработке собственного типа, который предназначен для использования в многопоточной программе, вы должны сделать те же две вещи. Во-первых, вы должны гарантировать, что различные потоки могут использовать различные объекты вашего типа без использования блокировок (заметим, что обычно тип с изменяемыми статическими данными не в состоянии обеспечить такую гарантию). Во-вторых, вы должны документировать, что именно должны сделать пользователи для того, чтобы безопасно использовать один и тот же объект в разных потоках. Фундаментальный вопрос проектирования заключается в том, как распределить ответственность за корректное выполнение программы (без условий гонки и взаимоблокировок) между классом и его клиентом. Вот основные возможности.
•
• Push
, Pop
). В общем случае заметим, что этот вариант применим, только если вы знаете две вещи.
Во-первых, вы должны заранее знать о том, что объекты данного типа практически всегда будут совместно использоваться разными потоками; в противном случае вы просто разработаете бесполезную блокировку. Заметим, что большинство типов не удовлетворяют этому условию; подавляющее большинство объектов даже в программах с интенсивным использованием многопоточности не разделяются разными потоками (и это хорошо — см. рекомендацию 10).
Во-вторых, вы должны заранее быть уверены, что блокировка на уровне функций обеспечивает корректный уровень модульности, которого достаточно для большинства вызывающий функций. В частности, интерфейс типа должен быть спроектирован в пользу самодостаточных операций с невысокой степенью детализации. Если вызывающий код должен блокировать несколько операций, аfind
, возвращающий верный ответ, который становится неверным до того, как вы им воспользуетесь, или пользователь напишет код if (с.empty()) с.push_back(x);
(другие примеры можно найти в [Sutter02]). В таких случаях вызывающая функция должна выполнить внешнюю блокировку на время выполнения всех отдельных вызовов функций-членов, так что отдельные блокировки для каждой функции-члена оказываются ненужной расточительностью.
Итак, внутренняя блокировка связана с открытым интерфейсом типа. Она становится применима только тогда, когда отдельные операции типа являются сами по себе завершенными; другими словами, когда уровень абстракции типа растет и выражается и инкапсулируется более точно (например, как у очереди производитель/потребитель по отношению к обычному контейнеру vector
). Объединение примитивных операций для образования более крупных общих операций — этот подход требуется для того, чтобы обеспечить возможность простого вызова функции с большим внутренним содержанием. В ситуациях, когда комбинирование примитивов может быть произвольным и вы не можете определить разумный набор сценариев использования в виде одной именованной операции, имеются две альтернативы. Можно воспользоваться моделью функций обратного вызова (т.е. вызывающая функция должна вызвать одну функцию-член, передавая ей задание, которое следует выполнить, в виде команды или объекта-функции; см. рекомендации с 87 по 89). Второй метод состоит в некотором способе предоставления вызывающему коду возможности блокировки в открытом интерфейсе.