C# volatile переменная: память ограждения VS. кэширование
Поэтому я довольно долго изучал эту тему, и мне кажется, что я понимаю самые важные концепции, такие как релиз, и приобретаю ограждения памяти.
Тем не менее, я не нашел удовлетворительного объяснения связи между volatile
и кеширование основной памяти.
Итак, я понимаю, что каждое чтение и запись в / из volatile
Поле обеспечивает строгий порядок чтения, а также операций записи, которые предшествуют и следуют за ним (чтение-получение и запись-выпуск). Но это только гарантирует порядок операций. Это ничего не говорит о времени, когда эти изменения видны другим потокам / процессорам. В частности, это зависит от времени очистки кэша (если вообще). Я помню, как читал комментарий Эрика Липперта, который говорил что-то вроде "присутствия volatile
Поля автоматически отключает оптимизацию кэша ". Но я не уверен, что именно это означает. Означает ли это, что кэширование полностью отключено для всей программы только потому, что у нас есть один volatile
поле где-нибудь? Если нет, то для чего гранулируется кеш?
Кроме того, я прочитал кое-что о сильной и слабой изменчивой семантике, и что C# следует строгой семантике, где каждая запись всегда идет прямо в основную память, независимо от того, volatile
поле или нет. Я очень смущен всем этим.
3 ответа
Я отвечу на последний вопрос первым. Реализация Microsoft.NET имеет семантику релиза при записи1. Это не C# как таковой, поэтому одна и та же программа, независимо от языка, в другой реализации может иметь слабые энергонезависимые записи.
Видимость побочных эффектов касается нескольких потоков. Забудьте о процессорах, ядрах и кешах. Вместо этого представьте, что у каждого потока есть моментальный снимок того, что находится в куче, которая требует некоторой синхронизации для передачи побочных эффектов между потоками.
Итак, что говорит C#? Спецификация языка C# ( более новый черновик) в основном соответствует стандарту Common Language Infrastructure (CLI; ECMA-335 и ISO / IEC 23271) с некоторыми отличиями. Я поговорю о них позже.
Итак, что говорит CLI? Это только летучие операции являются видимыми побочными эффектами.
Обратите внимание, что в нем также говорится, что энергонезависимые операции в куче также являются побочными эффектами, но не гарантированно будут видимыми. Не менее важно то, что2 не означает, что они гарантированно не будут видны.
Что именно происходит на волатильных операциях? Волатильное чтение имеет приобретенную семантику, оно предшествует любой следующей ссылке в памяти. Энергозависимая запись имеет семантику выпуска, она следует за любой предшествующей ссылкой на память.
Получение блокировки выполняет энергозависимое чтение, а снятие блокировки выполняет энергозависимую запись.
Interlocked
операции приобретают и выпускают семантику.
Есть еще один важный термин для изучения - атомарность.
Чтение и запись, энергозависимые или нет, гарантированно будут атомарными при простых значениях до 32 бит на 32-битных архитектурах и до 64 бит на 64-битных архитектурах. Они также гарантированно будут атомными для ссылок. Для других типов, таких как длинные struct
s, операции не являются атомарными, они могут требовать нескольких независимых обращений к памяти.
Однако, даже с изменчивой семантикой, операции чтения-изменения-записи, такие как v += 1
или эквивалент ++v
(или же v++
(с точки зрения побочных эффектов), не являются атомными.
Блокированные операции гарантируют атомарность для определенных операций, как правило, сложение, вычитание и сравнение-и-своп (CAS), т.е. записывают некоторое значение тогда и только тогда, когда текущее значение все еще является некоторым ожидаемым значением. .NET также имеет атомный Read(ref long)
метод для целых 64-битных чисел, который работает даже в 32-битных архитектурах.
Я буду продолжать ссылаться на получение семантики как volatile чтения и выпуска семантики как volatile записи, а также одну или обе, как volatile операций.
Что все это значит с точки зрения порядка?
Что энергозависимое чтение - это точка, до которой никакие ссылки на память не могут пересекаться, а энергозависимая запись - это точка, после которой никакие ссылки на память не могут пересекаться как на уровне языка, так и на уровне компьютера.
Эти энергонезависимые операции могут переходить после следующих энергозависимых чтений, если между ними нет энергозависимых записей, и переходить до предшествующих энергозависимых записей, если между ними нет энергозависимых чтений.
Эти изменчивые операции в потоке являются последовательными и не могут быть переупорядочены.
Эти изменчивые операции в потоке становятся видимыми для всех других потоков в том же порядке. Однако не существует полного порядка энергозависимых операций из всех потоков, т. Е. Если один поток выполняет V1 и затем V2, а другой поток выполняет V3 и затем V4, то любой порядок, имеющий V1 перед V2 и V3 перед V4, может наблюдаться любым нить. В этом случае это может быть одно из следующих:
То есть любой возможный порядок наблюдаемых побочных эффектов действителен для любого потока для одного выполнения. Не существует требования к общему упорядочению, так что все потоки соблюдают только один из возможных приказов за одно выполнение.
Как все синхронизировано?
По сути, это сводится к следующему: точка синхронизации - это то место, где у вас есть энергозависимое чтение, которое происходит после энергозависимой записи.
На практике вы должны определить, произошло ли volatile чтение в одном потоке после volatile записи в другом потоке3. Вот основной пример:
public class InefficientEvent
{
private volatile bool signalled = false;
public Signal()
{
signalled = true;
}
public InefficientWait()
{
while (!signalled)
{
}
}
}
Однако, как правило, неэффективно, вы можете запустить два разных потока, так что один вызывает InefficientWait()
и еще один звонит Signal()
и побочные эффекты последнего, когда он возвращается из Signal()
стать видимым для первого, когда он вернется из InefficientWait()
,
Изменчивые доступы не так полезны, как блокированные доступы, которые не так полезны, как примитивы синхронизации. Я советую вам сначала разработать безопасный код, используя при необходимости примитивы синхронизации (блокировки, семафоры, мьютексы, события и т. Д.), И если вы найдете причины для повышения производительности на основе фактических данных (например, профилирования), то и только потом посмотрим, сможете ли вы улучшить.
Если вы когда-либо достигнете высокой конкуренции за быстрые блокировки (используется только для нескольких операций чтения и записи без блокировки), в зависимости от степени конкуренции, переключение на блокированные операции может либо улучшить, либо снизить производительность. Особенно когда вам приходится прибегать к циклам сравнения и обмена, таким как:
var currentValue = Volatile.Read(ref field);
var newValue = GetNewValue(currentValue);
var oldValue = currentValue;
var spinWait = new SpinWait();
while ((currentValue = Interlocked.CompareExchange(ref field, newValue, oldValue)) != oldValue)
{
spinWait.SpinOnce();
newValue = GetNewValue(currentValue);
oldValue = currentValue;
}
Это значит, что вы также должны профилировать решение и сравнивать его с текущим состоянием. И знать о проблеме ABA.
Есть также SpinLock
, который вы действительно должны профилировать против блокировок на основе монитора, потому что, хотя они могут давать текущий поток, они не переводят текущий поток в спящий режим, сродни показанному использованию SpinWait
,
Переключение на энергозависимые операции похоже на игру с огнем. Вы должны убедиться в аналитическом доказательстве того, что ваш код верен, иначе вы можете получить ожог, когда вы меньше всего этого ожидаете.
Обычно лучший подход к оптимизации в случае высокой конкуренции - это избежать конкуренции. Например, чтобы выполнить параллельное преобразование большого списка, часто лучше разделить и делегировать проблему нескольким рабочим элементам, которые генерируют результаты, которые объединяются на последнем этапе, вместо того, чтобы несколько потоков блокировали список для обновлений. Это имеет стоимость памяти, поэтому она зависит от длины набора данных.
Каковы различия между спецификацией C# и спецификацией CLI относительно изменчивых операций?
C# определяет побочные эффекты, не говоря уже об их видимости между потоками, как чтение или запись энергозависимого поля, запись в энергонезависимую переменную, запись во внешний ресурс и создание исключения.
C# определяет критические точки выполнения, в которых эти побочные эффекты сохраняются между потоками: ссылки на изменчивые поля, lock
заявления, а также создание и завершение потока.
Если мы берем критические точки выполнения как точки, где побочные эффекты становятся видимыми, это добавляет к спецификации CLI, что создание и завершение потока являются видимыми побочными эффектами, т.е. new Thread(...).Start()
имеет семантику освобождения в текущем потоке и получает семантику в начале нового потока, а выход из потока имеет семантику освобождения в текущем потоке и thread.Join()
приобрел семантику ожидающего потока.
C# вообще не упоминает изменчивые операции, такие как выполняемые классами в System.Threading
вместо использования только полей, объявленных как volatile
и используя lock
заявление. Я считаю, что это не намеренно.
C# утверждает, что захваченные переменные могут быть одновременно доступны нескольким потокам. CIL не упоминает об этом, потому что замыкания являются языковой конструкцией.
1.
Есть несколько мест, где сотрудники Microsoft (бывшие) и MVP заявляют, что записи имеют семантику выпуска:
В моем коде я игнорирую эту деталь реализации. Я предполагаю, что энергонезависимые записи не гарантированно станут видимыми.
2.
Существует распространенное заблуждение, что вам разрешено вводить чтение в C# и / или CLI.
Комментарии к модели памяти CLI и конкретные спецификации, автор Jon Skeet
C# - Модель памяти C# в теории и практике, часть 2, Игорь Островский
Однако это верно только для локальных аргументов и переменных.
Для статических полей и полей экземпляров, или массивов, или чего-либо в куче, вы не можете разумно вводить чтения, так как такое введение может нарушить порядок выполнения, как видно из текущего потока выполнения, либо из законных изменений в других потоках, либо из изменений через отражение.
То есть вы не можете включить это:
object local = field;
if (local != null)
{
// code that reads local
}
в это:
if (field != null)
{
// code that replaces reads on local with reads on field
}
если вы когда-нибудь можете сказать разницу. В частности, NullReferenceException
быть брошенным путем доступа local
участники.
В случае захваченных переменных C# они эквивалентны полям экземпляра.
Важно отметить, что стандарт CLI:
говорит, что энергонезависимый доступ не гарантированно будет видимым
не говорит, что энергонезависимые доступы гарантированно не будут видны
говорит, что изменчивый доступ влияет на видимость энергонезависимого доступа
Но вы можете включить это:
object local2 = local1;
if (local2 != null)
{
// code that reads local2 on the assumption it's not null
}
в это:
if (local1 != null)
{
// code that replaces reads on local2 with reads on local1,
// as long as local1 and local2 have the same value
}
Вы можете включить это:
var local = field;
local?.Method()
в это:
var local = field;
var _temp = local;
(_temp != null) ? _temp.Method() : null
или это:
var local = field;
(local != null) ? local.Method() : null
потому что ты никогда не сможешь понять разницу. Но опять же, вы не можете превратить это в это:
(field != null) ? field.Method() : null
Я полагаю, что в обеих спецификациях было разумно заявить, что оптимизирующий компилятор может переупорядочивать операции чтения и записи, пока один поток выполнения наблюдает их как записанные, вместо того, чтобы вообще вводить и исключать их вообще.
Обратите внимание, что исключение чтенияможет быть выполнено либо компилятором C#, либо JIT-компилятором, то есть множественные чтения в одном и том же энергонезависимом поле, разделенные инструкциями, которые не записывают в это поле и которые не выполняют энергозависимые операции или эквивалентные, может быть свернуто до одного чтения. Как будто поток никогда не синхронизируется с другими потоками, поэтому он продолжает наблюдать одно и то же значение:
public class Worker
{
private bool working = false;
private bool stop = false;
public void Start()
{
if (!working)
{
new Thread(Work).Start();
working = true;
}
}
public void Work()
{
while (!stop)
{
// TODO: actual work without volatile operations
}
}
public void Stop()
{
stop = true;
}
}
Там нет никакой гарантии, что Stop()
остановит работника. Внедрение Microsoft.NET гарантирует, что stop = true;
является видимым побочным эффектом, но это не гарантирует, что чтение на stop
внутри Work()
не относится к этому:
public void Work()
{
bool localStop = stop;
while (!localStop)
{
// TODO: actual work without volatile operations
}
}
Этот комментарий говорит о многом. Чтобы выполнить эту оптимизацию, компилятор должен доказать, что нет никаких изменчивых операций, ни непосредственно в блоке, ни косвенно во всем дереве вызовов методов и свойств.
Для этого конкретного случая одной правильной реализацией является объявление stop
как volatile
, Но есть и другие варианты, такие как использование эквивалентного Volatile.Read
а также Volatile.Write
, с помощью Interlocked.CompareExchange
, используя lock
Заявление о доступе к stop
, используя что-то эквивалентное блокировке, такой как Mutex
, или же Semaphore
а также SemaphoreSlim
если вы не хотите, чтобы блокировка имела привязку к потоку, то есть вы можете освободить ее в потоке, отличном от того, который ее получил, или используя ManualResetEvent
или же ManualResetEventSlim
вместо stop
в этом случае вы можете сделать Work()
перерыв в ожидании сигнала остановки перед следующей итерацией и т. д.
3.
Одно из существенных отличий энергозависимой синхронизации.NET по сравнению с энергозависимой синхронизацией Java заключается в том, что Java требует от вас использования одного и того же энергозависимого местоположения, в то время как.NET требует только, чтобы при выпуске (volatile write) происходило получение (volatile read). Итак, в принципе вы можете синхронизировать в.NET следующий код, но вы не можете синхронизировать с эквивалентным кодом в Java:
using System;
using System.Threading;
public class SurrealVolatileSynchronizer
{
public volatile bool v1 = false;
public volatile bool v2 = false;
public int state = 0;
public void DoWork1(object b)
{
var barrier = (Barrier)b;
barrier.SignalAndWait();
Thread.Sleep(100);
state = 1;
v1 = true;
}
public void DoWork2(object b)
{
var barrier = (Barrier)b;
barrier.SignalAndWait();
Thread.Sleep(200);
bool currentV2 = v2;
Console.WriteLine("{0}", state);
}
public static void Main(string[] args)
{
var synchronizer = new SurrealVolatileSynchronizer();
var thread1 = new Thread(synchronizer.DoWork1);
var thread2 = new Thread(synchronizer.DoWork2);
var barrier = new Barrier(3);
thread1.Start(barrier);
thread2.Start(barrier);
barrier.SignalAndWait();
thread1.Join();
thread2.Join();
}
}
Этот сюрреалистический пример ожидает потоков и Thread.Sleep(int)
занять точное количество времени. Если это так, он синхронизируется правильно, потому что DoWork2
выполняет волатильное чтение (приобретение) после DoWork1
выполняет изменчивую запись (выпуск).
В Java, даже при таких сюрреалистических ожиданиях это не гарантировало бы синхронизацию. В DoWork2
, вам придется читать из того же изменчивого поля, в которое вы написали DoWork1
,
Я читаю спецификации, и они ничего не говорят о том, будет ли когда-либо наблюдаться энергозависимая запись другим потоком (летучим чтением или нет). Это правильно или нет?
Позвольте мне перефразировать вопрос:
Верно ли, что в спецификации ничего не сказано по этому вопросу?
Нет. Спецификация очень ясна в этом вопросе.
Гарантируется ли изменчивая запись в другом потоке?
Да, если другой поток имеет критическую точку выполнения. Специальный побочный эффект гарантированно будет заказан относительно критической точки выполнения.
Энергозависимая запись является особым побочным эффектом, и ряд вещей являются критическими точками выполнения, включая запуск и остановку потоков. Смотрите спецификацию для списка таких.
Предположим, например, что поток Alpha устанавливает поле volatile int v
к одному и начинает поток Браво, который читает v
, а затем присоединяется к Браво. (То есть блоки на браво заканчиваются.)
На данный момент у нас есть специальный побочный эффект - запись - критическая точка выполнения - запуск потока - и второй специальный побочный эффект - изменчивое чтение. Поэтому Браво должен прочитать один из v
, (Разумеется, при условии, что ни один другой поток не написал его за это время.)
Браво теперь увеличивается v
до двух и заканчивается. Это особый побочный эффект - запись - и критическая точка выполнения - конец потока.
Когда поток Альфа теперь возобновляет и делает изменчивое чтение v
требуется, чтобы он читал два. (Предполагая, что никакой другой поток не написал в это время конечно.)
Порядок побочного эффекта написания Браво и прекращения действия Браво должен быть сохранен; ясно, что Альфа не запускается снова до окончания Браво, и поэтому требуется соблюдать запись.
Да, volatile
о заборах и заборах о заказе. Так когда? не входит в сферу применения и фактически представляет собой детали реализации всех уровней (компилятор, JIT, CPU и т. д.), но каждая реализация должна иметь достойный и практичный ответ на вопрос.