Является ли популярный шаблон "неустойчивого опроса"?

Предположим, что я хочу использовать флаг состояния boolean для совместной отмены между потоками. (Я понимаю, что лучше использовать CancellationTokenSource вместо этого, это не вопрос этого вопроса.)

private volatile bool _stopping;

public void Start()
{
    var thread = new Thread(() =>
    {
        while (!_stopping)
        {
            // Do computation lasting around 10 seconds.
        }
    });

    thread.Start();
}

public void Stop()
{
    _stopping = true;
}

Вопрос. Если я вызываю Start() в 0s и Stop() в 3s в другом потоке, будет ли цикл завершен в конце текущей итерации со скоростью около 10 секунд?

Подавляющее большинство источников, которые я видел, показывают, что вышеуказанное должно работать как ожидалось; видеть: MSDN; Jon Skeet; Брайан Гидеон; Марк Гравелл; Ремус Русану.

Тем не менее, volatile генерирует только захват при чтении и освобождение при записи:

Волатильное чтение имеет "приобретать семантику"; то есть он гарантированно будет происходить до любых ссылок на память, которые происходят после него в последовательности команд. У изменчивой записи есть "семантика выпуска"; то есть, это гарантировано произойдет после любых ссылок на память до команды записи в последовательности команд. (Спецификация С#)

Таким образом, нет гарантии, что волатильная запись и изменчивое чтение не будут (по-видимому) заменены, как отмечено Joseph Albahari. Следовательно, возможно, что фоновый поток будет продолжать читать устаревшее значение _stopping (а именно, false) после окончания текущей итерации. Конкретно, если я назову Start() в 0s и Stop() на 3s, возможно, фоновая задача не будет завершена на 10 секунд, как ожидалось, но на 20 или 30 секунд или никогда вообще не будет.

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

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

Time   |     Thread 1                     |     Thread 2
       |                                  |
 0     |     Start() called:              |        read value of _stopping
       |                                  | <----- acquire-fence ------------
 1     |                                  |     
 2     |                                  |             
 3     |     Stop() called:               |             ↑
       | ------ release-fence ----------> |             ↑
       |        set _stopping to true     |             ↑
 4     |             ↓                    |             ↑
 5     |             ↓                    |             ↑
 6     |             ↓                    |             ↑
 7     |             ↓                    |             ↑
 8     |             ↓                    |             ↑
 9     |             ↓                    |             ↑
 10    |             ↓                    |        read value of _stopping
       |             ↓                    | <----- acquire-fence ------------
 11    |             ↓                    |    
 12    |             ↓                    |             
 13    |             ↓                    |             ↑
 14    |             ↓                    |             ↑
 15    |             ↓                    |             ↑
 16    |             ↓                    |             ↑
 17    |             ↓                    |             ↑
 18    |             ↓                    |             ↑
 19    |             ↓                    |             ↑
 20    |                                  |        read value of _stopping
       |                                  | <----- acquire-fence ------------

Наиболее важными частями являются заграждения памяти, отмеченные --> и <--, которые представляют точки синхронизации потока. Нестабильное считывание _stopping может (только отображаться) перемещаться до самой нити за предыдущий забор. Тем не менее, волатильная запись может (как представляется,) перемещаться вниз на неопределенный срок, так как в ее потоке нет другого забора. Другими словами, нет " synchronizes-with" (отношение "бывает раньше", "есть-видимо-к" ) между напишите на _stopping и любые его чтения.

P.S. Я знаю, что MSDN дает очень сильные гарантии для ключевого слова volatile. Однако экспертный консенсус заключается в том, что MSDN неверна (и не подкреплена спецификацией ECMA):

В документации MSDN указано, что использование ключевого слова volatile "гарантирует, что самое актуальное значение всегда присутствует в поле". Это неверно, поскольку, как мы видели [в предыдущем примере], запись с последующим чтением может быть переупорядочена. (Джозеф Альбахари)

Ответы

Ответ 1

Если я вызываю Start() в 0s и Stop() на 3s в другом потоке, будет ли цикл завершен в конце текущей итерации примерно в 10 секунд?

Да, 7 секунд, безусловно, достаточно, чтобы один поток мог пережить изменение переменной _stopping.

Почти формальные объяснения

Для каждой переменной, которая обеспечивает любой тип видимости (порядок памяти), спецификация для любого языка должна обеспечивать гарантию, что:

Любое изменение переменной (с специальным порядком памяти) из одного потока будет наблюдаться в других потоках во время финитного и ограниченного периода время.

Без этой гарантии даже функции порядка памяти в переменной бесполезны.

Спецификация для С# определенно предоставляет такую ​​гарантию относительно изменчивой переменной, но я не могу найти соответствующий текст.

Обратите внимание, что такая гарантия о конечном времени не связана с гарантиями памяти ( "приобретать", "выпускать" и т.д.), а не может быть выведен из определений барьеров и заказов памяти.

Формально-неформальные объяснения

Когда говорят

Я вызываю Stop() в 3s

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

С тем, что С# spec играет изящно ( "10.10 Execution order" ):

Выполнение должно выполняться таким образом, чтобы побочные эффекты каждого исполняемого потока сохранялись в критических точках выполнения. Побочный эффект определяется как чтение или запись изменчивого поля, запись в энергонезависимую переменную, запись к внешнему ресурсу и бросанию исключения. Критические точки выполнения, при которых порядок этих побочных эффектов должен быть сохранен, являются ссылками на изменчивые поля (§17.4.3), операторы блокировки (§15.12) и создание и завершение потоков.

Предполагая, что печать - это критическая точка выполнения (вероятно, она использует блокировки), вы можете быть уверены, что на данный момент присвоение переменной _stopping изменчивой переменной как побочный эффект отображается для другого потока, который проверяет данную переменную.

Неофициальные объяснения

В то время как компилятору разрешено переносить присвоение переменной volatile в коде, он не может делать это неопределенно:

  • присваивание не может быть перемещено после вызова функции, потому что компилятор ничего не может принять в отношении тела функции.

  • Если задание выполняется внутри цикла, оно должно быть завершено до следующего присваивания в следующем цикле.

  • в то время как можно представить код с 1000 последовательными простыми назначениями (для других переменных), поэтому волатильное назначение может быть деионизировано для 1000 инструкций, компилятор просто выполняет такое дефферирование. И даже если это произойдет, выполнение 1000 простых инструкций на современном процессоре занимает не более нескольких микросекунд.

Со стороны CPU ситуация проще: ни один процессор не будет деактивировать назначение ячейки памяти более чем ограниченное количество инструкций.

В целом присвоение изменчивой переменной может делить только на очень ограниченное количество инструкций.