Все права на текст принадлежат автору: Энтони Уильямс.
Это короткий фрагмент для ознакомления с книгой.
Параллельное программирование на С++ в действии. Практика разработки многопоточных программЭнтони Уильямс

Энтони Уильямс Параллельное программирование на С++ в действии Практика разработки многопоточных программ

Ким, Хью и Ирен.

Предисловие

С идеей многопоточного программирования я столкнулся на своей первой работе после окончания колледжа. Мы занимались приложением, которое должно было помещать входные записи в базу данных. Данных было много, но все они были независимы и требовали значительной предварительной обработки. Чтобы задействовать всю мощь нашего десятипроцессорного компьютера UltraSPARC, мы организовали несколько потоков, каждый из которых обрабатывал свою порцию входных данных. Код был написан на языке С++, с использованием потоков POSIX. Ошибок мы наделали кучу — многопоточность для всех была внове — но до конца все-таки добрались. Именно во время работы над этим проектом я впервые услыхал о комитете по стандартизации С++ и о недавно опубликованном стандарте языка С++.

С тех мой интерес к многопоточному программированию и параллелизму не затухает. Там, где другим видятся трудности и источник разнообразных проблем, я нахожу мощный инструмент, который позволяет программе использовать всё наличное оборудование и в результате работать быстрее. Позднее я научился применять эти идеи и при наличии всего одного процессора или ядра, чтобы улучшить быстроту реакции и повысить производительность, — благодаря тому, что одновременная работа нескольких потоков дает программе возможность не простаивать во время таких длительных операций, как ввод/вывод. Я также узнал, как это устроено на уровне ОС и как в процессорах Intel реализовано контекстное переключение задач.

Тем временем интерес к С++ свел меня с членами Ассоциации пользователей С и С++ (ACCU), а затем с членами комиссии по стандартизации С++ при Институте стандартов Великобритании (BSI) и разработчиками библиотек Boost. Я с интересом наблюдал за началом разработки библиотеки многопоточности Boost, а когда автор забросил проект, я воспользовался шансом перехватить инициативу. С тех пор разработка и сопровождение библиотеки Boost Thread Library лежит в основном на мне.

По мере того как в работе комитета по стандартизации С++ наметился сдвиг от исправления дефектов в существующем стандарте в сторону выработки предложений для нового стандарта (получившего условное название С++0х в надежде, что его удастся завершить до 2009 года, и официально названного С++11, так как он наконец был опубликован в 2011 году), я стал принимать более активное участие в деятельности BSI и даже вносить собственные предложения. Когда стало ясно, что многопоточность стоит на повестке дня, я по-настоящему встрепенулся — многие вошедшие в стандарт предложения по многопоточности и параллелизму написаны как мной самим, так и в соавторстве с коллегами. Я считаю большой удачей, что таким образом удалось совместить две основных сферы моих интересов в области программирования — язык С++ и многопоточность.

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

Благодарности

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

Далее я хочу поблагодарить коллектив издательства Manning, благодаря которому эта книга появилась на свет: Марджана Баджи (Marjan Васе), главного редактора; Майкла Стивенса (Michael Stephens), его заместителя; Синтию Кейн (Cynthia Kane), моего редактора-консультанта; Карен Тегтмейер (Karen Tegtmeyer), выпускающего редактора; Линду Ректенвальд (Linda Recktenwald), редактора; Кати Теннант (корректора) и Мэри Пирджис, начальника производства. Без их стараний вы не читали бы сейчас эту книгу. Я хочу также поблагодарить других членов комитета по стандартизации С++, которые подавали на рассмотрение материалы, относящиеся к многопоточности: Андрея Александреску (Andrei Alexandrescu), Пита Беккера (Pete Becker), Боба Блэйнера (Bob Blainer), Ханса Бема (Hans Boehm), Бимана Доуса (Beman Dawes), Лоуренса Кроула (Lawrence Crowl), Петера Димова (Peter Dimov), Джеффа Гарланда (Jeff Garland), Кевлина Хэнни (Kevlin Henney), Ховарда Хиннанта (Howard Hinnant), Бена Хатчингса (Ben Hutchings), Йана Кристоферсона (Jan Kristofferson), Дуга Ли (Doug Lea), Пола Маккинни (Paul МсKenney), Ника Макларена (Nick McLaren), Кларка Нельсона (Clark Nelson), Билла Пью (Bill Pugh), Рауля Силвера (Raul Silvera), Герба Саттера (Herb Sutter), Детлефа Вольмана (Detlef Vollmann) и Майкла Вонга (Michael Wong), а также всех тех, кто рецензировал материалы, принимал участие в их обсуждении на заседаниях комитета и иными способами содействовал оформлению поддержки многопоточности и параллелизма в С++11.

Наконец, хочу выразить благодарность людям, чьи предложения позволили заметно улучшить книгу: д-ру Джейми Оллсопу (Jamie Allsop), Петеру Димову, Ховарду Хиннанту, Рику Моллою (Rick Molloy), Джонатану Уэйкли (Jonathan Wakely) и д-ру Расселу Уиндеру (Russel Winder). Отдельное спасибо Расселу за подробные рецензии и Джонатану, который в качестве технического редактора, тщательно проверил окончательный текст на предмет наличия вопиющих ошибок. (Все оставшиеся ошибки — целиком моя вина.) И напоследок выражаю признательность группе рецензентов: Райану Стивенсу (Ryan Stephens), Нилу Хорлоку (Neil Horlock), Джону Тейлору младшему (John Taylor Jr.), Эзре Дживану (Ezra Jivan), Джошуа Хейеру (Joshua Heyer), Киту С. Киму (Keith S. Kim), Мишель Галли (Michele Galli) Майку Тянь-Чжань Чжану (Mike Tian-Jian Jiang), Дэвиду Стронгу (David Strong), Роджеру Орру (Roger Orr), Вагнеру Рику (Wagner Rick), Майку Буксасу (Mike Buksas) и Бас Воде (Bas Vodde). Также спасибо всем читателям предварительного издания, которые нашли время указать на ошибки и отметить места, нуждающиеся в уточнении.

Об этой книге

Эта книга представляет собой углубленное руководство по средствам поддержки многопоточности и параллелизма в новом стандарте С++, от базового использования классов и функций из пространств имел std::thread, std::mutex и std::async до сложных вопросов, связанных с атомарными операциями и моделью памяти.

Структура книги
В первых четырех главах описываются различные библиотечные средства и порядок работы с ними.

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

В главах 6 и 7 начинается изучение программирования на более высоком уровне, с примерами использования базовых средств для построения сложных структур данных — с блокировками (глава 6) и без блокировок (глава 7).

В главе 8 эта линия продолжается: даются рекомендации по проектированию многопоточных программ, рассматриваются аспекты, влияющие на производительность, и приводятся примеры реализации различных параллельных алгоритмов.

Глава 9 посвящена средствам управления потоками, рассматриваются пулы потоков, очереди работ и прерывание операций.

Тема главы 10 — тестирование и отладка: типы ошибок, методы их отыскания, способы тестирования и так далее.

В приложениях вы найдете краткое описание некоторых языковых средств, добавленных в новый стандарт и имеющих отношение к многопоточности; детали реализации библиотеки передачи сообщениями, упомянутой в главе 4, и полный справочник по библиотеке С++11 Thread Library.

На кого рассчитана эта книга
Если вы пишете многопоточный код на С++, то эта книга для вас. Если вы пользуетесь средствами многопоточности из стандартной библиотеки С++, то здесь вы найдете руководство по основным вопросам. Если вы работаете с другими библиотеками многопоточности, то описанные рекомендации и приемы все равно могут оказаться полезным подспорьем.

Предполагается владение языком С++ на рабочем уровне, по предварительное знакомство с новыми языковыми средствами необязательно — они описаны в приложении А. Также не требуются знания или опыт работы в области многопоточного программирования, хотя их наличие было бы плюсом.

Как пользоваться этой книгой
Если раньше вы не писали многопоточных программ, то я рекомендую читать книгу последовательно от начала до конца, опустив, быть может, кое-какие детали из главы 5. Глава 7 опирается на материал главы 5, поэтому если вы пропустите главу 5, то отложите также чтение седьмой главы.

Если вам не доводилось использовать новые языковые средства, вошедшие в стандарт С++11, то имеет смысл с самого начала бегло просмотреть приложение А, чтобы понимать приведенные в тексте примеры. Впрочем, в основном тексте упоминания о новых средствах графически выделены, так что, встретив что-то незнакомое, вы всегда можете обратиться к приложению.

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

Даже после того как вы освоите библиотеку С++ Thread Library, приложение D все равно останется полезным, потому что в нем легко найти детали использования каждого класса и функции. Время от времени вы, наверное, будет заглядывать и в основные главы, чтобы освежить в памяти порядок работы с той или иной конструкцией или взглянуть на пример кода.

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

Исходный код всех примеров можно скачать с сайта издательства по адресу www.manning.com/CPlusPlusConcurrencyinAction.

Требования к программному обеспечению
Чтобы приведенный в этой книге код работал без модификаций, понадобится версия компилятора С++ с поддержкой тех вошедших в стандарт С++11 средств, которые перечислены в приложении А. Кроме того, нужна стандартная библиотека многопоточности С++ (Standard Thread Library).

На момент написания этой книги единственный известный мне компилятор, поставляемый с библиотекой Standard Thread Library, — это g++, хотя в предварительную версию Microsoft Visual Studio 2011 она также входит. Что касается g++, то первая реализация основных возможностей библиотеки многопоточности была включена в версию g++ 4.3, а впоследствии добавлялись улучшения и расширения. Кроме того, в g++ 4.3 впервые появилась поддержка некоторых новых языковых средств С++11, и в каждой новой версии она расширяется. Дополнительные сведения см. на странице текущего состояния реализации С++11 в g++[1].

В составе Microsoft Visual Studio 2010 также имеются некоторые новые средства из стандарта С++11, например лямбда-функции и ссылки на r-значения, по реализация библиотеки Thread Library отсутствует.

Моя компания, Just Software Solutions Ltd, продает полную реализацию стандартной библиотеки С++11 Standard Thread Library для Microsoft Visual Studio 2005, Microsoft Visual Studio 2008, Microsoft Visual Studio 2010 различных версий g++[2]. Именно эта реализация применялась для тестирования примеров из этой книги.

В библиотеке Boost Thread Library[3], протестированной на многих платформах, реализовал API, основанный на предложениях, поданных в комитет по стандартизации С++. Большинство приведенных в книге примеров будут работать с Boost Thread Library, если заменить std:: на boost:: и включить подходящие директивы #include. Но некоторые возможности в библиотеке Boost Thread Library либо не поддерживаются вовсе (например, std::async), либо называются по-другому (например, boost::unique_future).

