Когда ключевое слово volatile должно использоваться в C #?

299

Кто-нибудь может дать хорошее объяснение ключевого слова volatile в C #? Какие проблемы он решает, а какие нет? В каких случаях это спасет меня от использования блокировки?

Дорон Яакоби
источник
6
Почему вы хотите сэкономить на использовании блокировки? Необусловленные блокировки добавляют несколько наносекунд к вашей программе. Неужели вы не можете позволить себе несколько наносекунд?
Эрик Липперт

Ответы:

273

Я не думаю, что есть лучший человек, чтобы ответить на это, чем Эрик Липперт (акцент в оригинале):

В C # «volatile» означает не только «убедитесь, что компилятор и джиттер не выполняют переупорядочение кода или не регистрируют оптимизацию кэширования для этой переменной». Это также означает «сказать процессорам делать все, что им нужно, чтобы убедиться, что я читаю последнее значение, даже если это означает остановку других процессоров и синхронизацию основной памяти с их кешами».

На самом деле, этот последний бит является ложью. Истинная семантика изменчивых операций чтения и записи значительно сложнее, чем я описал здесь; фактически они не гарантируют, что каждый процессор останавливает свою работу и обновляет кэши в / из основной памяти. Скорее, они предоставляют более слабые гарантии того, что доступ к памяти до и после чтения и записи может быть упорядочен относительно друг друга . Некоторые операции, такие как создание нового потока, ввод блокировки или использование одного из методов семейства Interlocked, предоставляют более строгие гарантии наблюдения за порядком. Если вы хотите больше подробностей, прочитайте разделы 3.10 и 10.5.3 спецификации C # 4.0.

Честно говоря, я отговариваю вас от создания нестабильного поля . Изменчивые поля являются признаком того, что вы делаете что-то совершенно безумное: вы пытаетесь прочитать и записать одно и то же значение в двух разных потоках, не устанавливая блокировку на месте. Блокировки гарантируют, что считывание или изменение памяти внутри блокировки считается согласованным, блокировки гарантируют, что только один поток одновременно обращается к данному фрагменту памяти, и так далее. Количество ситуаций, в которых блокировка слишком медленная, очень мало, и вероятность того, что вы ошибетесь в коде, потому что не понимаете точную модель памяти, очень велика. Я не пытаюсь писать какой-либо код с низкой блокировкой, за исключением самых тривиальных операций с блокировкой. Я оставляю использование «volatile» реальным специалистам.

Для дальнейшего чтения смотрите:

Охад Шнайдер
источник
29
Я бы проголосовал против, если бы мог. Там много интересной информации, но она не отвечает на его вопрос. Он спрашивает об использовании ключевого слова volatile в отношении блокировки. Некоторое время (до версии 2.0 RT) ключевое слово volatile было необходимо использовать для того, чтобы должным образом сделать поток статического поля безопасным, если у экземпляра поля был какой-либо код инициализации в конструкторе (см. Ответ AndrewTek). В рабочей среде все еще много кода 1.1 RT, и разработчики, которые его поддерживают, должны знать, почему это ключевое слово существует и безопасно ли его удалять.
Пол Пасхи
3
@PaulEaster тот факт, что его можно использовать для блокировки с проверкой на doulbe (обычно в шаблоне синглтона), не означает, что он должен это делать . Использование модели памяти .NET, вероятно, является плохой практикой - вместо этого следует полагаться на модель ECMA. Например, вы можете захотеть портировать на моно один день, который может иметь другую модель. Я также должен понимать, что различные аппаратные архитектуры могут изменить ситуацию. Для получения дополнительной информации см .: stackoverflow.com/a/7230679/67824 . Для лучших альтернативных версий (для всех версий .NET) см .: csharpindepth.com/articles/general/singleton.aspx
Охад Шнайдер
6
Другими словами, правильный ответ на вопрос: если ваш код выполняется во время выполнения 2.0 или более поздней, ключевое слово volatile почти никогда не требуется и приносит больше вреда, чем пользы, если используется без необходимости. Но в более ранних версиях среды выполнения это необходимо для правильной двойной проверки блокировки статических полей.
Павел Пасха
3
Означает ли это, что блокировки и изменчивые переменные являются взаимоисключающими в следующем смысле: если я использовал блокировки вокруг какой-либо переменной, больше нет необходимости объявлять эту переменную как изменчивую?
Гиоргим
4
@ Джорджи да - гарантированные барьеры памяти volatileбудут существовать благодаря замку
Охад Шнайдер
54

Если вы хотите немного больше узнать о том, что делает ключевое слово volatile, рассмотрите следующую программу (я использую DevStudio 2005):

