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

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

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

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

  3. 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)


  1. jpegqs
    17.08.2023 13:48
    +2

    Пример состояния гонки, как не надо создавать потоки в С++. По мотивам найденного в реальном софте. Воспроизводилось только на определённой архитектуре.

    Будет печатать "111 -1", а не как предполагается "111 999", "X() finished" напечатает после чисел. Потому что поток стартует раньше чем завершится инициализация класса. Вместо задержки в 1мс была инициализация кучи других классов.

    #include <stdio.h>
    #include <chrono>
    #include <thread>
    #include <cstring>
    #include <new>
    
    struct S {
        S() {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        }
    };
    
    struct X {
        void worker() { printf("%d %d\n", first, last); }
        X() : t(&X::worker, this) { puts("X() finished"); }
        int first = 111;
        std::thread t;
        S s;
        int last = 999;
    };
    
    int main() {
        alignas(X) char buf[sizeof(X)];
        memset(buf, -1, sizeof(buf));
        new(buf) X();
    }


    1. bogolt
      17.08.2023 13:48
      +6

      Какой ужас хрестоматийный пиздец


    1. SpiderEkb
      17.08.2023 13:48

      Потому что поток стартует раньше чем завершится инициализация класса.

      Не, ну голова-то дана не только чтобы в нее есть...

      Потоки создаем в состоянии suspended, завершаем всю инициализацию и только потом переводим его в состояние active. Как-то так...

      Это не про гонки и не про синхронизацию разделяемых ресурсов. Это просто про понимание того что когда и как там внутри происходит.


  1. SpiderEkb
    17.08.2023 13:48
    +2

    Давайте представим, что мы работаем над созданием интернет-банкинга, предоставляющего клиентам доступ к своим финансовым данным и операциям через веб-интерфейс. 

    Вы бы хоть другой пример выбрали. Потому что там все это происходит ну совсем не так. И никаких гонок на уровне клиентов там нет и быть не может.

    Второй момент. Есть разделяемые ресурсы, совместное использование которых приходится регулировать разработчику. Например, разделяемая память. И есть ресурсы, за совместное использование которых отвечает система. Например, очереди, сокеты, файлы.

    Строго говоря, к С++ все это вообще никакого отношения не имеет, это вопрос общей архитектуры.

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

    И вот здесь нет смысла связываться с разделяемой памятью - это слишком сложно и оправданно только тогда когда требуется предельная скорость именно обмена данными.

    Можно использовать системные средства. Например, именованные локальные сокеты (UNIX socket). Или майлслоты (Win). Или очереди (*usrq, *dtaq в as/400). Каждый поток (процесс) создает свой объект с уникальным именем который является "почтовым ящиком" для него. Он оттуда будет читать все, что ему посылают другие. Все остальные могут туда писать все что нужно передать данному потоку.

    И здесь все вопросы синхронизации отдаются на откуп системе, она за всем этим следит. Лично использовал такой подход в ситуации когда было три потока - один коммуникация с удаленными контроллерами по UDP, второй коммуникация с интерфейсными клиентами по TCP и третий - обработка и маршрутизация посылок от клиента к контроллеру и от контроллера к клиенту. И никаких гонок, никаких проблем с синхронизацией при минимальных затратах.

    Второй пример, с чем часто сталкиваюсь сейчас - параллельная обработка больших объемов данных. Есть головное задание, которое производит первичный отбор данных для обработки, есть несколько (5-10) заданий-обработчиков которые уже занимаются обработкой каждого блока данных. И есть конвейер (у нас - *usrq). Головное задание выкладывает отобранные данные на конвейер, обработчики их оттуда разбирают и обрабатывают. И опять - вся синхронизация на системе. На разработчике только "положить в очередь" и "взять из очереди".


    1. Stolyarrr Автор
      17.08.2023 13:48
      +1

      Может стоит добавить в обе статьи про UB дисклеймер: "Приведённые в статье фрагменты кода служат сугубо для демонстрации, не стоит использовать их в реальных проектах (ну, по крайней мере берите фрагменты, которые помечены как исправленный код)"?

      Ну, а если серьёзно, то большое спасибо за такой развёрнутый фидбек. Очень интересный и полезный комментарий.

      Пример, как было описано, сугубо синтетический и не несёт в себе не то, что близости к продовскому коду, а даже и особой осмысленности:) Как-то раз, я ревьювил смарт-контракт на Solidity и думал: "Ё моё, в этот писец бы ещё многопоточку добавить". Ну, время пришло и я реализовал что-то похожее. Интернет-банком это стало по остаточному принципу.


      1. SpiderEkb
        17.08.2023 13:48

        Я просто знаю как мобильные клиенты устроены (точнее, как они связаны с реальными данными) - от реальных данных они бесконечно далеко. У них есть REST API - они просто дергают запросы. А дальше это длинным путем идет до центрального сервера, где уже выполняется. Там даже нет понятия "аккаунт". Есть ПИН клиента и все запросы идут по нему.

        И перевести деньги со счета на счет - это не просто переложить их из одного аккаунта в другой. Это создать платежный документ и отправить его в очередь на обработку. Т.е. никаких "гонок" и конфликтов тут в принципе не бывает.


      1. SpiderEkb
        17.08.2023 13:48
        +1

        А если говорить про сложности работы с теми объектами, которые вам нужно синхронизировать руками (например, разделяемая память), то там проблем очень много и в про них даже не упомянули.

        Например, нужно выставлять семафор что произошли какие-то изменения в состоянии разделяемого объекта. Ну вот простой пример. Есть 4 потока. Первый поток генерирует что-то такое, чем нужно поделиться с остальными тремя. Он выкладывает это в разделяемую память и выставляет флаг "есть изменения, разбирайте".
        Дальше он генерирует новую порцию изменений. Но он не может их выложить просто так - прежде нужно убедиться что предыдущая порция была принята всеми тремя потоками. Если это не так - он будет стоять и ждать. При плотном двустороннем обмене еще дедлоки весьма вероятны

        Это даже без учета блокировок чтение-запись.

        Если использовать подход "почтовых ящиков", все становится проще. По сути это очередь входящих сообщений для каждого потока/процесса. Т.е. тут уже исключаются гонки и дедлоки т.к. хранилище данных для процессов разделены. Остаются только блокировки на уровне чтение-запись, но они решаются или использованием конкурентных очередей или использованием системных объектов где за всем этим следит сама система. И все становится просто - нужно что-то кому-то отправить - бросаешь в нужный "почтовый ящик" и идешь дальше заниматься своими делами. Ну и свой ящик проверяешь на предмет входящих.

        В подавляющем большинстве случаев такой подход обеспечивает достаточную производительность при минимальных затратах.

        Но, повторюсь, все это не имеет отношения к конкретному языку, все это из области общей архитектуры IPC (InterProcess Communications).