Лекция 5. Многопоточное программирование в языке С++. Работа с потоками. Защита данных. Синхронизация. Будущие результаты Пазников Алексей Александрович Кафедра вычислительных систем СибГУТИ Сайт курса: http://cpct.sibsutis.ru/~apaznikov/teaching/ Q/A: https://piazza.com/sibsutis.ru/spring2015/pct2015spring Параллельные вычислительные технологии Весна 2015 (Parallel Computing Technologies, PCT 15)
176
Embed
ПВТ - весна 2015 - Лекция 5. Многопоточное программирование в С++. Синхронизация, будущие результаты
This document is posted to help you gain knowledge. Please leave a comment to let me know what you think about it! Share it to your friends and learn new things together.
Transcript
Лекция 5. Многопоточное программирование в языке С++. Работа с потоками. Защита данных. Синхронизация. Будущие результаты
Пазников Алексей АлександровичКафедра вычислительных систем СибГУТИ
for (auto i = 0; i < 10; i++) { threads.push_back(std::thread([i](){ std::this_thread::sleep_for( std::chrono::milliseconds(100 * i)); std::cout << i << "\n"; })); table.insert(std::make_pair(threads.back().get_id(), i % 2));}
std::cout << "value of 5: " << table[threads[5].get_id()] << std::endl;std::cout << "value of 6: " << table[threads[6].get_id()] << std::endl;
wrapper obj;obj.proc_data(unsafe_func); unprotected->do_something(); // незащищённый доступ к data
Любой код, имеющий доступ к указателю или ссылке, может делать с ним всё, что угодно, не захватывая мьютекс.
Нельзя передавать указатели и ссылки на защищённые данные за пределы области видимости блокировки никаким образом.
32
Адаптация интерфейсов к многопоточности
template <...> class stack {public: // ...
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&&);
};
33
Адаптация интерфейсов к многопоточности
template <...> class stack {public: // ...
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&&);
};
34
Адаптация интерфейсов к многопоточности
template <...> class stack {public: // ...
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&&);
};
некорректный результат как решить?
stack<int> s;
if (!s.empty()) {
int const value = s.top();
s.pop();
// ...
}
35
Адаптация интерфейсов к многопоточности
std::vector<int> result;mystack.pop(result);
1. Передавать ссылку в функцию pop
2. Потребовать наличия копирующего или перемещающего конструктора, не возбуждающего исключений (доказано, что можно объединить pop и top, но это можно сделать только если конструкторы не вызывают исключений)
bool Cmp(Widget &lhs, Widget &rhs) { // обе операции совершаются под защитой мьютекса int const lhs_val = lhs.getval(); int const rhs_val = rhs.getval(); std::lock(lock1, lock2);
void pop_and_process() { std::unique_lock<std::mutex> lock(mut); Widget data = queue.pop(); // получить элемент данных lock.unlock(); // освободить мьютекс super_widget result = process(data); // обработать данные lock.lock(); // опять захватить мьютекс output_result(data, result); // вывести результат}
Минимизация блокировок!
▪ блокировать данные, а не операции▪ удерживать мьютекс столько, сколько необходимо
▫ тяжёлые операции (захват другого мьютекса, ввод/вывод и т.д.) - вне текущей критической секции
public: NetFacility(connect_info &_info): {} void send_data(data_packet const &d) { // вызывается только один раз std::call_once(connection_flag, &NetFacility::open_connection, this); connection.send(data); }
void recv_data() { /* ... */ }}
51
R/W-мьютексы в С++
class Widget { mutable std::shared_timed_mutex mut; int data;public: Widget& operator=(const R& rhs) { // эксклюзивные права на запись в *this std::unique_lock<std::shared_timed_mutex> lhs(mut, std::defer_lock);
// разделяемые права на чтение rhs std::shared_lock<std::shared_timed_mutex> rhs(other.mut, std::defer_lock);
std::lock(lhs, rhs);
// выполнить присваивание data = rhs.data; return *this; }};
void print_value(std::future<int>& fut) { int x = fut.get(); std::cout << "value: " << x << std::endl;}
int compute_value() { std::this_thread::sleep_for(std::chrono::seconds(1)); return 42;}
int main () { std::promise<int> prom;
// Получаем объект future из созданного promise (обещаем) std::future<int> fut = prom.get_future(); // Отправляем будущее значение в новый поток std::thread th1 (print_value, std::ref(fut));
int val = compute_value(); prom.set_value(val); // Выполняем обещание th1.join();} 72
“Обещанные” результаты (std::promise) - пример 1
mainmain thread
th1print_value
prom.set_value()
print_value
th1(print_value, std::ref(fut))
fut.get()
compute_value
работа ожидание
создание/завершениепотоков синхронизация
73
“Обещанные” результаты (std::promise) - пример 2
int main() { std::istringstream iss_numbers{"3 1 42 23 -23 93 2 -289"}; std::istringstream iss_letters{" a 23 b,e k k?a;si,ksa c"};
numbers_ready.wait(); // Ждать когда числа будут готовы
std::sort(numbers.begin(), numbers.end());
if (letter_ready.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout) { // выводим числа, пока обрабатываются символы for (int num : numbers) std::cout << num << ' '; std::cout << '\n'; numbers.clear(); // Числа уже были напечатаны }
for (char let : letters) std::cout << let << ' '; std::cout << '\n';
// If numbers were already printed, it does nothing. for (int num : numbers) std::cout << num << ' '; std::cout << '\n';
value_reader.join();}
75
“Обещанные” результаты (std::promise) - пример 2
mainmain
работа ожидание
value_reader
letters_promise.set_value()value_reader
fut.get()
iss_numbers iss_letters
number_ready.wait()
sort
letter_ready.wait_for
sort output
numbers_promise.set_value()
создание/завершениепотоков синхронизация
76
“Обещанные” результаты (std::promise), варианты
a a a a b c e i k k k s s -289 -23 1 2 3 4 23 42 93 93
-289 -23 1 2 3 23 42 93 a a a b c e i k k k s s
77
Полезные советы
78
Используйте задачи, а не потоки
79
Проблемы с параллелизмом на основе потоков
int doWork();
std::thread t(doWork); // 1// илиauto fut = std::async(doWork); // 2
80
▪ Вариант, основанный на задаче (2), предпочтительней, т.к. предполагает возвращаемое значение, которое можно получить fut.get().
▪ Если doWork выбрасывает исключение, то get() позволяет обработать исключения, в то время как в первом случае выброс исключения приведёт к завершению программы.
Проблемы с параллелизмом на основе потоков
81
Параллелизм задач находится на более высоком уровне абстракции по сравнению с параллелизмом потоков, освобождает программиста от деталей реализации:▪ Аппаратные потоки (software threads) - те, которые
действительно выполняют вычисления (по числу ядер).▪ Программные потоки (hardware threads) - потоки,
которые планируются ОС и выполняются на аппаратных потоках. ▫ Легковесные потоки (lightweight threads) - потоки,
которые выполняются целиком в пространстве пользователя.
▪ std::thread - объекты С++, которые соответствуют определённым программным потокам, с которыми можно выполнять операции join и detach
Ограниченность количества программных потоков
82
Программные потоки - ограниченный ресурс. Попытка создать больше заданного числа потоков вызовет исключение, даже если
int doWork() noexcept;
std::thread t(doWork); // может быть исключение!
▪ Запустить doWork в текущем потоке?▪ Или подождать, пока освободится программный поток?
Состояние перегруженности аппаратных потоков oversubscription возникает, когда в системе большое количество runnable-потоков. Планировщик ОС выделяет программным потокам порции (time-slice) процессорного времени. После окончания порции происходит переключение контекста (context switch), особенно в случае, когда поток назначается на разные ядра:▪ Кэш-память не загружена, большое количество
промахов по кэшу.▪ Запуск нового потока на ядре перезаписывает записи
для старого потока, который, вероятно, будет опять назначен на это ядро. Это опять приводит к промахам по кэшу.
▪ std::launch::async - функция будет запущена асинхронного, т.е. в отдельном потоке
T1main thread
T2func
fut.get()
▪ std::launch::deferred - функция может быть запущена только, когда вызан методв get или wait для объекта future в потоке, вызывающем get (wait).
T1main thread
T2func
fut.get()
async
async
Помните про политику запуска задачи
95
▪ std::launch::async - функция будет запущена асинхронного, т.е. в отдельном потоке
▪ std::launch::deferred - функция может быть запущена только, когда вызан методв get или wait для объекта future в потоке, вызывающем get (wait).
auto fut = std::async(func); // использовать политику // запуска по умолчанию
▪ Нельзя предугадать, будет ли func выполняться асинхронно
▪ Нельзя предугадать, будет ли func запущена на потоке, отличном, от потока, вызывающего get (wait)
▪ Нельзя предугадать, что func будет выполнена.
Проблема: std::async и Thread Local Storage (TLS)
96
auto fut = std::async([](){ // Может использоваться thread_local local_var; // TLS для независимого ... // потока});...fut.get(); // а может и для этого!
Политика запуска по умолчанию конфликтует с использованием переменных thread_local:
Проблема: std::async и цикла на основе wait_for
97
using namespace std::literals; // C++14 суффиксыauto fut = std::async([]() { std::this_thread::sleep_for(1s);});
// Цикл, ожидающий выполнения std::async, // может не завершитьсяwhile (fut.wait_for(100ms) != std::future_status::ready){ /* делать что-то асинхронного */}
Использование циклов на основе вызова wait_for или wait_until может привести к вечному ожиданию, если задача будет запущена как отложенная (std::launch::deferred):
Проблема: std::async и цикла на основе wait_for - решение
98
auto fut = std::async([]() { std::this_thread::sleep_for(1s);});
if (fut.wait_for(0s) == std::future_satus::deferred) { fut.get(); // ожидаем результата ...} else { while (fut.wait_for(100ms) != std::future_status::ready) { // делать какую-то работу асинхронного, // пока ждём завершения выполнения задачи } // здесь fut готово}
Когда использовать политику запуска по умолчанию
99
▪ Задача не требует асинхронного запуска в отдельном потоке, отличном от вызывающего get (wait).
▪ Не важно, thread_local-переменные какого потока будут использоваться.
▪ Или есть гарантия, что get (wait) будут вызваны для объекта future, возвращённого std::async, или задача может быть вовсе на запущена.
▪ При использовании wait_for или wait_until допускается возможность отложенного запуска задачи.
Если какие-либо пункты не выполняются, лучше гарантировать асинхронный запуск задачи через передачу std::launch::async
Используйте потоки, не требующие вызова join (unjoinable)
100
Joinable и unjoinable
101
Объект std::thread может пребывать в двух состояниях:▪ joinable: объект соответствует потоку, который
выполняется или может быть запущен.▪ unjoinable: объект, с которым нельзя выполнить
операцию join:▫ выполнен конструктур по умолчанию для std::
thread, т.е. std::thread может не имеет функции для выполнения и поэтому не соответствует реальному потоку.
▫ std::thread, который был перемещён (moved)▫ std::thread, который был присоединён (joined)▫ std::thread, который был отсоденинён (detached)
Проблема с joinable-потоками
102
constexpr auto n = 10'000'000; // C++14-style
bool doWork(std::function<bool(int)> pred, // условие int maxVal = n) {
std::thread t([&pred, maxVals, &goodVals]{ for (auto i = 0; i <= maxVals; i++) { if (pred(i)) goodVals.push_back(i); } });
auto nh = t.native_handle(); ...
if (conditionAreSatisfied()) { t.join(); performComputation(goodVals); return true; // ok, т.к. был t.join() } return false; // не было t.join()! выброс исключения} // и аварийное завершение программы 104
Проблема с joinable-потоками
Как можно решить проблему (наивно):▪ Неявный join. Деструктор std::thread будет ожидать
завершения потока. Но это приведёт к неочевидному коду, например, когда поток ждёт завершения doWork, уже зная, что условие не выполнено.
▪ Неявный detach. Деструктор разрывает связь между std::thread и потоком выполнения. Поток продолжает работать. В этом случае, например, при завершении функции doWork, поток продолжает работать. Этот поток может использовать автоматические переменные из стека doWork.
Поэтому стандарт запретил уничтожение потока в сотоянии joinable: деструктур такого объекта вызывает завершение программы.
105
Проблема с joinable-потоками
106
▪ Программист должен следить за тем, что объект должен находиться в состоянии unjoinable вне его области видимости.
▪ Обеспечение этого требования - задача непростая, поскольку требует отслеживания всех выходов из функции через return, continue, break, goto, exception.
▪ Необходимо обеспечить выполнение определённого действия каждый раз при выходе из блока.
Make it RAII!
▪ Программист должен следить за тем, что объект должен находиться в состоянии unjoinable вне его области видимости.
▪ Обеспечение этого требования - задача непростая, поскольку требует отслеживания всех выходов из функции через return, continue, break, goto, exception.
▪ Необходимо обеспечить выполнение определённого действия каждый раз при выходе из блока.
Решение: RAII-объекты (Resouce Acquisition Is Initialization), (к которым относятся std::unique_ptr, std::shared_ptr, std::lock_guard, std::fstream и др.), деструктор которых содержит необходимое действие.
107
RAII
108
class ThreadRAII {public: enum class DestrAction { join, detach }; ThreadRAII(std::thread&& t, DestrAction a): action{a}, t{std::move(t)} { } ~ThreadRAII() { // действие выполняется в деструкторе if (t.joinable()) { if (action == DestrAction::join) { t.join(); } else { t.detach(); } } } std::thread& get() { return t; }private: DestrAction action; // action in destuctor std::thread t;};
RAII
109
bool doWork(std::function<bool(int)> pred, int maxVal = n) {
ThreadRAII t{ // использовать RAII-объект std::thread([&stencil, maxVals, &goodVals]{ for (auto i = 0; i <= maxVals; i++) { if (pred(i)) goodVals.push_back(i); } }), ThreadRAII::DestrAction::join // действие }; // в деструкторе
auto nh = t.get().native_handle(); ...
if (conditionAreSatisfied()) { t.get().join(); performComputation(goodVals); return true; } return false;}
Используйте future-promise для синхронизации потоков (а не cond vars)
110
“Обещанные” результаты (std::promise)
Msg received!
ok, let’s move!
cv.notify_one()
111
Условные переменные?
std::unique_lock<std::mutex> lk(m);cv.wait(lk)
cv
Недостатки синхр-ции на основе условных переменных
void producer() { for (;;) { Widget const w = get_request(); std::lock_guard<std::mutex> lock(mut); widget_queue.push(data); cond.notify_one();} }
void consumer() { for (;;) { std::unique_lock<std::mutex> lock(mut); cond.wait(lock, []{return !widget_queue.empty();}); Widget w = widget_queue.pop(); lock.unlock(); process(widget);} }
112
Недостатки синхр-ции на основе условных переменных
▪ Необходимость использования мьютексаstd::unique_lock<std::mutex> lock(mut);
cond.wait(lock, ...);
А что, если потоки выполняют код, который не нуждается в блокировке мьютекса? Например, один поток инициализирует структуру, после чего сообщает другому, что структура готова.
▪ Пропущенный сигналПоток может отправить сигнал (notify_one/all) тогда, когда другой поток ещё не начал его ожидать.
▪ Ложное пробуждение (spurious wakeup)Поток может проснуться тогда, когда сигнал не был отправлен Или когда он был отправлен потока, а затем условие перестало выполняться. Поэтому нужна дополнительная проверка:
auto result1 = std::async(std::launch::async, fun1); auto result2 = std::async(std::launch::async, fun2);
// ждать, пока потоки не будут готовы t1_ready_promise.get_future().wait(); t2_ready_promise.get_future().wait();
// потоки готовы - начать отчёт времени start = std::chrono::high_resolution_clock::now();
// запустить потоки ready_promise.set_value();
std::cout << "Thread 1 received the signal " << result1.get().count() << " ms after start\n" << "Thread 2 received the signal " << result2.get().count() << " ms after start\n";}
125
Разделяемые будущие результаты shared_future
main
работа ожидание
создание/завершениепотоков синхронизация
T1
T2t2_ready.set_value
start
return
return
output
ready.set_value
t1_ready.set_value
126
Учитывайте поведение деструктора объекта future
127
Хранение результата для future
Деструктор объекта future ведёт себя иногда так, как будто он выполняет неявный join, а в некоторых случае - как будто выполняет неявный detach.
128
Вызываемый поток
Вызывающий поток
future std::promise
Где хранится результат вызывающего потока?Вызывающий поток может завершиться до того, как вызываемый выполнит fut.get(), и результат не может храниться в объекте std::promise вызываемого потока.Объект future не может быть хранилищем для результата, т.к. он может быть скопирован в объекты shared_future, после чего возникает вопрос, какая из копий соответствует результату?
Два варианта поведения деструктора future
129
Вызываемый поток
Вызывающий поток
future std::promise
Результат вызывающего
Поведение деструктора future зависит от разделяемого состояния (shared state): ▪ Деструктор последнего объекта future,
указывающего на разделяемое состояние (shared state) для какой-то асинхронной задачи, блокируется до завершения выполнения этой задачи, т.е. выполняет “join”.
▪ Деструкторы всех других объектов future просто уничтожают объект future. Это аналогично вызову detach для потока.
shared state
Два варианта поведения деструктора future
130
▪ Деструктор последнего объекта future, указывающего на разделяемое состояние, выполняет “join”, если:▫ объект указывает на разделяемое состояние,
созданное std::async▫ задача, породившая future, была запущена
асинхронно▫ future - это последний объект future, указывающий
на разделяемое состояниеЗачем это нужно? ▪ Чтобы избежать неявного вызова detach для потока, в
котором выполняется задача.▪ Срабатывание деструктора не должно приводить к
завершению программы (попытка компромиса)
Два варианта поведения деструктора future
131
// Деструктор futs может блокироватьсяstd::vector<std::future<void>> futs;
// Объект может блокироваться при уничтоженииclass Widget {private: std::shared_future<double> fut;};
Два варианта поведения деструктора future - пример
void do_some_stuff() { std::cout << "do some useful stuff"; }
void do_some_other_stuff() { std::cout << "do other stuff"; }137
Неблокирующие будущие результаты (then)
int main() { auto f1 = std::async(func1); auto f2 = std::async(func2, f1.get()); auto f3 = std::async(func3, f2.get());
std::cout << "waiting for the answer...\n";
do_some_stuff();
std::cout << "answer: " << f3.get() << std::endl;
do_some_other_stuff();
138
Неблокирующие будущие результаты (then)
int main() { auto f1 = std::async(func1); auto f2 = std::async(func2, f1.get()); auto f3 = std::async(func3, f2.get());
std::cout << "waiting for the answer...\n";
do_some_stuff();
std::cout << "answer: " << f3.get() << std::endl;
do_some_other_stuff();
Каждый раз после получения результата выполняется создание новой асинхронной задачи.
Поток может быть заблокирован при вызове get() для ожидания результата.
139
Неблокирующие будущие результаты (then)
$ ./prog begin thinking over the answer...continue thinking over the answer...waiting for the answer...do some useful stuffanswer: still thinking...number 42do some other useful stuff
$ ./progwaiting for the answer...do some useful stuffbegin thinking over the answer...continue thinking over the answer...still thinking...answer: number 42do some other useful stuff
145
Неблокирующие будущие результаты (then)
Блокирующие future Неблокирующие future
f2
f3
f1
f
▪ устанавливается явный порядок выполнения
▪ нет блокировок
▪ поток один
▪ порядок выполнения неопределён
▪ возможны блокировки
▪ для каждой задачи создаётся отдельный поток 146
Ожидание выполнения всех задач (when_all)
f2
f1f3
Будущий результат f4 зависит от выполнения всех будущих результатов f1, f2, f3 и начинает выполняться после завершения выполнения задач, им соответствующих (подобно барьерной синхронизации).
auto join_task = boost::when_all(task_chunk.begin(), task_chunk.end()) .then([](auto results){ auto res = 0; for (auto &elem: results.get()) res += elem.get(); return res; });
$ ./prog hello from task 1hello from task 3hello from task 2do some useful stuffresult: 42
151
Ожидание выполнения какой-либо задачи (when_any)
f2
f1f3
Будущий результат f4 зависит от выполнения одного из будущих результатов f1, f2, f3 и начинает выполняться после завершения выполнения хотя бы одной задачи (подобно синхронизации “эврика”).
auto join_task = boost::when_any(task_chunk.begin(), task_chunk.end()) .then([](auto results) { for (auto &elem: results.get()) { if (elem.is_ready()) { return elem.get(); } } exit(1); // this will never happen });
2. Универсальность. Заранее может быть неизвестны методы, которые необходимо обернуть. Некоторые методы сложно обернуть: конструкторы, операторы, шаблоны и т.д.
Реализация отдельных методов может не обеспечить необходимую гранулярность.
159
Паттерн: обёртка над данными с блокировками
template<typename T>class wrapper {private: T t; // оборачиваемый объект ... // состояние враппера
public: monitor(T _t): t(_t) { }
template <typename F>
// 1. получаем любую функцию // 2. подставляем в неё оборачиваемый объект // 3. выполняем и возвращаем результат auto operator()(F f) -> decltype(f(t)) { // работа враппера auto ret = f(t); // ... return ret; }}; 160
Потокобезопасная обёртка над данными с блокировками
template<typename T>class monitor {private: T t; std::mutex m;
// создаём объект promise (shared_ptr<promise>) auto p = std::make_shared<std::promise<decltype(f(t))>>();
// получаем из promise объект future auto ret = p->get_future();
q.push([=]{ // выполняем обещание уже внутри потока try { p->set_value(f(t)); } catch (...) { p->set_exception(std::current_exception()); } });
return ret;}
Данная версия operator() позволяет вернуть значение при вызове функции:
172
Потокобезопасная обёртка над данными на основе очереди задач
template<typename F>auto operator()(F f) -> std::future<decltype(f(t))> { auto p = std::make_shared<std::promise<decltype(f(t))>>(); auto ret = p->get_future();
Потокобезопасная обёртка над данными на основе очереди задач
template<typename F>auto operator()(F f) -> std::future<decltype(f(t))> { auto p = std::make_shared<std::promise<decltype(f(t))>>(); auto ret = p->get_future();