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


До сих пор мы во всех примерах использовали std::future. Однако у этого шаблонного класса есть ограничения, и не в последнюю очередь тот факт, что результата может ожидать только один поток. Если требуется, чтобы одного события ждали несколько потоков, то придётся воспользоваться классом std::shared_future.

4.2.5. Ожидание в нескольких потоках

Хотя класс std::future сам заботится о синхронизации, необходимой для передачи данных из одного потока в другой, обращения к функциям-членам одного и того же экземпляра std::future не синхронизированы между собой. Работа с одним объектом std::future из нескольких потоков без дополнительной синхронизации может закончиться гонкой за данными и неопределенным поведением. Так и задумано: std::future моделирует единоличное владение результатом асинхронного вычисления, и одноразовая природа get() в любом случае делает параллельный доступ бессмысленнымизвлечь значение может только один поток, поскольку после первого обращения к get() никакого значения не остается.

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

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

Рис. 4.1. Использование нескольких объектов std::shared_future, чтобы избежать гонки за данными

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

Экземпляры std::shared_future, ссылающиеся на некоторое асинхронное состояние, конструируются из экземпляров std::future, ссылающихся на то же состояние. Поскольку объект std::future не разделяет владение асинхронным состоянием ни с каким другим объектом, то передавать владение объекту std::shared_future необходимо с помощью std::move, что оставляет std::future с пустым состоянием, как если бы он был сконструирован по умолчанию:

std::promise<int> p;

std::future<int> f(p.get_future())(1) Будущий результат f

assert(f.valid());                 действителен

std::shared_future<int> sf(std::move(f));

assert(!f.valid());(2) f больше не действителен

assert(sf.valid());(3) sf теперь действителен

Здесь будущий результат f в начальный момент действителен (1), потому что ссылается на асинхронное состояние обещания p, но после передачи состояния объекту sf результат f оказывается недействительным (2), a sfдействительным (3).

Как и для других перемещаемых объектов, передача владения для r-значения производится неявно, поэтому объект std::shared_future можно сконструировать прямо из значения, возвращаемого функцией-членом get_future() объекта std::promise, например:

std::promise<std::string> p;(1) Неявная передача владения

std::shared_future<std::string> sf(p.get_future());

Здесь передача владения неявная; объект std::shared_future<> конструируется из r-значения типа std::future<std::string> (1).

У шаблона std::future есть еще одна особенность, которая упрощает использование std::shared_future совместно с новым механизмом автоматического выведения типа переменной из ее инициализатора (см. приложение А, раздел А.6). В шаблоне std::future имеется функция-член share(), которая создает новый объект std::shared_future и сразу передаёт ему владение. Это позволяет сделать код короче и проще для изменения:

std::promise<

 std::map<SomeIndexType, SomeDataType, SomeComparator,

          SomeAllocator>::iterator> p;

auto sf = p.get_future().share();

В данном случае для sf выводится тип std::shared_future<std::map<SomeIndexType, SomeDataType, SomeComparator, SomeAllocator>::iterator>, такое название произнести-то трудно. Если компаратор или распределитель изменятся, то вам нужно будет поменять лишь тип обещания, а тип будущего результата изменится автоматически.

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

4.3. Ожидание с ограничением по времени

Все блокирующие вызовы, рассмотренные до сих пор, приостанавливали выполнение потока на неопределенно долгое времядо тех пор, пока не произойдёт ожидаемое событие. Часто это вполне приемлемого в некоторых случаях время ожидания желательно ограничить. Например, это может быть полезно, когда нужно отправить сообщение вида «Я еще жив» интерактивному пользователю или другому процессу или когда ожидание действительно необходимо прервать, потому что пользователь устал ждать и нажал Cancel.

Можно задать таймаут одного из двух видов: интервальный, когда требуется ждать в течение определённого промежутка времени (к примеру, 30 миллисекунд) или абсолютный, когда требуется ждать до наступления указанного момента (например, 17:30:15.045987023 UTC 30 ноября 2011 года). У большинства функций ожидания имеются оба варианта. Варианты, принимающие интервальный таймаут, оканчиваются словом _for, а принимающие абсолютный таймаутсловом _until.