#include <iostream>
void main()
{
  int j = 0;
  for (int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
  {
    j += i;
  }
  std::cout << j;
}

Используя стандартные оптимизированные (релизные) параметры компилятора, компилятор создает следующий ассемблер (IA32):

void main()
{
00401000  push        ecx  
  int j = 0;
00401001  xor         ecx,ecx 
  for (int i = 0 ; i < 100 ; ++i)
00401003  xor         eax,eax 
00401005  mov         edx,1 
0040100A  lea         ebx,[ebx] 
  {
    j += i;
00401010  add         ecx,eax 
00401012  add         eax,edx 
00401014  cmp         eax,64h 
00401017  jl          main+10h (401010h) 
  }
  for (volatile int i = 0 ; i < 100 ; ++i)
00401019  mov         dword ptr [esp],0 
00401020  mov         eax,dword ptr [esp] 
00401023  cmp         eax,64h 
00401026  jge         main+3Eh (40103Eh) 
00401028  jmp         main+30h (401030h) 
0040102A  lea         ebx,[ebx] 
  {
    j += i;
00401030  add         ecx,dword ptr [esp] 
00401033  add         dword ptr [esp],edx 
00401036  mov         eax,dword ptr [esp] 
00401039  cmp         eax,64h 
0040103C  jl          main+30h (401030h) 
  }
  std::cout << j;
0040103E  push        ecx  
0040103F  mov         ecx,dword ptr [__imp_std::cout (40203Ch)] 
00401045  call        dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)] 
}
0040104B  xor         eax,eax 
0040104D  pop         ecx  
0040104E  ret              

Глядя на вывод, компилятор решил использовать регистр ecx для хранения значения переменной j. Для энергонезависимого цикла (первого) компилятор назначил i в регистр eax. Довольно просто. Однако есть пара интересных битов - инструкция lea ebx, [ebx] фактически является многобайтовой nop-инструкцией, поэтому цикл переходит к 16-байтовому выровненному адресу памяти. Другое - использование edx для увеличения счетчика цикла вместо использования команды inc eax. Команда add reg, reg имеет меньшую задержку на нескольких ядрах IA32 по сравнению с инструкцией inc reg, но никогда не имеет большей задержки.

Теперь для цикла со счетчиком изменчивых циклов. Счетчик хранится в [esp], а ключевое слово volatile сообщает компилятору, что значение всегда должно считываться / записываться в память и никогда не присваиваться регистру. Компилятор даже заходит так далеко, что не выполняет загрузку / приращение / сохранение как три отдельных шага (загрузка eax, inc eax, save eax) при обновлении значения счетчика, вместо этого память напрямую изменяется в одной инструкции (добавление памяти). , р). Способ создания кода гарантирует, что значение счетчика цикла всегда актуально в контексте одного ядра ЦП. Никакая операция с данными не может привести к повреждению или потере данных (следовательно, не используется load / inc / store, поскольку значение может измениться во время inc, таким образом теряясь в хранилище). Поскольку прерывания могут обслуживаться только после завершения текущей инструкции,

После того, как вы введете в систему второй ЦП, ключевое слово volatile не защитит данные, обновляемые одновременно другим ЦП. В приведенном выше примере вам понадобится выровнять данные, чтобы получить потенциальное повреждение. Ключевое слово volatile не предотвратит потенциальное повреждение, если данные не могут быть обработаны атомарно, например, если счетчик цикла имел тип long long (64 бита), то для обновления значения потребовались бы две 32-битные операции, в середине какое прерывание может произойти и изменить данные.

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

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

Если вы обрабатываете большие данные или у вас несколько процессоров, вам потребуется система блокировки более высокого уровня (OS) для правильной обработки доступа к данным.

Skizz
источник
Это C ++, но принцип применим к C #.
Skizz
6
Эрик Липперт пишет, что volatile в C ++ только мешает компилятору выполнить некоторые оптимизации, в то время как в C # volatile дополнительно осуществляет некоторую связь между другими ядрами / процессорами, чтобы обеспечить чтение последнего значения.
Питер Хубер
44

Если вы используете .NET 1.1, ключевое слово volatile необходимо при двойной проверке блокировки. Зачем? Поскольку до .NET 2.0 следующий сценарий мог заставить второй поток обращаться к ненулевому, но не полностью сконструированному объекту:

  1. Поток 1 спрашивает, является ли переменная нулевой. //if(this.foo == null)
  2. Поток 1 определяет, что переменная равна нулю, поэтому вводит блокировку. //lock(this.bar)
  3. Поток 1 снова спрашивает, является ли переменная нулевой. //if(this.foo == null)
  4. Поток 1 по-прежнему определяет, что переменная имеет значение null, поэтому он вызывает конструктор и присваивает значение переменной. //this.foo = new Foo ();

