Мы продолжаем цикл статей, посвящённых теме undefined behavior. Ранее мы исследовали предпосылки неопределённого поведения в C++, предоставили формальные определения и рассмотрели несколько примеров. Сегодня углубимся в проблему: сосредоточимся на случаях UB при многопоточности и неправильном использовании move-семантики. Подобные ситуации могут казаться тривиальными на первый взгляд. При этом они служат основой для более сложных и реальных сценариев, с которыми разработчики порой сталкиваются в своей практике. Привет, Хабр! Меня зовут Владислав Столяров, в МойОфис я аналитик безопасности продуктов. Результаты моей предыдущей статьи — в частности, активность в комментариях — убедили меня в том, что тема UB по-прежнему интересна C++ разработчикам. Ниже представляю вторую часть моего мини-цикла: описание новых UB-проблем и способов их решения. Одним из монументальных примеров неопределённого поведения является гонка данных (Data race). Это тип ошибки в многопоточных программах, когда два или более потока одновременно обращаются к одному и тому же общему ресурсу (например, переменной, памяти или файлу) без соответствующей синхронизации, при этом хотя бы один из потоков выполняет операцию записи. Ситуация может привести к таким проблемам, как некорректные результаты вычислений, потеря данных, аварийное завершение программы или другие неожиданные последствия. Для предотвращения гонок данных используются механизмы синхронизации: мьютексы, семафоры, блокировки и другие средства обеспечивают правильное взаимодействие между потоками и гарантируют, что доступ к общим данным будет происходить безопасно и последовательно. Этот пример навеян моей академической деятельностью, в рамках которой мы с товарищем пишем компилятор для языка программирования Solidity. В процессе приходится изучать много исходного кода смарт-контрактов, которые могли бы быть очень страшными в многопоточном контексте их плюсовой интерпретации. Давайте представим, что мы работаем над созданием интернет-банкинга, предоставляющего клиентам доступ к своим финансовым данным и операциям через веб-интерфейс. В рамках проекта разработана программа, позволяющая клиентам входить в свои банковские аккаунты, просматривать балансы, выполнять переводы и операции. Для обработки запросов клиентов используются многопоточные серверы, каждый из которых обрабатывает запросы определенного клиента. Упрощённый код серверной части программы мог бы выглядеть таким образом: Как нетрудно заметить, в этом коде есть гонка данных. Она может возникнуть при выполнении операций с балансами, например, при одновременных переводах со счетов двух разных клиентов. Несколько потоков одновременно пытаются обновить баланс одного и того же банковского счета, что приводит к неправильному изменению баланса или даже к потере денежных средств. Для решения проблемы при обновлении балансов банковских счетов можно использовать мьютексную синхронизация при доступе к общему состоянию счетов. Например, добавить accountMutex, который будет использоваться при выполнении операций deposit и withdraw для каждого банковского счета. Это позволит избежать гонки данных и обеспечить корректное обновление балансов. Вот исправленный фрагмент кода: Для борьбы с гонкой данных в C++ можно использовать следующие подходы и механизмы: Мьютексы (std::mutex). Мьютексы позволяют организовать критические секции, в которых только один поток может выполнять определенный участок кода. Мьютексы обеспечивают блокировку доступа к общим данным, пока другой поток не завершит свою работу. Стандартная библиотека атомарных операций (std::atomic). Атомарные операции позволяют выполнять операции над общими данными без необходимости использования мьютексов. Они гарантируют атомарное выполнение операций чтения и записи. Lock-free структуры данных. Lock-free структуры данных и алгоритмы позволяют избегать блокировок, обеспечивая потокобезопасный доступ к данным. Они используют атомарные операции для обеспечения согласованности данных. Thread-Local Storage (TLS). Использование TLS позволяет каждому потоку иметь собственное «частное» копирование общих данных, тем самым избегая гонок. Архитектурные решения. Разработка архитектуры программы с учетом потокобезопасности, например, использование потокобезопасных контейнеров и структур данных, может существенно уменьшить вероятность гонок данных. Анализ кода. Использование статического и динамического анализа кода, а также инструментов для выявления гонок данных позволяет обнаружить потенциальные проблемы и устранить их на ранних этапах разработки. Важно понимать, что выбор конкретного подхода зависит от конкретной задачи и требований к потокобезопасности. Комбинирование различных методов может обеспечить надёжную защиту от гонок данных. Конечно, сам по себе deadlock не является неопределённым поведением. Это конкретная ситуация, когда два или более потока оказываются заблокированными и ожидают друг друга, чтобы освободить ресурсы, необходимые для продолжения выполнения. Эта проблема четко определенна в многопоточных программах, и ее наличие может быть обнаружено и диагностировано. Однако, лёгким движением руки, deadlock может быть усугублен неопределённым поведением. Например, представьте такую ситуацию: В этом примере два потока пытаются захватить два ресурса в разном порядке. В зависимости от того, какой поток начнёт выполнение первым, может возникнуть deadlock, или же один из потоков может успешно завершиться. Таким образом, результат выполнения этой программы будет неопределённым из-за наличия deadlock и непредсказуемого порядка захвата ресурсов. Для предотвращения deadlock в данном примере можно использовать стратегию упорядочивания ресурсов (Resource Ordering). Это означает, что все потоки должны запрашивать ресурсы в одном и том же порядке, чтобы избежать блокировки. В данном случае можно упорядочить запросы на блокировку ресурсов в лексикографическом порядке. Вот исправленный код: Здесь мы изменили порядок блокировки ресурсов для второго потока. Теперь оба потока будут запрашивать ресурсы в одном и том же порядке: сначала resource1Mutex, затем resource2Mutex. Это гарантирует, что deadlock не возникнет, так как все потоки будут следовать одной и той же стратегии блокировки ресурсов. Семантика перемещения — это концепция, которая позволяет эффективно перемещать ресурсы между объектами без лишних копирований данных. Это особенно полезно для улучшения производительности при работе с динамической памятью и другими ресурсами. Семантика перемещения была введена в C++11 с целью уменьшить накладные расходы при передаче и возврате объектов, которые могут быть эффективно перемещены, и при создании временных объектов. Это позволяет избежать дорогостоящих операций копирования и улучшает производительность программ. Основные компоненты семантики перемещения: Rvalue-ссылки (&&). Используются для идентификации временных объектов (rvalues), которые могут быть безопасно перемещены. Они представляют правую сторону выражения и являются ключевой частью семантики перемещения. Перемещающие конструкторы и операторы присваивания. Классы могут определять специальные конструкторы и операторы присваивания для перемещения ресурсов из одного объекта в другой. Это позволяет эффективно перемещать данные без лишних копирований. std::move(). Эта функция преобразует lvalue в rvalue-ссылку, позволяя явно указать, что объект должен быть перемещён. Она используется для передачи объектов в функции, которые ожидают rvalue-ссылки. Проблемы с семантикой перемещения могут возникнуть, например, в такой ситуации: В этом примере класс Resource представляет ресурс, а ResourceManager управляет этим ресурсом. После перемещения manager1 в manager2, оба объекта будут иметь доступ к одному и тому же ресурсу. При завершении программы возникнет неопределённое поведение, так как и manager1, и manager2 попытаются разрушить один и тот же ресурс, что может привести к ошибкам или непредсказуемым последствиям. Мы используем умный указатель std::unique_ptr для управления ресурсом в классе ResourceManager. Это позволяет правильно управлять владением ресурса и гарантировать, что каждый объект ResourceManager будет иметь свой собственный экземпляр ресурса. Use after move — это ситуация в программировании, когда объект используется или разыменовывается после того, как его ресурсы были перемещены в другой объект с использованием семантики перемещения. Это приводит к неопределённому поведению программы, так как оригинальный объект больше не владеет ресурсами, и попытка обращения к этим ресурсам может вызвать ошибки, утечку памяти или другие непредсказуемые последствия. Избежать Use after move можно, правильно обрабатывая перемещение ресурсов и обновляя указатели на них после перемещения. Для демонстрации проблемы можно написать что-то такое: В этом примере объекты класса Data перемещаются из вектора source в вектор destination с помощью функции std::move(). После перемещения объекта, попытка использовать его через индекс source[0] приводит к неопределённому поведению, так как объект был перемещён и больше не является действительным. Для избежания проблемы следует обновить код так, чтобы он не зависел от объектов, которые были перемещены. В данном случае вы можете извлечь значение из перемещённых объектов до перемещения: Если же вам действительно требуется использовать данные из перемещённых объектов после перемещения, следует скопировать значения вместо перемещения: Таким образом можно выбрать подход, который наиболее точно соответствует целям и требованиям к коду. Как мы видим из представленных выше примеров, даже несущественные на первый взгляд ошибки могут привести к непредсказуемым результатам. При этом реальные случаи неопределённого поведения бывают куда более сложными и разнообразными, чем кейсы, описанные в статье. Помните, что детали имеют значение, а соблюдение рекомендаций и стандартов языка помогает избежать потенциальных проблем. На пути к созданию надежного и безопасного ПО эти тезисы крайне важно учитывать.  Разработчик