Атомарность, изменчивость и неизменяемость – это разные вещи. Часть 3
Так что же означает ключевое слово «volatile»? На этот счет есть множество заблуждений.
Прежде всего, давайте начнем с простого факта: правила языка C# были спроектированы таким образом, чтобы любые операции чтения или записи volatile -переменных были атомарными. (Конечно же, обратное утверждение не является верным; так, операция вполне может быть атомарной, когда она осуществляется и не с volatile-переменной.)
Это реализовано очень просто: правила языка C# позволяют помечать поля ключевым словом «volatile» только если тип гарантирует атомарность операций чтения и записи.
Для этого свойства нет логического требования; логически понятия «изменяемый» (volatile) и «атомарный» являются ортогональными. Существует, например, волатильное-не-атомарное чтение. Одна только мысль об этом вызывает у меня мурашки по коже! Получение актуального значения, которое может быть расщеплено посередине, кажется ужасным. Я очень рад тому, что язык C# гарантирует, что любое волатильное чтение или запись, также является атомарным чтением или записью.
Понятия «изменчивый» (volatile) и «неизменяемый» (immutable) по сути, являются противоположными; как мы вскоре увидим, основная идея волатильности заключается в обеспечении некоторой надежности в некоторых особо опасных случаях изменяемости ( mutability ).
Так что же означает понятие «изменяемый» (volitile)? Чтобы осознать его, нам нужно вернуться в старые дни языка программирования С. Предположим, вы пишите драйвер на языке C, для устройства регистрации температуры на метеорологической станции:
int* currentBuf = bufferStart;
while(currentBuf < bufferEnd)
{
int temperature = deviceRegister[0];
*currentBuf = temperature;
currentBuf++;
}
Вполне возможно, что оптимизатор будет рассуждать следующим образом: мы знаем, что переменные bufferStart, bufferEnd и deviceRegister инициализируются в начале программы и никогда больше не изменяются; поэтому их можно рассматривать как константы. Мы знаем, что память, отображаемая на переменную deviceRegister, никогда не пересекает память, выделенную для буфера. Также мы видим, что в этой программе в переменную deviceRegister[0] никаких операций записи не производится. Таким образом, оптимизатор может решить, что вы имели в виду следующее:
int* currentBuf = bufferStart;
int temperature = deviceRegister[0];
while(currentBuf < bufferEnd)
{
*currentBuf = temperature;
currentBuf++;
}
Что в нашем случае приводит к совершенно другому результату. Оптимизатор делает вроде бы разумное предположение, что если он может доказать, что переменная никогда не изменяется, то читать ее можно только один раз. Но если рассматриваемая переменная помечена ключевым словом «volatile», означающим, что она может изменяться сама по себе, за пределами кода текущей программы, тогда компилятор не может выполнять эту оптимизацию.
Вот что означает ключевое слово «volatile» в языке C. (У этого ключевого слова есть и другие следствия; оно также предотвращает оптимизации, которые могут испортить нормальную работу нелокальных goto, и ряд других сценариев.)
Давайте проведем аналогию.
Представим, что у нас в руках лежит трехтомник на тысячу страниц под названием Большая книга памяти. Каждая страница содержит тысячу чисел, написанных карандашом таким образом, что каждое из них может быть изменено. У вас также есть «страница регистров», которая может содержать только двенадцать чисел, при этом каждое из них обладает собственным смыслом. Когда вам нужно выполнить некоторую операцию с числом, вы вначале переворачиваете книгу на нужную страницу, затем находите нужное число, и переписываете его на страницу регистров. Вы выполняете вычисления только с регистровой страницей. После завершения вычислений вы можете переписать полученное число куда-нибудь обратно в книгу, или вы можете прочитать еще одно число, чтобы выполнить еще одну операцию.
Предположим, вы делаете некоторые операции в цикле, аналогично нашему примеру с датчиком температуры. В качестве оптимизации вы можете предположить, что определенное число на странице книги никогда не будет изменяться. Поэтому вместо чтения этого числа каждый раз, вы можете скопировать это число из книги на регистровую страницу и никогда не перечитывать его. Именно эту оптимизацию мы рассматривали ранее. Если одно из этих чисел может изменяться без вашего ведома, то подобная оптимизация является некорректной. В этом случае, нужно обращаться к книге каждый раз.
Вы обратили внимание, что в нашей истории о ключевом слове « volatile » в языке С ничего не говорится о многопоточности. В языке С это ключевое слово всего лишь указывает компилятору отключить оптимизацию, поскольку компилятор не может делать правильные предположения о том, изменяется ли эта переменная или нет. Это не делает операции потокобезопасными. Давайте посмотрим почему! (*)
Давайте предположим, что у нас есть поток, который изменяет значение переменной и еще один поток, который читает значение этой же переменной. Вы можете подумать, что это аналогично нашему сценарию использования ключевого слова volatile в C. Давайте ради примера представим, что в нашем выражении «deviceRegister[0]» происходит не чтение значения аппаратного регистра, которое могло измениться из-за каких-то внешних факторов, а просто читаются данные из некоторой ячейки памяти, которая могла измениться из-за операции получения температуры, выполненной в другом потоке. Решает ли «C-style volatile» нашу проблему многопоточности?
Ну, в общем… нет. Сделанное ранее предположение о том, что значение в памяти изменится из-за повышения температуры на улице логически эквивалентно тому, что значение в памяти изменится из другого потока. Однако в общем случае это предположение не обосновано. Давайте продолжим аналогию, чтобы увидеть, почему с первого взгляда может показаться, что рассматриваемая модель гарантирует потокобезопасность.
Давайте предположим, что вместо значения, изменяемого волшебным образом при изменении температуры, у нас есть два человека, использующих по очереди одну книгу. Один является Читателем, а другой – Писателем. Они вынуждены сотрудничать, поскольку у них есть только одна книга и только одна страница регистров.
Кроме того, читатель может несколько раз читать одну и ту же страницу. Читатель может решить прочитать страницу только один раз, скопировать ее на страницу регистров и использовать затем только копию. И так он и делает в течение некоторого времени. Когда приходит очередь Писателю пользоваться книгой, Читатель переписывает текущую страницу регистров на специальную страницу, зарезервированную для использования Читателем. Затем Читатель отдает книгу и страницу регистров Писателю.
Писатель переписывает на страницу регистров что-то из книги, и продолжает заниматься тем, что ему положено, т.е. писать новые страницы книги. Когда же Писатель решит сделать перерыв, он, опять-таки, перепишет страницу регистров в книгу и вернет ее Читателю.
Проблема должна быть очевидной. Если Писатель изменит страницы, предварительно закэшированные Читателем в страницу регистров, тогда Читатель будет принимать решения на основе неактуальных данных.
Если эта аналогия является правильным описанием модели памяти процессора, тогда пометка общих переменных с помощью «C-style volatile» правильна. Читатель знает о том, что кешировать данные не нужно; для получения актуальных данных нужно каждый раз перечитывать их из книги, поскольку он не знает, были ли они изменены Писателем, когда управление было у того в последний раз. (Это особенно справедливо для некооперативной многозадачности; возможно Читатель и Писатель не договорились об очередности!)
Однако, к сожалению, это не соответствует моделям памяти большинства современных многопроцессорных компьютеров. Реальная модель памяти выглядит примерно так:
Предположим, у нас есть два человека, совместно пользующихся книгой – это опять, Читатель и Писатель. У каждого из них есть собственная страница регистров, а также пустая страница книги. Читатель собирается прочитать что-то из книги. Но книги нет! Есть только Библиотекарь. Читатель просит Библиотекаря найти книгу, и Библиотекарь говорит: «ты не можешь пользоваться книгой в одиночку; она слишком ценная для этого. Но дай мне свою страницу, и я скопирую на нее что-нибудь из этой книги». Читатель понимает, что это лучше, чем ничего. На самом деле, если об этом хорошенько подумать, это просто здорово! Библиотекарь возвращает Читателю целую страницу, а не одну строку, запрошенную Читателем. Теперь Читатель может использовать любые данные из этой страницы для эффективных вычислений и не обращаться к Библиотекарю каждый раз. И только при выходе за границы скопированной страницы ему придется возвращаться к Библиотекарю. Производительность Читателя серьезно возросла.
Аналогично, в это же время, Писатель собрался сделать запись в книге. (Помните, что теперь Читатель и Писатель не должны действовать по очереди, ведь у каждого из них есть своя собственная регистровая страница.) Но Библиотекарь не позволяет этого сделать. Библиотекарь говорит: «а дай-ка я сделаю копию страницы, которую ты хочешь записать. Ты вносишь изменения в свою копию, и после того, как ты все сделаешь, дай мне знать и я обновлю страницу целиком». Писатель думает: «Да это же здорово!» Писатель может писать все что угодно и не обращаться к Библиотекарю до тех пор, пока ему не понадобиться записать (или прочитать) другую страницу. Когда это произойдет, Писатель отдает измененную копию страницы Библиотекарю, который переписывает страницу Писателя обратно в книгу и отдает копию новой страницы, которая нужна Писателю.
Очевидно, что это всё здорово, если Читатель и Писатель не работают с одной и той же страницей книги. Но что, если это так? C-style volatile в этом случае совершенно не поможет! Предположим, что Читатель решает, ага, это место в памяти помечено ключевым словом volatile, так что я не буду кэшировать прочитанное значение в своей странице регистров. Поможет ли это? Нисколько! Даже если читатель всегда будет возвращаться к исходной странице, он будет возвращаться к копии этой страницы, сделанной Библиотекарем. Предположим, что тогда Читатель говорит: «Хорошо, если эта штука помечена как volatile, тогда при каждом чтении, черт, я буду обращаться к Библиотекарю снова, чтобы он делал мне свежую копию страницы». Поможет ли это? Нет, потому что Писатель может еще не отправить изменения обратно Библиотекарю! Писатель делает изменения в локальной копии.
Для решения этой проблемы Читатель может сказать Библиотекарю о том, что ему нужно прочитать самую актуальную версию содержимого Книги. Библиотекарю тогда придется найти Писателя и попросить Писателя прекратить свою работу и прислать обратно все изменения. Читатель и Писатель оба должны прервать свою работу, пока Библиотекарь не удостоверится в актуальности состояния Книги. (И, конечно же, мы еще не рассматривали ситуацию с несколькими писателями, работающими одновременно с одной и той же страницей.) Либо Писатель может сказать Библиотекарю: «Я собираюсь изменить это значение; найди всех потенциальных читателей и скажи им, что им придется обновить свои копии, когда я закончу». Не важно, какой вариант использовать; идея в том, что все должны работать совместно, чтобы гарантировать согласованное представление всех изменений.
Эта стратегия ведет к серьезному повышению производительности в распространенном случае, когда множество читателей и писателей работают со смежными данными, т.е. каждый читатель и писатель практически всю свою работу выполняет на локальной копии, и не обращаются каждый раз к Библиотекарю. Но это приводит к серьезному падению производительности, когда читатели и писатели работают с одной и той же страницей и не могут терпеть несогласованность неактуальных данных; читатели и писатели постоянно возвращаются к Библиотекарю, и вместо своей настоящей работы, занимаются копированием данных туда и обратно в Книгу памяти, для гарантии согласованности локальных кэшей.
У нас явно есть проблема. Если C-style volatile не решает эту проблему, то, что решает? C #- stylevolatile , вот что.
Но, довольно хитрым образом.
В языке C#, ключевое слово «volatile» означает только следующее: «убедитесь, что компилятор языка C # или JIT -компилятор не выполняют никакого переупорядочивания кода или кэширования в регистрах для этой переменной». Это также значит, «скажи всем процессорам, выполнить все, что им понадобиться, для гарантии того, что я прочитаю последнее значение, даже если при этом понадобиться приостановить работу всех процессоров для синхронизации основной памяти с их кэшами».
Последняя фраза, на самом деле, неверна. Настоящая семантика volatile чтения и записи значительно сложнее, чем эта; на самом деле, никто не гарантирует остановку всех процессоров для синхронизации их кэшей с основной памятью. Скорее, обеспечивается более слабая гарантия того, как будет происходить доступ к памяти до того и после того, как результаты операций чтения и записи могут быть видимы другими, чтобы упорядочить эти обращения нужным образом. Некоторые операции, такие как создание нового потока, захват блокировки или использование interlocked-методов предоставляют более сильные гарантии видимости (observation) и упорядочивания. За подробностями обращайтесь в разделы 3.10 и 10.5.3 спецификации C# 4.0.
Честно говоря, я не советую вам когда-либо делать volatile -поля. Пометка поля ключевым словом volatile говорит о том, что вы собираетесь делать что-то безумное. Например, вы будете читать и писать одно и то же значение из разных потоков без использования блокировок. Блокировки гарантируют, что память, прочитанная или модифицированная внутри блокировки, будет гарантированно находиться в согласованном состоянии; блокировки гарантируют, что только один поток может иметь доступ к некоторому участку памяти и т.д. Очень мало ситуаций, когда блокировка работает слишком медленно, а вероятность ошибки из-за недопонимания конкретной модели памяти – очень высока. Я не пытаюсь писать код без блокировок, за исключением простых сценариев использования interlocked- операций. И я оставляю использование ключевого слова «volatile» настоящим экспертам.
Вот, где можно посмотреть более подробную информацию по этой, невероятно сложной теме:
Why C-style volatile is almost useless for multi-threaded programming
Joe Duffy on why attempting to 'fix' volatile in C# is a waste of time
Vance Morrison on incoherent caches and other aspects of modern memory models
-----
(*) Конечно, мы уже знаем одну причину: volatile- операции не являются атомарными, а потокобезопасность требует атомарности. Но есть и более глубокие причины, почему C-style volatility не гарантирует потокобезопасности.