Автор в Сети
Приобретение книги «Параллелизм на С++ в действии» открывает бесплатный доступ к закрытому форуму, организованному издательством Manning Publications, где вы можете оставить свои комментарии к книге, задать технические вопросы и получить помощь от автора и других пользователей. Получить доступ к форуму и подписаться на список рассылки можно на странице www.manning.com/CPlusPlusConcurrencyinAction. Там же написано, как зайти на форум после регистрации, на какую помощь можно рассчитывать, и изложены правила поведения в форуме.

Издательство Manning обязуется предоставлять читателям площадку для общения с другими читателями и автором. Однако это не означает, что автор обязан как-то участвовать в обсуждениях; его присутствие на форуме остается чисто добровольным (и не оплачивается). Мы советуем задавать автору хитроумные вопросы, чтобы его интерес к форуму не угасал!

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

Об иллюстрации на обложке

Рисунок на обложке книги «Параллельное программирование на С++ в действии» называется «Традиционный костюм японской девушки». Репродукция взята из четырехтомного «Собрания костюмов разных пародов», напечатанного в Лондоне между 1757 и 1772 годом. Это издание, включающее изумительные раскрашенные вручную гравюры на меди с изображениями одежды пародов мира, оказало большое влияние на дизайн театральных костюмов. Разнообразие рисунков позволяет составить наглядное представление о великолепии костюма на Лондонской сцене свыше 200 лет назад. Костюмы, исторические и того времени, позволяли познакомиться с традиционной одеждой людей, живших в разное время в разных странах, и тем самым сделать их ближе и понятнее лондонской театральной публике.

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

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

Глава 1. Здравствуй, параллельный мир!

В этой главе:
■ Что понимается под параллелизмом и многопоточностью.

■ Зачем использовать параллелизм и многопоточность в своих приложениях.

■ Замечания об истории поддержки параллелизма в С++.

■ Структура простой многопоточной программы на С++.

Для программистов на языке С++ настали радостные дни. Спустя тринадцать лет после публикации первой версии стандарта С++ в 1998 году комитет по стандартизации С++ решил основательно пересмотреть как сам язык, так и поставляемую вместе с ним библиотеку. Новый стандарт С++ (обозначаемый С++11 или С++0х), опубликованный в 2010 году, несёт многочисленные изменения, призванные упростить программирование на С++ и сделать его более продуктивным.

К числу наиболее существенных новшеств в стандарте С++11 следует отнести поддержку многопоточных программ. Впервые комитет официально признал существование многопоточных приложений, написанных на С++, и включил в библиотеку компоненты для их разработки. Это позволит писать на С++ многопоточные программы с гарантированным поведением, не полагаясь на зависящие от платформы расширения. И как раз вовремя, потому что разработчики, стремясь повысить производительность приложений, все чаще посматривают в сторону параллелизма вообще и многопоточного программирования в особенности.

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

Итак, что же я понимаю под параллелизмом и многопоточностью?

1.1. Что такое параллелизм?

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

1.1.1. Параллелизм в вычислительных системах

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

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

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

На рис. 1.1 показан идеализированный случай: компьютер, исполняющий ровно две задачи, каждая из которых разбита на десять одинаковых этапов. На двухъядерной машине каждая задача может исполняться в своем ядре. На одноядерной машине с переключением задач этапы той и другой задачи чередуются. Однако между ними существует крохотный промежуток времени (на рисунке эти промежутки изображены в виде серых полосок, разделяющих более широкие этапы выполнения) — чтобы обеспечить чередование, система должна произвести контекстное переключение при каждом переходе от одной задачи к другой, а на это требуется время. Чтобы переключить контекст, ОС должна сохранить состояние процессора и счетчик команд для текущей задачи, определить, какая задача будет выполняться следующей, и загрузить в процессор состояние новой задачи. Не исключено, что затем процессору потребуется загрузить команды и данные новой задачи в кэш-память; в течение этой операции никакие команды не выполняются, что вносит дополнительные задержки.

Рис. 1.1. Два подхода к параллелизму: параллельное выполнение на двухъядерном компьютере и переключение задач на одноядерном

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

Рис. 1.2. Переключение задач на двухъядерном компьютере

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

1.1.2. Подходы к организации параллелизма

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

А теперь представьте, что всех разработчиков собрали в одной комнате. У них появилась возможность обсуждать между собой проект приложения, рисовать на бумаге или на доске диаграммы, обмениваться мыслями. Содержать придется только один офис и одного комплекта документации вполне хватит. Но есть и минусы теперь им труднее сконцентрироваться и могут возникать проблемы с общим доступом к ресурсам («Ну куда опять запропастилось это справочное руководство?»).

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

Параллелизм за счет нескольких процессов
Первый способ распараллелить приложение — разбить его на несколько однопоточных одновременно исполняемых процессов. Именно так вы и поступаете, запуская вместе браузер и текстовый процессор. Затем эти отдельные процессы могут обмениваться сообщениями, применяя стандартные каналы межпроцессной коммуникации (сигналы, сокеты, файлы, конвейеры и т.д.), как показано на рис. 1.3. Недостаток такой организации связи между процессами в его сложности, медленности, а иногда том и другом вместе. Дело в том, что операционная система должна обеспечить защиту процессов, так чтобы ни один не мог случайно изменить данные, принадлежащие другому. Есть и еще один недостаток — неустранимые накладные расходы на запуск нескольких процессов: для запуска процесса требуется время, ОС должна выделить внутренние ресурсы для управления процессом и т.д.

Рис. 1.3. Коммуникация между двумя параллельно работающими процессами

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

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

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

Рис. 1.4. Коммуникация между двумя параллельно исполняемыми потоками в одном процессе

Благодаря общему адресному пространству и отсутствию защиты данных от доступа со стороны разных потоков накладные расходы, связанные с наличием нескольких потоков, существенно меньше, так как на долю операционной системы выпадает гораздо меньше учетной работы, чем в случае нескольких процессов. Однако же за гибкость разделяемой памяти приходится расплачиваться — если к некоторому элементу данных обращаются несколько потоков, то программист должен обеспечить согласованность представления этого элемента во всех потоках. Возникающие при этом проблемы, а также средства и рекомендации по их разрешению рассматриваются на протяжении всей книги, а особенно в главах 3, 4, 5 и 8. Эти проблемы не являются непреодолимыми, надо лишь соблюдать осторожность при написании кода. Но само их наличие означает, что коммуникацию между потоками необходимо тщательно продумывать.

Низкие накладные расходы на запуск потоков внутри процесса и коммуникацию между ними стали причиной популярности этого подхода во всех распространенных языках программирования, включая С++, даже несмотря на потенциальные проблемы, связанные с разделением памяти. Кроме того, в стандарте С++ не оговаривается встроенная поддержка межпроцессной коммуникации, а, значит, приложения, основанные на применении нескольких процессов, вынуждены полагаться на платформенно-зависимые API. Поэтому в этой книге мы будем заниматься исключительно параллелизмом на основе многопоточности, и в дальнейшем всякое упоминание о параллелизме предполагает использование нескольких потоков.

Определившись с тем, что понимать под параллелизмом, посмотрим, зачем он может понадобиться в приложениях.

1.2. Зачем нужен параллелизм?

Существует две основных причины для использования параллелизма в приложении: разделение обязанностей и производительность. Я бы даже рискнул сказать, что это единственные причины — если внимательно приглядеться, то окажется, что все остальное сводится к одной или к другой (или к обеим сразу). Ну, конечно, если не рассматривать в качестве аргумента «потому что я так хочу».

1.2.1. Применение параллелизма для разделения обязанностей

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

Рассмотрим приложение, имеющее графический интерфейс и выполняющее сложные вычисления, например DVD-проигрыватель на настольном компьютере. У такого приложения два принципиально разных набора обязанностей: во-первых, читать данные с диска, декодировать изображение и звук и своевременно посылать их графическому и звуковому оборудованию, чтобы при просмотре фильма не было заминок, а, во-вторых, реагировать на действия пользователя, например, на нажатие кнопок «Пауза», «Возврат в меню» и даже «Выход». Если бы приложение было однопоточным, то должно было бы периодически проверять, не было ли каких-то действий пользователя, поэтому код воспроизведения DVD перемежался бы кодом, относящимся к пользовательскому интерфейсу Если же для разделения этих обязанностей использовать несколько потоков, то код интерфейса и воспроизведения уже не будут так тесно переплетены: один поток может заниматься отслеживанием действий пользователя, а другой - воспроизведением. Конечно, как-то взаимодействовать они все равно должны, например, если пользователь нажимает кнопку «Пауза», но такого рода взаимодействия непосредственно связаны с решаемой задачей.

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

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

1.2.2. Применение параллелизма для повышения производительности

Многопроцессорные системы существуют уже десятки лет, но до недавнего времени они использовались исключительно в суперкомпьютерах, больших ЭВМ и крупных серверах. Однако ныне производители микропроцессоров предпочитают делать процессоры с 2, 4, 16 и более ядрами на одном кристалле, а не наращивать производительность одного ядра. Поэтому все большее распространение получают настольные компьютеры и даже встраиваемые устройства с многоядерными процессорами. Увеличение вычислительной мощи в этом случае связано не с тем, что каждая отдельная задача работает быстрее, а с тем, что несколько задач исполняются параллельно.

В прошлом программист мог откинуться на спинку стула и наблюдать, как его программа работает все быстрее с каждым новым поколением процессоров, без каких-либо усилий с его стороны. Но теперь, как говорит Герб Саттер, «время бесплатных завтраков закончилось» [Sutter 2005]. Если требуется, чтобы программа выигрывала от увеличения вычислительной мощности, то ее необходимо проектировать как набор параллельных задач. Поэтому программистам придется подтянуться, и те, кто до сих пор не обращал внимания на параллелизм, должны будут добавить его в свой арсенал.

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

Алгоритмы, легко поддающиеся такому распараллеливанию, часто называют естественно параллельными (embarrassingly parallel, naturally parallel, conveniently concurrent.). Они очень хорошо масштабируются — если число располагаемых аппаратных потоков увеличивается, то и степень параллелизма алгоритма возрастает. Такой алгоритм — идеальная иллюстрации пословицы «берись дружно, не будет грузно». Те части алгоритма, которые не являются естественно параллельными, можно разбить на фиксированное (и потому не масштабируемое) число параллельных задач. Техника распределения задач по потокам рассматривается в главе 8.

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

1.2.3. Когда параллелизм вреден?

Понимать, когда параллелизмом пользоваться не следует, не менее важно. Принцип простой: единственная причина не использовать параллелизм — ситуация, когда затраты перевешивают выигрыш. Часто параллельная программа сложнее для понимания, поэтому для написания и сопровождения многопоточного кода требуются дополнительные интеллектуальные усилия, а, стало быть, возрастает и количество ошибок. Если потенциальный прирост производительности недостаточно велик или достигаемое разделение обязанностей не настолько очевидно, чтобы оправдать дополнительные затраты времени на разработку, отладку и сопровождение многопоточной программы, то не используйте параллелизм.

