Является ли конструкция C# "lock" устаревшей с помощью Interlocked.CompareExchange<T>?
Резюме:
Мне кажется, что:
- упаковка полей, представляющих логическое состояние, в один неизменный расходный объект
- обновление авторитетной ссылки объекта с помощью вызова
Interlocked.CompareExchange<T>
- и обработка ошибок обновления соответственно
обеспечивает своего рода параллелизм, который делает конструкцию "блокировки" не только ненужной, но и действительно вводящей в заблуждение конструкцией, которая уклоняется от некоторых реалий о параллелизме и в результате приводит к множеству новых проблем.
Обсуждение проблемы:
Сначала рассмотрим основные проблемы с использованием блокировки:
- Блокировки снижают производительность и должны использоваться в тандеме для чтения и записи.
- Блокирует выполнение потоков блоков, препятствуя параллелизму и риску тупиков.
Рассмотрим нелепое поведение, вдохновленное "замком". Когда возникает необходимость одновременного обновления логического набора ресурсов, мы "блокируем" набор ресурсов и делаем это с помощью свободно связанного, но выделенного объекта блокировки, который в противном случае не имеет смысла (красный флаг #1).
Затем мы используем шаблон "блокировки" для разметки области кода, где происходит логически непротиворечивое изменение состояния на множестве полей данных, и все же мы стреляем себе в ногу, смешивая поля с несвязанными полями в одном и том же объекте, оставляя их все изменяемыми, а затем загоняя себя в угол (красный флаг #2), где мы также должны использовать блокировки при чтении этих различных полей, чтобы мы не перехватили их в несогласованном состоянии.
Очевидно, есть серьезная проблема с этим дизайном. Это несколько нестабильно, поскольку требует тщательного управления объектами блокировки (порядок блокировки, вложенные блокировки, координация между потоками, блокировка / ожидание ресурса, используемого другим потоком, который ожидает, чтобы вы что-то сделали, и т. Д.), Что зависит от контекст. Мы также слышим, как люди говорят о том, что избежать тупика "сложно", хотя на самом деле это очень просто: не кради туфли человека, которого ты собираешься попросить устроить гонку для тебя!
Решение:
Прекратите использовать "блокировку" вообще. Правильно сверните ваши поля в нетленный / неизменный объект, представляющий согласованное состояние или схему. Возможно, это просто пара словарей для преобразования в и из отображаемых имен и внутренних идентификаторов, или, может быть, это головной узел очереди, содержащий значение и ссылку на следующий объект; что бы это ни было, заверните его в свой собственный объект и запечатайте его для согласованности.
Распознайте ошибку записи или обновления как возможность, обнаружите ее, когда это происходит, и примите контекстуально обоснованное решение повторить попытку немедленно (или позже) или сделать что-то еще вместо бесконечной блокировки.
В то время как блокировка кажется простым способом поставить задачу в очередь, которая кажется обязательной, не все потоки настолько выделены и эгоистичны, что могут позволить себе делать такие вещи с риском для компрометации всей системы. Не только лениво сериализовать вещи с "блокировкой", но и побочным эффектом попытки сделать вид, что запись не должна завершиться неудачей, вы блокируете / замораживаете свой поток, так что он становится там безответственным и бесполезным, оставляя все другие обязанности в его упрямый ждать, чтобы сделать то, что он намеревался сделать некоторое время назад, не зная о том, что оказание помощи другим иногда необходимо для выполнения это собственные обязанности.
Условия гонки нормальны, когда независимые, спонтанные действия происходят одновременно, но в отличие от неконтролируемых коллизий Ethernet, поскольку у программистов мы имеем полный контроль над нашей "системой" (то есть детерминированным цифровым оборудованием) и ее входами (независимо от того, насколько случайным и насколько случайным может быть ноль или единица на самом деле?) и выводит, и память, в которой хранится состояние нашей системы, поэтому живая блокировка не должна быть проблемой; кроме того, у нас есть атомарные операции с барьерами памяти, которые решают тот факт, что одновременно может работать много процессоров.
Подвести итоги:
- Захватите объект текущего состояния, используйте его данные и создайте новое состояние.
- Поймите, что другие активные потоки будут делать то же самое и могут побить вас этим, но все соблюдают авторитетную контрольную точку, представляющую "текущее" состояние.
- Используйте Interlocked.CompareExchange, чтобы одновременно увидеть, является ли объект состояния, на котором вы основали свою работу, по-прежнему самым текущим состоянием, и заменить его новым, в противном случае произойдет сбой (потому что другой поток завершился первым) и предпринять соответствующие корректирующие действия.
Самая важная часть - как вы справляетесь с неудачей и возвращаетесь на свою лошадь. Здесь мы избегаем блокировок, слишком много думаем, недостаточно делаем или делаем правильно. Я бы сказал, что замки создают иллюзию того, что вы никогда не упадете с лошади, несмотря на то, что едете в давке, и хотя в такой фантастической стране мечтает нить, остальная часть системы может развалиться и рухнуть и сгореть.
Итак, есть ли что-то, что может сделать конструкция "блокировки", которая не может быть достигнута (лучше, менее нестабильно) с реализацией без блокировки, использующей CompareExchange и неизменяемые объекты логического состояния?
Все это - осознание, к которому я пришел самостоятельно после интенсивной работы с блокировками, но после некоторого поиска в другом потоке облегчает ли многопоточное программирование без блокировок что-нибудь проще? Кто-то упоминает, что программирование без блокировок будет очень важно, когда мы сталкиваемся с высокопараллельными системами с сотнями процессоров, в которых мы не можем позволить себе использовать жесткие блокировки.
6 ответов
Ваше предложение сравнить-обмен-предложение имеет один большой недостаток - оно несправедливо, потому что оно предпочитает короткие задачи. Если в системе много коротких задач, шансы на выполнение длинной задачи могут быть очень низкими.
Есть четыре условия для гонки.
- Первое условие состоит в том, что существуют области памяти, доступные из более чем одного потока. Как правило, эти местоположения являются глобальными / статическими переменными, или кучи памяти доступны из глобальных / статических переменных.
- Второе условие состоит в том, что существует свойство (часто называемое инвариантом), которое связано с этими местами общей памяти, которые должны быть истинными или действительными, чтобы программа функционировала правильно. Как правило, свойство должно иметь значение true, прежде чем происходит обновление, чтобы обновление было корректным.
- Третье условие заключается в том, что свойство инварианта не сохраняется во время некоторой части фактического обновления. (Это временно недействительно или ложно во время некоторой части обработки).
Четвертое и последнее условие, которое должно произойти для гонки, состоит в том, что другой поток обращается к памяти, в то время как инвариант нарушается, вызывая, таким образом, непоследовательное или неправильное поведение.
Если у вас нет места в общей памяти, доступного из нескольких потоков, или вы можете написать свой код, чтобы либо исключить эту переменную общей памяти, либо ограничить доступ к ней только для одного потока, тогда не будет возможности состязания. и вам не нужно ни о чем беспокоиться. В противном случае оператор блокировки или какая-либо другая подпрограмма синхронизации абсолютно необходимы и не могут быть проигнорированы.
Если нет инварианта (скажем, все, что вы делаете, это записываете в эту общую папку памяти, и ничто в операции потока не считывает ее значение), то снова проблема не возникает.
Если инвариант никогда не является недействительным, опять же нет проблем. (скажем, разделяемая память - это поле даты и времени, в котором хранится дата и время последнего запуска кода, тогда она не может быть недействительной, если поток вообще не может ее записать...
Чтобы исключить nbr 4, вы должны ограничить доступ на запись к блоку кода, который обращается к общей памяти из более чем одного потока одновременно, используя блокировку или некоторую сопоставимую методологию синхронизации.
"Удар параллелизма" в этом случае не только неизбежен, но и абсолютно необходим. Интеллектуальный анализ того, что именно является общей памятью, и что именно является вашим критическим "инвариантом", позволяет вам кодировать систему, чтобы минимизировать этот параллелизм "попадания". (т. е. максимизировать параллелизм безопасно.)
Я хотел бы знать, как бы вы выполнили эту задачу, используя свой стиль программирования без блокировки? У вас есть несколько рабочих потоков, все периодически попадающих в общие списки задач для следующего задания. (в настоящее время) Они блокируют список, находят элемент в заголовке, удаляют его и разблокируют список. Пожалуйста, примите во внимание все условия ошибок и возможные гонки данных, чтобы ни два потока не могли закончить работу над одной и той же задачей или чтобы задача была случайно пропущена.
Я подозреваю, что код для этого может страдать от чрезмерной сложности и может привести к снижению производительности в случае высокой конкуренции.
Большое преимущество блокировки над операцией CAS, такой как Interlocked.CompareExchange, заключается в том, что вы можете изменить несколько областей памяти в пределах блокировки, и все изменения будут видны другим потокам / процессам одновременно.
В CAS атомарно обновляется только одна переменная. Код без блокировки, как правило, значительно сложнее, потому что вы можете не только представить обновление только одной переменной (или двух смежных переменных с CAS2) одновременно другим потокам, вы также должны иметь возможность обрабатывать условия "сбой", когда CAS не не удастся. Кроме того, вам необходимо решить проблемы с ABA и другие возможные осложнения.
Существует множество методов, таких как низкая блокировка, точная зернистая блокировка, чередующиеся блокировки, блокировки чтения-записи и т. Д., Которые могут сделать простой код блокировки намного более удобным для многоядерности.
Тем не менее, есть много интересных применений как для блокировки, так и для кода без блокировки. Однако, если вы ДЕЙСТВИТЕЛЬНО не знаете, что делаете, создавая свой собственный код без блокировки, это не для новичка. Используйте либо код без блокировки, либо хорошо зарекомендовавшие себя алгоритмы, и тщательно их протестируйте, потому что очень трудно найти граничные условия, которые вызывают сбой во многих попытках без блокировки.
Я бы сказал, что это не более устарело, чем говорить в целом, что пессимистический параллелизм устарел при оптимистичном параллелизме или что паттерн A устарел из-за паттерна B. Я думаю, что это связано с контекстом. Безблокировочная система является мощной, но, возможно, нет смысла применять ее в одностороннем порядке, потому что не каждая проблема идеально подходит для этого. Есть компромиссы. Тем не менее, было бы хорошо иметь универсальный оптимистический подход без блокировок, если он не был реализован традиционно. Короче говоря, да, блокировка может сделать что-то, чего нельзя достичь с помощью другого подхода: представить потенциально более простое решение. С другой стороны, может случиться так, что оба имеют одинаковый результат, если определенные вещи не имеют значения. Я полагаю, что немного двусмысленно...
Теоретически, если есть определенный объем работы, программа, которая использует Interlocked.CompareExchange
удастся сделать все это без блокировки. К сожалению, в условиях высокой конкуренции цикл read/compute-new/compareExchange может привести к такому ускорению, что каждый из 100 процессоров, пытающихся выполнить одно обновление для общего элемента данных, может занять больше времени - в режиме реального времени - чем один процессор, выполняющий 100 обновлений в последовательности. Параллелизм не улучшит производительность - он убьет ее. Использование блокировки для защиты ресурса будет означать, что только один процессор за раз сможет его обновить, но улучшит производительность, чтобы соответствовать случаю с одним ЦП.
Одним из реальных преимуществ программирования без блокировок является то, что функциональность системы не будет подвергаться негативному воздействию, если поток будет задержан на произвольное количество времени. Можно сохранить это преимущество, избегая при этом ошибок в производительностиCompareExchange
программирование с использованием комбинации блокировок и тайм-аутов. Основная идея заключается в том, что при наличии конкуренции ресурс переключается на синхронизацию на основе блокировок, но если поток слишком долго удерживает блокировку, будет создан новый объект блокировки, а более ранняя блокировка будет проигнорирована. Это будет означать, что если этот бывший поток все еще пытался сделать CompareExchange
Цикл, он потерпит неудачу (и должен начинаться заново), но более поздние потоки не будут заблокированы, и правильность не будет принесена в жертву.
Обратите внимание, что код, необходимый для арбитража всего вышеперечисленного, будет сложным и хитрым, но если кто-то хочет, чтобы система была устойчивой при наличии определенных условий отказа, такой код может потребоваться.