Итак, мы пишем программу, которая торопится. Для того, чтобы успеть мы как взрослые пацаны решили все распараллелить по тредам, ведь уже 21-й век и меньше двух тредов осталось разве что в отсталых сотовых телефонах.
Однако, многие треды приводят к многим печалям и для того, чтобы все работало как мы задумали нам нужно как-то ограничивать паралелизм. Надежным способом для этого служит mutex/critical_section/semaphore/youname
Однако, если мы действительно торопимся, то исполнять всего один тред из всех это совсем не то, что мы хотим делать. Особенно, если система может этот единственный тред вытеснить и наш тред будет ожидать какого-нибудь "важного" процесса типа чтения блока из swap-file-a. Btw, если вы торопитесь, то swap лучше таки отрубить напрочь. По идее, вытеснение единственного работающего треда в процессе это редкость. Однако, если ваша система носит гордое имя FreeBSD, то событие становится вероятным. А если вы имели неосторожность запустить свою программу на кластере и обязательно ждете все N-сто ответов, то вероятность успешно превращается в закономерность.
Как же нам обойти проблему и не ждать? Для этого человечество в лице Мориса Херлихуя (затрудняюсь правильно скзаать по-русски Herlihy) изобрело wait free synchronization. Точнее, Морис доказал, что с помощью примитива CAS (compare and swap) можно реализовать любой wait free алгоритм. Инструкция CAS имеет 3 параметра - адрес переменной, тестовое значение и новое значение. Если в переменной лежит тествое значение, то CAS заменяет его на новое, иначе значение переменной не меняется. На правильных процессорах CAS может выполняться атомарно.
bool CAS(void **addr, void *new, void *old)
if (*addr == old) {
*addr = new;
return true;
} else
return false;
} |
| _Winnie C++ Colorizer |
Способ сделать любую операцию lock free, например, такой: У нас есть некая структура данных и есть указатель на нее по имени Ptr. Мы берем текущее значение Ptr, пусть это pDataOld. Мы копируем структуру и проводим нужную операцию с копией. В результате мы получаем модифицированную копию pDataNew. Теперь мы с помощью CAS пытаемся заменить значение на новое: CAS(Ptr, pDataNew, pDataOld). Если CAS прошла успешно, то все получилось, если обломалась, то кто-то успел раньше нас и нам надо начинать с начала.
Начиная с 486-го в процессорах Intel есть реализация CAS под именем cmpxchg. Сделать ее atomic можно с помощью префикса lock. Звать ее можно так.
Отлично. Не откладывая в долгий ящик переходим на lock-free синхронизацию между потоками с помощью lock-free stack. Lock-free stack - пожалуй самая простая структура данных, которую можно использовать для передачи данных из одного потока в другой. Используя свеже полученные знания быстро кодаем:
Push(T *obj)
n = new Node(obj);
n->Val = obj;
do {
n->Next = Top;
} while (!CAS(&Top, n, n->Next));
T *Pop()
for(;;) {
Node *t = Top;
if (t == 0)
return 0;
if (CAS(&Top, t->Next, t)) {
T *res = t->Val;
delete t; // жопа-кеды
return res;
}
} |
| _Winnie C++ Colorizer |
Готово! Изготовив подобный код я не замедлил его запустить. И чудо произошло - программа магическим образом ускорилась даже на моем рабочем компе всего с 2-мя ядрами. Теперь она не ждала впустую на тупых critical sections, а молотила во все транзисторы.
Однако, у вас может возникнуть резонный вопрос - что это за комментарий такой после delete t? Дело в том, что если эту строчку закомментировать, то все будет отлично работать, разве что будет бесконечно есть память. Но вы можете это побороть регулярно рестартуя программу :) Если это не входит в ваши планы, то строчку придется вернуть и успех наступает неотвратимо. Тот, кто самостоятельно догадается о механизме возникновения успеха будет пользоваться моим безграничным уважением.
Для остальных сообщаю, что проблема имеет название ABA и происходит это так:
Первый тред выполняет Pop(), считывает текущий Top, записывает его в t и готовится
Что же делать, что же делать??? Очевидный ответ - не использовать delete t, однако, думаю, он не всем подойдет :) Далее мнения разошлись и предлагаются такие варианты исправления:
1. В качестве указателя использовать пару (указатель, счетчик). Счетчик прибавлять за каждую операцию. Другие публикации автора. Однако, этот метод требует более широкой операции CAS (т.н. CAS2), которая могла бы менять одновременно 8 байт для 32-битной и 16 байт для 64-битной платформы. Такие инструкции в x86 есть, но связываться с ними совсем не хочется. Это совсем не common path, а редко используемые инструкции. Поэтому у тех, кто их таки использует велика вероятность стать экспертом по особенностям реализации cmpxchg8b на каком-нибудь новом или маргинальном процессоре типа VIA. Еще в gamedev-е меня научили тратить время более продуктивным способом, чем искать ошибки в hardware/драйверах. Можно, конечно, реализовать и без CAS2 и даже есть такая бесплатная "реализация". Однако, так как их замена CAS2 не является атомарной, то и вся эта бесплатная "реализация" некорректна.
1а. Т.к. CAS2 редко где есть, то расположим counter в младших битах указателя. Без комментариев.
1б. Используем в качестве указателя 32-битное число. Скажем, в старших битах индекс в массиве, в младших - counter. Уже лучше, но тогда нам нужно либо ограничить сверху количество Node, которые потребуются программе (если закажем мало - стремно, могут кончится, если много - жалко память), либо нужен lock-free dynamic array. К тому же counter может wrap-нуться...
2. Hazard pointers - вроде изобретение небезызвестного Doug Lea. Рабочая идея, но треды, которые знают про другие треды... Пожалуйста, без меня, я такого с memory manager`ом уже наелся :)
3. Cнабдить каждый Node RefCounter-ом. Изобретение товарища Valois.
Изобретении Valois-a мне сначала понравилось. Выглядит самым простым решением - добавляем в Node RefCount. Чтение указателей на Node реализуется с помощью функции SafeRead:
Node *SafeRead(Node *volatile*ptr)
for(;;) {
Node * volatile res = *ptr;
if (res == 0)
return 0;
atomic_add(res->RefCount, 1);
if (res == *ptr)
return res;
else
atomic_add(res->RefCount, -1);
} |
| _Winnie C++ Colorizer |
Т.е. если несколько объектов прочитали в Pop() указатель на один и тот же объект, то объект не будет освобожден, пока все треды не прекратят на него смотреть. ABA удалось избежать. Открываем шампанское!
Закрываем шампанское. Теперь мы можем прибавлять RefCount объекту, который был освобожден в соседнем треде и уже болтается во free memory! Для этого переключение потоков должно произойти между "res = *ptr" и "atomic_add()". Мы, конечно, после этого вернем все "на место" и вычтем прибавленную единичку, но какие волшебные баги это может породить, если память из под Node была использована в другом месте мне даже представить страшно. То ли я чего-то не понял, то ли товарищ Valois предлагает форменную диверсию или забыл сообщить, что под Node надо сделать выделенный пул и выделять память под Node только из него. Да и race-condition-ами пугают.
Посмотрев на все это и отчаявшись найти короткий кусок кода, который делает только то, что нужно и ничего больше, напесал свой. Для решения проблемы ABA я сделал RefCount на весь стек. Память будет освободжаться только когда никто не использует стек. Конечно, если все треды только и делают, что лезут в стек такое никогда не случится и память будет поглощаться неограниченно. Однако, в моем случае использование таких стеков гораздо менее напряженное. Код здесь. Если вы вдруг найдете ошибку в этом коде буду очень вам признателен.
Рассмотренные нами реализации стека - lock free, но вовсе не wait-free, как могло показаться. Ведь в них таки есть циклы и они могут в них крутиться, а wait-free гарантирует выполнение за конечное число шагов. Ну что, хотите по-настоящему? Тогда вас заинтересуют wait-free алгоритмы (подробнее про различия).
Например, wait free stack по имени elimination-backoff stack
Удачи!
Update Ошибку наконец нашли, ура! Исправить, имхо, таки можно, например, так
Deleted comment
October 18 2008, 14:26:32 UTC 3 years ago
October 18 2008, 14:33:53 UTC 3 years ago
во-первых, для новых элементов стэка вызывается new, я не уверен, но вроде два потока не смогут выполнить обращение к memory manager одновременно (может не на всех ОС).
во-вторых, всё же эффективнее обойтись без swap, а делать изменение объекта in-place. в некоторых условиях один поток in-place обгонит 2 и более со swap.
в-третьих, синхронизация - неизбежное зло. на мой взгляд выход в том, чтобы минимизировать необходимость синхронизации на уровне алгоритма. часто можно написать алгоритм, который будет гарантировать, что одновременного обращения к одному элементу быть не может, либо это встречается крайне редко и вынесено в отдельный случай. но тут всё зависит от специфики задачи. при data-параллелизме это относительно просто, а при function-параллелизме сложнее.
October 18 2008, 14:39:04 UTC 3 years ago
я работаю в HPC, есть больше свободы, data-параллелизм и проблема синхронизации стоит не так остро.
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
October 18 2008, 16:43:27 UTC 3 years ago
И в CUDA, и в DX11 есть CAS (а хорошо бы CAS2).
А жопа-кеды это не я, это Давыдов (http://davydov.blogspot.com/).
October 18 2008, 18:25:08 UTC 3 years ago
Мне всегда нравился CSP, и не менее всегда нравились LL-SC/CAS2.
Локи кстати как рефкаунты - они мозг разжижают и не дают выползти из локальной оптимизации.
October 18 2008, 16:46:21 UTC 3 years ago
Решение удалять элементы потом, когда никто не трогает очередь - отличное и почти бесплатное.
October 18 2008, 17:01:51 UTC 3 years ago
Таким образом в случае сильной нагрузки можем получить ситуацию, когда N-k тредов копируют, копируют и копируют.
October 18 2008, 18:21:31 UTC 3 years ago
Но это не отменяет моей нелюбви к "мониторам"!
October 18 2008, 18:22:01 UTC 3 years ago
October 18 2008, 20:31:03 UTC 3 years ago
October 18 2008, 19:08:04 UTC 3 years ago
А тут товарищи зажигают с hash_maps:
http://www.azulsystems.com/events/javao
http://blogs.azulsystems.com/cliff/2
October 19 2008, 21:27:59 UTC 3 years ago
3 years ago
3 years ago
October 18 2008, 23:43:56 UTC 3 years ago
Допустим, есть дерево в каждом узле хранится много результатов, зависящих от результатов, хранящихся в дочерних узлах. Дерево пересчитывается много раз для разных параметров. Как такое лучше параллелить? Дерево может быть сильно несбалансированным, так что вычисление в разных тредах разных ветвей выглядит не слишком привлекательно. Так как дерево предполагается неглубоким, есть вариант тупо создать каждому треду свою копию дерева, и считать в разных тредах для разных входных параметрах. В какую сторону тут лучше смотреть 8) ? Спасибо.
October 19 2008, 08:01:20 UTC 3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
October 19 2008, 09:15:21 UTC 3 years ago
но тут такое дело. если мы бьёмся за latency (например, нам нужно быстро-быстро перерисовывать сложный объект), то такой подход имеет смысл. то есть мы жертвуем overhead-ом того, что какие-то треды считают впустую, чтобы добиться latency. если же нас интересует throughput - то есть, когда все ядра и так гарантированно делают что-то полезное, то от такого подхода будет только хуже - поскольку часть работы теперь будет выкидываться в помойку.
October 19 2008, 18:19:30 UTC 3 years ago
October 19 2008, 09:20:10 UTC 3 years ago
Почитать текст со всеми комментариями можно тут
Это Ваш 1-й ТОПовый пост за последний год. Посмотреть статистику автора можно тут.
Этот бот не имеет отношения к Блогуну © НадежныйИсточник
October 19 2008, 11:48:39 UTC 3 years ago
Спасибо.
Deleted comment
October 19 2008, 17:55:25 UTC 3 years ago
3 years ago
Deleted comment
October 19 2008, 18:17:26 UTC 3 years ago
правильный new/delete не делает lock. При желании можно сделать custom alloc, но зачем? Это место не тормозит, оно просто дает шанс системе отобрать управление у потока, если использовать critical section. Задача - не давать системе шанса забрать управление :)
3 years ago
October 19 2008, 21:07:12 UTC 3 years ago
Получается, что volatile read (в ассемблере?) выставляет memory barrier?
October 19 2008, 21:26:39 UTC 3 years ago Edited: October 19 2008, 21:29:51 UTC
Push(T *obj) n = new Node(obj); n->Val = obj; do { n->Next = Top; } while (!CAS(&Top, n, n->Next));А чем цикл, который жрет CPUшные ресурсы, лучше чем засыпание потока?
PS: Кстати, мне кажется подобный подход к синхронизации чем-то похож на многоверсионность.
October 20 2008, 22:13:33 UTC 3 years ago
October 20 2008, 22:31:54 UTC 3 years ago
October 21 2008, 04:59:09 UTC 3 years ago
3 years ago
3 years ago
November 14 2008, 12:00:32 UTC 3 years ago
Сейчас появилось много разговоров о воскрешении functional programming именно из-за "ставшей очевидной" проблемы сложности создания и сопровождения многопоточных программ в реальной параллельности, которая сейчас стала доступна каждому. Как вы к этому относитесь?
По сути то, что вы описываете - это достаточно низкоуровневое программирование, почему не использовать более высокоуровневые инструменты, если они не накладывают серьезного ограничения на performance?
Что в Yandex думают о том же Erlang?
November 15 2008, 05:25:56 UTC 3 years ago
нам кажется, что полное функциональное программирование это перебор. Для повседных нужд нам вполне хватает такого
April 7 2009, 00:26:57 UTC 3 years ago
Вот ты там пишешь выше, что на правильных процессорах CAS может быть атомарным. Это неправда. Если бы была по-настоящему атомарная операция
cmpxchg [mem], [reg1], reg2
которая сравнивает значения в ячейке [mem] и reg2 и? если они равны, записывает в [mem] значение [reg1], то не было бы проблем.
Кладешь в reg2 значение t, в reg1 кладешь t + смещение указателя Next относительно начала структуры и вперед с песнями, mem = &Top. Процессор атомарно зачитывает Top, сравнивает его с t, и если совпадает пишет t->Next в Top!!
А в Интеле атомарность кастрированная, там можно только сравнить аккумулятор с одной ячейкой памяти и записить туда содержимое другого регистра, а не другой ячейки памяти.
April 7 2009, 07:15:22 UTC 3 years ago
3 years ago
3 years ago
3 years ago
March 8 2010, 08:37:35 UTC 2 years ago
Да, и что они там на 64 разрядах намутили, я не ковырялся? интересно же.
March 8 2010, 17:08:52 UTC 2 years ago
А мне как-то не с руки первым использовать новомодную инструкцию и стать экспертом в том, что установленный бит поддержки cmpxchg16b может не означать ее наличия, в том, что intrinsic кривоватый и подобном добре.
кто в 64bit намутил?
ЗЫ кстати, в коде ошибка, до сих пор никто из читателей ее не обнаружил, что лишний раз говорит нам, что lockfree лучше всего обойти стороной :)
2 years ago
2 years ago
2 years ago
March 11 2010, 19:57:39 UTC 2 years ago
http://rsdn.ru/forum/dotnet/3721636.1.a
March 12 2010, 13:49:16 UTC 2 years ago