Кроме того, прирост производительности может оказаться меньше ожидаемого: с запуском потоков связаны неустранимые накладные расходы, потому что ОС должна выделить ресурсы ядра и память для стека и сообщить о новом потоке планировщику, а на все это требуется время. Если задача, исполняемая в отдельном потоке, завершается быстро, то может оказаться, что в общем времени ее работы доминируют именно накладные расходы на запуск потока, поэтому производительность приложения в целом может оказаться хуже, чем если бы задача исполнялась в уже имеющемся потоке.

Далее, потоки — это ограниченный ресурс. Если одновременно работает слишком много потоков, то ресурсы ОС истощаются, что может привести к замедлению работы всей системы. Более того, при чрезмерно большом количестве потоков может исчерпаться память или адресное пространство, выделенное процессу, так как каждому потоку необходим собственный стек. Особенно часто эта проблема возникает в 32-разрядных процессах с «плоской» структурой памяти, где на размер адресного пространства налагается ограничение 4 ГБ: если у каждого потока есть стек размером 1 МБ (типичное соглашение во многих системах), то 4096 потоков займут все адресное пространство, не оставив места для кода, статических данных и кучи. В 64-разрядных системах (и системах с большей разрядностью слова) такого ограничения на размер адресного пространства нет, но ресурсы все равно конечны: если запустить слишком много потоков, то рано или поздно возникнут проблемы. Для ограничения количества потоков можно воспользоваться пулами потоков (см. главу 9), но и это не панацея — у пулов есть и свои проблемы.

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

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

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

Но предположим, что вы уже решили, что хотите распараллелить приложение, будь то для повышения производительности, ради разделения обязанностей или просто потому, что сегодня «День многопоточности». Что это означает для программиста на С++?

1.3. Параллелизм и многопоточность в С++

Стандартизованная поддержка параллелизма за счет многопоточности — вещь новая для С++. Только новый стандарт С++11 позволит писать многопоточный код, не прибегая к платформенно-зависимым расширениям. Чтобы разобраться в подоплёке многочисленных решений, принятых в новой стандартной библиотеке С++ Thread Library, необходимо вспомнить историю.

1.3.1. История многопоточности в С++

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

Разумеется, производители компиляторов вправе добавлять в язык любые расширения, а наличие различных API для поддержки многопоточности в языке С, например, в стандарте POSIX С Standard и в Microsoft Windows API, заставило многих производителей компиляторов С++ поддержать многопоточность с помощью платформенных расширений. Как правило, эта поддержка ограничивается разрешением использовать соответствующий платформе С API с гарантией, что библиотека времени исполнения С++ (в частности, механизм обработки исключений) будет корректно работать при наличии нескольких потоков. Хотя лишь очень немногие производители компиляторов предложили формальную модель памяти с поддержкой многопоточности, практическое поведение компиляторов и процессоров оказалось достаточно приемлемым для создания большого числа многопоточных программ на С++.

Не удовлетворившись использованием платформенно-зависимых С API для работы с многопоточностью, программисты на С++ пожелали, чтобы в используемых ими библиотеках классов были реализованы объектно-ориентированные средства для написания многопоточных программ. В различные программные каркасы типа MFC и в универсальные библиотеки на С++ типа Boost и АСЕ были включены наборы классов С++, которые обертывали платформенно-зависимые API и предоставляли высокоуровневые средства для работы с многопоточностью, призванные упростить программирование. Детали реализации в этих библиотеках существенно различаются, особенно в части запуска новых потоков, но общая структура классов очень похожа. В частности, во многих библиотеках классов С++ применяется крайне полезная идиома захват ресурса есть инициализация (RAII), которая материализуется в виде блокировок, гарантирующих освобождение мьютекса при выходе из соответствующей области видимости.

Во многих случаях поддержка многопоточности в имеющихся компиляторах С++ вкупе с доступностью платформенно-зависимых API и платформенно-независимых библиотек классов типа Boost и АСЕ оказывается достаточно прочным основанием, на котором можно писать многопоточные программы. В результате уже написаны многопоточные приложения на С++, содержащие миллионы строк кода. Но коль скоро прямой поддержки в стандарте нет, бывают случаи, когда отсутствие модели памяти, учитывающей многопоточность, приводит к проблемам. Особенно часто с этим сталкиваются разработчики, пытающиеся увеличить производительность за счет использования особенностей конкретного процессора, а также те, кто пишет кросс-платформенный код, который должен работать независимо от различий между компиляторами на разных платформах.

1.3.2. Поддержка параллелизма в новом стандарте

Все изменилось с выходом стандарта С++11. Мало того что в нем определена совершенно новая модель памяти с поддержкой многопоточности, так еще и в стандартную библиотеку С++ включены классы для управления потоками (глава 2), защиты разделяемых данных (глава 3), синхронизации операций между потоками (глава 4) и низкоуровневых атомарных операций (глава 5).

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

Поддержка параллелизма — лишь одна из новаций в стандарте С++. Как уже отмечалось в начале главы, в сам язык тоже внесено много изменений, призванных упростить жизнь программистам. Хотя, вообще говоря, сами по себе они не являются предметом настоящей книги, некоторые оказывают прямое влияние на библиотеку многопоточности и способы ее использования. В приложении А содержится краткое введение в эти языковые средства.

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

1.3.3. Эффективность библиотеки многопоточности для С++

Одна из проблем, с которыми сталкиваются разработчики высокопроизводительных приложений при использовании языка С++ вообще и классов, обертывающих низкоуровневые средства, типа тех, что включены в стандартную библиотеку С++ Thread Library, в частности, — это эффективность. Если вас интересует достижение максимальной производительности, то необходимо понимать, что использование любых высокоуровневых механизмов вместо обертываемых ими низкоуровневых средств влечет за собой некоторые издержки. Эти издержки называются платой за абстрагирование.

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

Комитет по стандартизации С++ поставил и другую цель — обеспечить достаточное количество низкоуровневых средств для желающих работать на уровне «железа», чтобы выдавить из него все, что возможно. Поэтому наряду с новой моделью памяти включена полная библиотека атомарных операций для прямого управления на уровне битов и байтов, а также средства межпоточной синхронизации и обеспечения видимости любых изменений. Атомарные типы и соответствующие операции теперь можно использовать во многих местах, где раньше разработчики были вынуждены опускаться на уровень языка ассемблера для конкретной платформы. Таким образом, код с применением новых стандартных типов и операций получается более переносимым и удобным для сопровождения.

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

В некоторых случаях высокоуровневые средства обеспечивают большую функциональность, чем необходимо для конкретной задачи. Как правило, это не страшно: вы не платите за то, чем не пользуетесь. Редко, но бывает, что избыточная функциональность негативно сказывается на производительности других частей программы. Если ее стоимость слишком высока, а производительность имеет первостепенное значение, то, быть может, имеет смысл вручную запрограммировать необходимую функциональность, пользуясь низкоуровневыми средствами. Но в подавляющем большинстве случаев дополнительная сложность и возможность внести ошибки намного перевешивают небольшой выигрыш в производительности. Даже если профилирование показывает, что средства стандартной библиотеки С++ действительно являются узким местом, не исключено, что проблема в неудачном дизайне приложения, а не в плохой реализации библиотеки. Например, когда слишком много потоков конкурируют за один мьютекс, производительность упадет — и сильно. Но лучше не пытаться чуть-чуть ускорить операции с мьютексами, а изменить структуру приложения, так чтобы снизить конкуренцию. Вопрос о том, как проектировать приложения, чтобы уменьшить конкуренцию, обсуждается в главе 8.

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

1.3.4. Платформенно-зависимые средства

Хотя библиотека многопоточности для С++ содержит достаточно полный набор средств для создания многопоточных программ, на любой платформе имеются специальные средства, помимо включенных в библиотеку. Чтобы можно было получить доступ к этим средствам, не отказываясь от использования стандартной библиотеки, типы, имеющиеся в библиотеки многопоточности, иногда содержат функцию-член native_handle(), которая позволяет работать на уровне платформенного API. По природе своей любые операции, выполняемые с помощью функции native_handle(), зависят от платформы и потому в данной книге (как и в самой стандартной библиотеке С++) не рассматриваются.

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

1.4. В начале пути

Итак, вы получили новенький, с пылу с жару компилятор, совместимый со стандартом С++11. Что дальше? Как выглядит многопоточная программа на С++? Да примерно так же, как любая другая программа, — с переменными, классами и функциями. Единственное существенное отличие состоит в том, что некоторые функции могут работать параллельно, поэтому нужно следить за тем, чтобы доступ к разделяемым данным был безопасен (см. главу 3). Понятно, что для параллельного исполнения необходимо использовать специальные функции и объекты, предназначенные для управления потоками.

1.4.1. Здравствуй, параллельный мир

Начнем с классического примера — программы, которая печатает фразу «Здравствуй, мир». Ниже приведена тривиальная однопоточная программа такого сорта, от нее мы будем отталкиваться при переходе к нескольким потокам.

#include <iostream>

int main() {

 std::cout << "Здравствуй, мир\n";

}

Эта программа всего лишь выводит строку Здравствуй мир в стандартный поток вывода. Сравним ее с простой программой «Здравствуй, параллельный мир», показанной в листинге 1.1, — в ней для вывода сообщения запускается отдельный поток.

#include <iostream>

#include <thread> ← (1)


void hello()      ← (2)

{

 std::cout << "Здравствуй, параллельный мир\n";

}


int

main() {

 std::thread t(hello); ← (3)

 t.join();             ← (4)

}

Прежде всего, отметим наличие дополнительной директивы #include <thread> (1). Все объявления, необходимые для поддержки многопоточности, помещены в новые заголовочные файлы; функции и классы для управления потоками объявлены в файле <thread>, а те, что нужны для защиты разделяемых данных, — в других заголовках.

Далее, код вывода сообщения перемещен в отдельную функцию (2). Это объясняется тем, что в каждом потоке должна быть начальная функция, в которой начинается исполнение потока. Для первого потока в приложении таковой является main(), а для всех остальных задается в конструкторе объекта std::thread. В данном случае в качестве начальной функции объекта типа std::thread, названного t (3), выступает функция hello().

Есть и еще одно отличие вместо того, чтобы сразу писать на стандартный вывод или вызывать hello() из main(), эта программа запускает новый поток, так что теперь общее число потоков равно двум: главный, с начальной функцией main(), и дополнительный, начинающий работу в функции hello().

После запуска нового потока (3) начальный поток продолжает работать. Если бы он не ждал завершения нового потока, то просто дошел бы до конца main(), после чего исполнение программы закончилась бы быть может, еще до того, как у нового потока появился шанс начать работу. Чтобы предотвратить такое развитие событие, мы добавили обращение к функции join() (4); в главе 2 объясняется, что это заставляет вызывающий поток (main()) ждать завершения потока, ассоциированного с объектом std::thread, — в данном случае t.

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

1.5. Резюме

