Бесконтактная многопоточность предназначена для реальных экспертов по резьбе

Я читал ответ, который Jon Skeet дал на вопрос, и в нем он упомянул об этом

Насколько мне известно, многопоточность без блокировки предназначена для реальных экспертов по потокам, из которых я не один.

Не в первый раз, когда я это слышал, но я нахожу очень мало людей, говорящих о том, как вы на самом деле это делаете, если вам интересно узнать, как писать незакрепленный многопоточный код.

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

Приветствия

Ответ 1

Существующие "незакрепленные" реализации в большинстве случаев следуют за одним и тем же шаблоном:

  • * прочитайте некоторое состояние и сделайте его копию **
  • * изменить копию **
  • выполнить операцию блокировки
  • повторить попытку, если он не работает

(* необязательно: зависит от структуры/алгоритма данных)

Последний бит ужасно похож на спин-блокировку. Фактически, это основной spinlock.:)
Я согласен с @nobugz в этом: стоимость взаимосвязанных операций, используемых в бесключевой многопоточности, в которой доминируют задачи кэширования и памяти, которые она должна выполнять,

Однако вы получаете, однако, структуру данных, которая является "незакрепленной", так это то, что ваши "блокировки" очень мелкие. Это уменьшает вероятность того, что два параллельных потока обращаются к одной и той же "блокировке" (ячейке памяти).

Фокус в большинстве случаев заключается в том, что у вас нет выделенных блокировок - вместо этого вы лечите, например. все элементы в массиве или все узлы в связанном списке как "spin-lock". Вы читаете, изменяете и пытаетесь обновить, если обновление не было после вашего последнего чтения. Если есть, повторите попытку.
Это делает вашу "блокировку" (о, извините, не блокирует:) очень мелкозернистую, не вводя дополнительную память или требования к ресурсам.
Чем более мелкозернистый, тем меньше вероятность ожидания. Сделать его как можно более мелким, без дополнительных требований к ресурсам, здорово, не так ли?

Большая часть удовольствия, однако, может исходить от обеспечения правильной загрузки/сохранения магазина.
В отличие от одной интуиции, процессоры могут свободно изменять порядок чтения/записи в памяти - они очень умны, кстати: вам будет трудно наблюдать это из одного потока. Вы, однако, столкнетесь с проблемами, когда начинаете многопоточность на нескольких ядрах. Ваши интуиции сломаются: просто потому, что инструкция ранее в вашем коде, это не означает, что это произойдет на самом деле раньше. Процессоры могут обрабатывать инструкции не по порядку: и они особенно любят делать это с инструкциями с обращениями к памяти, чтобы скрыть затухание основной памяти и лучше использовать их кеш.

Теперь, убежденный против интуиции, что последовательность кода не течет "сверху вниз", вместо этого она работает так, как будто не было никакой последовательности - и ее можно назвать "дьявольской площадкой". Я считаю, что невозможно дать точный ответ на вопрос о том, какие переупорядочения груза/магазина будут иметь место. Вместо этого, каждый всегда говорит с точки зрения мейсов, мотивов и банок и готовится к худшему. "О, CPU может переупорядочить это чтение до того, как он напишет, поэтому лучше всего поставить здесь барьер памяти на этом месте".

Вопросы осложняются тем, что даже эти mays и mights могут различаться по архитектуре CPU. Например, может случиться так, что что-то, что гарантировано не произойдет в одной архитектуре, может случиться с другой.


Чтобы получить "незащищенную" многопоточность, вам нужно понять модели памяти.
Однако получение модели памяти и гарантий правильности не является тривиальным, о чем свидетельствует эта история, в которой Intel и AMD внесли некоторые исправления в документацию MFENCE, вызвав некоторые разжигание среди разработчиков JVM. Как оказалось, документация, на которую разработчики полагались с самого начала, была не столь точной в первую очередь.

Замки в .NET приводят к неявному барьеру памяти, поэтому вы можете безопасно их использовать (большую часть времени, то есть... см., например, это Joe Duffy - Brad Abrams - Vance Morrison greatness о ленивой инициализации, блокировках, летучих и барьерах памяти.:) (Не забудьте следовать ссылкам на этой странице.)

