Мы продолжаем цикл статей, посвящённых теме undefined behavior. Ранее мы исследовали предпосылки неопределённого поведения в C++, предоставили формальные определения и рассмотрели несколько примеров. Сегодня углубимся в проблему: сосредоточимся на случаях UB при многопоточности и неправильном использовании move-семантики.
Подобные ситуации могут казаться тривиальными на первый взгляд. При этом они служат основой для более сложных и реальных сценариев, с которыми разработчики порой сталкиваются в своей практике.
Привет, Хабр! Меня зовут Владислав Столяров, в МойОфис я аналитик безопасности продуктов. Результаты моей предыдущей статьи — в частности, активность в комментариях — убедили меня в том, что тема UB по-прежнему интересна C++ разработчикам. Ниже представляю вторую часть моего мини-цикла: описание новых UB-проблем и способов их решения.
Гонка данных
Одним из монументальных примеров неопределённого поведения является гонка данных (Data race). Это тип ошибки в многопоточных программах, когда два или более потока одновременно обращаются к одному и тому же общему ресурсу (например, переменной, памяти или файлу) без соответствующей синхронизации, при этом хотя бы один из потоков выполняет операцию записи. Ситуация может привести к таким проблемам, как некорректные результаты вычислений, потеря данных, аварийное завершение программы или другие неожиданные последствия.
Для предотвращения гонок данных используются механизмы синхронизации: мьютексы, семафоры, блокировки и другие средства обеспечивают правильное взаимодействие между потоками и гарантируют, что доступ к общим данным будет происходить безопасно и последовательно.
Этот пример навеян моей академической деятельностью, в рамках которой мы с товарищем пишем компилятор для языка программирования Solidity. В процессе приходится изучать много исходного кода смарт-контрактов, которые могли бы быть очень страшными в многопоточном контексте их плюсовой интерпретации.
Давайте представим, что мы работаем над созданием интернет-банкинга, предоставляющего клиентам доступ к своим финансовым данным и операциям через веб-интерфейс. В рамках проекта разработана программа, позволяющая клиентам входить в свои банковские аккаунты, просматривать балансы, выполнять переводы и операции. Для обработки запросов клиентов используются многопоточные серверы, каждый из которых обрабатывает запросы определенного клиента. Упрощённый код серверной части программы мог бы выглядеть таким образом:
#include <iostream>
#include <thread>
#include <vector>
class BankAccount {
public:
BankAccount(double initialBalance) : balance(initialBalance) {}
double getBalance() const {
return balance;
}
void deposit(double amount) {
balance += amount;
}
void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
}
}
private:
double balance;
};
void transferFunds(BankAccount& from, BankAccount& to, double amount) {
from.withdraw(amount);
to.deposit(amount);
}
int main() {
BankAccount account1(1000);
BankAccount account2(1500);
std::thread transferThread1([&]() {
for (int i = 0; i < 5; ++i) {
transferFunds(account1, account2, 100);
}
});
std::thread transferThread2([&]() {
for (int i = 0; i < 5; ++i) {
transferFunds(account2, account1, 150);
}
});
transferThread1.join();
transferThread2.join();
std::cout << "Account 1 balance: " << account1.getBalance() << std::endl;
std::cout << "Account 2 balance: " << account2.getBalance() << std::endl;
return 0;
}
Как нетрудно заметить, в этом коде есть гонка данных. Она может возникнуть при выполнении операций с балансами, например, при одновременных переводах со счетов двух разных клиентов. Несколько потоков одновременно пытаются обновить баланс одного и того же банковского счета, что приводит к неправильному изменению баланса или даже к потере денежных средств.
Для решения проблемы при обновлении балансов банковских счетов можно использовать мьютексную синхронизация при доступе к общему состоянию счетов. Например, добавить accountMutex
, который будет использоваться при выполнении операций deposit
и withdraw
для каждого банковского счета. Это позволит избежать гонки данных и обеспечить корректное обновление балансов. Вот исправленный фрагмент кода:
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
class BankAccount {
public:
BankAccount(double initialBalance) : balance(initialBalance) {}
double getBalance() const {
return balance;
}
void deposit(double amount) {
std::lock_guard<std::mutex> lock(accountMutex);
balance += amount;
}
void withdraw(double amount) {
std::lock_guard<std::mutex> lock(accountMutex);
if (balance >= amount) {
balance -= amount;
}
}
private:
double balance;
mutable std::mutex accountMutex;
};
void transferFunds(BankAccount& from, BankAccount& to, double amount) {
from.withdraw(amount);
to.deposit(amount);
}
int main() {
BankAccount account1(1000);
BankAccount account2(1500);
std::thread transferThread1([&]() {
for (int i = 0; i < 5; ++i) {
transferFunds(account1, account2, 100);
}
});
std::thread transferThread2([&]() {
for (int i = 0; i < 5; ++i) {
transferFunds(account2, account1, 150);
}
});
transferThread1.join();
transferThread2.join();
std::cout << "Account 1 balance: " << account1.getBalance() << std::endl;
std::cout << "Account 2 balance: " << account2.getBalance() << std::endl;
return 0;
}
Для борьбы с гонкой данных в C++ можно использовать следующие подходы и механизмы:
Мьютексы (std::mutex). Мьютексы позволяют организовать критические секции, в которых только один поток может выполнять определенный участок кода. Мьютексы обеспечивают блокировку доступа к общим данным, пока другой поток не завершит свою работу.
Стандартная библиотека атомарных операций (std::atomic). Атомарные операции позволяют выполнять операции над общими данными без необходимости использования мьютексов. Они гарантируют атомарное выполнение операций чтения и записи.
Lock-free структуры данных. Lock-free структуры данных и алгоритмы позволяют избегать блокировок, обеспечивая потокобезопасный доступ к данным. Они используют атомарные операции для обеспечения согласованности данных.
Thread-Local Storage (TLS). Использование TLS позволяет каждому потоку иметь собственное «частное» копирование общих данных, тем самым избегая гонок.
Архитектурные решения. Разработка архитектуры программы с учетом потокобезопасности, например, использование потокобезопасных контейнеров и структур данных, может существенно уменьшить вероятность гонок данных.
Анализ кода. Использование статического и динамического анализа кода, а также инструментов для выявления гонок данных позволяет обнаружить потенциальные проблемы и устранить их на ранних этапах разработки.
Важно понимать, что выбор конкретного подхода зависит от конкретной задачи и требований к потокобезопасности. Комбинирование различных методов может обеспечить надёжную защиту от гонок данных.
Deadlock
Конечно, сам по себе deadlock не является неопределённым поведением. Это конкретная ситуация, когда два или более потока оказываются заблокированными и ожидают друг друга, чтобы освободить ресурсы, необходимые для продолжения выполнения. Эта проблема четко определенна в многопоточных программах, и ее наличие может быть обнаружено и диагностировано. Однако, лёгким движением руки, deadlock может быть усугублен неопределённым поведением.
Например, представьте такую ситуацию:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex resource1Mutex;
std::mutex resource2Mutex;
void threadFunction1() {
std::unique_lock<std::mutex> lock1(resource1Mutex);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::unique_lock<std::mutex> lock2(resource2Mutex);
std::cout << "Thread 1 acquired resources." << std::endl;
}
void threadFunction2() {
std::unique_lock<std::mutex> lock2(resource2Mutex);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::unique_lock<std::mutex> lock1(resource1Mutex);
std::cout << "Thread 2 acquired resources." << std::endl;
}
int main() {
std::thread t1(threadFunction1);
std::thread t2(threadFunction2);
t1.join();
t2.join();
return 0;
}
В этом примере два потока пытаются захватить два ресурса в разном порядке. В зависимости от того, какой поток начнёт выполнение первым, может возникнуть deadlock, или же один из потоков может успешно завершиться. Таким образом, результат выполнения этой программы будет неопределённым из-за наличия deadlock и непредсказуемого порядка захвата ресурсов.
Для предотвращения deadlock в данном примере можно использовать стратегию упорядочивания ресурсов (Resource Ordering). Это означает, что все потоки должны запрашивать ресурсы в одном и том же порядке, чтобы избежать блокировки. В данном случае можно упорядочить запросы на блокировку ресурсов в лексикографическом порядке. Вот исправленный код:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex resource1Mutex;
std::mutex resource2Mutex;
void threadFunction1() {
std::unique_lock<std::mutex> lock1(resource1Mutex);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::unique_lock<std::mutex> lock2(resource2Mutex);
std::cout << "Thread 1 acquired resources." << std::endl;
}
void threadFunction2() {
std::unique_lock<std::mutex> lock1(resource1Mutex);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::unique_lock<std::mutex> lock2(resource2Mutex);
std::cout << "Thread 2 acquired resources." << std::endl;
}
int main() {
std::thread t1(threadFunction1);
std::thread t2(threadFunction2);
t1.join();
t2.join();
return 0;
}
Здесь мы изменили порядок блокировки ресурсов для второго потока. Теперь оба потока будут запрашивать ресурсы в одном и том же порядке: сначала resource1Mutex
, затем resource2Mutex
. Это гарантирует, что deadlock не возникнет, так как все потоки будут следовать одной и той же стратегии блокировки ресурсов.
Семантика перемещения
Семантика перемещения — это концепция, которая позволяет эффективно перемещать ресурсы между объектами без лишних копирований данных. Это особенно полезно для улучшения производительности при работе с динамической памятью и другими ресурсами.
Семантика перемещения была введена в C++11 с целью уменьшить накладные расходы при передаче и возврате объектов, которые могут быть эффективно перемещены, и при создании временных объектов. Это позволяет избежать дорогостоящих операций копирования и улучшает производительность программ.
Основные компоненты семантики перемещения:
Rvalue-ссылки (&&). Используются для идентификации временных объектов (rvalues), которые могут быть безопасно перемещены. Они представляют правую сторону выражения и являются ключевой частью семантики перемещения.
Перемещающие конструкторы и операторы присваивания. Классы могут определять специальные конструкторы и операторы присваивания для перемещения ресурсов из одного объекта в другой. Это позволяет эффективно перемещать данные без лишних копирований.
std::move(). Эта функция преобразует lvalue в rvalue-ссылку, позволяя явно указать, что объект должен быть перемещён. Она используется для передачи объектов в функции, которые ожидают rvalue-ссылки.
Проблемы с семантикой перемещения могут возникнуть, например, в такой ситуации:
#include <iostream>
#include <string>
class Resource {
public:
Resource(const std::string& name) : resourceName(name) {
std::cout << "Resource " << resourceName << " created." << std::endl;
}
~Resource() {
std::cout << "Resource " << resourceName << " destroyed." << std::endl;
}
private:
std::string resourceName;
};
class ResourceManager {
public:
ResourceManager(const std::string& name) : resource(name) {}
ResourceManager(ResourceManager&& other) noexcept : resource(std::move(other.resource)) {}
ResourceManager& operator=(ResourceManager&& other) noexcept {
if (this != &other) {
resource = std::move(other.resource);
}
return *this;
}
private:
Resource resource;
};
int main() {
ResourceManager manager1("Manager1");
ResourceManager manager2("Manager2");
manager2 = std::move(manager1);
return 0;
}
В этом примере класс Resource
представляет ресурс, а ResourceManager
управляет этим ресурсом. После перемещения manager1
в manager2
, оба объекта будут иметь доступ к одному и тому же ресурсу. При завершении программы возникнет неопределённое поведение, так как и manager1
, и manager2
попытаются разрушить один и тот же ресурс, что может привести к ошибкам или непредсказуемым последствиям.
#include <iostream>
#include <string>
class Resource {
public:
Resource(const std::string& name) : resourceName(name) {
std::cout << "Resource " << resourceName << " created." << std::endl;
}
~Resource() {
std::cout << "Resource " << resourceName << " destroyed." << std::endl;
}
private:
std::string resourceName;
};
class ResourceManager {
public:
ResourceManager(const std::string& name) : resource(std::make_unique<Resource>(name)) {}
ResourceManager(ResourceManager&& other) noexcept : resource(std::move(other.resource)) {}
ResourceManager& operator=(ResourceManager&& other) noexcept {
if (this != &other) {
resource = std::move(other.resource);
}
return *this;
}
private:
std::unique_ptr<Resource> resource;
};
int main() {
ResourceManager manager1("Manager1");
ResourceManager manager2("Manager2");
manager2 = std::move(manager1);
return 0;
}
Мы используем умный указатель std::unique_ptr
для управления ресурсом в классе ResourceManager
. Это позволяет правильно управлять владением ресурса и гарантировать, что каждый объект ResourceManager
будет иметь свой собственный экземпляр ресурса.
Use after move
Use after move — это ситуация в программировании, когда объект используется или разыменовывается после того, как его ресурсы были перемещены в другой объект с использованием семантики перемещения. Это приводит к неопределённому поведению программы, так как оригинальный объект больше не владеет ресурсами, и попытка обращения к этим ресурсам может вызвать ошибки, утечку памяти или другие непредсказуемые последствия. Избежать Use after move можно, правильно обрабатывая перемещение ресурсов и обновляя указатели на них после перемещения.
Для демонстрации проблемы можно написать что-то такое:
#include <iostream>
#include <vector>
class Data {
public:
Data(int value) : data(value) {}
int getValue() const {
return data;
}
private:
int data;
};
int main() {
std::vector<Data> source;
source.push_back(Data(42));
std::vector<Data> destination(std::move(source));
int result = source[0].getValue();
std::cout << "Result: " << result << std::endl;
return 0;
}
В этом примере объекты класса Data
перемещаются из вектора source в вектор destination
с помощью функции std::move()
. После перемещения объекта, попытка использовать его через индекс source[0]
приводит к неопределённому поведению, так как объект был перемещён и больше не является действительным.
Для избежания проблемы следует обновить код так, чтобы он не зависел от объектов, которые были перемещены. В данном случае вы можете извлечь значение из перемещённых объектов до перемещения:
#include <iostream>
#include <vector>
class Data {
public:
Data(int value) : data(value) {}
int getValue() const {
return data;
}
private:
int data;
};
int main() {
std::vector<Data> source;
source.push_back(Data(42));
int result = source[0].getValue();
std::vector<Data> destination(std::move(source));
std::cout << "Result: " << result << std::endl;
return 0;
}
Если же вам действительно требуется использовать данные из перемещённых объектов после перемещения, следует скопировать значения вместо перемещения:
#include <iostream>
#include <vector>
class Data {
public:
Data(int value) : data(value) {}
int getValue() const {
return data;
}
private:
int data;
};
int main() {
std::vector<Data> source;
source.push_back(Data(42));
std::vector<Data> destination(std::move(source));
int result = destination[0].getValue();
std::cout << "Result: " << result << std::endl;
return 0;
}
Таким образом можно выбрать подход, который наиболее точно соответствует целям и требованиям к коду.
***
Как мы видим из представленных выше примеров, даже несущественные на первый взгляд ошибки могут привести к непредсказуемым результатам. При этом реальные случаи неопределённого поведения бывают куда более сложными и разнообразными, чем кейсы, описанные в статье.
Помните, что детали имеют значение, а соблюдение рекомендаций и стандартов языка помогает избежать потенциальных проблем. На пути к созданию надежного и безопасного ПО эти тезисы крайне важно учитывать.
Комментарии (7)
SpiderEkb
17.08.2023 13:48+2Давайте представим, что мы работаем над созданием интернет-банкинга, предоставляющего клиентам доступ к своим финансовым данным и операциям через веб-интерфейс.
Вы бы хоть другой пример выбрали. Потому что там все это происходит ну совсем не так. И никаких гонок на уровне клиентов там нет и быть не может.
Второй момент. Есть разделяемые ресурсы, совместное использование которых приходится регулировать разработчику. Например, разделяемая память. И есть ресурсы, за совместное использование которых отвечает система. Например, очереди, сокеты, файлы.
Строго говоря, к С++ все это вообще никакого отношения не имеет, это вопрос общей архитектуры.
Как пример - у вас есть несколько потоков или процессов (не важно), которым необходимо обмениваться некоторой информацией. И вы знаете что скорость обмена для вас не является критичным параметром в данной задаче (но т.е. будет там 1мс на блок или 1мкс не играет роли на фоне всего остального).
И вот здесь нет смысла связываться с разделяемой памятью - это слишком сложно и оправданно только тогда когда требуется предельная скорость именно обмена данными.
Можно использовать системные средства. Например, именованные локальные сокеты (UNIX socket). Или майлслоты (Win). Или очереди (*usrq, *dtaq в as/400). Каждый поток (процесс) создает свой объект с уникальным именем который является "почтовым ящиком" для него. Он оттуда будет читать все, что ему посылают другие. Все остальные могут туда писать все что нужно передать данному потоку.
И здесь все вопросы синхронизации отдаются на откуп системе, она за всем этим следит. Лично использовал такой подход в ситуации когда было три потока - один коммуникация с удаленными контроллерами по UDP, второй коммуникация с интерфейсными клиентами по TCP и третий - обработка и маршрутизация посылок от клиента к контроллеру и от контроллера к клиенту. И никаких гонок, никаких проблем с синхронизацией при минимальных затратах.
Второй пример, с чем часто сталкиваюсь сейчас - параллельная обработка больших объемов данных. Есть головное задание, которое производит первичный отбор данных для обработки, есть несколько (5-10) заданий-обработчиков которые уже занимаются обработкой каждого блока данных. И есть конвейер (у нас - *usrq). Головное задание выкладывает отобранные данные на конвейер, обработчики их оттуда разбирают и обрабатывают. И опять - вся синхронизация на системе. На разработчике только "положить в очередь" и "взять из очереди".
Stolyarrr Автор
17.08.2023 13:48+1Может стоит добавить в обе статьи про UB дисклеймер: "Приведённые в статье фрагменты кода служат сугубо для демонстрации, не стоит использовать их в реальных проектах (ну, по крайней мере берите фрагменты, которые помечены как исправленный код)"?
Ну, а если серьёзно, то большое спасибо за такой развёрнутый фидбек. Очень интересный и полезный комментарий.
Пример, как было описано, сугубо синтетический и не несёт в себе не то, что близости к продовскому коду, а даже и особой осмысленности:) Как-то раз, я ревьювил смарт-контракт на Solidity и думал: "Ё моё, в этот писец бы ещё многопоточку добавить". Ну, время пришло и я реализовал что-то похожее. Интернет-банком это стало по остаточному принципу.SpiderEkb
17.08.2023 13:48Я просто знаю как мобильные клиенты устроены (точнее, как они связаны с реальными данными) - от реальных данных они бесконечно далеко. У них есть REST API - они просто дергают запросы. А дальше это длинным путем идет до центрального сервера, где уже выполняется. Там даже нет понятия "аккаунт". Есть ПИН клиента и все запросы идут по нему.
И перевести деньги со счета на счет - это не просто переложить их из одного аккаунта в другой. Это создать платежный документ и отправить его в очередь на обработку. Т.е. никаких "гонок" и конфликтов тут в принципе не бывает.
SpiderEkb
17.08.2023 13:48+1А если говорить про сложности работы с теми объектами, которые вам нужно синхронизировать руками (например, разделяемая память), то там проблем очень много и в про них даже не упомянули.
Например, нужно выставлять семафор что произошли какие-то изменения в состоянии разделяемого объекта. Ну вот простой пример. Есть 4 потока. Первый поток генерирует что-то такое, чем нужно поделиться с остальными тремя. Он выкладывает это в разделяемую память и выставляет флаг "есть изменения, разбирайте".
Дальше он генерирует новую порцию изменений. Но он не может их выложить просто так - прежде нужно убедиться что предыдущая порция была принята всеми тремя потоками. Если это не так - он будет стоять и ждать. При плотном двустороннем обмене еще дедлоки весьма вероятныЭто даже без учета блокировок чтение-запись.
Если использовать подход "почтовых ящиков", все становится проще. По сути это очередь входящих сообщений для каждого потока/процесса. Т.е. тут уже исключаются гонки и дедлоки т.к. хранилище данных для процессов разделены. Остаются только блокировки на уровне чтение-запись, но они решаются или использованием конкурентных очередей или использованием системных объектов где за всем этим следит сама система. И все становится просто - нужно что-то кому-то отправить - бросаешь в нужный "почтовый ящик" и идешь дальше заниматься своими делами. Ну и свой ящик проверяешь на предмет входящих.
В подавляющем большинстве случаев такой подход обеспечивает достаточную производительность при минимальных затратах.
Но, повторюсь, все это не имеет отношения к конкретному языку, все это из области общей архитектуры IPC (InterProcess Communications).
jpegqs
Пример состояния гонки, как не надо создавать потоки в С++. По мотивам найденного в реальном софте. Воспроизводилось только на определённой архитектуре.
Будет печатать "111 -1", а не как предполагается "111 999", "X() finished" напечатает после чисел. Потому что поток стартует раньше чем завершится инициализация класса. Вместо задержки в 1мс была инициализация кучи других классов.
bogolt
Какой
ужасхрестоматийный пиздецSpiderEkb
Не, ну голова-то дана не только чтобы в нее есть...
Потоки создаем в состоянии suspended, завершаем всю инициализацию и только потом переводим его в состояние active. Как-то так...
Это не про гонки и не про синхронизацию разделяемых ресурсов. Это просто про понимание того что когда и как там внутри происходит.