В этой главе мы говорили о том, что такое параллелизм и многопоточность и почему стоит (или не стоит) использовать их в программах. Мы также рассмотрели историю многопоточности в С++ — от полного отсутствия поддержки в стандарте 1998 года через различные платформенно-зависимые расширения к полноценной поддержке в новом стандарте С++11. Эта поддержка, появившаяся очень вовремя, дает программистам возможность воспользоваться преимуществами аппаратного параллелизма, которые стали доступны в современных процессорах, поскольку их производители пошли но пути наращивания мощности за счет реализации нескольких ядер, а не увеличения быстродействия одного ядра.

Мы также видели (пример в разделе 1.4), как просто использовать классы и функции из стандартной библиотеки С++. В С++ использование нескольких потоков само по себе несложно — сложно спроектировать программу так, чтобы она вела себя, как задумано.

Закусив примерами из раздела 1.4, пора приступить к чему-нибудь более питательному. В главе 1 мы рассмотрим классы и функции для управления потоками.

Глава 2. Управление потоками

В этой главе:
■ Запуск потоков и различные способы задания кода, исполняемого в новом потоке.

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

■ Уникальные идентификаторы потоков.

Итак, вы решили написать параллельную программу, а конкретно — использовать несколько потоков. И что теперь? Как запустить потоки, как узнать, что поток завершился, и как отслеживать их выполнение? Средства, имеющиеся в стандартной библиотеке, позволяют относительно просто решить большинство задач управления потоками. Как мы увидим, почти все делается с помощью объекта std::thread, ассоциированного с потоком. Для более сложных задач библиотека позволяет построить то, что нужно, из простейших кирпичиком.

Мы начнем эту главу с рассмотрения базовых операций: запуск потока, ожидание его завершения, исполнение в фоновом режиме. Затем мы поговорим о передаче дополнительных параметров функции потока в момент запуска и о том, как передать владение потока от одного объекта std::thread другому. Наконец, мы обсудим вопрос о том, сколько запускать потоков и как идентифицировать отдельный поток.

2.1. Базовые операции управления потоками

В каждой программе на С++ имеется по меньшей мере один поток, запускаемый средой исполнения С++: тот, в котором исполняется функция main(). Затем программа может запускать дополнительные потоки с другими функциями в качестве точки входа. Эти потоки работают параллельно друг с другом и с начальным потоком. Мы знаем, что программа завершает работу, когда main() возвращает управление; точно так же, при возврате из точки входа в поток этот поток завершается. Ниже мы увидим, что, имея объект std::thread для некоторого потока, мы можем дождаться завершения этого потока, но сначала посмотрим, как потоки запускаются.

2.1.1. Запуск потока

В главе 1 мы видели, что для запуска потока следует сконструировать объект std::thread, который определяет, какая задача будет исполняться в потоке. В простейшем случае задача представляет собой обычную функцию без параметров, возвращающую void. Эта функция работает в своем потоке, пока не вернет управление, и в этом момент поток завершается. С другой стороны, в роли задачи может выступать объект-функция, который принимает дополнительные параметры и выполняет ряд независимых операций, информацию о которых получает во время работы от той или иной системы передачи сообщений. И останавливается такой поток, когда получит соответствующий сигнал, опять же с помощью системы передачи сообщений. Вне зависимости от того, что поток будет делать и откуда он запускается, сам запуск потока в стандартном С++ всегда сводится к конструированию объекта std::thread:

void do_some_work();

std::thread my_thread(do_some_work);

Как видите, все просто. Разумеется, как и во многих других случаях в стандартной библиотеке С++, класс std::thread работает с любым типом, допускающим вызов (Callable), поэтому конструктору std::thread можно передать экземпляр класса, в котором определен оператор вызова:

class background_task {

public:

 void operator()() const {

  do_something();

  do_something_else();

 }

};


background_task f;

std::thread my_thread(f);

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

