Мы продолжаем цикл статей, посвящённых теме 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 не возникнет, так как все потоки будут следовать одной и той же стратегии блокировки ресурсов.
Use after move
Семантика перемещения — это концепция, которая позволяет эффективно перемещать ресурсы между объектами без лишних копирований данных. Это особенно полезно для улучшения производительности при работе с динамической памятью и другими ресурсами.
Семантика перемещения была введена в C++11 с целью уменьшить накладные расходы при передаче и возврате объектов, которые могут быть эффективно перемещены, и при создании временных объектов. Это позволяет избежать дорогостоящих операций копирования и улучшает производительность программ.
Основные компоненты семантики перемещения:
Rvalue-ссылки (&&). Используются для идентификации временных объектов (rvalues), которые могут быть безопасно перемещены. Они представляют правую сторону выражения и являются ключевой частью семантики перемещения.
Перемещающие конструкторы и операторы присваивания. Классы могут определять специальные конструкторы и операторы присваивания для перемещения ресурсов из одного объекта в другой. Это позволяет эффективно перемещать данные без лишних копирований.
std::move(). Эта функция преобразует lvalue в rvalue-ссылку, позволяя явно указать, что объект должен быть перемещён. Она используется для передачи объектов в функции, которые ожидают rvalue-ссылки.
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;
}
Таким образом можно выбрать подход, который наиболее точно соответствует целям и требованиям к коду.
***
Как мы видим из представленных выше примеров, даже несущественные на первый взгляд ошибки могут привести к непредсказуемым результатам. При этом реальные случаи неопределённого поведения бывают куда более сложными и разнообразными, чем кейсы, описанные в статье.
Помните, что детали имеют значение, а соблюдение рекомендаций и стандартов языка помогает избежать потенциальных проблем. На пути к созданию надежного и безопасного ПО эти тезисы крайне важно учитывать.