До версии .NET 2.0 this.foo мог быть назначен новый экземпляр Foo до завершения работы конструктора. В этом случае может прийти второй поток (во время вызова потока 1 для конструктора Foo) и испытать следующее:

  1. Поток 2 спрашивает, является ли переменная нулевой. //if(this.foo == null)
  2. Поток 2 определяет, что переменная НЕ является нулевой, поэтому пытается использовать ее. //this.foo.MakeFoo ()

До версии .NET 2.0 вы могли объявить this.foo нестабильным, чтобы обойти эту проблему. Начиная с .NET 2.0 вам больше не нужно использовать ключевое слово volatile для двойной блокировки.

В Википедии на самом деле есть хорошая статья о блокировке с двойной проверкой, и она кратко затрагивает эту тему: http://en.wikipedia.org/wiki/Double-checked_locking

AndrewTek
источник
2
это именно то, что я вижу в унаследованном коде, и мне было интересно об этом. Вот почему я начал более глубокое исследование. Спасибо!
Питер Порфи
1
Я не понимаю, как поток 2 будет присваивать значение foo? Разве поток 1 не блокируется this.barи, следовательно, только поток 1 сможет инициализировать foo в данный момент времени? Я имею в виду, что вы проверяете значение после
снятия
25

Иногда компилятор оптимизирует поле и использует регистр для его хранения. Если поток 1 выполняет запись в поле и другой поток обращается к нему, так как обновление было сохранено в регистре (а не в памяти), 2-й поток получит устаревшие данные.

Вы можете подумать о ключевом слове volatile, которое говорит компилятору: «Я хочу, чтобы вы сохранили это значение в памяти». Это гарантирует, что 2-й поток извлекает последнее значение.

Benoit
источник
21

Из MSDN : модификатор volatile обычно используется для поля, к которому обращаются несколько потоков, без использования оператора блокировки для сериализации доступа. Использование модификатора volatile гарантирует, что один поток получит самое последнее значение, записанное другим потоком.

Доктор боб
источник
13

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

Вы, очевидно, теряете некоторую оптимизацию, но она делает код более простым.

Джозеф Дейгл
источник
3

Я нашел эту статью Joydip Kanjilal очень полезной!

When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value

Я просто оставлю это здесь для справки

Кристина Кацакула
источник
0

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

 private static int _flag = 0;
 private static int _value = 0;

 var t1 = Task.Run(() =>
 {
     _value = 10; /* compiler could switch these lines */
     _flag = 5;
 });

 var t2 = Task.Run(() =>
 {
     if (_flag == 5)
     {
         Console.WriteLine("Value: {0}", _value);
     }
 });

Если вы запустите t1 и t2, вы не ожидаете вывода или «Value: 10» в качестве результата. Возможно, компилятор переключает строку внутри функции t1. Если затем выполняется t2, возможно, значение _flag равно 5, а значение _value равно 0. Таким образом, ожидаемая логика может быть нарушена.

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

private static volatile int _flag = 0;

Вы должны использовать volatile только в том случае, если оно действительно вам нужно, потому что оно отключает определенные оптимизации компилятора, оно снижает производительность Он также не поддерживается всеми языками .NET (Visual Basic не поддерживает его), поэтому он препятствует взаимодействию языков.

Алексей Манюк
источник
2
Ваш пример действительно плохой. У программиста никогда не должно быть никаких ожиданий относительно значения _flag в задаче t2, основанного на том факте, что код t1 пишется первым. Написано первым! = Исполнено первым. Не имеет значения, если компилятор переключает эти две строки в t1. Даже если компилятор не переключал эти операторы, ваш Console.WriteLne в ветви else может все еще выполняться, даже с ключевым словом volatile в _flag.
Jakotheshadows
@jakotheshadows, вы правы, я отредактировал свой ответ. Моя главная идея состояла в том, чтобы показать, что ожидаемая логика может быть нарушена, когда мы одновременно запускаем t1 и t2
Алексей Манюк,
0

Итак, чтобы подвести итог всего этого, правильный ответ на вопрос таков: если ваш код выполняется во время выполнения 2.0 или более поздней версии, ключевое слово volatile почти никогда не требуется и приносит больше вреда, чем пользы, если используется без необходимости. IE никогда не используй это. НО в более ранних версиях среды выполнения это необходимо для правильной двойной проверки блокировки статических полей. В частности, статические поля, класс которых имеет код инициализации статического класса.

Пол Пасхи
источник
-4

несколько потоков могут получить доступ к переменной. Последнее обновление будет в переменной

user2943601
источник