В этой главе мы рассмотрели различные виды ошибок, связанных с параллелизмом, — от взаимоблокировок и активных блокировок до гонок за данными и других проблематичных состояний гонки. Были описаны различные методы поиска ошибок. Я сформулировал вопросы, над которыми следует поразмыслить, дал рекомендации по написанию тестопригодного кода и рассказал о том, как структурировать тесты для параллельных программ. И, наконец, мы затронули вопрос о некоторых служебных компонентах, которые могут оказать помощь в процессе тестирования.
Приложение А.Краткий справочник по некоторым конструкциям языка С++
Новый стандарт С++ отнюдь не исчерпывается поддержкой параллелизма; в нем появилось немало других языковых средств и новых библиотек. В этом приложении я вкратце расскажу о тех новых возможностях, которые используются в библиотеке многопоточности и встречаются в этой книге. За исключением модификатора
thread_local
(рассматриваемого в разделе А.8), все они не имеют прямого отношения к параллелизму, однако важны и (или) полезны для написания многопоточного кода. Я ограничился лишь теми конструкциями, которые либо необходимы (например, ссылки на r-значения), либо делают код проще и яснее. Поначалу разобраться в программе, где применяются эти конструкции, будет трудно, но, познакомившись с ними поближе, вы согласитесь, что, вообще говоря, включающий их код проще, а не сложнее для понимания. По мере распространения С++11 описываемые средства будут встречаться в программах все чаще.А теперь, без дальнейших предисловий, начнем с изучения ссылок на r-значения — средства, которое широко используется в библиотеке Thread Library для передачи владения (потоками, блокировками и вообще всем на свете) от одного объекта другому.
А.1. Ссылки на r-значения
Всякий, кто программировал на С++, знаком со ссылками; в С++ ссылки служат для создания альтернативного имени существующего объекта. Любой доступ к объекту по ссылке, в том числе для модификации, приводит к манипуляциям с исходным объектом. Например:
int var = 42; │
Создаем ссылкуint& ref = var;←┘
на varref = 99; │
В результате присваивания ссылкеassert (var == 99);←┘
изменен оригиналСсылки, к которым мы все давно привыкли, являются ссылками на l-значения. Термин l-значение появился еще в языке С и обозначает любую конструкцию, которая может находиться в левой части выражения присваивания, — именованные объекты, объекты, созданные в стеке или в куче, или члены других объектов, то есть сущности, расположенные по определенному адресу в памяти. Термин r-значение также происходит из С и обозначает конструкции, которые могут находиться только в правой части выражения присваивания, — например, литералы и временные объекты. Ссылки на l-значения можно связать только с l-значениями, но не с r-значениями. Так, невозможно написать
int& i = 42;
потому что 42 — это r-значение. Впрочем, это не совсем верно; всегда разрешалось связывать r-значение с константной ссылкой на l-значение:
int const& i = 42;
Однако в стандарте это исключение сделано сознательно задолго до появления ссылок на r-значения, и смысл его в том, чтобы разрешить передавать временные объекты функциям, принимающим ссылки. Благодаря этому механизму становятся возможны неявные преобразования, например, можно написать:
void print(std::string const& s);
print("hello");
Как бы то ни было, в стандарте C++11 официально введены ссылки на r-значения, которые связываются только с r-значениями, но не с l-значениями, и объявляются с помощью двух знаков амперсанда:
int&& i = 42;
int j = 42;
int&& k = j;
Таким образом, функцию можно перегрузить в зависимости от того, являются параметры l-значениями или r-значениями, — один вариант будет принимать ссылку на l-значение, другой — на r-значение. Эта возможность — краеугольный камень семантики перемещения.
A.1.1. Семантика перемещения
r-значения — это обычно временные объекты, поэтому их можно спокойно модифицировать; если известно, что параметр функции — r-значение, то его можно использовать как временную память, то есть «позаимствовать» его содержимое без ущерба для корректности программы. Это означает, что вместо копирования параметра, являющегося r-значением, мы можем просто переместить его содержимое. В случае больших динамических структур это позволяет сэкономить на выделении памяти и оставляет простор для оптимизации.
Рассмотрим функцию, которая принимает в качестве параметра
std::vector
и хочет иметь его внутреннюю копию для модификации, так чтобы не затрагивать оригинал. Раньше мы для этого должны были принимать параметр как const
-ссылку на l-значение и делать внутреннюю копию:void process_copy(std::vector const& vec_) {
std::vector vec(vec_);
vec.push_back(42);
}
При этом функция может принимать как l-значения, так и r-значения, но копирование производится всегда. Однако, если добавить перегруженный вариант, который принимает ссылку на r-значение, то в этом случае можно будет избежать копирования, поскольку нам точно известно, что оригинал разрешается модифицировать:
void process_copy(std::vector&& vec) {
vec.push_back(42);
}
Если функция является конструктором класса, то можно умыкнуть содержимое r-значения и воспользоваться им для создания нового экземпляра. Рассмотрим класс, показанный в листинге ниже. В конструкторе по умолчанию он выделяет большой блок памяти, а в деструкторе освобождает его.
Листинг А.1. Класс с перемещающим конструктором
class X {
private:
int* data;
public:
X() : data(new int[1000000]) {}
~X() {
delete [] data;
}
X(const X& other) : ←
(1) data(new int[1000000]) {
std::copy(other.data, other.data + 1000000, data);
}
X(X&& other) : ←
(2) data(other.data) {
other.data = nullptr;
}
};
Копирующий конструктор(1) определяется как обычно: выделяем новый блок памяти и копируем в него данные. Но теперь у нас есть еще один конструктор, который принимает ссылку на r-значение (2). Это перемещающий конструктор. В данном случае мы копируем только указатель на данные, а в объекте
other
остается нулевой указатель. Таким образом, мы обошлись без выделения огромного блока памяти и сэкономили время на копировании данных из r-значения.В классе
X
перемещающий конструктор — всего лишь оптимизация, но в ряде случаев такой конструктор имеет смысл определять, даже когда копирующий конструктор не предоставляется. Например, идея std::unique_ptr<>
в том и заключается, что любой ненулевой экземпляр является единственным указателем на свой объект, поэтому копирующий конструктор лишен смысла. Однако же перемещающий конструктор позволяет передавать владение указателем от одного объекта другому, поэтому std::unique_ptr<>
можно использовать в качестве возвращаемого функцией значения — указатель перемещается, а не копируется.Чтобы явно переместить значение из именованного объекта, который больше заведомо не будет использоваться, мы можем привести его к типу r-значения либо с помощью
static_cast
, либо путем вызова функции std::move()
:X x1;
X x2 = std::move(x1);
X x3 = static_cast(x2);
Это особенно удобно, когда требуется переместить значение параметра в локальную переменную или переменную-член без копирования, потому что хотя параметр, являющийся ссылкой на r-значение, и может связываться с r-значениями, но внутри функции он трактуется как l-значение:
void do_stuff(X&& x_) {
X a(x_); ←
Копируется X b(std::move(x_)); ←
Перемещается} │
r-значение связываетсяdo_stuff(X());←┘
со ссылкой наr-значениеX x; │
Ошибка,l-значение нельзя связыватьdo_stuff(x);←┘
со ссылкой наr-значениеСемантика перемещения сплошь и рядом используется в библиотеке Thread Library — и в случаях, когда копирование не имеет смысла, но сами ресурсы можно передавать, и как оптимизация, чтобы избежать дорогостоящего копирования, когда исходный объект все равно будет уничтожен. Один пример мы видели в разделе 2.2, где
std::move()
использовалась для передачи экземпляра std::unique_ptr<>
новому потоку, а второй — в разделе 2.3, когда рассматривали передачу владения потоком от одного объекта std::thread
другому.Ни один из классов
std::thread
, std::unique_lock<>
, std::future<>
, std::promise<>
, std::packaged_task<>
не допускает копирования, но в каждом из них имеется перемещающий конструктор, который позволяет передавать ассоциированный ресурс другому экземпляру и возвращать объекты этих классов из функций. Объекты классов std::string
и std::vector<>
можно копировать, как и раньше, но дополнительно они обзавелись перемещающими конструкторами и перемещающими операторами присваивания, чтобы избежать копирования данных из r-значений.Стандартная библиотека С++ никогда не делает ничего с объектом, который был явно перемещён в другой объект, кроме его уничтожения или присваивания ему значения (путем копирования или, что более вероятно, перемещения). Однако рекомендуется учитывать в инвариантах класса состояние перемещен-из. Например, экземпляр
std::thread
, содержимое которого перемещено, эквивалентен объекту std::thread
, сконструированному по умолчанию, а экземпляр std::string
, бывший источником перемещения, все же находится в согласованном состоянии, хотя не дается никаких гарантий относительно того, что это за состояние (в терминах длины строки или содержащихся в ней символов).А.1.2. Ссылки на r-значения и шаблоны функций
Еще один нюанс имеет отношение к использованию ссылок на r-значения в качестве параметров шаблона функции: если параметр функции — ссылка на r-значение типа параметра шаблона, механизм автоматического выведения типа аргумента шаблона заключает, что тип — это ссылка на l-значение, если функции передано l-значение, или обычный не-ссылочный тип, если передано r-значение. Фраза получилась довольно запутанной, поэтому приведём пример. Рассмотрим такую функцию:
template
void foo(T&& t) {}
Если при вызове передать ей r-значение, как показано ниже, то в качестве
T
выводится тип этого значения:foo(42);
foo(3.14159);
fоо(std::string());
Но если вызвать
foo
, передав l-значение, то механизм выведения типа решит, что T
— ссылка на l-значение:int i = 42;
foo(i);
Поскольку объявлено, что параметр функции имеет тип
T&&
, то получается, что это ссылка на ссылку, и такая конструкция трактуется как обычная одинарная ссылка. Таким образом, сигнатура функции foo()
такова:void foo(int& t);
Это позволяет одному шаблону функции принимать параметры, являющиеся как l-, так и r-значениями. В частности, это используется в конструкторе
std::thread
(см. разделы 2.1 и 2.2), чтобы в случае, когда переданный допускающий вызов объект является r-значением, его можно было бы не копировать, а переместить во внутреннюю память.А.2. Удаленные функции