Параллельное программирование на С++ в действии — страница 14 из 53

Все блокирующие вызовы, рассмотренные до сих пор, приостанавливали выполнение потока на неопределенно долгое время — до тех пор, пока не произойдёт ожидаемое событие. Часто это вполне приемлемого в некоторых случаях время ожидания желательно ограничить. Например, это может быть полезно, когда нужно отправить сообщение вида «Я еще жив» интерактивному пользователю или другому процессу или когда ожидание действительно необходимо прервать, потому что пользователь устал ждать и нажал 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
какого-то другого класса часов. Все эти классы определены в заголовке
наряду с прочими средствами работы со временем.

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

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

Интервалы — самая простая часть подсистемы поддержки времени; они представлены шаблонным классом

std::chrono::duration<>
(все имеющиеся в С++ средства работы со временем, которые используются в библиотеке Thread Library, находятся в пространстве имен
std::chrono
). Первый параметр шаблона — это тип представления (
int
,
long
или
double
), второй — дробь, показывающая, сколько секунд представляет один интервал. Например, число минут, хранящееся в значении типа
short
, равно
std::chrono::duration>
, потому что в одной минуте 60 секунд. С другой стороны, число миллисекунд, хранящееся в значении типа
double
, равно
std::chrono::duration>
, потому что миллисекунда — это 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::chrono::duration_cast<>
:

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

std::chrono::seconds s =

 std::chrono::duration_cast(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 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::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();

std::cout << "do_something() заняла "

<< std::chrono::duration<

     double, std::chrono::seconds>(stop-start).count()

<< " секунд" << std::endl;

Однако параметр

clock
объекта
std::chrono::time_point<>
не только определяет эпоху. Если передать момент времени функции с ожиданием, принимающей абсолютный таймаут, то указанный в нем параметр
clock
используется для измерения времени. Это существенно в случае, когда часы подводятся, потому что механизм ожидания замечает, что наказания часов изменились, и не дает функции вернуть управление, пока функция-член часов
now()
не вернет значение, большее, чем задано в таймауте. Если часы подведены вперёд, то это может уменьшить общее время ожидания (измеренное но стабильным часам), а если назад — то увеличить.

Как и следовало ожидать, моменты времени применяются в вариантах функций с ожиданием, имена которых заканчиваются словом

_until
. Как правило, таймаут задается в виде смещения от значения
some-clock::now()
, вычисленного в определенной точке программы, хотя моменты времени, ассоциированные с системными часами, можно получить из
time_t
с помощью статической функции-члена
std::chrono::system_clock::to_time_point()
, если при планировании операций требуется использовать время в понятном пользователю масштабе. Например, если на ожидание события, связанного с условной переменной, отведено не более 500 мс, то можно написать такой код.


Листинг 4.11. Ожидание условной переменной с таймаутом

#include 

#include 

#include 


std::condition_variable cv;

bool done;

std::mutex m;


bool wait_loop() {

 auto const timeout = std::chrono::steady_clock::now() +

                      std::chrono::milliseconds(500);

 std::unique_lock lk(m);

 while(!done) {

  if (cv.wait_until(lk, timeout) == std::cv_status::timeout)

   break;

 }

 return done;

}

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

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

Вооружившись знаниями о том, как задавать таймауты, рассмотрим функции, в которых таймауты используются.

4.3.4. Функции, принимающие таймаут

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

std::this_thread::sleep_for()
и
std::this_thread::sleep_until()
. Обе работают как будильник: поток засыпает либо на указанный интервал (в случае
sleep_for()
), либо до указанного момента времени (в случае
sleep_until()
). Функцию
sleep_for()
имеет смысл применять в ситуации, описанной в разделе 4.1, когда что-то необходимо делать периодически и важна лишь продолжительность периода. С другой стороны, функция
sleep_until()
позволяет запланировать пробуждение потока в конкретный момент времени, например: запустить в полночь резервное копирование, начать в 6 утра распечатку платёжной ведомости или приостановить поток до момента следующего обновления кадра при воспроизведении видео.

Разумеется, таймаут принимают не только функции типа

sleep
. Выше мы видели, что таймаут можно задавать при ожидании условных переменных и будущих результатов. А также при попытке захватить мьютекс, если сам мьютекс такую возможность поддерживает. Обычные классы
std::mutex
и
std::recursive_mutex
не поддерживают таймаут при захвате, зато его поддерживают классы
std::timed_mutex
и
std::recursive_timed_mutex
. В том и в другом имеются функции-члены
try_lock_for()
и
try_lock_until()
, которые пытаются получить блокировку в течение указанного интервала или до наступления указанного момента времени. В табл. 4.1 перечислены функции из стандартной библиотеки С++, которые принимают таймауты, их параметры и возвращаемые значения. Параметр
duration
должен быть объектом типа
std::duration<>
, а параметр
time_point
— объектом типа
std::time_point<>
.


Таблица 4.1. Функции, принимающие таймаут

Класс / пространство именФункцииВозвращаемые значения
std::this_thread
пространство имен
sleep_for(duration) sleep_until(time_point)
Неприменимо
std::condition_variable
или
std::condition_variable_any
wait_for(lock, duration) wait_until(lock, time_point)
std::cv_status::timeout
или
std::cv_status::no_timeout
wait_for(lock, durationpredicate) wait_until(lock, time_point, predicate)
bool
 — значение, возвращенное предикатом
predicate
при пробуждении
std::timed_mutex
или
std::recursive_timed_mutex
try_lock_for(duration) try_lock_until(time_point)
bool
 —
true
, если мьютекс захвачен, иначе
false
std::unique_lock<TimedLockable>
unique_lock(lockableduration) unique_lock(lockable, time_point)
Неприменимо — функция
owns_lock()
для вновь сконструированного объекта возвращает
true
, если мьютекс захвачен, иначе
false
try_lock_for(duration) try_lock_until(time_point)
bool
 —
true
, если мьютекс захвачен, иначе
false
std::future
или
std::shared_future
wait_for(duration) wait_until(time_point)
std::future_status::timeout
, если истек таймаут,
std::future_status::ready
, если будущий результат готов,
std::future_status::deferred
, если в будущем результате хранится отложенная функция, которая еще не начала исполняться

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

4.4. Применение синхронизации операций для упрощения кода