Как стать автором
Обновить
88.13
МойОфис
Платформа для работы с документами и коммуникаций

Подводные камни C++. Решаем загадки неопределённого поведения, ч. 2

Время на прочтение9 мин
Количество просмотров8.3K

Мы продолжаем цикл статей, посвящённых теме 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 с целью уменьшить накладные расходы при передаче и возврате объектов, которые могут быть эффективно перемещены, и при создании временных объектов. Это позволяет избежать дорогостоящих операций копирования и улучшает производительность программ.

Основные компоненты семантики перемещения:

  1. Rvalue-ссылки (&&). Используются для идентификации временных объектов (rvalues), которые могут быть безопасно перемещены. Они представляют правую сторону выражения и являются ключевой частью семантики перемещения.

  2. Перемещающие конструкторы и операторы присваивания. Классы могут определять специальные конструкторы и операторы присваивания для перемещения ресурсов из одного объекта в другой. Это позволяет эффективно перемещать данные без лишних копирований.

  3. 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;
}

Таким образом можно выбрать подход, который наиболее точно соответствует целям и требованиям к коду.

***

Как мы видим из представленных выше примеров, даже несущественные на первый взгляд ошибки могут привести к непредсказуемым результатам. При этом реальные случаи неопределённого поведения бывают куда более сложными и разнообразными, чем кейсы, описанные в статье.

Помните, что детали имеют значение, а соблюдение рекомендаций и стандартов языка помогает избежать потенциальных проблем. На пути к созданию надежного и безопасного ПО эти тезисы крайне важно учитывать.

Теги:
Хабы:
Всего голосов 29: ↑28 и ↓1+28
Комментарии9

Публикации

Информация

Сайт
myoffice.ru
Дата регистрации
Дата основания
2013
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
МойОфис