Хабрахабр

[Перевод] Почему компилятор превратил мой цикл с условием в бесконечный?

Один из пользователей компилятора 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**)
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».

Что же здесь происходит? А теперь вернёмся к основному вопросу.

Это означает, что любая попытка доступа к ptr или *ptr из двух разных потоков ведёт к неопределённому поведению. Даже самый беглый взгляд на код скажет нам, что здесь нет ни переменных типа std::atomic, ни использования std::memory_order (ни явного, ни неявного). Единственными точками, где компилятор по стандарту ОБЯЗАН задуматься о доступе к данным из разных потоков, является использование 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 ...
}

Это совершенно противоречит духу языка С++. Стандарт языка говорит, что если вы модифицируете переменную и рассчитываете увидеть эту модификацию в другом потоке — вы должны ЯВНО об этом сказать: использовать атомарную операцию или упорядочение доступа к памяти (обычно с помощью использования объекта синхронизации).

Так что, пожалуйста, именно так и поступайте.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть