Потоки и неявные барьеры памяти
Попытка понять модель памяти .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 использовать память барьер?