Пережеванные выкладки программиста

или же трудные будни ленивца-скурпулезы

Previous Entry Share Next Entry
Многопоточное программирование в C++0x
mylogo, xydan, xydan83_logo
xydan
В связи с появлением нового стандарта C++11, в котором появилось множество "новых" инструментов, которые раньше решались на уровне сторонних библиотеки и API ОС, все еще мало документации по использованию таких встроенных средств самого языка. Недавно набрел на  одну интересную статью, в которой описывается организация многопоточности с использованием нового стандарта, появившегося еще с C++0x (до окончательного выхода C++11). Понравилось то, что в статье полностью охватывается данная тема с примерами и достаточно хорошо организованна, в связи с чем и выкладываю в помощь программистам.

Многопоточное программирование в C++0x

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


std::thread


Первый класс, с которым стоит ознакомиться - это std::thread. Класс, необходимый для создание потоков в C++ находится в заголовочном файле thread (#include <thread>) и имеет конструктор, принимающий функцию/функтор, которая должна выполняться в отдельном потоке и опционально принимает аргументы, которые будут передаваться в функцию.



Класс std::thread нельзя копировать, но его можно перемещать (std::move) и присваивать. Присваивать можно только те объекты, которые не связаны ни с каким потоком, тогда объекту будет присвоено только состояние, а при перемещении объекту передается состояние и право на управление потоком.


Каждый поток имеет свой идентификатор типа std::thread::id, который можно получить вызовом метода get_id или же вызовом статического метода std::thread::this_thread::get_id из функции самого потока.


Пример




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

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


std::mutex


В идеальном мире любую задачу можно было разделить на N подзадач, которые могли бы быть выполнены параллельно, тем самым мы бы получили 100% прирост производительности на 2х процессорах, 400% на 4-х и так далее, но в реальном мире далеко не все задачи можно так разделить. В большинстве задач приходится иметь дело с общими данными, но доступ изменения общих данных разными потоками необходимо синхронизировать, чтобы потоки не мешали друг другу. Для этих целей служит объект синхронизации std::mutex (mutual exclusion). Перед тем, как обращаться к общим данным поток должен заблокировать mutex вызовом метода lock, и разблокировать его вызовом unlock когда работа с общими данными завершена.




Принцип довольно прост. Один поток блокирует mutex, другой поток при входе в функцию lock mutex-а, который уже заблокирован, входит в режим ожидания и просыпается тогда, когда mutex освободится (т.е. заблокировавший его поток вызовет unlock).

Библиотека также содержит класс std::recursive_mutex, который можно блокировать более одного раза одним и тем же потоком. Если для обычного заблокированного mutex-а его повторное блокирование тем же потоком приведет к неопределенному поведению, то для рекурсивной версии придется вызывать unlock столько раз, сколько вызывался lock, чтобы его разблокировать.


std::lock_guard


Тем не менее не рекомендуется использовать класс std::mutex напрямую, так-как если между вызовами lock и unlock будет сгенерировано исключение - произойдет deadlock (т.е. заблокированный поток так и останется ждать). Проблема безопасности исключений в C++ threading library решена довольно обычным для C++ способом - применением техники RAII (Resource Acquisition Is Initialization). Оберткой служит шаблонный класс std::lock_guard. Это простой класс, конструктор которого вызывает метод lock для заданного объекта, а деструктор вызывает unlock. Также в конструктор класса std::lock_guard можно передать аргумент std::adopt_lock - индикатор, означающий, что mutex уже заблокирован и блокировать его заново не надо. std::lock_guard не содержит никаких других методов, его нельзя копировать, переносить или присваивать.




std::unique_lock


Еще одним классом, контролирующим блокировки mutex-а является std::unique_lock, который предоставляет немного больше возможностей, чем std::lock_guard. Помимо RAII, std::unique_lock предоставляет возможность ручной блокировки и разблокировки контролируемого mutex-а с помощью методов lock и unlock соответственно. std::unique_lock также можно перемещать с помощью вызова std::move, но наиболее важным отличием является то, что объект классa std::unique_lock может не владеть правами на mutex, который он контролирует. При создании объекта можно отложить блокирование mutex-а передачей аргумента std::defer_lock конструктору std::unique_lock и указать, что объект на владеет правами на mutex и вызывать unlock в деструкторе не надо. Права на mutex можно получить позже, вызвав метод lock для объекта. Функцией owns_lock можно проверить, владеет ли текущий объект правами на mutex.


std::lock


Вернемся к deadlock-у. Описанная ситуация с исключениями к сожалению не единственный источник deadlock-а. Deadlock также возможен в том случае, если потоки блокируют более одного mutex-a. - Представьте ситуацию, когда два mutex-а A и B защищают два разных ресурса, и, двум потокам, одновременно необходим доступ к этим двум ресурсам. Блокировка одного mutex-а - атомарна, но блокировка двух mutex-ов - это два отдельных действия, и, если первый поток заблокирует mutex A, в то время, как второй заблокирует mutex B, оба потока зависнут ожидая друг друга. Чтобы избежать подобных ситуаций всегда нужно блокировать mutex-ы в одной и той же последовательности. Если блокировать mutex A всегда до того, как блокировать mutex B - deadlock станет невозможным. К сожалению, этот принцип применять можно не всегда. Но, для решения этой проблемы, стандартная библиотека C++0x предоставляет функцию std::lock, которая блокирует переданные ей mutex-ы без опасности deadlock-а. Функция принимает бесконечное количество шаблонных аргументов, которые должны иметь методы lock и unlock.





В этом примере, мы блокируем два mutex-а, с помощью функции std::lock. std::unique_lock нам необходим для того, чтобы контролировать mutex в случае исключения, а именно: std::unique_lock (а не std::lock_guard) нам необходим для того, чтобы его можно было передать в функцию std::lock. После вызова функции std::lock, объекты la и lb станут владельцами mutex-а, и разблокируют его в деструкторе.

std::call_once


std::call_once создан для того, чтобы защищать общие данные во время инициализации. По сути, это техника, позволяющая вызвать некий участок кода один раз, независимо от количества потоков, которые пытаются выполнить этот участок кода. std::call_once - быстрый и удобный механизм для создания потокобезопасных singleton-ов. Рассмотрим пример использования




Здесь создается 2 потока, которые пытаются создать объект класса x, но конструктор класса будет вызван лишь один раз.

std::condition_variable и std::condition_variable_any


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

std::condition_variable - это объект синхронизации, предназначенный для блокирования одного потока, пока он не будет оповещен о наступлении некоего события из другого.

Рассмотрим пример




Рассмотрим, для начала, работу функции thread_func2. Сперва, mutex блокируется, и, дальше, вызывается метод wait, который с блокированным mutex-ом вызывает предикат (в данном случае лямбда-выражение), который должен проверить условие наступления события. Если предикат возвращает false, метод разблокирует mutex и перейдет в режим ожидания до тех пор, пока не получит оповещение. Блокирование mutex-а при проверке необходимо для того, чтобы проверка могла затрагивать общие ресурсы.

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


Единственное отличие между std::condition_variable и std::condition_variable_any заключается в том, что std::condition_variable работает только с блокировками типа std::unique_lock, в то время, как std::condition_variable_any может работать с любыми блокировками, поддерживающими соответствующий интерфейс.


std::async и std::future


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



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



В стандартной библиотеке содержатся, также, классы std::unique_future и std::shared_future, построенные по тому же принципу, что и std::unique_ptr и std::shared_ptr - соответственно. А std::atomic_future работает так же, как и std::shared_future, но он, к тому же, может использоваться одновременно из разных потоков.


std::promise


std::promise - это базовый механизм, позволяющий передавать значение между потоками. Каждый объект std::promise связан с объектом std::future. Это пара классов, один из которых (std::promise) отвечает за установку значения, а другой (std::future) - за его получение. Первый поток может ожидать установки значения с помощью вызова метода std::future::wait или std::future::get, в то время, как второй поток установит это значение с помощью вызова метода std::promise::set_value, или передаст первому исключение вызовом метода std::promise::set_exception.


Рассмотрим пример


Первый поток устанавливает значение 10, в то время как второй ожидает, пока значение будет установлено, и выводит его на экран при получении. Для передачи исключения, должен вызываться метод std::promise::set_exception, который принимает объект типа std::exception_ptr. Получить объект этого типа можно, либо вызвав std::current_exception() из блока catch, либо создать объект этого типа напрямую с помощью вызова функции std::make_exception.

Пример





std::packaged_task


Класс std::packaged_task связывает результат функции с объектом типа std::future. Объект класса std::future будет готов тогда, когда функция завершит свою работу и вернет значение. Этот класс позволяет реализовать нечто подобное функции std::async, но он дает возможность пользователю самому управлять потоками.


Рассмотрим пример




Task запускается в одном из потоков, в котором вызывается функция foo и связывает его возвращаемое значение с объектом типа std::future<T>. В то время, другой поток может получить из этого task-а связанный с ним объект std::future<T> вызовом метода get_future, и получить ассоциированное с ним значение или исключение вызовом метода get.


std::atomic<T> и другие атомарные типы


Атомарность операции - означает ее неделимость, т.е. ни один поток не может увидеть ее промежуточное состояние. Стандарт C++0x гарантирует, что все операции с типами std::atomic<T> и std::atomic_* - атомарны. Ниже перечислены атомарные типы, входящие в состав стандартной библиотеки C++.





































Атомарный тип




































Соответствующий встроенный тип




































Атомарный тип




































Соответствующий встроенный тип
std::atomic_bool bool std::atomic_int_least8_t int_least8_t
std::atomic_char char std::atomic_uint_least8_t uint_least8_t
std::atomic_schar signed char std::atomic_int_least16_t int_least16_t
std::atomic_uchar unsigned char std::atomic_uint_least16_t uint_least16_t
std::atomic_int int std::atomic_int_least32_t int_least32_t
std::atomic_uint unsigned int std::atomic_uint_least32_t uint_least32_t
std::atomic_short short std::atomic_int_least64_t int_least64_t
std::atomic_ushort unsigned short std::atomic_uint_least64_t uint_least64_t
std::atomic_long long std::atomic_int_fast8_t int_fast8_t
std::atomic_ulong unsigned long std::atomic_uint_fast8_t uint_fast8_t
std::atomic_llong long long std::atomic_int_fast16_t int_fast16_t
std::atomic_char16_t char16_t std::atomic_uint_fast16_t uint_fast16_t
std::atomic_char32_t char32_t std::atomic_int_fast32_t int_fast32_t
std::atomic_wchar_t wchar_t std::atomic_uint_fast32_t uint_fast32_t
std::atomic_address void* std::atomic_int_fast64_t int_fast64_t
std::atomic_uint_fast64_t uint_fast64_t std::atomic_intptr_t intptr_t
std::atomic_uintptr_t uintptr_t std::atomic_size_t size_t
std::atomic_ssize_t ssize_t std::atomic_ptrdiff_t ptrdiff_t
std::atomic_intmax_t intmax_t std::atomic_uintmax_t uintmax_t
std::atomic_ullong unsigned long long std::atomic<T> T

Библиотека также содержит примитивный тип std::atomic_flag, который используется для создания других типов и средств синхронизации. В то же время, std::atomic_flag - единственный класс, который гарантировано является неблокирующим (lock-free) по стандарту, остальные типы можно проверить в помощью метода is_lock_free. Проверка всех типов (кроме std::atomic<T>) на gcc 4.5.1 показала, что операции со всеми вышеперечисленными типами являются неблокирующими. Ниже перечислены операции, которые можно совершить с атомарными типами std::atomic_*




































Метод




































Описание
load Вернуть текущее значение
store Установить новое значение
exchange Установить новое значение и вернуть предыдущее
compare_exchange_strong Условная замена значения. Сравнить значение обьекта с другим, и, при успешном сравнении, заменть значение текущего
compare_exchange_weak Аналог вышеописанной функции с одним отличием
fetch_add Увеличить значение переменной на N, аналог оператора +=
fetch_sub Уменьшить значение переменной на N, аналог оператора -=
fetch_and Побитовый AND текущего значения заменяет *this на *this & N. Аналог оператора &=
fetch_or Побитовый OR текущего значения заменяет *this на *this | N. Аналог оператора |=
is_lock_free Возвращает true, если операции на данном типе неблокирующие

Кроме того, типы предоставляют операции по средствам стандартных операторов +=, -=, ++, --.

Разница между compare_exchange_weak и compare_exchange_strong заключается в том, что compare_exchange_weak, на некоторых платформах, может ложно не сработать (т.е. вернуть false и не выполнить обмен, даже в случае равных значений). compare_exchange_strong вызывает compare_exchange_weak в цикле. Когда compare_exchange_strong вызывается в цикле, имеет смысл использовать compare_exchange_weak, чтобы избежать вложенных циклов.


Класс std::atomic<T> специализирован для всех интегральных типов. Например, std::atomic<int> - наследник класса std::atomic_int, std::atomic<bool> - наследник std::atomic_bool, а std::atomic<T*> наследует std::atomic_address.


Все вышеперечисленные функции принимают также аргумент, имеющий значение по умолчанию. Дополнительным аргументом всех модифицирующих операций является тип упорядочения, имеющий значение по умолчанию std::memory_order_seq_cst, что обозначает последовательно-консистентную модель. Это самая простая для понимания модель памяти. При использовании этой модели, все операции с атомарными типами выполняются, и наблюдаются другими потоками в той очередности, в которой они были написаны.  Широко используемые процессорные архитектуры (x86-64) предоставляют последовательную консистентность без особых потерь, но, на других процессорах, это может значительно повлиять на производительность. Это стоит учитывать, при оптимизации для конкретного процессора. C++ предоставляет возможность не навязывать порядок на атомарные операции, но, тогда, программисту самому придется заботиться о правильной установке барьеров памяти, которые, кстати, напрямую поддерживаются новым стандартом C++.


В статье описано большинство классов, входящих в стандартную библиотеку потоков C++0x, более детальное описание вы можете найти в стандарте C++0x и единственной на сегодняшний день книге о многопоточности в C++0x - C++ Concurrency in Action.


Оригинал статьи: http://www.data-race.com/2010/12/03/многопоточное-программирование-в-cpp0x/

?

Log in