Потоки и неявные барьеры памяти
Попытка понять модель памяти .net, когда речь заходит о потоковой обработке. Этот вопрос строго теоретический, и я знаю, что он может быть решен другими способами, такими как использование lock
или маркировка _task
как volatile
.
Возьмем для примера следующий фрагмент кода:
class Test
{
Task _task;
int _working = 0;
public void Run()
{
if (Interlocked.CompareExchange(ref _working, 1, 0) == 0)
{
_task = Task.Factory.StartNew(() =>
{
//do some work...
});
_task.ContinueWith(antecendent => Interlocked.Exchange(ref _working, 0));
}
}
public void Dispose()
{
if (Interlocked.CompareExchange(ref _working, _working, 0) == 1)
{
_task.ContinueWith(antecendent => { /*do some other work*/ });
}
}
}
Теперь сделайте следующие предположения:
-
Run
может вызываться несколько раз (из разных потоков) и никогда не будет вызываться после вызоваDispose
. -
Dispose
будет называться ровно один раз.
_task
(в методе Dispose
) всегда "свежим" значением, то есть будет ли оно считываться из "основной памяти", а не из регистра? Из того, что я читал Interlocked
, создается полный барьер памяти, поэтому я предполагаю, что _task
будет считываться из основной памяти или я полностью отключен?2 ответа:
Помимо сложностей использования фразы "fresh read" слишком свободно, то да,
_task
будет повторно получен из основной памяти. Однако с вашим кодом может возникнуть отдельная и еще более тонкая проблема. Рассмотрим альтернативную, но точно эквивалентную структуру для вашего кода, которая должна облегчить обнаружение потенциальной проблемы.public void Dispose() { int register = _working; if (Interlocked.CompareExchange(ref _working, register, 0) == 1) { _task.ContinueWith(antecendent => { /*do some other work*/ }); } }
Второй параметр
CompareExchange
передается по-значению, чтобы его можно было кэшировать в регистре. Я представляю себе следующее сценарий.
- поток A вызывает
Run
- поток A делает что-то еще с
_working
, что заставляет его кэшировать его в регистре.- поток B завершает задачу и вызывает
Exchange
из делегатаContinueWith
.- поток A вызывает
Dispose
.В приведенном выше сценарии
Лично я думаю, что этот сценарий маловероятен в основном потому, что я не думаю, что_working
изменится на 1, затем 0, а затемDispose
переключит его обратно на 1 (потому что это значение было кэшировано в регистре), даже не входя в инструкциюif
. В этот момент_working
может быть в непоследовательном состоянии._working
будет кэшироваться таким образом, особенно если вы всегда обеспечивали защиту доступа к нему с помощью блокированных операций.Во всяком случае, я надеюсь, что это даст вам пищу для размышлений о том, насколько сложными могут быть методы без блокировки.
Я не кодирую на C#, но если используется полный барьер памяти, то ваша интерпретация верна. Компилятор не должен повторно использовать значение, хранящееся в регистрах, а скорее извлекать его таким образом, чтобы барьеры упорядочения памяти не маскировали фактическое значение, присутствующее в подсистеме памяти.
Я также нашел этот ответ, который ясно объясняет, что это на самом деле так, поэтому документация, которую вы прочитали, кажется правильной: блокируется.CompareExchange использовать память барьер?