Один из пользователей компилятора Visual C++ привёл следующий пример кода и спросил, почему его цикл с условием выполняется бесконечно, хотя в какой-то момент условие должно перестать выполняться и цикл должен закончиться:
#include <windows.h>
int x = 0, y = 1;
int* ptr;
DWORD CALLBACK ThreadProc(void*)
{
Sleep(1000);
ptr = &y;
return 0;
}
int main(int, char**)
{
ptr = &x; // starts out pointing to x
DWORD id;
HANDLE hThread = CreateThread(nullptr, 0, ThreadProc, 0, &id);
// Ждём, пока другой поток изменит значение по указателю ptr
// на некоторое ненулевое число
while (*ptr == 0) { }
return 0;
}
Для тех, кому не знакомы специфичные для платформы Windows функции, вот эквивалент на чистом С++:
#include <chrono>
#include <thread>
int x = 0, y = 1;
int* ptr = &x;
void ThreadProc()
{
std::this_thread::sleep_for(std::chrono::seconds(1));
ptr = &y;
}
int main(int, char**)
{
ptr = &x; // starts out pointing to x
std::thread thread(ThreadProc);
// Ждём, пока другой поток изменит значение по указателю ptr
// на некоторое ненулевое число
while (*ptr == 0) { }
return 0;
}
Далее пользователь привёл своё понимание работы программы:
Условный цикл был превращён компилятором в бесконечный. Я вижу это по сгенерированному ассемблерному коду, который однажды загружает значение указателя ptr в регистр (при старте цикла), а затем на каждой итерации сравнивает значение этого регистра с нулём. Поскольку повторной загрузки значения из ptr больше никогда не происходит — то и цикл никогда не заканчивается.
Я понимаю, что объявление ptr как «volatile int*» должно привести к тому, что компилятор отбросит оптимизации и будет считывать значение ptr на каждой итерации цикла, что исправит проблему. Но всё же хотелось бы узнать, почему компилятор не может быть достаточно умным, чтобы делать подобные вещи автоматически? Очевидно, что глобальная переменная, используемая в двух разных потоках, может быть изменена, а значит её нельзя просто закешировать в регистре. Почему компилятор не может сразу сгенерировать корректный код?
Перед тем, как ответить на этот вопрос, начнём с маленькой придирки: «volatile int* ptr» не объявляет переменную ptr в качестве «указателя, для которого запрещены оптимизации». Это «обычный указатель на переменную, для которой запрещены оптимизации». То, что имел в виду автор вышеуказанного вопроса, должно было быть объявлено как «int* volatile ptr».
А теперь вернёмся к основному вопросу. Что же здесь происходит?
Даже самый беглый взгляд на код скажет нам, что здесь нет ни переменных типа std::atomic, ни использования std::memory_order (ни явного, ни неявного). Это означает, что любая попытка доступа к ptr или *ptr из двух разных потоков ведёт к неопределённому поведению. Интуитивно об этом можно думать так: «Компилятор оптимизирует каждый поток так, как-будто он работает в программе один. Единственными точками, где компилятор по стандарту ОБЯЗАН задуматься о доступе к данным из разных потоков, является использование std::atomic или std::memory_order.»
Это объясняет, почему программа вела себя не так, как того ожидал программист. С момента, когда вы допускаете неопределённое поведение — уже нельзя гарантировать совершенно ничего.
Но ладно, давайте задумаемся о второй части его вопроса: почему компилятор недостаточно умён, чтобы распознать такую ситуацию и автоматически отключить оптимизацию с загрузкой значения указателя в регистр? Ну, компилятор автоматически применяет все возможные и не противоречащие стандарту оптимизации. Странно было бы требовать от него уметь читать мысли программиста и отключать какие-то оптимизации, не противоречащие стандарту, которые, возможно, по мнению программиста должны были бы изменить логику работы программы в лучшую сторону. «Ой, а вдруг этот цикл ожидает изменения значения глобальной переменной в другом потоке, хотя и не объявил об этом явно? Возьму-ка я его замедлю в сотню раз, чтобы быть готовым к этой ситуации!». Неужели должно быть так? Вряд ли.
Но допустим, что мы добавим в компилятор правило вроде «Если оптимизация привела к появлению бесконечного цикла, то нужно её отменить и собрать код без оптимизации». Или даже вот так: «Последовательно отменять отдельные оптимизации, пока результатом не станет не-бесконечный цикл». Кроме поразительных сюрпризов, которые это принесёт, даст ли это вообще какую-то пользу?
Да, в этом теоретическом случае мы не получим бесконечный цикл. Он прервётся, если какой-то другой поток запишет в *ptr ненулевое значение. А ещё он прервётся, если другой поток запишет ненулевой значение в переменную x. Становится не понятно, как глубоко должен отработать анализ зависимостей, чтобы «поймать» все случаи, которые могут повлиять на ситуацию. Поскольку компилятор вообще-то не запускает созданную программу и не анализирует её поведение на рантайме, то единственным выходом будет предположить, что вообще никакие обращения к глобальным переменным, указателям и ссылкам нельзя оптимизировать.
int limit;
void do_something()
{
...
if (value > limit)
value = limit; // перечитываем переменную limit
...
for (i = 0; i < 10; i++)
array[i] = limit; // перечитываем переменную limit
...
}
Это совершенно противоречит духу языка С++. Стандарт языка говорит, что если вы модифицируете переменную и рассчитываете увидеть эту модификацию в другом потоке — вы должны ЯВНО об этом сказать: использовать атомарную операцию или упорядочение доступа к памяти (обычно с помощью использования объекта синхронизации).
Так что, пожалуйста, именно так и поступайте.
Автор: tangro