В C и C++ есть ключевое слово volatile, которое указывает компилятору, что значение в соответствующей области памяти может быть изменено в произвольный момент и потому нельзя оптимизировать доступ к этой области. Обычно описание ключевого слова сразу приводит пример с данными, которые могут быть в любой момент изменены из другой нити, аппаратным обеспечением или операционной системой. Прочитав описание примера, большинство читателей глубоко зевает, решает, что в этой жизни им такое не понадобится, и переходит к следующему разделу.
Сегодня рассмотрим менее экзотический сценарий использования ключевого слова volatile.
Стандарт C++ определяет так называемое наблюдаемое поведение как последовательность операций ввода-вывода и чтения-записи данных, объявленных как volatile (1.9/6). В пределах сохранения наблюдаемого поведения компилятору позволено оптимизировать код как угодно.
Вот например… Ваш код выделяет память средствами операционной системы, и вы хотите, чтобы операционная система выделила физические страницы памяти под всю запрошенную область. Многие ОС выделяют страницы при первом реальном обращении, а это может приводить к дополнительным задержкам, а вы, например, хотите этих задержек избежать и перенести их на более ранний момент. Вы можете написать такой код:
Этот код проходит по всей области и читает по одному байту из каждой страницы памяти. Одна проблема – компилятор этот код оптимизирует и полностью удалит. Имеет полное право – этот код не влияет на наблюдаемое поведение. Ваши переживания о выделении страниц операционной системой и вызванных этим задержке к наблюдаемому поведению не относятся.
Что же делать, что же делать… А, точно! Давайте мы запретим компилятору оптимизировать этот код.
Отлично, в результате…
1. использована #pragma, которая делает код плохо переносимым, плюс…
2. оптимизация выключается полностью, а это увеличивает объем машинного кода в три раза, плюс в Visual C++, например, эта #pragma может быть использована только снаружи функции, соответственно, рассчитывать на встраивание этого кода в вызывающий код и дальнейшую оптимизацию тоже не приходится.
Здесь отлично помогло бы ключевое слово volatile:
И все, достигается ровно нужный эффект – код предписывает компилятору обязательно выполнить чтение с заданным шагом. Оптимизация компилятором не имеет права менять это поведение, потому что теперь последовательность чтений относится к наблюдаемому поведению.
Теперь попробуем перезаписать память во имя безопасности и паранойи (это не бред, вот как это бывает в реальной жизни). В том посте упоминается некая волшебная функция SecureZeroMemory(), которая якобы гарантированно перезаписывает нулями указанную область памяти. Если вы используете memset() или эквивалентный ей написанный самостоятельно цикл, например, такой:
для локальной переменной, то есть риск, что компилятор удалит этот цикл, потому что цикл не влияет на наблюдаемое поведение (доводы в том посте к наблюдаемому поведению тоже не относятся).
Что же делать, что же делать… А, мы «обманем» компилятор… Вот что можно найти по запросу “prevent memset optimization”:
1. замена локальной переменной на переменную в динамической памяти со всеми вытекающими накладными расходами и риском утечки (сообщение в архиве рассылки linux-kernel)
2. макрос с ассемблерной магией (сообщение в архиве рассылки linux-kernel)
3. предложение использовать специальный символ препроцессора, который запрещает встраивание memset() по месту и затрудняет компилятору оптимизацию (естественно, такая возможность должна быть поддержана в используемой версии библиотеки, плюс Visual C++ 10 умеет оптимизировать даже код функций, помеченных как не подлежащие встраиванию)
4. всевозможные последовательности чтения-записи с использованием глобальных переменных (кода становится заметно больше и такой код не потокобезопасен)
5. последующее чтение с сообщением об ошибке в случае, если считаны не те данные, что были записаны (компилятор имеет право заметить, что «не тех» данных оказаться не может, и удалить этот код)
У всех этих способов много общих черт – они плохо переносимы и их сложно проверить. Например, вы «обманули» какую-то версию компилятора, а более новая будет иметь более умный анализатор, который догадается, что код не имеет смысла, и удалит его, и сделает так не везде, а только в некоторых местах.
Вы можете скомпилировать функцию перезаписи в отдельную единицу трансляции, чтобы компилятор «не увидел», что она делает. После очередной смены компилятора в игру вступит генерация кода линкером (LTCG в Visual C++, LTO в gcc или как это называется в используемом вами компилятором) – и компилятор прозреет и увидит, что перезапись памяти «не имеет смысла», и удалит ее.
Не зря появилась поговорка you can’t lie to a compiler.
А что если посмотреть на типичную реализацию SecureZeroMemory()? Она по сути такая:
И все – компилятор более не имеет права удалять запись…
КРАЙНЕ НЕОЖИДАННО… вопреки всем суевериям зачеркнутое утверждение выше неверно.
На самом деле – имеет. Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile. Вот для таких:
Если сами данные не имеют квалификатора volatile, а квалификатор volatile добавляется указателю на эти данные, чтение-запись этих данных уже не относится к наблюдаемому поведению:
Вся надежда на разработчиков компилятора – в настоящий момент и Visual C++, и gcc не оптимизируют обращения к памяти через указатели с квалификатором volatile – в том числе потому, что это один из важных сценариев использования таких указателей.
Не существует гарантированного Стандартом способа перезаписать данные функцией, эквивалентной SecureZeroMemory(), если переменная с этими данными не имеет квалификатора volatile. Точно так же невозможно кодом как в самом начале поста гарантированно прочитать память. Все возможные решения не являются абсолютно переносимыми.
Причина этому банальна – это «не нужно».
Ситуации, когда переменная с подлежащими записи данными выходит из области видимости, а затем занимаемая ей память переиспользуется под другую переменную и из новой переменной выполняется чтение без предварительной инициализации, относятся к неопределенному поведению. Стандарт ясно говорит, что в таких случаях допустимо любое поведение. Обычно просто читается «мусор», который был записан в эту память раньше.
Поэтому с точки зрения Стандарта гарантированная перезапись таких переменных перед выходом из области видимости не имеет смысла. Точно так же не имеет смысла читать память ради чтения памяти.
Использование указателей на volatile является, скорее всего, самым эффективным способом решения проблемы. Во-первых, разработчики компиляторов обычно сознательно выключают оптимизацию доступа к памяти. Во-вторых, накладные расходы минимальны. В-третьих, относительно легко проверить, работает этот способ или нет на конкретной реализации, – достаточно посмотреть, какой машинный код будет сгенерирован для тривиальных примеров выше из этого поста.
volatile – не только для драйверов и операционных систем.
Дмитрий Мещеряков,
департамент продуктов для разработчиков
Сегодня рассмотрим менее экзотический сценарий использования ключевого слова volatile.
Стандарт C++ определяет так называемое наблюдаемое поведение как последовательность операций ввода-вывода и чтения-записи данных, объявленных как volatile (1.9/6). В пределах сохранения наблюдаемого поведения компилятору позволено оптимизировать код как угодно.
Вот например… Ваш код выделяет память средствами операционной системы, и вы хотите, чтобы операционная система выделила физические страницы памяти под всю запрошенную область. Многие ОС выделяют страницы при первом реальном обращении, а это может приводить к дополнительным задержкам, а вы, например, хотите этих задержек избежать и перенести их на более ранний момент. Вы можете написать такой код:
for( char* ptr = start; ptr < start + size; ptr += MemoryPageSize ) {
*ptr;
}
Этот код проходит по всей области и читает по одному байту из каждой страницы памяти. Одна проблема – компилятор этот код оптимизирует и полностью удалит. Имеет полное право – этот код не влияет на наблюдаемое поведение. Ваши переживания о выделении страниц операционной системой и вызванных этим задержке к наблюдаемому поведению не относятся.
Что же делать, что же делать… А, точно! Давайте мы запретим компилятору оптимизировать этот код.
#pragma optimize( "", off )
for( char* ptr = start; ptr < start + size; ptr += MemoryPageSize ) {
*ptr;
}
#pragma optimize( "", on )
Отлично, в результате…
1. использована #pragma, которая делает код плохо переносимым, плюс…
2. оптимизация выключается полностью, а это увеличивает объем машинного кода в три раза, плюс в Visual C++, например, эта #pragma может быть использована только снаружи функции, соответственно, рассчитывать на встраивание этого кода в вызывающий код и дальнейшую оптимизацию тоже не приходится.
Здесь отлично помогло бы ключевое слово volatile:
for( volatile char* ptr = start; ptr < start + size; ptr += MemoryPageSize ) {
*ptr;
}
И все, достигается ровно нужный эффект – код предписывает компилятору обязательно выполнить чтение с заданным шагом. Оптимизация компилятором не имеет права менять это поведение, потому что теперь последовательность чтений относится к наблюдаемому поведению.
Теперь попробуем перезаписать память во имя безопасности и паранойи (это не бред, вот как это бывает в реальной жизни). В том посте упоминается некая волшебная функция SecureZeroMemory(), которая якобы гарантированно перезаписывает нулями указанную область памяти. Если вы используете memset() или эквивалентный ей написанный самостоятельно цикл, например, такой:
for( size_t index = 0; index < size; index++ )
ptr[index] = 0;
для локальной переменной, то есть риск, что компилятор удалит этот цикл, потому что цикл не влияет на наблюдаемое поведение (доводы в том посте к наблюдаемому поведению тоже не относятся).
Что же делать, что же делать… А, мы «обманем» компилятор… Вот что можно найти по запросу “prevent memset optimization”:
1. замена локальной переменной на переменную в динамической памяти со всеми вытекающими накладными расходами и риском утечки (сообщение в архиве рассылки linux-kernel)
2. макрос с ассемблерной магией (сообщение в архиве рассылки linux-kernel)
3. предложение использовать специальный символ препроцессора, который запрещает встраивание memset() по месту и затрудняет компилятору оптимизацию (естественно, такая возможность должна быть поддержана в используемой версии библиотеки, плюс Visual C++ 10 умеет оптимизировать даже код функций, помеченных как не подлежащие встраиванию)
4. всевозможные последовательности чтения-записи с использованием глобальных переменных (кода становится заметно больше и такой код не потокобезопасен)
5. последующее чтение с сообщением об ошибке в случае, если считаны не те данные, что были записаны (компилятор имеет право заметить, что «не тех» данных оказаться не может, и удалить этот код)
У всех этих способов много общих черт – они плохо переносимы и их сложно проверить. Например, вы «обманули» какую-то версию компилятора, а более новая будет иметь более умный анализатор, который догадается, что код не имеет смысла, и удалит его, и сделает так не везде, а только в некоторых местах.
Вы можете скомпилировать функцию перезаписи в отдельную единицу трансляции, чтобы компилятор «не увидел», что она делает. После очередной смены компилятора в игру вступит генерация кода линкером (LTCG в Visual C++, LTO в gcc или как это называется в используемом вами компилятором) – и компилятор прозреет и увидит, что перезапись памяти «не имеет смысла», и удалит ее.
Не зря появилась поговорка you can’t lie to a compiler.
А что если посмотреть на типичную реализацию SecureZeroMemory()? Она по сути такая:
volatile char *volatilePtr = static_cast<volatile char*>(ptr);
for( size_t index; index < size; index++ )
* volatilePtr = 0;
}
КРАЙНЕ НЕОЖИДАННО… вопреки всем суевериям зачеркнутое утверждение выше неверно.
На самом деле – имеет. Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile. Вот для таких:
volatile buffer[size];
Если сами данные не имеют квалификатора volatile, а квалификатор volatile добавляется указателю на эти данные, чтение-запись этих данных уже не относится к наблюдаемому поведению:
buffer[size];
SecureZeroMemory(buffer, sizeof(buffer));
Вся надежда на разработчиков компилятора – в настоящий момент и Visual C++, и gcc не оптимизируют обращения к памяти через указатели с квалификатором volatile – в том числе потому, что это один из важных сценариев использования таких указателей.
Не существует гарантированного Стандартом способа перезаписать данные функцией, эквивалентной SecureZeroMemory(), если переменная с этими данными не имеет квалификатора volatile. Точно так же невозможно кодом как в самом начале поста гарантированно прочитать память. Все возможные решения не являются абсолютно переносимыми.
Причина этому банальна – это «не нужно».
Ситуации, когда переменная с подлежащими записи данными выходит из области видимости, а затем занимаемая ей память переиспользуется под другую переменную и из новой переменной выполняется чтение без предварительной инициализации, относятся к неопределенному поведению. Стандарт ясно говорит, что в таких случаях допустимо любое поведение. Обычно просто читается «мусор», который был записан в эту память раньше.
Поэтому с точки зрения Стандарта гарантированная перезапись таких переменных перед выходом из области видимости не имеет смысла. Точно так же не имеет смысла читать память ради чтения памяти.
Использование указателей на volatile является, скорее всего, самым эффективным способом решения проблемы. Во-первых, разработчики компиляторов обычно сознательно выключают оптимизацию доступа к памяти. Во-вторых, накладные расходы минимальны. В-третьих, относительно легко проверить, работает этот способ или нет на конкретной реализации, – достаточно посмотреть, какой машинный код будет сгенерирован для тривиальных примеров выше из этого поста.
volatile – не только для драйверов и операционных систем.
Дмитрий Мещеряков,
департамент продуктов для разработчиков