Я занимаюсь разработкой на ядре Zynq Ultrascale+ ARM, и у меня возникла странная проблема: включение оптимизации компилятора приводит к преждевременному выходу из цикла. Компилятор — «aarch64-none-elf-g++».
Вот код, который есть код. Изначально программа была более сложной, но при отладке я удалил все, что мог, при этом воспроизведя ошибку.
#include <cstdint>
#include <string>
#include <vector>
class TestClass
{
private:
std::vector<int> command_buffer;
public:
TestClass() : command_buffer({}) {}
void read_command();
};
void TestClass::read_command()
{
printf("Before %lu\n\r", 67ul);
size_t s = 42;
while (true)
{
s = this->command_buffer.size();
if (s > 0)
{
break;
}
}
printf("Size %lu:\n\r", s);
}
int main()
{
TestClass tester{ };
tester.read_command();
}
По сути, я ожидаю, что программа будет непрерывно работать в цикле, проверяя размер вектора до тех пор, пока он не станет отличным от нуля. Поскольку я никогда не добавляю в вектор никаких элементов, он должен зацикливаться вечно. Когда уровень оптимизации установлен на -O0, все работает как положено. Однако установка значения -O2 приводит к немедленному выходу программы из цикла и печати размера вектора, равного 0.
Вывод консоли без оптимизации (-O0):
Раньше: 67
Вывод консоли с оптимизацией (-O2):
Раньше: 67
После: 0
Глядя на ассемблерный код, сгенерированный компилятором, кажется, что размер вектора рассчитывается, но никогда не проверяется, просто предполагается, что он не равен нулю.
.LC0:
.string "Before: %lu\n\r"
.align 3
.LC1:
.string "After: %lu\n\r"
.text
.align 2
.p2align 4,,11
.global _ZN9TestClass12read_commandEv
.type _ZN9TestClass12read_commandEv, %function
_ZN9TestClass12read_commandEv:
.LFB2157:
.cfi_startproc
stp x29, x30, [sp, -32]!
.cfi_def_cfa_offset 32
.cfi_offset 29, -32
.cfi_offset 30, -24
adrp x1, .LC0
mov x29, sp
str x19, [sp, 16]
.cfi_offset 19, -16
mov x19, x0
add x0, x1, :lo12:.LC0
mov x1, 67
bl printf
ldp x2, x1, [x19]
adrp x0, .LC1
ldr x19, [sp, 16]
add x0, x0, :lo12:.LC1
ldp x29, x30, [sp], 32
.cfi_restore 30
.cfi_restore 29
.cfi_restore 19
.cfi_def_cfa_offset 0
sub x1, x1, x2
asr x1, x1, 2
b printf
.cfi_endproc
Есть идеи, почему это происходит?
К вашему сведению, вам нужно использовать только синтаксис this->
, чтобы различать параметры и члены. Хорошее руководство/стиль кодирования устранит необходимость.
Я не понимаю. vector
никогда не меняет свой размер, поэтому его размер постоянен, поэтому цикл можно исключить.
На моем Mac на базе ARM с чипом M3 приведенный выше код генерирует сбой исключения «ловушки трассировки».
Прогресс_гарантия
@ThomasMatthews Я предположил, что, поскольку вектор никогда не меняет размер, код после цикла будет недоступен и, следовательно, не должен выполняться. Я не знал о неопределенном поведении.
Код имеет неопределенное поведение.
В соответствии с текущей спецификацией стандарта C++ компилятору разрешено предполагать (например, в целях оптимизации), что поток в конечном итоге всегда будет:
volatile
,Ваш цикл, если он не выйдет из строя на первой итерации, никогда не выполнит ни одного из этих действий, а также не сможет завершиться в этом сценарии, поскольку ни один другой поток не сможет изменить вектор, не вызывая неопределенного поведения из-за гонка данных.
Таким образом, компилятор может предположить, что условие цикла не должно выполняться при первой проверке, чтобы можно было полностью оптимизировать цикл.
Хуже того, он может увидеть, что ваш вектор действительно изначально гарантированно будет пустым, а это означает, что программа гарантирует неопределенное поведение и может затем заменить цикл, например, ловушка, указывающая на невозможный путь.
Обратите внимание, что в C ситуация несколько иная, где компилятору не разрешено предполагать, что такой цикл завершается, если его условием является целочисленная константа, результатом которой является 1
. В настоящее время в комитете по стандартизации C++ рассматриваются предложения по изменению правил C++ аналогичным образом.
Однако такая петля обычно является признаком более фундаментальной проблемы. Какой смысл проверять условие цикла каждый раз, когда оно не может быть выполнено на следующей итерации цикла?
Если вы предполагаете, что другой поток изменит размер вектора, то это и есть ваша настоящая проблема. std::vector
не является потокобезопасным. Любая попытка изменить его из другого потока, пока показанный поток выполняет цикл, будет считаться гонкой данных и вызывать неопределенное поведение.
Вам нужно будет добавить мьютекс или какой-либо другой механизм синхронизации, который затем автоматически обеспечит выполнение приведенных выше предположений о ходе вперед.
Насколько я могу судить, единственным реальным вариантом использования бесконечного цикла такого типа, который не удовлетворяет допущениям, является остановка потока на неопределенный срок. Однако в этом случае это будет просто записано как while(true);
, и даже это имеет смысл только в автономном виде, поскольку в противном случае цикл над std::this_thread::sleep_for
или std::this_thread::yield
, вероятно, был бы более подходящим. (Хотя они также не считаются действиями прогресса в списке вверху.)
На самом деле я пропустил, что изменения для тривиальных бесконечных циклов уже были включены в черновик для C++26.
Благодаря этим изменениям в C++26 приведенный выше список действий расширен и теперь включает вызовы std::this_thread::yield
.
Более того, некоторые конкретные конструкции циклов, такие как while(true);
, о которых я упоминал выше, теперь называются тривиальными бесконечными циклами и также добавляются в список и больше не имеют неопределенного поведения. Примечательно, что в отличие от C это применимо только к циклам с пустыми операторами итерации.
За исключением автономных реализаций (где is определяется реализацией), тривиальные бесконечные циклы заменяются циклом, имеющим то же поведение, что и while(true) std::this_thread::yield();
.
Спасибо за подробное объяснение! Я не осознавал, что это неопределенное поведение в C++, поскольку мне это кажется довольно неинтуитивным. Я согласен, что пример цикла не имеет никакого смысла, кроме как проиллюстрировать то, что я считал ошибкой. Первоначально я собирался обновить подпрограмму службы прерываний и добавить элементы в вектор, думая, что, поскольку это будет один производитель и один потребитель, все будет в порядке, если я удостоверюсь, что вектор никогда не придется перераспределять, но я не сделал этого. Я еще не реализовал это, когда обнаружил эту проблему.
@MatthewDvorsky Нет, доступ к вектору в обработчике прерывания также является UB. Обработчик прерываний, как и обработчик сигналов, должен обращаться к общим объектам только в том случае, если они относятся к типу volatile sig_atomic_t
или являются атомарными объектами без блокировки. См. en.cppreference.com/w/cpp/utility/program/signal#Signal_handler. Компилятор разрешен и будет оптимизировать при условии, что в обработчике сигнала или прерывания больше ничего не происходит. Очевидным примером такой оптимизации является сохранение размера вектора в регистре, даже если цикл не оптимизирован.
(В дополнение к вышесказанному компиляторы обычно предоставляют нестандартные гарантии атомарности и/или порядка памяти для некоторых других volatile
доступа в зависимости от деталей архитектуры.)
При взаимодействии с обработчиком сигнала/прерывания в том же потоке вам все равно необходимо учитывать атомарность и порядок памяти вашей синхронизации. Некоторые вещи немного проще между потоком и работающим в нем обработчиком сигнала/прерывания, поскольку прерывание является только односторонним, но обычно вам все равно нужно будет убедиться, например, что используйте порядок получения/освобождения памяти, где это возможно. Когда вы используете атомику std
, единственная практическая разница состоит в том, что вы можете заменить ограничители нитей на более простые сигнальные ограждения.
Компилятору разрешено предполагать, что цикл в конечном итоге завершится. Здесь это происходит только в том случае, если размер с самого начала не равен нулю, так что это должно быть правдой!