В качестве дополнительного бонуса вы получите представление о модели памяти .NET на стороннем квесте.:)

Существует также "oldie but goldie" от Vance Morrison: Что каждый Dev должен знать о многопоточных приложениях.

... и, конечно, как упоминалось @Eric, Joe Duffy является окончательным чтением по этому вопросу.

Хорошая STM может приблизиться к мелкозернистой блокировке по мере ее поступления и, вероятно, обеспечит производительность, близкую или эквивалентную реализации вручную. Одним из них является STM.NET из Проекты DevLabs MS.

Если вы не просто фанатик .NET, Дуг Ли сделал отличную работу в JSR-166. Cliff Click имеет интересный подход к хеш-таблицам, который не полагается на блокировку-striping - как это делают обычные хэш-таблицы Java и .NET - и похоже, хорошо масштабируются до 750 процессоров.

Если вы не боитесь рисковать на территорию Linux, следующая статья дает больше информации о внутренних компонентах существующих архитектур памяти и о том, как совместное использование кеш-памяти может разрушить производительность: Что каждый программист должен знать о памяти.

@Ben сделал много комментариев о MPI: я искренне согласен с тем, что MPI может сиять в некоторых областях. Решение на основе MPI может быть проще рассуждать, проще реализовать и с меньшей степенью подверженности ошибкам, чем реализация с половинной выдержкой блокировки, которая пытается быть умной. (Тем не менее - субъективно - также верно для решения на основе STM.) Я также хотел бы сделать ставку на то, что легче на лету легче правильно писать достойное распределенное приложение, например. Эрланг, как показывают многие успешные примеры.

MPI, однако, имеет свои собственные затраты и собственные проблемы, когда он запускается в одной многоядерной системе. Например. в Erlang существуют проблемы, которые необходимо решить вокруг синхронизации планирования процессов и очередей сообщений.
Кроме того, в их основе, MPI-системы обычно реализуют своего рода кооперативный N: M scheduling для "легких процессов". Это, например, означает, что между легкими процессами существует неизбежный контекст. Это правда, что это не "классический контекстный переключатель", а в основном операция с пользовательским пространством, и это можно сделать быстро, однако я искренне сомневаюсь, что он может быть приведен в 20-200 циклов, выполняемых с блокировкой. Переключение контекста пользовательского режима конечно медленнее даже в библиотеке Intel McRT. Планирование N: M с легкими процессами не нова. LWPs были в Solaris в течение длительного времени. Они были оставлены. В NT были волокна. Сейчас они являются реликвией. В NetBSD были "активизации". Они были оставлены. У Linux был свой собственный подход к потоку N: M. Похоже, что он уже несколько мертв.
Время от времени появляются новые соперники: например McRT от Intel или последний раз "Планирование пользовательского режима" вместе с ConCRT от Microsoft.
На самом низком уровне они выполняют то, что делает планировщик N: M MPI. Erlang - или любая система MPI - может сильно повлиять на системы SMP, используя новый UMS.

Я думаю, что вопрос OP не касается достоинств и субъективных аргументов для/против какого-либо решения, но если бы я должен был ответить на это, я полагаю, это зависит от задачи: для построения низкоуровневых высокопроизводительных базовых структур данных, которые работайте в единой системе со многими ядрами, либо с помощью технологии с низким уровнем блокировки/ "без блокировки", либо с помощью STM обеспечит наилучшие результаты с точки зрения производительности и, вероятно, будет бить MPI-решение в любое время по производительности, даже если вышеуказанные морщины сглаживаются, например в Эрланг.
Для создания чего-либо умеренно более сложного, который работает на одной системе, я бы выбрал классическую крупнозернистую блокировку или если производительность вызывает большую озабоченность, STM.
Для создания распределенной системы, система MPI, вероятно, сделает естественный выбор.
Обратите внимание, что реализация MPI для .NET также (хотя они, похоже, не так активны).

Ответ 2

Книга Джо Даффи:

http://www.bluebytesoftware.com/books/winconc/winconc_book_resources.html

Он также пишет блог по этим темам.

