Лекция 8. Многопоточное программирование без блокировок. Модель потребитель-производитель. Потокобезопасный стек Пазников Алексей Александрович Кафедра вычислительных систем СибГУТИ Сайт курса: http://cpct.sibsutis.ru/~apaznikov/teaching/ Вопросы: https://piazza.com/sibsutis.ru/spring2015/pct2015spring Параллельные вычислительные технологии Осень 2014 (Parallel Computing Technologies, PCT 14)
195
Embed
ПВТ - весна 2015 - Лекция 8. Многопоточное программирование без использования блокировок
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
Лекция 8. Многопоточное программирование без блокировок. Модель потребитель-производитель. Потокобезопасный стек
Пазников Алексей АлександровичКафедра вычислительных систем СибГУТИ
Многопоточное программирование без использования блокировок
2
Если вы думаете, что программирование без блокировок это просто, значит или вы - один из тех 50 человек,
которые умеют это делать, или же используете атомарные инструкции недостаточно аккуратно.
Герб Саттер
Цели многопоточного программирования без блокировок
3
Однопоточная программа
Многопоточная программа с блокировками
Многопоточная программа без блокировок
Цели многопоточного программирования без блокировок
▪ Повышение масштабируемости путём сокращения блокировок и ожиданий в алгоритмах и структурах данных.
▪ Решение проблем, связанных с блокировками:
▫ Гонки: нужно не забыть заблокировать, причём именно нужный участок кода
▫ Дедлоки: необходимо запирать в нужном порядке различные потоки
▫ Сложность выбора критической секции (простота или масштабируемость)
▫ Голоданеие, инверсия приоритетов и др.4
Виды алгоритмов, свободных от блокировок
▪ Свободные от ожиданий (wait-free). “Никто никогда не ждёт”. Каждая операция завершается за N шагов без каких-либо условий. Гарантии:
▫ максимум пропускной способности системы
▫ отсутствие голодания
▪ Свободные от блокировок (lock-free). “Всегда какой-то из потоков работает”. Каждый шаг приближает итоговое решение. Гарантии:
▫ максимум пропускной способности системы
▫ отсутствие голодания (один поток может постоянно ожидать)
▪ Свободные от препятствий (obstruction-free). “Поток работает, если нет конфликтов”. За ограниченное число шагов поток, при условии, что другие “мешающие” ему потоки остановлены, достигает результата.
▫ Все потоки не блокируются из-за проблем (задержек, ошибок) с другими потоками.
▫ Не гарантируется прогресс, если одновременно работают два или больше потоков.
5
Цели многопоточного программирования без блокировок
6
?
Цели многопоточного программирования без блокировок
7
Цели многопоточного программирования без блокировок
8
t ≥ 30 мин.■ 40 мин.■ 1 час■ 2 часа■ ...
Цели многопоточного программирования без блокировок
9
t ≥ 30 мин.■ 40 мин.■ 1 час■ 2 часа■ ...
Цели многопоточного программирования без блокировок
10
t ≥ 30 мин.■ 40 мин.■ 1 час■ 2 часа■ ...
t = 1 час ± 10 мин.
Цели многопоточного программирования без блокировок
11
locks (с блокировками)
lock-free (без блокировок)
t ≥ 30 мин.■ 40 мин.■ 1 час■ 2 часа■ ...
t = 1 час ± 10 мин.
Реализация модели потребитель-производитель без блокировок
while (slot[curr] != null) // если null, то проверить curr = (curr + 1) % numOfConsumers; // следующий слот
slot[curr] = task; sem[curr].signal();}
// Фаза 2: выставить флаги “done” во всех слотахnumNotified = 0;while (numNotified < numOfConsumers) { while (slot[curr] != null) // если null, то проверить curr = (curr + 1) % numOfConsumers; // следующий
можно ли поменять две строки?нужно ли это сделать?
N производителей, K потребителей
35
1 2 3 K
? ? ? ?
? ? ? ?
N производителей, K потребителей
36
1 2 3 K
? ? ? ?
? ? ? ?
гонка!
N производителей, K потребителей
37
is empty?
is empty?
is empty?
is empty?
1 2 3 K
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
N производителей, K потребителей
38
is empty?
is empty?
is empty?
is empty?
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
1 2 3 K
CAS – compare and swap (compare exchange)
CAS – атомарное сравнение с обменом
N производителей, K потребителей
39
producer (вариант):
curr = 0; while (ThereAreMoreTasks()) { task = AllocateAndBuildNewTask();
while (!slot[curr].CAS(null, task)) curr = (curr + 1) % numOfConsumers;
sem[curr].signal();} Не получилось –
идём дальше
Пытаемся атомарно сделать из пустого слота полный
N производителей, M потребителей
40
1 2 K3
? ? ? ?
? ? ? ?
N производителей, M потребителей
41
1 2 K3
? ? ? ?
? ? ? ?
гонка!
гонка!
N производителей, M потребителей
42
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
CAS(full, empty)
CAS(empty, full)
CAS(empty, full)
CAS(empty, full)
1 2 K3
CAS – атомарное сравнение с обменом
CAS – compare and swap (compare exchange)
N производителей, M потребителей
43
consumer (вариант):
task = null;curr = 0;while (true) {
task = slot[curr]; if (slot[curr] != null) if (!slot[curr].CAS(task, null)) curr = (curr + 1) % numOfSlots;
if (task != done) DoWork(task); else break;}
Пытаемся атомарно сделать из полного слота пустой
Не получилось – идём дальше
Запоминаем состояние слота и проверяем, не пустой ли он
Реализация стека, свободного от блокировок, на основе сборщика мусора
44
Стек, свободный от блокировок
45
T T T T
head
1. Конструктор
2. Деструктор
3. Поиск узла (find)
4. Добавление узла (push)
5. Удаление узла (pop)
Стек, свободный от блокировок
46
template <typename T>class lfstack {public: lfstack(); ~lfstack(); T* find(T data) const; // найти элемент, равный data void push(T data); // добавить элемент в голову
private: struct node { // атомарные операции T data; // не требуются node* next; }; std::atomic<node*> head{nullptr}; // атомарный указатель}; // на голову списка
Конструктор и деструктор
47
template <typename T>lfstack<T>::lfstack() {}
Объект создаётся в одном потоке, поэтому не нужно обеспечивать параллельный доступ. Нельзя использовать стек до тех пор, пока он не будет создан, т.е. до выполнения конструктора, и после того, как он будет уничтожен, т.е. после выполнения деструктора.
template <typename T>lfstack<T>::~lfstack() { auto first = head.load(); while (first) { auto unlinked = first; first = first->next; delete unlinked; }}
Функция push
48
1. Создать новый узел.
2. Записать в его указатель next текущее значение head.
3. Записать в head указатель на новый узел.
void push(T const& data) { auto new_node = new node{data}; // (1) node_node->next = head.load(); // (2) head = new_node; // (3)}
Первый добавляемый элемент пропал, остался только второй
T
head
TTT
T
T T
Функция push
54
void push(T const& data) { auto new_node = new node{data}; // (1) node_node->next = head.load(); // (2) head = new_node; // (3) while (!head.compare_exchange_weak(new_node->next, new_node)); // (3)}
1. Создать новый узел.
2. Записать в его указатель next текущее значение head.
3. Записать в head указатель на новый узел, при этом с помощью операции сравнить-и-обменять гарантировать то, что head не был модифицирован с момента чтения на шаге 2.
Функция pop (ошибочная)
55
void pop(T& result) { node* old_head = head.load(); head = head->next; result = old_head->data; }
Поток А был вытеснен и другие потоки удалили два узла из стека
Решения проблемы АВА
67
1. “Ленивый” сборщик мусора
2. Указатели опасности
3. Счётчик ссылок на элемент
4. Сделать узлы уникальными
5. Вообще не удалять узлы
6. Добавление дополнительных узлов
7. и т.д.
Функция pop (наивная)
68
// количество потоков, выполняющих popstd::atomic<unsigned> threads_in_pop;
std::shared_ptr<T> pop() { threads_in_pop++;
node* old_head = head_load(); while (old_head && !head.compare_exchange_weak(old_head, old_head->next)); std::shared_ptr<T> res; if (old_head) res.swap(old_head->data); // не копировать, // а обменять данные
static void delete_nodes(node* nodes); while (nodes) { node* next = nodes->next; delete nodes; nodes = next; } }
Функция try_reclaim освобождения удалённых узлов
70
void try_reclaim(node* old_head) { if (threads_in_pop == 1) { // я единственный в pop?
node* nodes_to_delete = // захватить список delete_list.exchange{nullptr}; // на удаление
if (!--thread_in_pop) // точно единственный? delete_nodes(nodes_to_delete)); // удалить всё! else if (nodes_to_delete) // если в захваченном списке // что-то было // вернуть это в общий список узлов на удаление chain_pending_nodes(nodes_to_delete);
delete old_head; // удаляем хотя бы только что // исключённый узел } else { // удалим узел как-нибудь потом chain_pending_node(old_head); --threads_in_pop; }}
Функция try_reclaim освобождения удалённых узлов
71
// добавляем захваченный список в общий список узлов,// подлежащих удалениюvoid chain_pending_nodes(node* nodes) { node* last = nodes; while (node* const next = last->next) last = next; chain_pending_nodes(nodes, last);}
// добавить список узлов в список узлов на удалениеvoid chain_pending_nodes(node* first, node* last) { last->next = delete_list; while (!delete_list.compare_exchange_weak(last->next, first));}
// добавить узел в список узлов на удалениеvoid chain_pending_node(node* n) { chain_pending_nodes(n, n);}
Функция try_reclaim освобождения удалённых узлов
72
4
head
321
delete_list 0threads_in_pop
4
head
321
delete_list
threads_in_pop 1
5
5A A удаляет узел 1 и
вытесняется в pop() после 1-го чтения threads_in_pop
Функция try_reclaim освобождения удалённых узлов
73
4
head
32
delete_list 2threads_in_pop
43
delete_list
threads_in_pop 2
5
2
С удаляет узел и продолжает работать до момента выхода из pop()
old_head
BB вызывает pop() и
вытесняется после 1-го чтения head
A
headold_head
B4
C
5
A
Функция try_reclaim освобождения удалённых узлов
74threads_in_pop 2
43
delete_list
threads_in_pop 2
2
A возобновляет выполнение и захватывает список на удаление. После этого он должен 2-й раз проверить,
один ли он в pop()
headold_head
B2
5
A
43
headold_head
B2
delete_list A2 5
delete_list
B возобновляет выполнение, выполняет CAS и переходит
к 3 узлу
Реализация стека, свободного от блокировок, на основе указателей опасности
75
Указатели опасности (hazard pointers)
76
4
old_head
321
head->next
43
Поток А выполняет удаление узла из вершины стека и помечает узел 1
как узел, который он использует.
old_head old_head->next
Поток А был вытеснен и другие потоки удалили два узла из стека, но не
освобождают память из-под первого узла.
2
head
head
1
A “понимает”, что головной узел head изменился и нужно
выполнить compare_exchange
43
old_head old_head->next
21
Функция pop на основе указателей опасности
77
std::shared_ptr<T> pop() { std::atomic<void*>& hp = get_hazard_pointer_for_current_thread();
// установить указатель опасности перед чтением указателя, // который мы собираемся разыменовывать node* old_head = head.load(); node* temp; do { temp = old_head; hp.store(old_head); old_head = head.load(); } while (old_head != temp); // ...}
Функция pop на основе указателей опасности
78
std::shared_ptr<T> pop() { std::atomic<void*>& hp = get_hazard_pointer_for_current_thread();
node* old_head = head.load(); do { node* temp; do { temp = old_head; hp.store(old_head); // устанавливаем УО old_head = head.load(); } while (old_head != temp); } while (old_head && // получаем узел !head.compare_exchange_strong(old_head, old_head->next)); hp.store(nullptr);
Указатели опасности (hazard pointers)
79
4
old_head
321head
hp
Указатели опасности (hazard pointers)
80
4
old_head
321head
temp = old_head
temp
hp
Указатели опасности (hazard pointers)
81
4
old_head
321head
hp
temp = old_head
temp
hp.store(old_head)
Указатели опасности (hazard pointers)
82
4
old_head
321
temp = old_head
head
temp
hp.store(old_head)
old_head = head.load()“old old_head”
“new old_head”
hp
Указатели опасности (hazard pointers)
83
4
old_head
321
temp = old_head
head
temp
hp.store(old_head)
old_head = head.load()“old old_head”
“new old_head”
hp
== ?
Таким образом, внутренний цикл гарарантирует то, что указатель опасности будет указывать на тот головной элемент head, с котором
мы будем работать (сдвигать указатель на следующий элемент)
Проверка позволяет определить, не изменился ли головной элемент с тех пор, когда мы запомнили его в указателе опасности.
Указатели опасности (hazard pointers)
84
4
old_head
321
temp = old_head
head
temp
hp.store(old_head)
old_head = head.load()“old old_head”
“new old_head”
hp
== ?
Во внешнем цикле сдвигаем указатель с head на следующий элемент с уверенностью, что никто не подменит элемент head.
Указатели опасности (hazard pointers)
85
После того, как поток А успешно выполнил compare_exchange,
указатель опасности можно обнулять hp.store(nullptr), т.к. никто пока не сможет удалить old_head, кроме А, поскольку head изменён потоком А
43
old_head old_head->next
21
Вариант 1
43
old_head->next
1
Вариант 2
old_head
2
Функция pop на основе указателей опасности
86
std::shared_ptr<T> res; if (old_head) { res.swap(old_head->data); // извлекаем данные
if (outstanding_hazard_pointers_for(old_head)) // если опасно, удаляем потом reclaim_later(old_head); else // если не опасно, удаляем сейчас delete old_head;
hp_owner(): hp{nullptr} { for (auto i = 0; i < max_hazard_pointers; i++) { std::thread::id old_id; // пустой незанятый УО
// если i-й УО не занят, завладеть им, записав в него // свой идентификатор потока if (hazard_pointers[i].id.compare_exchange_strong( old_id, std::this_thread::get_id())) { hp = &hazard_pointers[i]; // я владею i-м УО break; } }
// таблица УО закончилась, указателей нам не досталось if (!hp) throw std::runtime_error("No hazard ptrs available");}
Реализация указателей опасности
93
hp_owner(): hp{nullptr} { for (auto i = 0; i < max_hazard_pointers; i++) { std::thread::id old_id; if (hazard_pointers[i].id.compare_exchange_strong( old_id, std::this_thread::get_id())) { hp = &hazard_pointers[i]; break; } } if (!hp) throw std::runtime_error("No hazard ptrs available");}std::atomic<void*>& get_pointer() { return hp->pointer;}~hp_owner() { hp->pointer.store(nullptr); hp->id.store(std::thread::id());}
Реализация указателей опасности
94
// вернуть указатель опасности для текущего потокаstd::atomic<void*>& get_hazard_pointer_for_current_thread() { thread_local static hp_owner hazard; return hazard.get_pointer();}
Реализация указателей опасности
95
// вернуть указатель опасности для текущего потокаstd::atomic<void*>& get_hazard_pointer_for_current_thread() { thread_local static hp_owner hazard; return hazard.get_pointer();}
// проверить, не ссылается ли на указатель какой-то из УОbool outstanding_hazard_pointers_for(void* p) { for (auto i = 0; i < max_hazard_pointers; i++) { if (hazard_pointers[i].pointer.load() == p) { return true; } } return false;}
// добавить элемент в список на удалениеvoid add_to_reclaim_list(data_to_reclaim* node) { node->next = nodes_to_reclaim.load();
while (!nodes_to_reclaim.compare_exchange_weak( node->next, node));}
// удалить элемент позжеtemplate<typename T>void reclaim_later(T* data) { add_to_recalim_list(new data_to_reclaim(data));}
Реализация функции освобождения памяти
98
void delete_nodes_with_no_hazards() { // захватить текущий список data_to_reclaim* current = nodes_to_reclaim.exchange(nullptr);
while (current) { data_to_reclaim* const next = current->next;
if (!outstanding_hazard_pointers_for(current->data)) // если не опасно, удалить сейчас delete current; else // если опасно удалить потом add_to_reclaim_list(current);
current = next; }}
Реализация функции освобождения памяти
99
4321
nodes_to_reclaim
Реализация функции освобождения памяти
100
nodes_to_reclaim
4321current
current = nodes_to_reclaim. exchange(nullptr);
Реализация функции освобождения памяти
101
nodes_to_reclaim
432current
1
1
add_to_reclaim_list(current);
Реализация функции освобождения памяти
102
nodes_to_reclaim
432current
1
delete current;
Реализация функции освобождения памяти
103
nodes_to_reclaim
43current
1 5
add_to_reclaim_list() при выполнении pop()
Реализация функции освобождения памяти
104
nodes_to_reclaim
4current
1 5 3
add_to_reclaim_list(current);
1
Реализация функции освобождения памяти
105
nodes_to_reclaim
4current
1 5 3
Реализация функции освобождения памяти
106
nodes_to_reclaim
current
1 5 3
Недостатки указетелей опасности
107
1. Просмотр массива указателей опаности требует в худшем случае max_hazard_pointers атомарных переменных.
2. Атомарные операции могут работать медленнее эквивалентных обычных операций
3. При освобождении узла также требуется просмотреть список указателей опаности, т.е. max_hazard_pointers в худшем случае.
Функция pop дорогостоящая. Решения?
Недостатки указетелей опасности
108
1. Просмотр массива указателей опаности требует в худшем случае max_hazard_pointers атомарных переменных.
2. Атомарные операции могут работать медленнее эквивалентных обычных операций
3. При освобождении узла также требуется просмотреть список указателей опаности, т.е. max_hazard_pointers в худшем случае.
Функция pop дорогостоящая. Решения?
1. Вместо просмотра max_hazard_pointers в каждом pop(), проверяем 2 * max_hazard_pointers через каждые max_hazard_pointers вызовов pop() и освобождаем не менее max_hazard_pointers. В среднем проверяем два узла при каждом вызове pop() и один освобождаем.
2. Каждый поток хранит собственный список освобождения в локальных данных потока. Это потребует выделения памяти под max_hazard_pointers2 узлов.
Реализация стека, свободного от блокировок, с помощью умного указателя
109
Реализация на основе атомарного умного указателя
110
▪ Удалять узлы можно только при отсутствии обращения к ним из других потоков
▪ Если на узел нет ссылки, то его можно удаляь
Реализация на основе атомарного умного указателя
111
▪ Удалять узлы можно только при отсутствии обращения к ним из других потоков
▪ Если на узел нет ссылки, то его можно удаляь
▪ Умный указатель shared_ptr как раз решает эту задачу!
Реализация на основе атомарного умного указателя
112
▪ Удалять узлы можно только при отсутствии обращения к ним из других потоков
▪ Если на узел нет ссылки, то его можно удаляь
▪ Умный указатель shared_ptr как раз решает эту задачу!
...
▪ Но, к сожалению, атомарные операции c shared_ptr в большинстве реализаций не свободны от блокировок.
for (;;) { increase_head_count(old_head); node* const ptr = old_head.ptr; if (!ptr) return std::shared_ptr<T>();
if (head.compare_exchange_strong(old_head, ptr->next)) { std::shared_ptr<T> res; res.swap(ptr->data); int const count_increase = old_head.external_count - 2;
if (ptr->internal_count.fetch_add(count_increase) == -count_increase) delete ptr; return res;
} else if (ptr->internal_count.fetch_sub(1) == 1) delete ptr; } }
Двойной счётчик ссылок
123
Попытаться выполнить удаление узла
1. Если получилось, забрать данные2. Прибавить внутренний счётчик к внешнему3. Если счётчик стал равным 0, удалить узел4. Вернуть результат (даже если счётчик не стал равным 0)
for (;;) { increase_head_count(old_head); node* const ptr = old_head.ptr; if (!ptr) return std::shared_ptr<T>();
if (head.compare_exchange_strong(old_head, ptr->next)) { std::shared_ptr<T> res; res.swap(ptr->data); int const count_increase = old_head.external_count - 2;
if (ptr->internal_count.fetch_add(count_increase) == -count_increase) delete ptr; return res;
} else if (ptr->internal_count.fetch_sub(1) == 1) delete ptr; } }
Двойной счётчик ссылок
124
Если не получилось выполнить удаление узла (какой-то поток удалил узел раньше нас)1. Уменьшить счётчик ссылок на 1.2. Если другие потоки на узел не ссылаются, освободить память
(убрать за тем потоком, который выполнил удаление)
// Остальные пары CAS модифицируем аналогичным образом.
Схема устранения парных операций
168
T1: item1
state EMPTY
slot null
curritem
currstate
slot.load();
state.load();
slot null
Схема устранения парных операций
169
T1: item1
state EMPTYCAS(old_content,
new_content)
curritem
currstate
null
EMPTY
old_content
content
item1
WAITING
new_content
Схема устранения парных операций
170
state WAITING
T1: item1
slot item1
curritem
currstate
null
EMPTY
old_content
Схема устранения парных операций
171
state WAITING
T1: item1
slot item1
curritem
currstate
null
EMPTY
state.load() == BUSY?
Схема устранения парных операций
172
state WAITING
T1: item1
slot item1
curritem
currstate
slot.load();
state.load();
T2: item2
old_content
Схема устранения парных операций
173
state WAITING
T1: item1
slot item1
curritem
currstate
T2: item2
old_contentnew_content
item1
WAITING
item2
BUSY
CAS(old_content,
new_content)
slot item2
Схема устранения парных операций
174
state BUSY
T1: item1 T2: item2
curritem
currstate
old_content
item1
WAITING
return curritem
Схема устранения парных операций
175
slot item2
T1: item1 T2: item2
curritem
currstate
null
BUSY
state.load() == BUSY?
state BUSY
YES!
Схема устранения парных операций
176
state item2
T1: item1 T2: item2
curritem
currstate
item2
BUSY
slot BUSY
state.load()
return curritem
Стек
Схема устранения парных операций
177
a b c
head
Вспомогательный массив(elimination array)
T3: pop()
T1: pop()
T2: push(d)
В худшем случае (если встречаются следуют не парно), вершина становится узким местом!
Elimination Tree (дерево устранения)
178
2
5
34
0
0
0
1
lock-freestack
lock-freestack
lock-freestack
lock-freestack
путь 0
путь 1
Линеаризация linearization
Согласованность относительно “состояния покоя” quiescent consistency
Линеаризация – такое выполнение параллельных операций, при котором они соответствуют какому-то последовательному выполнению операций со стеком. Т.е. потокобезопасный стек (или другая структура) “ведёт себя” как обычный последовательный стек.
Elimination Tree (дерево устранения)
179
2
5
34
0
0
0
1
lock-freestack
lock-freestack
lock-freestack
lock-freestack
путь 0
путь 1
Линеаризация linearization
Согласованность относительно “состояния покоя” quiescent consistency
Согласованность относительно “состояния покоя” – такое выполнение параллельных операций, при котором операции могут выполняться со стеком одновременно до момента, когда наступает “тишина”, т.е. нет текущих операций со структурой.