Не нарушен ли популярный шаблон "флаг с изменчивым опросом"?
Предположим, что я хочу использовать логический флаг состояния для совместной отмены между потоками. (Я понимаю, что желательно использовать 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()
в 0 и Stop()
на 3 с в другом потоке, гарантированно ли завершится цикл в конце текущей итерации примерно через 10 с?
Подавляющее большинство источников, которые я видел, указывают, что вышеприведенное должно работать так, как ожидалось; см.: MSDN; Джон Скит; Брайан Гидеон; Марк Гравелл; Ремус Русану.
Тем не мение, volatile
генерирует забор при чтении и при отпускании при записи:
Изменчивое чтение имеет "приобретенную семантику"; то есть гарантированно произойдет до любых ссылок на память, которые происходят после нее в последовательности команд. Изменчивая запись имеет "семантику релиза"; то есть это гарантированно произойдет после любых ссылок на память перед инструкцией записи в последовательности команд. ( Спецификация C#)
Следовательно, нет никакой гарантии, что энергозависимая запись и энергозависимое чтение не будут (как представляется) заменены, как заметил Джозеф Албахари. Следовательно, возможно, что фоновый поток будет продолжать читать устаревшее значение _stopping
(А именно, false
) после окончания текущей итерации. Конкретно, если я позвоню Start()
в 0 и Stop()
в 3 с, возможно, что фоновая задача не завершится в 10 секунд, как ожидалось, но в 20 или 30 секунд, или никогда вообще.
Основываясь на семантике получения и выпуска, здесь есть две проблемы. Во-первых, энергозависимое чтение будет ограничено для обновления поля из памяти (абстрактно говоря) не в конце текущей итерации, а в конце следующей, так как забор чтения происходит после самого чтения. Во-вторых, что более важно, ничто не заставляет энергозависимую запись когда-либо фиксировать значение в памяти, поэтому нет гарантии, что цикл когда-либо вообще прекратится.
Рассмотрим следующий поток последовательности:
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
может быть перемещен только (кажется) к предыдущему приобретающему ограждению своей нити. Тем не менее, энергозависимая запись может (как представляется) перемещаться вниз бесконечно, так как за ней нет другого ограничителя выпуска, следующего за ней. Другими словами, не существует отношения " синхронизирует с " ("происходит до", "виден") между записью в _stopping
и любой из его читает.
PS Я знаю, что MSDN дает очень сильные гарантии на volatile
ключевое слово. Тем не менее, эксперт согласен с тем, что MSDN является неправильным (и не подкрепляется спецификацией ECMA):
В документации MSDN говорится, что использование ключевого слова volatile "гарантирует, что в поле всегда присутствует самое последнее значение". Это неверно, поскольку, как мы видели [в предыдущем примере], запись с последующим чтением может быть переупорядочена. ( Джозеф Албахари)
1 ответ
Если я позвоню
Start()
в 0 иStop()
на 3 с в другом потоке, гарантированно ли завершится цикл в конце текущей итерации примерно через 10 с?
Да, 7 секунд определенно достаточно для того, чтобы один поток воспринял изменение _stopping
переменная.
Почти формальные объяснения
Для каждой переменной, которая обеспечивает любой тип барьера видимости (порядок памяти), спецификация для любого языка должна обеспечивать гарантию, что:
Любое изменение переменной (с особым порядком памяти) из одного потока будет наблюдаться в других потоках в течение конечного и ограниченного периода времени.
Без этой гарантии даже функции порядка памяти в переменной бесполезны.
Спецификация для C# определенно предоставляет такую гарантию для volatile- переменной, но я не могу найти соответствующий текст.
Обратите внимание, что такая гарантия относительно конечного времени не связана с гарантиями заказов памяти ("приобретать", "выпускать" и т. Д.) И не может быть выведена из определений барьеров и заказов памяти.
Формально-неформальные объяснения
Когда говорят
Я звоню
Stop()
в 3 с
одно подразумевает, что был некоторый видимый эффект (например, информация, напечатанная в терминале), который позволяет ему требовать отметку времени около 3 с (потому что оператор печати был выдан после Stop()
).
С этим C# спецификация играет изящно ("10.10 Порядок выполнения"):
Выполнение должно продолжаться таким образом, чтобы побочные эффекты каждого исполняющего потока сохранялись в критических точках выполнения. Побочный эффект определяется как чтение или запись энергозависимого поля, запись в энергонезависимую переменную, запись во внешний ресурс и создание исключения. Критическими точками выполнения, в которых должен быть сохранен порядок этих побочных эффектов, являются ссылки на изменчивые поля (§17.4.3), операторы блокировки (§15.12), а также создание и завершение потока.
Предполагая, что печать является критической точкой выполнения (вероятно, она использует блокировки), вы можете быть уверены, что на данный момент назначение _stopping
переменная volatile как побочный эффект видна другому потоку, который проверяет данную переменную.
Неофициальные объяснения
Хотя компилятору разрешено перемещать назначение изменяемой переменной вперед в коде, он не может делать это бесконечно:
назначение не может быть перемещено после вызова функции, потому что компилятор не может предполагать что-либо о теле функции.
Если присвоение выполняется внутри цикла, оно должно быть выполнено до следующего присвоения в следующем цикле.
в то время как можно представить код с 1000 последовательных простых присваиваний (другим переменным), так что энергозависимое присваивание может быть отложено для 1000 инструкций, компилятор просто выполняет такую отсрочку. И даже если это произойдет, выполнение 1000 простых инструкций на современном процессоре занимает не более нескольких микросекунд.
Со стороны ЦП ситуация проще: ни один ЦП не будет задерживать назначение ячейке памяти больше, чем ограниченное количество инструкций.
В целом, присвоение переменной volatile может быть отложено только на очень ограниченное количество инструкций.