Потоки и неявные барьеры памяти


Попытка понять модель памяти .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*/ });
        }
    }
}
Теперь сделайте следующие предположения:
  1. Run может вызываться несколько раз (из разных потоков) и никогда не будет вызываться после вызова Dispose.
  2. Dispose будет называться ровно один раз.
Теперь к моему вопросу: будет ли значение _task (в методе Dispose) всегда "свежим" значением, то есть будет ли оно считываться из "основной памяти", а не из регистра? Из того, что я читал Interlocked, создается полный барьер памяти, поэтому я предполагаю, что _task будет считываться из основной памяти или я полностью отключен?
2 8

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