Барьеры в памяти и асинхронная изменчивость (volatility)

Рассмотрим следующий класс:

class Unsafe { static bool endIsNigh; static bool repented;   static void Main() { // Запустить поток, ждущий изменения флага в цикле... new Thread(Wait).Start(); Thread.Sleep(1000); // Дадим секунду на «прогрев»! repented = true; endIsNigh = true; Console.WriteLine("Понеслась..."); }   static void Wait() { while (!endIsNigh) // Крутимся в ожидании изменения значения endIsNigh ;   Console.WriteLine("Готово, " + repented); } }

Внимание, вопрос: насколько существенная задержка может разделять "Понеслась..." от "Готово" – другими словами, может ли цикл в методе Wait продолжать крутиться после того, как флаг endIsNigh был установлен в true? И еще, может ли метод Wait напечатать "Готово, false"?

Ответ на оба вопроса – теоретически да, может, на многопроцессорной машине, если планировщик потоков назначит эти два потока на разные CPU. Поля repented и endIsNigh могут кэшироваться в регистрах CPU для повышения производительности и записываться назад в память с некоторой задержкой. И порядок, в каком регистры записываются в память, необязательно совпадет с порядком обновления полей.

Это кэширование можно обойти, используя статические методы Thread.VolatileRead и Thread.VolatileWrite для чтения и записи полей. VolatileRead – это способ “читать последнее значение”; VolatileWrite означает “записать немедленно в память”. Того же эффекта можно достичь более изящно, объявлением полей с модификатором volatile:

class ThreadSafe { // используйте семантику чтения/записи volatile: volatile static bool endIsNigh; volatile static bool repented; ...

 

Если ключевое слово volatile используется как замена методов VolatileRead и VolatileWrite, можно просто думать, что оно означает “не использовать кэш потока для этого поля!”.

Тот же эффект может быть достигнут оборачиванием доступа к repented и endIsNigh в оператор lock. Это работает, так как побочный (но необходимый) эффект блокировки состоит в создании барьера в памяти – для гарантии, что асинхронная изменчивость полей, используемых внутри конструкции lock, не выходит за ее пределы. Другими словами, значения полей будут самими свежими при входе в lock (volatile-чтение) и будут записаны в память перед выходом из lock (volatile-запись).

Использование оператора lock было бы необходимо, если бы нужно было получить доступ к полям repented и endIsNigh атомарно, например, выполнить что-то типа такого:

lock (locker) { if (endIsNigh) repented = true; }

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

Асинхронная изменчивость присуща только примитивным интегральным типам (и unsafe-указателям) – другие типы не кэшируются в регистрах CPU и не могут быть объявлены с ключевым словом volatile. Семантика volatile-чтения и записи автоматически применяется к полям, когда доступ осуществляется через класс Interlocked.

Если ваша политика предполагает доступ к полям из разных потоков в операторе lock, volatile и Interlockedвам не нужны.