При передаче объекта-функции конструктору потока нужно избегать феномена «самого досадного разбора в С++» (C++'s most vexing parse). Синтаксически передача конструктору временного объекта вместо именованной переменной выглядит так же, как объявление функции, и именно так компилятор и интерпретирует эту конструкцию. Например, в предложении

std::thread my_thread(background_task());

объявлена функция my_thread, принимающая единственный параметр (типа указателя на функцию без параметров, которая возвращает объект background_task) и возвращающая объект std::thread. Никакой новый поток здесь не запускается. Решить эту проблему можно тремя способами: поименовать объект-функцию, как в примере выше; добавить лишнюю пару скобок или воспользоваться новым универсальным синтаксисом инициализации, например:

std::thread my_thread((background_task())); ← (1)

std::thread my_thread{background_task()};   ← (2)

В случае (1) наличие дополнительных скобок не дает компилятору интерпретировать конструкцию как объявление функции, так что действительно объявляется переменная my_thread типа std::thread. В случае (2) использован новый универсальный синтаксис инициализации с фигурными, а не круглыми скобками, он тоже приводит к объявлению переменной.

В стандарте С++11 имеется новый тип допускающего вызов объекта, в котором описанная проблема не возникает, — лямбда-выражение. Этот механизм позволяет написать локальную функцию, которая может захватывать некоторые локальные переменные, из-за чего передавать дополнительные аргументы просто не нужно (см. раздел 2.2). Подробная информация о лямбда-выражениях приведена в разделе А.5 приложения А. С помощью лямбда-выражений предыдущий пример можно записать в таком виде:

std::thread my_thread([](

 do_something();

 do_something_else();

});

После запуска потока необходимо явно решить, ждать его завершения (присоединившись к нему, см. раздел 2.1.2) или предоставить собственной судьбе (отсоединив его, см. раздел 2.1.3). Если это решение не будет принято к моменту уничтожения объекта std::thread, то программа завершится (деструктор std::thread вызовет функцию std::terminate()). Поэтому вы обязаны гарантировать, что поток корректно присоединен либо отсоединен, даже если возможны исключения. Соответствующая техника программирования описана в разделе 2.1.3. Отметим, что это решение следует принять именно до уничтожения объекта std::thread, к самому потоку оно не имеет отношения. Поток вполне может завершиться задолго до того, как программа присоединится к нему или отсоединит его. А отсоединенный поток может продолжать работу и после уничтожения объекта std::thread.

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

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


Листинг 2.1. Функция возвращает управление, когда поток имеет доступ к определенным в ней локальным переменным

struct func {

 int& i;

 func(int& i_) : i(i_){}

 void operator() () {

  for(unsigned j = 0; j < 1000000; ++j) {

   do_something(i); ←┐ Потенциальный доступ

  }                  (1) к висячей ссылке

 }

};


void oops() {

 int some_local_state = 0;        (2) He ждем завершения

 func my_func(some_local_state); ←┘ потока

 std::thread my_thread(my_func); ←┐ Новый поток, возможно,

 my_thread.detach();              (3) еще работает

}

В данном случае вполне возможно, что новый поток, ассоциированный с объектом my_thread, будет еще работать, когда функция oops вернет управление (2), поскольку мы явно решили не дожидаться его завершения, вызвав detach() (3). А если поток действительно работает, то при следующем вызове do_something(i) (1) произойдет обращение к уже уничтоженной переменной. Точно так же происходит в обычном однопоточном коде — сохранять указатель или ссылку на локальную переменную после выхода из функции всегда плохо, — но в многопоточном коде такую ошибку сделать проще, потому что не сразу видно, что произошло.

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

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

2.1.2. Ожидание завершения потока

Чтобы дождаться завершения потока, следует вызвать функцию join() ассоциированного объекта std::thread. В листинге 2.1 мы можем заменить вызов my_thread.detach() перед закрывающей скобкой тела функции вызовом my_thread.join(), и тем самым гарантировать, что поток завершится до выхода из функции, то есть раньше, чем будут уничтожены локальные переменные. В данном случае это означает, что запускать функцию в отдельном потоке не имело смысла, так как первый поток в это время ничего не делает, по в реальной программе исходный поток мог бы либо сам делать что-то полезное, либо запустить несколько потоков параллельно, а потом дождаться их всех.

Функция join() дает очень простую и прямолинейную альтернативу — либо мы ждем завершения потока, либо нет. Если необходим более точный контроль над ожиданием потока, например если необходимо проверить, завершился ли поток, или ждать только ограниченное время, то следует прибегнуть к другим механизмам, таким, как условные переменные и будущие результаты, которые мы будем рассматривать в главе 4. Кроме тот, при вызове join() очищается вся ассоциированная с потоком память, так что объект std::thread более не связан с завершившимся потоком — он вообще не связан ни с каким потоком. Это значит, что для каждого потока вызвать функцию join() можно только один раз; после первого вызова объект std::thread уже не допускает присоединения, и функция joinable() возвращает false.

2.1.3. Ожидание в случае исключения

Выше уже отмечалось, что функцию join() или detach() необходимо вызвать до уничтожения объекта std::thread. Если вы хотите отсоединить поток, то обычно достаточно вызвать detach() сразу после его запуска, так что здесь проблемы не возникает. Но если вы собираетесь дождаться завершения потока, то надо тщательно выбирать место, куда поместить вызов join(). Важно, чтобы из-за исключения, произошедшего между запуском потока и вызовом join(), не оказалось, что обращение к join() вообще окажется пропущенным.

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


Листинг 2.2. Ожидание завершения потока

struct func; ←┐ см. определение

              │ в листинге 2.1

void f() {

 int some_local_state = 0;

 func my_func(some_local_state)

 std::thread t(my_func);

 try {

  do_something_in_current_thread()

 }

 catch(...) {

  t.join(); ← (1)

  throw;

 }

 t.join();  ← (2)

}

В листинге 2.2 блок try/catch используется для того, чтобы поток, имеющий доступ к локальному состоянию, гарантированно завершился до выхода из функции вне зависимости оттого, происходит выход нормально (2) или вследствие исключения (1). Записывать блоки try/catch очень долго и при этом легко допустить ошибку, поэтому такой способ не идеален. Если необходимо гарантировать, что поток завершается до выхода из функции потому ли, что он хранит ссылки на локальные переменные, или по какой-то иной причине то важно обеспечить это на всех возможных путях выхода, как нормальных, так и в результате исключения, и хотелось бы иметь для этого простой и лаконичный механизм.

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


Листинг 2.3. Использование идиомы RAII для ожидания завершения потока

class thread_guard {

 std::threads t;

public:

 explicit thread_guard(std::thread& t_) : t(t_) {}

 ~thread_guard() {

  if (t.joinable()) ← (1)

  {

   t.join();        ← (2)

  }

 }

 thread_guard(thread_guard const&)=delete; ← (3)

 thread_guard& operator=(thread_guard const&)=delete;

};


struct func; ←┐ см.определение

              │ в листинге 2.1

void f() {

 int some_local_state;

 std::thread t(func(some_local_state));

 thread_guard g(t);

 do_something_in_current_thread();

}             ← (4)

Когда текущий поток доходит до конца f (4), локальные объекты уничтожаются в порядке, обратном тому, в котором были сконструированы. Следовательно, сначала уничтожается объект g типа thread_guard, и в его деструкторе (2) происходит присоединение к потоку Это справедливо даже в том случае, когда выход из функции f произошел в результате исключения внутри функции do_something_in_current_thread.

Деструктор класса thread_guard в листинге 2.3 сначала проверяет, что объект std::thread находится в состоянии joinable() (1) и, лишь если это так, вызывает join() (2). Это существенно, потому что функцию join() можно вызывать только один раз для данного потока, так что если он уже присоединился, то делать это вторично было бы ошибкой.

Копирующий конструктор и копирующий оператор присваивания помечены признаком =delete (3), чтобы компилятор не генерировал их автоматически: копирование или присваивание такого объекта таит в себе опасность, поскольку время жизни копии может оказаться дольше, чем время жизни присоединяемого потока. Но раз эти функции объявлены как «удаленные», то любая попытка скопировать объект типа thread_guard приведет к ошибке компиляции. Дополнительные сведения об удаленных функциях см. в приложении А, раздел А.2.

Если ждать завершения потока не требуется, то от проблемы безопасности относительно исключений можно вообще уйти, отсоединив поток. Тем самым связь потока с объектом std::thread разрывается, и при уничтожении объекта std::thread функция std::terminate() не будет вызвана. Но отсоединенный поток по-прежнему работает — в фоновом режиме.

2.1.4. Запуск потоков в фоновом режиме

Вызов функции-члeнa detach() объекта std::thread оставляет поток работать в фоновом режиме, без прямых способов коммуникации с ним. Теперь ждать завершения потока не получится — после того как поток отсоединен, уже невозможно получить ссылающийся на него объект std::thread, для которого можно было бы вызвать join(). Отсоединенные потоки действительно работают в фоне: отныне ими владеет и управляет библиотека времени выполнения С++, которая обеспечит корректное освобождение связанных с потоком ресурсов при его завершении.

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

В разделе 2.1.2 мы уже видели, что для отсоединения потока следует вызвать функцию-член detach() объекта std::thread. После возврата из этой функции объект std::thread уже не связан ни с каким потоком, и потому присоединиться к нему невозможно.

std::thread t(do_background_work);

t.detach();

assert(!t.joinable());

Разумеется, чтобы отсоединить поток от объекта std::thread, поток должен существовать: нельзя вызвать detach() для объекта std::thread, с которым не связан никакой поток. Это то же самое требование, которое предъявляется к функции join(), поэтому и проверяется оно точно так же — вызывать t.detach() для объекта t типа std::thread можно только тогда, когда t.joinable() возвращает true.

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

В листинге 2.4 приведен набросок кода, реализующего этот подход.


Листинг 2.4. Отсоединение потока для обработки другого документа

void edit_document(std::string const& filename) {

 open_document_and_display_gui(filename);

 while(!done_editing()) {

  user_command cmd = get_user_input();

  if (cmd.type == open_new_document) {

   std::string const new_name = get_filename_from_user();

   std::thread t(edit_document,new_name); ← (1)

   t.detach(); ← (2)

  }

  else {

   process_user_input(cmd);

  }

 }

}

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

Этот пример демонстрирует также, почему бывает полезно передавать аргументы функции потока: мы передаем конструктору объекта std::thread не только имя функции (1), но и её параметр — имя файла. Существуют другие способы добиться той же цели, например, использовать не обычную функцию с параметрами, а объект-функцию с данными-членами, но библиотека предлагает и такой простой механизм.

2.2. Передача аргументов функции потока

Из листинга 2.4 видно, что по существу передача аргументов вызываемому объекту или функции сводится просто к передаче дополнительных аргументов конструктору std::thread. Однако важно иметь в виду, что по умолчанию эти аргументы копируются в память объекта, где они доступны вновь созданному потоку, причем так происходит даже в том случае, когда функция ожидает на месте соответствующего параметра ссылку. Вот простой пример:

void f(int i, std::string const& s);

std::thread t(f, 3, "hello");

Здесь создается новый ассоциированный с объектом t поток, в котором вызывается функция f(3, "hello"). Отметим, что функция f принимает в качестве второго параметра объект типа std::string, но мы передаем строковый литерал char const*, который преобразуется к типу std::string уже в контексте нового потока. Это особенно важно, когда переданный аргумент является указателем на автоматическую переменную, как в примере ниже:

void f(int i, std::string const& s);


void oops(int some_param) {

 char buffer[1024];           ← (1)

 sprintf(buffer, "%i", some_param);

 std::thread t(f, 3, buffer); (2)

 t.detach();

}

В данном случае в новый поток передается (2) указатель на локальную переменную buffer (1), и есть все шансы, что выход из функции oops произойдет раньше, чем буфер будет преобразован к типу std::string в новом потоке. В таком случае мы получим неопределенное поведение. Решение заключается в том, чтобы выполнить преобразование в std::string до передачи buffer конструктору std::thread:

void f(int i,std::string const& s);


void not_oops(int some_param) {

 char buffer[1024];                         │ Использование

 sprintf(buffer, "%i", some_param);         │ std::string

 std::thread t(f, 3, std::string(buffer)); ←┘ позволяет избежать

 t.detach();                                  висячего указателя

}

В данном случае проблема была в том, что мы положились на неявное преобразование указателя на buffer к ожидаемому типу первого параметра std::string, а конструктор std::thread копирует переданные значения «как есть», без преобразования к ожидаемому типу аргумента.

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

void update_data_for_widget(widget_id w,widget_data& data); ← (1)


void oops_again(widget_id w) {

 widget_data data;

 std::thread t(update_data_for_widget, w, data); ← (2)

 display_status();

 t.join();

 process_widget_data(data);                      ← (3)

}

Здесь update_data_for_widget (1) ожидает, что второй параметр будет передан по ссылке, но конструктор std::thread (2) не знает об этом: он не в курсе того, каковы типы аргументов, ожидаемых функцией, и просто слепо копирует переданные значения. Поэтому функции update_data_for_widget будет передана ссылка на внутреннюю копию data, а не на сам объект data. Следовательно, по завершении потока от обновлений ничего не останется, так как внутренние копии переданных аргументов уничтожаются, и функция process_widget_data получит не обновленные данные, а исходный объект data (3). Для читателя, знакомого с механизмом std::bind, решение очевидно: нужно обернуть аргументы, которые должны быть ссылками, объектом std::ref. В данном случае, если мы напишем

std::thread t(update_data_for_widget, w, std::ref(data));

то функции update_data_for_widget будет правильно передана ссылка на data, а не копия data.

Если вы знакомы с std::bind, то семантика передачи параметров вряд ли вызовет удивление, потому что работа конструктора std::thread и функции std::bind определяется в терминах одного и того же механизма. Это, в частности, означает, что в качестве функции можно передавать указатель на функцию-член при условии, что в первом аргументе передается указатель на правильный объект:

class X {

public:

 void do_lengthy_work();

};


X my_x;

std::thread t(&X::do_lengthy_work, &my_x); ← (1)

Здесь мы вызываем my_x.do_lengthy_work() в новом потоке, поскольку в качестве указателя на объект передан адрес my_x (1). Так вызванной функции-члену можно передавать и аргументы: третий аргумент конструктора std::thread  станет первым аргументом функции-члена и т.д.

Еще один интересный сценарий возникает, когда передаваемые аргументы нельзя копировать, а можно только перемещать: данные, хранившиеся в одном объекте, переносятся в другой, а исходный объект остается «пустым». Примером может служить класс std::unique_ptr, который обеспечивает автоматическое управление памятью для динамически выделенных объектов. В каждый момент времени на данный объект может указывать только один экземпляр std::unique_ptr, и, когда этот экземпляр уничтожается, объект, на который он указывает, удаляется. Перемещающий конструктор и перемещающий оператор присваивания позволяют передавать владение объектом от одного экземпляра std::unique_ptr другому (о семантике перемещения см. приложение А, раздел А.1.1). После такой передачи в исходном экземпляре остается указатель NULL. Подобное перемещение значений дает возможность передавать такие объекты в качестве параметров функций или возвращать из функций. Если исходный объект временный, то перемещение производится автоматически, а если это именованное значение, то передачу владения следует запрашивать явно, вызывая функцию std::move(). В примере ниже показано применение функции std::move для передачи владения динамическим объектом потоку:

void process_big_object(std::unique_ptr<big_object>);


std::unique_ptr<big_object> p(new big_object);

p->prepare_data(42);

std::thread t(process_big_object,std::move(p));

Поскольку мы указали при вызове конструктора std::thread функцию std::move, то владение объектом big_object передается объекту во внутренней памяти вновь созданного потока, а затем функции process_big_object.

В стандартной библиотеке Thread Library есть несколько классов с такой же семантикой владения, как у std::unique_ptr, и std::thread — один из них. Правда, экземпляры std::thread не владеют динамическими объектами, как std::unique_ptr, зато они владеют ресурсами: каждый экземпляр отвечает за управление потоком выполнения. Это владение можно передавать от одного экземпляра другому, поскольку экземпляры std::thread перемещаемые, хотя и не копируемые. Тем самым гарантируется, что в каждый момент времени с данным потоком будет связан только один объект, но в то же время программист вправе передавать владение от одного объекта другому

2.3. Передача владения потоком

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

Именно здесь и оказывается полезной поддержка классом std::thread семантики перемещения. В предыдущем разделе отмечалось, что в стандартной библиотеке С++ есть много типов, владеющих ресурсами, например std::ifstream и std::unique_ptr, которые являются перемещаемыми, но не копируемыми, и один из них — std::thread. Это означает, что владение потоком можно передавать от одного экземпляра std::thread другому, как показано в примере ниже. В нем создается два потока выполнения, владение которыми передается между тремя объектами std::thread: t1, t2 и t3.

void some_function();

void some_other_function();


std::thread t1(some_function);         ← (1)

std::thread t2 = std::move(t1);        ← (2)

t1 = std::thread(some_other_function); ← (3)

std::thread t3;     ← (4)

t3 = std::move(t2); ← (5)

t1 = std::move(t3); ← (6) Это присваивание приводит

;                       к аварийному завершению программы

Сначала создастся новый поток (1) и связывается с объектом t1. Затем владение явно передается объекту t2 в момент его конструирования путем вызова std::move() (2). В этот момент с t1 уже не связан никакой поток выполнения: поток, в котором исполняется функция some_function, теперь связан с t2.

Далее создается еще один поток, который связывается с временным объектом типа std::thread (3). Для последующей передачи владения объекту t1 уже не требуется явный вызов std::move(), так как владельцем является временный объект, а передача владения от временных объектов производится автоматически и неявно.

Объект t3 конструируется по умолчанию (4), а это означает, что в момент создания с ним не связывается никакой поток. Владение потоком, который в данный момент связан с t2, передастся объекту t3 (5), опять-таки путем явного обращения к std::move(), поскольку t2 — именованный объект. После всех этих перемещений t1 оказывается связан с потоком, исполняющим функцию some_other_function, t2 не связан ни с каким потоком, a t3 связан с потоком, исполняющим функцию some_function .

Последнее перемещение (6) передает владение потоком, исполняющим some_function, обратно объекту t1, в котором исполнение этой функции началось. Однако теперь с t1 уже связан поток (который исполнял функцию some_other_function), поэтому вызывается std::terminate(), и программа завершается. Так делается ради совместимости с поведением деструктора std::thread. В разделе 2.1.1 мы видели, что нужно либо явно ждать завершения потока, либо отсоединить его до момента уничтожения; то же самое относится и к присваиванию: нельзя просто «прихлопнуть» поток, присвоив новое значение объекту std::thread, который им управляет.

Поддержка операции перемещения в классе std::thread означает, что владение можно легко передать при возврате из функции, как показано в листинге 2.5.


Листинг 2.5. Возврат объекта std::thread из функции

std::thread f() {

 void some_function();

 return std::thread(some_function);

}


std::thread g() {

 void some_other_function(int);

 std::thread t(some_other_function, 42);

 return t;

}

Аналогично, если требуется передать владение внутрь функции, то достаточно, чтобы она принимала экземпляр std::thread по значению в качестве одного из параметров, например:

void f(std::thread t);


void g() {

 void some_function();

 f(std::thread(some_function));

 std::thread t(some_function);

 f(std::move(t));

}

Одно из преимуществ, которые даёт поддержка перемещения в классе std::thread, заключается в том, что мы можем модифицировать класс thread_guard из листинга 2.3, так чтобы он принимал владение потоком. Это позволит избежать неприятностей в случае, когда время жизни объекта thread_guard оказывает больше, чем время жизни потока, на который он ссылается, а, кроме того, это означает, что никто другой не сможет присоединиться к потоку или отсоединить его, так как владение было передано объекту thread_guard. Поскольку основное назначение этого класса гарантировать завершение потока до выхода из области видимости, я назвал его scoped_thread. Реализация и простой пример использования приведены в листинге 2.6.


Листинг 2.6. Класс scoped_thread и пример его использования

class scoped_thread {

 std::thread t;

public:

 explicit scoped_thread(std::thread t_) : ← (1)

 t(std::move(t_)) {

 if (!t.joinable()) ← (2)

  throw std::logic_error("No thread");

 }

 ~scoped_thread() {

  t.join();         ← (3)

 }

 scoped_thread(scoped_thread const&)=delete;

 scoped_thread& operator=(scoped_thread const&)=delete;

};


struct func; ← см. листинг 2.1


void f() {

 int some_local_state;

 scoped_thread t(std::thread(func(some_local_state))); ← (4)

 do_something_in_current_thread();

}                   ← (5)

Этот пример очень похож на приведенный в листинге 2.3, только новый поток теперь передается непосредственно конструктору scoped_thread (4), вместо того чтобы создавать для него отдельную именованную переменную. Когда новый поток достигает конца f (5), объект scoped_thread уничтожается, а затем поток соединяется (3) с потоком, переданным конструктору (1). Если в классе thread_guard из листинга 2.3 деструктор должен был проверить, верно ли, что поток все еще допускает соединение, то теперь мы можем сделать это в конструкторе (2) и возбудить исключение, если это не так.

Поддержка перемещения в классе std::thread позволяет также хранить объекты этого класса в контейнере при условии, что класс контейнера поддерживает перемещение (как, например, модифицированный класс std::vector<>). Это означает, что можно написать код, показанный в листинге 2.7, который запускает несколько потоков, а потом ждет их завершения.


Листинг 2.7. Запуск нескольких потоков и ожидание их завершения

void do_work(unsigned id);


void f() {

 std::vector<std::thread> threads;

 for (unsigned i = 0; i < 20; ++i) {           │ Запуск

  threads.push_back(std::thread(do_work(i))); ←┘ потоков

 }                                            │ Поочередный

 std::for_each(threads.begin(), threads.end(),│ вызов join()

 std::mem_fn(&std::thread::join));           ←┘ для каждого потока

}

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

Хранение объектов std::thread в векторе std::vector — шаг к автоматизации управления потоками: вместо тот чтобы создавать отдельные переменные для потоков и выполнять соединение напрямую, мы можем рассматривать группу потоков. Можно пойти еще дальше и создавать не фиксированное число потоков, как в листинге 2.7, а определять нужное количество динамически, во время выполнения.

2.4. Задание количества потоков во время выполнения

В стандартной библиотеке С++ есть функция std::thread::hardware_concurrency(), которая поможет нам решить эту задачу. Она возвращает число потоков, которые могут работать по-настоящему параллельно. В многоядерной системе это может быть, например, количество процессорных ядер. Возвращаемое значение всего лишь оценка; более того, функция может возвращать 0, если получить требуемую информацию невозможно. Однако эту оценку можно с пользой применить для разбиения задачи на несколько потоков.

В листинге 2.8 приведена простая реализация параллельной версии std::accumulate. Она распределяет работу между несколькими потоками и, чтобы не создавать слишком много потоков, задает ограничение снизу на количество элементов, обрабатываемых одним потоком. Отмстим, что в этой реализации предполагается, что ни одна операция не возбуждает исключений, хотя в принципе исключения возможны; например, конструктор std::thread возбуждает исключение, если не может создать новый поток. Но если добавить в этот алгоритм обработку исключений, он перестанет быть таким простым; эту тему мы рассмотрим в главе 8.


Листинг 2.8. Наивная реализация параллельной версии алгоритма std::accumulate

template<typename Iterator, typename T>

 struct accumulate_block {

 void operator()(Iterator first, Iterator last, T& result) {

  result = std::accumulate(first, last, result);

 }

};


template<typename Iterator, typename T>

T parallel_accumulate(Iterator first, Iterator last, T init) {

 unsigned long const length = std::distance(first, last);

 if (!length) ← (1)

  return init;


 unsigned long const min_per_thread = 25;

 unsigned long const max_threads =

  (length+min_per_thread - 1) / min_per_thread; ← (2)


 unsigned long const hardware_threads =

  std::thread::hardware_concurrency();


 unsigned long const num_threads = ← (3)

  std::min(

   hardware.threads != 0 ? hardware_threads : 2, max_threads);


 unsigned long const block_size = length / num_threads; ← (4)


 std::vector<T> results(num_threads);

 std::vector<std::thread> threads(num_threads - 1); ← (5)


 Iterator block_start = first;

 for(unsigned long i = 0; i < (num_threads - 1); ++i) {

  Iterator block_end = block_start;

  std::advance(block_end, block_size); ← (6)


  threads[i] = std::thread( ← (7)

   accumulate_block<Iterator, T>(),

   block_start, block_end, std::ref(results(i)));

  block_start = block_end;  ← (8)

 }

 accumulate_block()(

  block_start, last, results[num_threads-1]); ← (9)


 std::for_each(threads.begin(), threads.end(),

 std::mem_fn(&std::thread::join)); ← (10)


 return

  std::accumulate(results.begin(), results.end(), init); ← (11)

}

Хотя функция довольно длинная, по существу она очень проста. Если входной диапазон пуст (1), то мы сразу возвращаем начальное значение init. В противном случае диапазон содержит хотя бы один элемент, поэтому мы можем разделить количество элементов на минимальный размер блока и получить максимальное число потоков (2).

Это позволит избежать создания 32 потоков на 32-ядерной машине, если диапазон состоит всего из пяти элементов.

Число запускаемых потоков равно минимуму из только что вычисленного максимума и количества аппаратных потоков (3): мы не хотим запускать больше потоков, чем может поддержать оборудование (это называется превышением лимита), так как из-за контекстных переключений при большем количестве потоков производительность снизится. Если функция std::thread::hardware_concurrency() вернула 0, то мы берем произвольно выбранное число, я решил остановиться на 2. Мы не хотим запускать слишком много потоков, потому что на одноядерной машине это только замедлило бы программу. Но и слишком мало потоков тоже плохо, так как это означало бы отказ от возможного параллелизма.

Каждый поток будет обрабатывать количество элементов, равное длине диапазона, поделенной на число потоков (4). Пусть вас не пугает случай, когда одно число нацело не делится на другое, — ниже мы рассмотрим его.

Теперь, зная, сколько необходимо потоков, мы можем создать вектор std::vector<T> для хранения промежуточных результатов и вектор std::vector<std::thread> для хранения потоков (5). Отметим, что запускать нужно на один поток меньше, чем num_threads, потому что один поток у нас уже есть.

Запуск потоков производится в обычном цикле: мы сдвигаем итератор block_end в конец текущего блока (6) и запускаем новый поток для аккумулирования результатов по этому блоку (7). Начало нового блока совпадает с концом текущего (8).

После того как все потоки запущены, главный поток может обработать последний блок (9). Именно здесь обрабатывается случай деления с остатком: мы знаем, что конец последнего блока — last, а сколько в нем элементов, не имеет значения.

Аккумулировав результаты но последнему блоку, мы можем дождаться завершения всех запущенных потоков с помощью алгоритма std::for_each (10), а затем сложить частичные результаты, обратившись к std::accumulate (11).

Прежде чем расстаться с этим примером, полезно отметить, что в случае, когда оператор сложения, определенный в типе T, не ассоциативен (например, если T — это float или double), результаты, возвращаемые алгоритмами parallel_accumulate и std::accumulate, могут различаться из-за разбиения диапазона на блоки. Кроме того, к итераторам предъявляются более жесткие требования: они должны быть по меньшей мере однонаправленными, тогда как алгоритм std::accumulate может работать и с однопроходными итераторами ввода. Наконец, тип T должен допускать конструирование по умолчанию (удовлетворять требованиям концепции DefaultConstructible), чтобы можно было создать вектор results. Такого рода изменения требований довольно типичны для параллельных алгоритмов: но самой своей природе они отличаются от последовательных алгоритмов, и это приводит к определенным последствиям в части как результатов, так и требований. Более подробно параллельные алгоритмы рассматриваются в главе 8. Стоит также отметить, что из-за невозможности вернуть значение непосредственно из потока, мы должны передавать ссылку на соответствующий элемент вектора results. Другой способ возврата значений из потоков, с помощью будущих результатов, рассматривается в главе 4.

В данном случае вся необходимая потоку информация передавалась в момент его запуска  в том числе и адрес, но которому необходимо сохранить результат вычисления. Так бывает не всегда; иногда требуется каким-то образом идентифицировать потоки во время работы. Конечно, можно было бы передать какой-то идентификатор, например значение i в листинге 2.7, но если вызов функции, которой этот идентификатор нужен, находится несколькими уровнями стека глубже, и эта функция может вызываться из любого потока, то поступать так неудобно. Проектируя библиотеку С++ Thread Library, мы предвидели этот случай, поэтому снабдили каждый поток уникальным идентификатором.

2.5. Идентификация потоков

Идентификатор потока имеет тип std::thread::id, и получить его можно двумя способами. Во-первых, идентификатор потока, связанного с объектом std::thread, возвращает функция-член get_id() этого объекта. Если с объектом std::thread не связан никакой поток, то get_id() возвращает сконструированный по умолчанию объект типа std::thread::id, что следует интерпретировать как «не поток». Идентификатор текущего потока можно получить также, обратившись к функции std::this_thread::get_id(), которая также определена в заголовке <thread>.

Объекты типа std::thread::id можно без ограничений копировать и сравнивать, в противном случае они вряд ли могли бы играть роль идентификаторов. Если два объекта типа std::thread::id равны, то либо они представляют один и тот же поток, либо оба содержат значение «не поток». Если же два таких объекта не равны, то либо они представляют разные потоки, либо один представляет поток, а другой содержит значение «не поток».

Библиотека Thread Library не ограничивается сравнением идентификаторов потоков на равенство, для объектов типа std::thread::id определен полный спектр операторов сравнения, то есть на множестве идентификаторов потоков задан полный порядок. Это позволяет использовать их в качестве ключей ассоциативных контейнеров, сортировать и сравнивать любым интересующим программиста способом. Поскольку операторы сравнения определяют полную упорядоченность различных значений типа std::thread::id, то их поведение интуитивно очевидно: если a<b и b<c то а<с и так далее. В стандартной библиотеке имеется также класс std::hash<std::thread::id>, поэтому значения типа std::thread::id можно использовать и в качестве ключей новых неупорядоченных ассоциативных контейнеров.

Объекты std::thread::id часто применяются для того, чтобы проверить, должен ли поток выполнить некоторую операцию. Например, если потоки используются для разбиения задач, как в листинге 2.8, то начальный поток, который запускал все остальные, может вести себя несколько иначе, чем прочие. В таком случае этот поток мог бы сохранить значение std::this_thread::get_id() перед тем, как запускать другие потоки, а затем в основной части алгоритма (общей для всех потоков) сравнить собственный идентификатор с сохраненным значением.

std::thread::id master_thread;


void some_core_part_of_algorithm() {

 if (std::this_thread::get_id() == master_thread) {

  do_master_thread_work();

 }

 do_common_work();

}

Или можно было бы сохранить std::thread::id текущего потока в некоторой структуре данных в ходе выполнения какой-то операции. В дальнейшем при операциях с той же структурой данных можно было сравнить сохраненный идентификатор с идентификатором потока, выполняющего операцию, и решить, какие операции разрешены или необходимы.

Идентификаторы потоков можно было бы также использовать как ключи ассоциативных контейнеров, если с потоком нужно ассоциировать какие-то данные, а другие механизмы, например поточно-локальная память, не подходят. Например, управляющий поток мог бы сохранить в таком контейнере информацию о каждом управляемом им потоке. Другое применение подобного контейнера — передавать информацию между потоками.

Идея заключается в том, что в большинстве случаев std::thread::id вполне может служить обобщенным идентификатором потока и лишь, если с идентификатором необходимо связать какую-то семантику (например, использовать его как индекс массива), может потребоваться другое решение. Можно даже выводить объект std::thread::id в выходной поток, например std::cout:

std::cout << std::this_thread::get_id();

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

2.6. Резюме

В этой главе мы рассмотрели основные средства управления потоками, имеющиеся в стандартной библиотеке С++: запуск потоков, ожидание завершения потока и отказ от ожидания вследствие того, что поток работает в фоновом режиме. Мы также научились передавать аргументы функции потока при запуске и передавать ответственность за управление потоком из одной части программы в другую. Кроме того, мы видели, как можно использовать группы потоков для разбиения задачи на части. Наконец, мы обсудили механизм идентификации потоков, позволяющий ассоциировать с потоком данные или поведение в тех случаях, когда использовать другие средства неудобно. Даже совершенно независимые потоки позволяют сделать много полезного, как видно из листинга 2.8, но часто требуется, чтобы работающие потоки обращались к каким-то общим данным. В главе 3 рассматриваются проблемы, возникающие при разделении данных между потоками, а в главе 4 — более общие вопросы синхронизации операций с использованием и без использования разделяемых данных.

Глава 3. Разделение данных между потоками

В этой главе:
■ Проблемы разделения данных между потоками.

■ Защита данных с помощью мьютексов.

■ Альтернативные средства защиты разделяемых данных.

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

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

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

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

3.1. Проблемы разделения данных между потоками

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

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

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

Шаги удаления узла из списка показаны на рис. 3.1:

1. Найти подлежащий удалению узел (N).

2. Изменить «указатель на следующий» в узле, предшествующем N, так чтобы он указывал на узел, следующий за N.

3. Изменить «указатель на предыдущий» в узле, следующем за N, так чтобы он указывал на узел, предшествующий N.

4. Удалить узел N.

Рис. 3.1. Удаление узла из двусвязного списка

Как видите, между шагами b и с указатели в одном направлении не согласуются с указателями в другом направлении, и инвариант нарушается.

Простейшая проблема, которая может возникнуть при модификации данных, разделяемых несколькими потоками, — нарушение инварианта. Если не предпринимать никаких мер, то в случае, когда один поток читает двусвязный список, а другой в это же время удаляет из списка узел, вполне может случиться, что читающий поток увидит список, из которого узел удален лишь частично (потому что изменен только один указатель, как на шаге b на рис. 3.1), так что инвариант нарушен. Последствия могут быть разными — если поток читает список слева направо, то он просто пропустит удаляемый узел. Но если другой поток пытается удалить самый правый узел, показанный на рисунке, то он может навсегда повредить структуру данных, и в конце концов это приведет к аварийному завершению программы. Как бы то ни было, этот пример иллюстрирует одну из наиболее распространенных причин ошибок в параллельном коде: состояние гонки (race condition).

3.1.1. Гонки

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

В параллельном программировании под состоянием гонки понимается любая ситуация, исход которой зависит от относительного порядка выполнения операций в двух или более потоках — потоки конкурируют за право выполнить операции первыми. Как правило, ничего плохого в этом нет, потому что все исходы приемлемы, даже если их взаимный порядок может меняться. Например, если два потока добавляют элементы в очередь для обработки, то вообще говоря неважно, какой элемент будет добавлен первым, лишь бы не нарушались инварианты системы. Проблема возникает, когда гонка приводит к нарушению инвариантов, как в приведенном выше примере удаления из двусвязного списка. В контексте параллельного программирования состоянием гонки обычно называют именно такую проблематичную гонку — безобидные гонки не так интересны и к ошибкам не приводят. В стандарте С++ определен также термин гонка за данными (data race), означающий ситуацию, когда гонка возникает из-за одновременной модификации одного объекта (детали см. в разделе 5.1.2); гонки за данными приводят к внушающему ужас неопределенному поведению.

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

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

3.1.2. Устранение проблематичных состояний гонки

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

Другой вариант — изменить дизайн структуры данных и ее инварианты, так чтобы модификация представляла собой последовательность неделимых изменений, каждое из которых сохраняет инварианты. Этот подход обычно называют программированием без блокировок (lock-free programming) и реализовать его правильно очень трудно; если вы работаете на этом уровне, то приходится учитывать нюансы модели памяти и разбираться, какие потоки потенциально могут увидеть те или иные наборы значений. Модель памяти обсуждается в главе 5, а программирование без блокировок — в главе 7.

Еще один способ справиться с гонками — рассматривать изменения структуры данных как транзакцию, то есть так, как обрабатываются обновления базы данных внутри транзакции. Требуемая последовательность изменений и чтений данных сохраняется в журнале транзакций, а затем атомарно фиксируется. Если фиксация невозможна, потому что структуру данных в это время модифицирует другой поток, то транзакция перезапускается. Это решение называется программной транзакционной памятью (Software Transactional Memory — STM), в настоящее время в этой области ведутся активные исследования. Мы не будем рассматривать STM в этой книге, потому что в С++ для нее нет поддержки. Однако к самой идее о том, чтобы выполнить какую-то последовательность действий и за один шаг зафиксировать результаты, я еще вернусь.

Самый простой механизм защиты разделяемых данных из описанных в стандарте С++ — это мьютекс, с него мы и начнем рассмотрение.

3.2. Защита разделяемых данных с помощью мьютексов

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

Что ж, это вовсе не сказка — именно такое поведение вы получаете при использовании примитива синхронизации, который называется мьютекс (слово mutex происходит от mutual exclusion — взаимное исключение). Перед тем как обратиться к структуре данных, программа захватывает (lock) мьютекс, а по завершении операций с ней освобождает (unlock) его. Библиотека Thread Library гарантирует, что если один поток захватил некоторый мьютекс, то все остальные потоки, пытающиеся захватить тот же мьютекс, будут вынуждены ждать, пока удачливый конкурент не освободит его. В результате все потоки видят согласованное представление разделяемых данных, без нарушенных инвариантов.

Мьютексы — наиболее общий механизм защиты данных в С++, но панацеей они не являются; важно структурировать код так, чтобы защитить нужные данные (см. раздел 3.2.2), и избегать состояний гонки, внутренне присущих интерфейсам (см раздел 3.2.3). С мьютексами связаны и собственные проблемы, а именно: взаимоблокировки (deadlock) (см. раздел 3.2.4), а также защита слишком большого или слишком малого количества данных (см. раздел 3.2.8). Но начнем с простого.

3.2.1. Использование мьютексов в С++

В С++ для создания мьютекса следует сконструировать объект типа std::mutex, для захвата мьютекса служит функция-член lock(), а для освобождения — функция-член unlock(). Однако вызывать эти функции напрямую не рекомендуется, потому что в этом случае необходимо помнить о вызове unlock() на каждом пути выхода из функции, в том числе и вследствие исключений. Вместо этого в стандартной библиотеке имеется шаблон класса std::lock_guard, который реализует идиому RAII — захватывает мьютекс в конструкторе и освобождает в деструкторе, — гарантируя тем самым, что захваченный мьютекс обязательно будет освобожден. В листинге 3.1 показано, как с помощью классов std::mutex и std::lock_guard защитить список, к которому могут обращаться несколько потоков. Оба класса определены в заголовке <mutex>.


Листинг 3.1. Защита списка с помощью мьютекса

#include <list>

#include <mutex>

#include <algorithm>


std::list<int> some_list; ← (1)

std::mutex some_mutex;    ← (2)


void add_to_list(int new_value) {

 std::lock_guard<std::mutex> guard(some_mutex); ← (3)

 some_list.push_back(new_value);

}


bool list_contains(int value_to_find) {

 std::lock_guard<std::mutex> guard(some_mutex); ← (4)

 return

  std::find(some_list.begin(), some_list.end(), value_to_find) !=

  some_list.end();

}

В листинге 3.1 есть глобальный список (1), который защищен глобальным же объектом std::mutex (2). Вызов std::lock_guard<std::mutex> в add_to_list() (3) и list_contains() (4) означает, что доступ к списку из этих двух функций является взаимно исключающим: list_contains() никогда не увидит промежуточного результата модификации списка, выполняемой в add_to_list().

Хотя иногда такое использование глобальных переменных уместно, в большинстве случаев мьютекс и защищаемые им данные помещают в один класс, а не в глобальные переменные. Это не что иное, как стандартное применение правил объектно-ориентированного проектирования; помещая обе сущности в класс, вы четко даете понять, что они взаимосвязаны, а, кроме того, обеспечиваете инкапсулирование функциональности и ограничение доступа. В данном случае функции add_to_list и list_contains следует сделать функциями-членами класса, а мьютекс и защищаемые им данные — закрытыми переменными-членами класса. Так будет гораздо проще понять, какой код имеет доступ к этим данным и, следовательно, в каких участках программы необходимо захватывать мьютекс. Если все функции-члены класса захватывают мьютекс перед обращением к каким-то другим данным-членам и освобождают по завершении действий, то данные оказываются надежно защищены от любопытствующих.

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

3.2.2. Структурирование кода для защиты разделяемых данных

Как мы только что видели, для защиты данных с помощью мьютекса недостаточно просто «воткнуть» объект std::lock_guard в каждую функцию-член: один-единственный «отбившийся» указатель или ссылка сводит всю защиту на нет. На некотором уровне проверить наличие таких отбившихся указателей легко — если ни одна функция-член не передает вызывающей программе указатель или ссылку на защищенные данные в виде возвращаемого значения или выходного параметра, то данные в безопасности. Но стоит копнуть чуть глубже, как выясняется, что всё не так просто, — а просто никогда не бывает. Недостаточно проверить, что функции-члены не возвращают указатели и ссылки вызывающей программе, нужно еще убедиться, что такие указатели и ссылки не передаются в виде входных параметров вызываемым ими функциям, которые вы не контролируете. Это ничуть не менее опасно — что, если такая функция сохранит где-то указатель или ссылку, а потом какой-то другой код обратится к данным, не захватив предварительно мьютекс? Особенно следует остерегаться функций, которые передаются во время выполнения в виде аргументов или иными способами, как показано в листинге 3.2.


Листинг 3.2. Непреднамеренная передача наружу ссылки на защищённые данные

class some_data {

 int а;

 std::string b;

public:

 void do_something();

};


class data_wrapper {

private:

 some_data data;

 std::mutex m;

public :

 template<typename Function>

 void process_data(Function func)  (1) Передаем

 {                                 │ "защищенные"

  std::lock_guard<std::mutex> l(m);│ данные поль-

  func(data);                     ←┘ зовательской

 }                                   функции

};


some_data* unprotected;


void malicious_function(some_data& protected_data) {

 unprotected = &protected_data;

}


data_wrapper x;


void foo                              (2) Передаем

{                                     │ вредоносную

 x.process_data(malicious_function); ←┘ функцию

 unprotected->do_something(); ← (3) Доступ к "защищенным"

}                                 данным в обход защиты

В этом примере функция-член process_data выглядит вполне безобидно, доступ к данным охраняется объектом std::lock_guard, однако наличие обращения к переданной пользователем функции func (1) означает, что foo может передать вредоносную функцию malicious_function, чтобы обойти защиту (2), а затем вызвать do_something(), не захватив предварительно мьютекс (3).

Здесь фундаментальная проблема заключается в том, что мы не сделали того, что собирались сделать: пометить все участки кода, в которых имеется доступ к структуре данных, как взаимно исключающие. В данном случае мы забыли о коде внутри foo(), который вызывает unprotected->do_something(). К сожалению, в этом стандартная библиотека С++ нам помочь не в силах: именно программист должен позаботиться о том, чтобы защитить данные мьютексом. Но не всё так мрачно — следование приведенной ниже рекомендации выручит в таких ситуациях. Не передавайте указатели и ссылки на защищенные данные за пределы области видимости блокировки никаким способом, будь то возврат из функции, сохранение в видимой извне памяти или передача в виде аргумента пользовательской функции.

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

3.2.3. Выявление состояний гонки, внутренне присущих интерфейсам

Тот факт, что вы пользуетесь мьютексами или другим механизмом для защиты разделяемых данных, еще не означает, что гонок можно не опасаться, — следить за тем, чтобы данные были защищены, все равно нужно. Вернемся снова к примеру двусвязного списка. Чтобы поток мог безопасно удалить узел, необходимо предотвратить одновременный доступ к трем узлам: удаляемому и двум узлам но обе стороны от него. Заблокировав одновременный доступ к указателям на каждый узел но отдельности, мы не достигнем ничего по сравнению с вариантом, где мьютексы вообще не используются, поскольку гонка по-прежнему возможна. Защищать нужно не отдельные узлы на каждом шаге, а структуру данных в целом на все время выполнения операции удаления. Простейшее решение в данном случае — завести один мьютекс, который будет защищать весь список, как в листинге 3.1.

Однако и после обеспечения безопасности отдельных операций наши неприятности еще не закончились — гонки все еще возможны, даже для самого простого интерфейса. Рассмотрим структуру данных для реализации стека, например, адаптер контейнера std::stack, показанный в листинге 3.3. Помимо конструкторов и функции swap(), имеется еще пять операций со стеком: push() заталкивает в стек новый элемент, pop() выталкивает элемент из стека, top() возвращает элемент, находящийся на вершине стека, empty() проверяет, пуст ли стек, и size() возвращает размер стека. Если изменить top(), так чтобы она возвращала копию, а не ссылку (в соответствии с рекомендацией из раздела 3.2.2), и защитить внутренние данные мьютексом, то и тогда интерфейс уязвим для гонки. Проблема не в реализации на основе мьютексов, она присуща самому интерфейсу, то есть гонка может возникать даже в реализации без блокировок.


Листинг 3.3. Интерфейс адаптера контейнера std::stack

template<typename T, typename Container = std::deque<T> >

class stack {

public:

 explicit stack(const Container&);

 explicit stack(Container&& = Container());

 template <class Alloc> explicit stack(const Alloc&);

 template <class Alloc> stack(const Container&, const Alloc&);

 template <class Alloc> stack(Container&&, const Alloc&);

 template <class Alloc> stack(stack&&, const Alloc&);

 bool empty() const;

 size_t size() const;

 T& top();

 T const& top() const;

 void push(T const&);

 void push(T&&);

 void pop();

 void swap(stack&&);

};

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

Если экземпляр stack не является разделяемым, то нет ничего страшного в том, чтобы проверить, пуст ли стек с помощью empty(), а затем, если стек не пуст, вызвать top() для доступа к элементу на вершине стека:

stack<int> s;

if (!s.empty())             ← (1)

{

 int const value = s.top(); ← (2)

 s.pop();                   ← (3)

 do_something(value);

}

Такой подход в однопоточном коде не только безопасен, но и единственно возможен: вызов top() для пустого стека приводит к неопределенному поведению. Но если объект stack является разделяемым, то такая последовательность операций уже не безопасна, так как между вызовами empty() (1) и top() (2) другой поток мог вызвать pop() и удалить из стека последний элемент. Таким образом, мы имеем классическую гонку, и использование внутреннего мьютекса для защиты содержимого стека ее не предотвращает. Это следствие дизайна интерфейса.

И что же делать? Поскольку проблема коренится в дизайне интерфейса, то и решать ее надо путем изменения интерфейса. Но возникает вопроса — как его изменить? В простейшем случае мы могли бы просто декларировать, что top() возбуждает исключение, если в момент вызова в стеке нет ни одного элемента. Формально это решает проблему, но затрудняет программирование, поскольку теперь мы должны быть готовы к перехвату исключения, даже если вызов empty() вернул false. По сути дела, вызов empty() вообще оказывается ненужным.

Внимательно присмотревшись к показанному выше фрагменту, мы обнаружим еще одну потенциальную гонку, на этот раз между вызовами top() (2) и pop() (3). Представьте, что этот фрагмент исполняют два потока, ссылающиеся на один и тот же объект s типа stack. Ситуация вполне обычная: при использовании потока для повышения производительности часто бывает так, что несколько потоков исполняют один и тот же код для разных данных, и разделяемый объект stack идеально подходит для разбиения работы между потоками. Предположим, что первоначально в стеке находится два элемента, поэтому можно с уверенностью сказать, что между empty() и top() не будет гонки ни в одном потоке. Теперь рассмотрим возможные варианты выполнения программы.

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

Поток А -                    - Поток В

if (!s.empty())

                            if (!s.empty())

 int const value = s.top();

                             int const value = s.top();

s.pop();

do_something(value);        s.pop();

                            do_something(value);

Как видите, если работают только эти два потока, то между двумя обращениями к top() никто не может модифицировать стек, так что оба потока увидят одно и то же значение. Однако беда в том, что между обращениями к pop() нет обращений к top(). Следовательно, одно из двух хранившихся в стеке значений никто даже не прочитает, оно будет просто отброшено, тогда как другое будет обработано дважды. Это еще одно состояние гонки, и куда более коварное, чем неопределенное поведение в случае гонки между empty() и top(), — на первый взгляд, ничего страшного не произошло, а последствия ошибки проявятся, скорее всего, далеко от места возникновения, хотя, конечно, всё зависит от того, что именно делает функция do_something().

Для решения проблемы необходимо более радикальное изменение интерфейса — выполнение обеих операций top() и pop() под защитой одного мьютекса. Том Каргилл[4] указал, что такой объединенный вызов приводит к проблемам в случае, когда копирующий конструктор объектов в стеке может возбуждать исключения. С точки зрения безопасности относительно исключений, задачу достаточно полно решил Герб Саттер[5], однако возможность возникновения гонки вносит в нее новый аспект.

Для тех, кто незнаком с историей вопроса, рассмотрим класс stack<vector<int>>. Вектор — это контейнер с динамически изменяемым размером, поэтому при копировании вектора библиотека должна выделить из кучи память. Если система сильно загружена или имеются жесткие ограничения на ресурсы, то операция выделения памяти может завершиться неудачно, и тогда копирующий конструктор вектора возбудит исключение std::bad_alloc. Вероятность такого развития событий особенно велика, если вектор содержит много элементов. Если бы функция pop() возвращала вытолкнутое из стека значение, а не только удаляла его из стека, то мы получили бы потенциальную проблему: вытолкнутое значение возвращается вызывающей программе только после модификации стека, но в процессе копирования возвращаемых данных может возникнуть исключение. Если такое случится, то только что вытолкнутые данные будут потеряны — из стека они удалены, но никуда не скопированы! Поэтому проектировщики интерфейса std::stack разбили операцию на две: получить элемент, находящийся на вершине (top()), а затем удалить его из стека (pop()). Теперь, данные, которые не удалось скопировать, остаются в стеке; если проблема связана с нехваткой памяти в куче, то, возможно, приложение сможет освободить немного памяти и попытаться выполнить операцию еще раз. ...



Все права на текст принадлежат автору: Энтони Уильямс.
Это короткий фрагмент для ознакомления с книгой.
Параллельное программирование на С++ в действии. Практика разработки многопоточных программЭнтони Уильямс