Трюк с целью получения программ с низким уровнем блокировки - это понять на самом глубоком уровне то, что правила модели памяти находятся в вашей конкретной комбинации аппаратного обеспечения, операционной системы и среды выполнения.

Я лично ничуть не настолько умен, чтобы делать правильное программирование с низким уровнем блокировки вне InterlockedIncrement, но если вы, отлично, идите на это. Просто убедитесь, что вы оставляете большую часть документации в коде, чтобы люди, которые не так умны, как вы случайно не сломали один из ваших инвариантов модели памяти и внесли ошибку, невозможную для поиска.

Ответ 3

В наши дни нет такой вещи, как "блокировка". Это была интересная площадка для академических кругов и т.п., Еще в конце прошлого века, когда компьютерное оборудование было медленным и дорогостоящим. Алгоритм Деккера всегда был моим любимым, современное оборудование вывело его на пастбище. Он больше не работает.

Два события закончились этим: растущее несоответствие между скоростью ОЗУ и процессором. И способность производителей чипов поставить на чип более одного ядра процессора.

Проблема с оперативной памятью требовала от разработчиков чипов помещать буфер на чип CPU. Буфер хранит код и данные, быстро доступные ядром ЦП. И можно читать и записывать из/в ОЗУ с гораздо меньшей скоростью. Этот буфер называется кешем ЦП, у большинства ЦП есть как минимум два из них. Кэш 1-го уровня маленький и быстрый, второй - большой и медленный. Пока CPU может считывать данные и инструкции из кеша 1-го уровня, он будет работать быстро. Недостаток кеша очень дорог, он заставляет процессор спать целых 10 циклов, если данные не находятся в 1-м кеше, целых 200 циклов, если он не во втором кэше, и его необходимо прочитать из ОЗУ.

Каждое ядро ​​ЦП имеет свой собственный кеш, они хранят собственный "вид" ОЗУ. Когда ЦП записывает данные, запись записывается в кеш, который затем медленно помещается в ОЗУ. Неизбежно, у каждого ядра теперь будет другое представление о содержимом ОЗУ. Другими словами, один ЦП не знает, что написал еще один процессор, пока цикл записи ОЗУ не завершится, а ЦП обновит свой собственный вид.

Это сильно несовместимо с потоками. Вам всегда очень важно, что состояние другого потока, когда вы должны читать данные, которые были написаны другим потоком. Чтобы обеспечить это, вам необходимо явно запрограммировать так называемый барьер памяти. Это примитив процессора низкого уровня, который гарантирует, что все кэши CPU находятся в согласованном состоянии и имеют современный вид ОЗУ. Все ожидающие записи должны быть сброшены в ОЗУ, тогда кеши должны быть обновлены.

Это доступно в .NET, метод Thread.MemoryBarrier() реализует один. Учитывая, что это 90% задания, которое делает оператор блокировки (и 95%% времени выполнения), вы просто не опережаете, избегая инструментов, которые .NET дает вам и пытается реализовать свои собственные.

Ответ 5

Когда дело доходит до многопоточности, вы должны точно знать, что вы делаете. Я имею в виду изучение всех возможных сценариев/случаев, которые могут возникнуть, когда вы работаете в многопоточной среде. Безпоточная многопоточность не является библиотекой или классом, который мы включаем, ее знаниями/опытом, которые мы зарабатываем во время нашего путешествия по потокам.

Ответ 6

Несмотря на то, что в .NET нет возможности блокировки потоков, часто вы можете сделать значительные улучшения при использовании блокировки, изучая то, что нужно заблокировать, и минимизируя заблокированную секцию... это также известно как минимизация блокировка.

В качестве примера просто скажите, что вам нужно сделать поток потоков безопасным. Не просто слепо бросить блокировку вокруг метода, выполняющего итерацию по коллекции, если он выполняет некоторые задачи с интенсивным процессором для каждого элемента. Вам может потребоваться только создать блокировку создания мелкой копии коллекции. Итерация по копии может работать без блокировки. Конечно, это сильно зависит от специфики вашего кода, но я смог исправить проблему lock convoy с этим подходом.