Например, в классе std::condition_variable есть по два перегруженных варианта функций-членов wait_for() и wait_until(), соответствующие двум вариантам wait()  первый ждет поступления сигнала или истечения таймаута или ложного пробуждения, второй проверяет при пробуждении переданный предикат и возвращает управление, только если предикат равен true (и условной переменной поступил сигнал) или истек таймаут.

Прежде чем переходить к детальному обсуждению функций с таймаутами, рассмотрим, как в С++ задается время, и начнем с часов.

4.3.1. Часы

С точки зрения стандартной библиотеки С++, часыэто источник сведений о времени. Точнее, класс часов должен предоставлять четыре элемента информации:

 текущее время now;

 тип значения для представления времени, полученного от часов;

 величина такта часов;

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

Получить от часов текущее время можно с помощью статической функции-члена now(); например, функция std::chrono::system_clock::now() возвращает текущее время по системным часам. Тип точки во времени для конкретного класса часов определяется с помощью члена typedef time_point, поэтому значение, возвращаемое функцией some_clock::now() имеет тип some_clock::time_point.

Тактовый период часов задается в виде числа долей секунды, которое определяется членом класса typedef period; например, если часы тикают 25 раз в секунду, то член period будет определён как std::ratio<1, 25>, тогда как в часах, тикающих один раз в 2,5 секунды, член period определён как std::ratio<5, 2>. Если тактовый период не известен до начала выполнения программы или может изменяться во время работы, то period можно определить как средний период, наименьший период или любое другое значение, которое сочтет нужным автор библиотеки. Нет гарантии, что тактовый период, наблюдаемый в любом конкретном прогоне программы, соответствует периоду, определённому с помощью члена period.

Если часы ходят с постоянной частотой (вне зависимости от того, совпадает эта частота с period или нет) и не допускают подведения, то говорят, что часы стабильны. Статический член is_steady класса часов равен true, если часы стабильны, и false в противном случае. Как правило, часы std::chrono::system_clock нестабильны, потому что их можно подвести, даже если такое подведение производится автоматически, чтобы учесть локальный дрейф. Из-за подведения более позднее обращение к now() может вернуть значение, меньшее, чем более раннее, а это нарушение требования к равномерному ходу часов. Как мы скоро увидим, стабильность важна для вычислений с таймаутами, поэтому в стандартной библиотеке С++ имеется класс стабильных часовstd::chrono::steady_clock. Помимо него, стандартная библиотека содержит класс std::chrono::system_clock (уже упоминавшийся выше), который представляет системный генератор «реального времени» и имеет функции для преобразования моментов времени в тип time_t и обратно, и класс std::chrono::high_resolution_clock, который представляет наименьший возможный тактовый период (и, следовательно, максимально возможное разрешение). Может статься, что этот тип на самом деле является псевдонимом typedef какого-то другого класса часов. Все эти классы определены в заголовке <chrono> наряду с прочими средствами работы со временем.

Чуть ниже мы рассмотрим представления моментов времени, но сначала познакомимся с представлением интервалов.

4.3.2. Временные интервалы

Интервалысамая простая часть подсистемы поддержки времени; они представлены шаблонным классом std::chrono::duration<> (все имеющиеся в С++ средства работы со временем, которые используются в библиотеке Thread Library, находятся в пространстве имен std::chrono). Первый параметр шаблонаэто тип представления (int, long или double), второйдробь, показывающая, сколько секунд представляет один интервал. Например, число минут, хранящееся в значении типа short, равно std::chrono::duration<short, std::ratio<60,1>>, потому что в одной минуте 60 секунд. С другой стороны, число миллисекунд, хранящееся в значении типа double, равно std::chrono::duration<double, std::ratio<1, 1000>>, потому что миллисекундаэто 1/1000 секунды.

В пространстве имен std::chrono имеется набор предопределенных typedef'ов для различных интервалов: nanoseconds, microseconds, milliseconds, seconds, minutes и hours. В них используется достаточно широкий целочисленный тип, подобранный так, чтобы можно было представить в выбранных единицах интервал продолжительностью свыше 500 лет. Имеются также typedef для всех определенных в системе СИ степеней 10  от std::atto (10-18) до std::exa (1018) (и более, если платформа поддерживает 128-разрядные целые числа)чтобы можно было определить нестандартные интервалы, например std::duration<double, std::centi> (число сотых долей секунды, хранящееся в значении типа double).

