Является ли популярный шаблон "неустойчивого опроса"?
Предположим, что я хочу использовать флаг состояния 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 ситуация проще: ни один процессор не будет деактивировать назначение ячейки памяти более чем ограниченное количество инструкций.
В целом присвоение изменчивой переменной может делить только на очень ограниченное количество инструкций.