Могу ли я избежать использования блокировок для моей редко изменяющейся переменной?

Я читал книгу Джо Даффи о параллельном программировании. У меня есть своего рода академический вопрос о запретной резьбе.

Во-первых: Я знаю, что беззаконная нить чревата опасностью (если вы мне не верите, прочитайте разделы в книге о модели памяти)

Тем не менее, у меня есть вопрос: предположим, что у меня есть класс с свойством int на нем.

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

Редко бывает, что значение изменится, и когда это произойдет, это будет единственный поток, который изменит его.

Если он изменится, а другая операция, которая его использует, находится в полете, никто не потеряет палец (первое, что его использует, это скопировать его в локальную переменную)

Я мог бы использовать блокировки (или readwriterlockslim, чтобы поддерживать чтение одновременно). Я могу отметить переменную volatile (много примеров, где это сделано)

Однако даже волатильность может нанести удар производительности.

Что делать, если я использую VolatileWrite, когда он изменяется, и оставьте нормальный доступ для чтения. Что-то вроде этого:

public class MyClass
{
  private int _TheProperty;
  internal int TheProperty
  {
    get { return _TheProperty; }
    set { System.Threading.Thread.VolatileWrite(ref _TheProperty, value); }
  }
}

Я не думаю, что я когда-нибудь попытаюсь это сделать в реальной жизни, но мне интересно узнать об этом (более того, как контрольный пункт, понимаю ли я модель памяти, которую я читал).

Ответ 1

Маркировка переменной как "volatile" имеет два эффекта.

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

2) Код, сгенерированный дрожанием, не будет "кэшировать" значение, которое, как кажется, логически не меняется.

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

Но мне кажется, что последний момент весьма уместен. Если у вас есть прямая блокировка на энергонезависимой переменной:

while(this.prop == 0) {}

джиттер находится в пределах своих прав на создание этого кода, как если бы вы написали

if (this.prop == 0) { while (true) {} }

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

Ответ 2

Вопрос в том, сможет ли поток чтения увидеть это изменение. Это не просто вопрос, видит ли он это немедленно.

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

Ответ 3

"Достижение производительности" volatile заключается в том, что компилятор теперь генерирует код, чтобы фактически проверять значение, а не оптимизировать его - другими словами, вам нужно будет использовать эту производительность независимо от того, что вы делаете.

Ответ 4

На уровне ЦП, да, каждый процессор в итоге увидит изменение адреса памяти. Даже без замков или барьеров памяти. Замки и барьеры просто гарантируют, что все это произошло в относительном упорядочении (другие инструкции), так что оно оказалось правильным для вашей программы.

Проблема не в кеш-когерентности (я надеюсь, что книга Джо Даффи не делает эту ошибку). Кэши остаются конгерентными - дело в том, что это требует времени, и процессоры не утруждают себя ожиданием, чтобы это произошло - если вы не применяете его. Таким образом, вместо этого процессор переходит к следующей инструкции, которая может или не может закончиться до предыдущей (потому что каждая запись чтения/записи в память занимает другое время. По иронии судьбы из-за времени, когда процессоры соглашаются когерентность и т.д. - это приводит к тому, что некоторые линии кэширования становятся конгерентными быстрее других (т.е. в зависимости от того, была ли строка изменена, "Исключена", "Совместно" или "Неверно", требуется больше или меньше работы для перехода в необходимое состояние).

Таким образом, чтение может показаться старым или из устаревшего кэша, но на самом деле это произошло раньше, чем ожидалось (обычно из-за прогноза ожидания и ветвления). Когда он действительно был прочитан, кеш был последовательным, он только что изменился с тех пор. Таким образом, значение не было старым, когда вы его читали, но сейчас оно вам нужно. Вы просто читали его слишком рано.: - (

Или, что то же самое, это было написано позже, чем логика вашего кода считала, что это будет написано.

Или оба.

Во всяком случае, если это C/С++, даже без блокировок/барьеров, вы в конечном итоге получите обновленные значения. (в течение нескольких сотен циклов, как правило, по мере того, как память длится так долго). В C/С++ вы можете использовать volatile (слабый non-thread volatile), чтобы гарантировать, что значение не было прочитано из регистра. (Теперь есть некогерентный кеш, т.е. Регистры)

В С# я не знаю достаточно о CLR, чтобы знать, как долго значение может оставаться в регистре, и как обеспечить, чтобы вы действительно перечитывали из памяти. Вы потеряли "слабую" изменчивость.

Я подозреваю, что до тех пор, пока доступ к переменной не будет полностью скомпилирован, вы, в конце концов, закончите регистры (для x86 не будет много чего начать) и получите повторное чтение.

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

Ответ 5

Это шаблон, который я использую, когда шаблон "последний автор выигрывает" применим к ситуации. Я использовал ключевое слово volatile, но после просмотра этого шаблона в примере кода из Jeffery Richter я начал использовать его.

Ответ 6

Для нормальных вещей (например, устройств с отображением памяти) протоколы кеш-когерентности, идущие внутри/между ЦП/ЦП, заключаются в том, что разные потоки, разделяющие эту память, получают согласованное представление о вещах (т.е. если я изменяю значение места памяти в одном CPU, это будет видно другим процессорам, которые имеют память в своих кешах). В связи с этим volatile поможет гарантировать, что оптимизатор не оптимизирует доступ к памяти (который всегда проходит через кеш в любом случае), скажем, считывая значение, кэшированное в регистре. Документация С# выглядит довольно понятной. Опять же, программисту приложений обычно не приходится иметь дело с самой когерентностью кэша.

Я настоятельно рекомендую прочитать свободно доступную бумагу "Что каждый программист должен знать о памяти". Много волшебства продолжается под капотом, что в основном предотвращает стрельбу в ногу.

Ответ 7

В С# тип int является потокобезопасным.

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

Однако вы можете объявить volatile, если поток ОС будет выполнять обновление.

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

a = !a;

не является атомарным. Если два потока читаются одновременно, у вас есть условие гонки.