Между типами интервалов существует неявное преобразование, если не требуется отсечение (то есть неявно преобразовать часы в секунды можно, а секунды в часы нельзя). Для явного преобразования предназначен шаблон функции std::chrono::duration_cast<>:

std::chrono::milliseconds ms(54802);

std::chrono::seconds s =

 std::chrono::duration_cast<std::chrono::seconds>(ms);

Результат отсекается, а не округляется, поэтому в данном примере s будет равно 54.

Для интервалов определены арифметические операции, то есть сложение и вычитание интервалов, а также умножение и деление на константу базового для представления типа (первый параметр шаблона) дает новый интервал. Таким образом, 5*seconds(1)то же самое, что seconds(5) или minutes(1) - seconds(55). Количество единиц в интервале возвращает функция-член count(). Так, std::chrono::milliseconds(1234).count() равно 1234.

Чтобы задать ожидание в течение интервала времени, используется функция std::chrono::duration<>. Вот, например, как задается ожидание готовности будущего результата в течение 35 миллисекунд:

std::future<int> f = std::async(some_task);

if (f.wait_for(std::chrono::milliseconds(35)) ==

    std::future_status::ready)

 do_something_with(f.get());

Все функции ожидания возвращают код, показывающий, истек ли таймаут или произошло ожидаемое событие. В примере выше мы ожидаем будущий результат, поэтому функция вернет std::future_status::timeout, если истек таймаут, std::future_status::ready  если результат готов, и std::future_status::deferred  если будущая задача отложена. Время ожидания измеряется с помощью библиотечного класса стабильных часов, поэтому 35 мс  это всегда 35 мс, даже если системные часы были подведены (вперёд или назад) в процессе ожидания. Разумеется, из-за особенностей системного планировщика и варьирующейся точности часов ОС фактическое время между вызовом функции в потоке и возвратом из нее может оказаться значительно больше 35 мс.

Разобравшись с интервалами, мы можем перейти к моментам времени.

4.3.3. Моменты времени

Момент времени представляется конкретизацией шаблона класса std::chrono::time_point<>, в первом параметре которой задаются используемые часы, а во второмединица измерения (специализация шаблона std::chrono::duration<>). Значением момента времени является промежуток времени (измеряемый в указанных единицах) с некоторой конкретной точки на временной оси, которая называется эпохой часов. Эпоха часов  это основополагающее свойство, однако напрямую его запросить нельзя, и в стандарте С++ оно не определено. Из типичных эпох можно назвать полночь (00:00) 1 января 1970 года и момент, когда в последний раз был загружен компьютер, на котором исполняется приложение. У разных часов может быть общая или независимые эпохи. Если у двух часов общая эпоха, то псевдоним типа typedef time_point в одном классе может ссылаться на другой класс как на тип, ассоциированный с time_point. Хотя узнать, чему равна эпоха, невозможно, вы можете получить время между данным моментом time_point и эпохой с помощью функции-члена time_since_epoch(), которая возвращает интервал.

Например, можно задать момент времени std::chrono::time_point <std::chrono::system_clock, std::chrono::minutes>. Он представляет время по системным часам, выраженное в минутах, а не в естественных для этих часов единицах (как правило, секунды или доли секунды).

К объекту std::chrono::time_point<> можно прибавить интервал или вычесть из него интервалв результате получится новый момент времени. Например, std::chrono::high_resolution_clock::now() + std::chrono::nanoseconds(500) соответствует моменту времени в будущем, который отстоит от текущего момента на 500 наносекунд. Это удобно для вычисления абсолютного таймаута, когда известна максимально допустимая продолжительность выполнения некоторого участка программы, и внутри этого участка есть несколько обращений к функциям с ожиданием или обращения к функциям, которые ничего не ждут, но предшествуют функции с ожиданием и занимают часть отведенного времени.

Можно также вычесть один момент времени из другого при условии, что они относятся к одним и тем же часам. В результате получиться интервал между двумя моментами. Это полезно для хронометража участков программы, например:

auto start = std::chrono::high_resolution_clock::now();

do_something();

auto stop = std::chrono::high_resolution_clock::now();

Назад Дальше