Skip to content

Latest commit

 

History

History
128 lines (99 loc) · 8.5 KB

race_condition.md

File metadata and controls

128 lines (99 loc) · 8.5 KB

Многопоточность. Data race

Разработка многопоточных приложений это всегда сложно. Проблема синхронизации доступа к разделяемым данным — вечная головная боль. Хорошо, если у вас уже есть оттестированная и проверенная временем библиотека контейнеров, высокоуровневых примитивов, параллельных алгоритмов, берущих на себя контроль за всеми инвариантами. Очень хорошо, если статические проверки компилятора не позволят вам использовать все это добро неправильно. Ах, как было бы хорошо...

До C++11 и стандартизации модели памяти пользоваться потоками в принципе можно было лишь на свой страх и риск. Начиная с C++11, без привлечения сторонних сил, в стандартной библиотеке есть довольно низкоуровневые примитивы. С С++17 еще появились разные параллельные вариации алгоритмов, но о тонкой настройке количества потоков и приоритетов в них можете даже не думать.

Так почему бы не взять какую-нибудь готовую серьезную библиотеку (boost, abseil) — там наверняка умные люди уже пострадали многие часы, чтобы предоставить удобные и безопасные инструменты — и забот не знать?!

Увы, так не работает. Правильность использования этих инструментов в C++ нужно контроллировать самостоятельно, пристально изучая каждую строчку кода. Мы все равно втыкаемся в проблемы синхронизации доступа, с аккуратным развешиванием мьютексов и атомарных переменных.

Ситуация (data race), в которой один поток программы модифицирует объект, а другой, в то же самое время, читает значения из этого объекта — или просто два потока одновременно пытаются модифицировать один объект — совершенно ясно, является ошибочной. Результат чтения может выдать какой-то странный промежуточный объект. Совместная запись — породить какое-то мутировавшее премешаное значение. Независимо от языка программирования.

Но в C++ это не просто ошибка. Это неопределенное поведение. И «возможности» для оптимизации

int func(const std::vector<int>& v) {
    int sum = 0;
    for (size_t i = 0; i < v.size(); ++i) {
        sum += v[i];
    }
    // Data race запрещен, от модификации v в
    // параллельном потоке нас «защищает» UB.
    
    // А значит можно соптимизировать вычисление size
    // const size_t v_size = v.size();
    // for (size_t i = 0; i < v_size; ++i) { ... }
    return sum;   
}

А теперь почти что многопоточный hello world:

int main() {
    bool terminated = false;
    using namespace std::literals::chrono_literals;

    int add_ms = 0;
    std::cin >> add_ms;

    std::jthread t1 { [&] {
        std::size_t cnt = 0;
        while (!terminated) {
            ++cnt;
        }
        std::cout << "count: " << cnt << "\n";
    } };

    std::jthread t2 { [&] {
        std::this_thread::sleep_for(500ms + 
                                    std::chrono::milliseconds(add_ms));
        terminated = true;
    } };
}

Мы не синхронизировали доступ к всего лишь какому-то bool. Ничего же страшного, ведь да? И в отладочной сборке все работает.

Но если включить оптимизации, цикл в первом потоке либо не выполнит ни одной итерации (clang), либо никогда не завершится (gcc)!

Оба компилятора видят, что доступ не синхронизирован. Data race запрещен. Значит, и синхронизировать не надо. Значит, при обращении к переменной terminate в заголовке цикла всегда должно быть одно и то же значение. gcc решает, что всегда будет false. clang обнаруживает присваивание terminated = true в другом потоке и вытягивает его перед началом цикла.

Конечно же, тут ошибка намеренная и исправляется легко заменой bool на std::atomic<bool>. Но в реальной кодовой базе допустить data race просто, а исправить сложнее.

Однажды я написал что-то подобное:

enum Task {
    done,
    hello
};
std::queue<Task> task_queue;
std::mutex mutex;

std::jthread t1 { [&] {
    std::size_t cnt_miss = 0;
    while (true) {
        if (!task_queue.empty()) {
            auto task = [&] {
                std::scoped_lock lock{mutex};
                auto t = task_queue.front();
                task_queue.pop();
                return t;
            }();
            if (task == done) {
                break;
            } else {
                std::cout << "hello\n";
            }
        } else {
            ++cnt_miss;
        }
    }
    std::cout << "count miss: " << cnt_miss << "\n";
} };

std::jthread t2 { [&] {
    std::this_thread::sleep_for(500ms);
    {
        std::scoped_lock lock{mutex};
        task_queue.push(done);
    }
} };

И оно прекрасно работало, пока код тестировался будучи собранным одним компилятором. Но при переносе на другую платформу с другим компилятором — все сломалось.

Если вы сразу поняли причину, то поздравляю. Иначе — обратите внимание на безобидный метод empty. Который «совершенно точно ничего не меняет» и «да ладно, как там вообще может нарушиться консистентность данных?!»


В поиске проблем с доступом к объектам из разных потоков вам помогут статические анализаторы и санитайзеры: например, tsan для gcc/clang (-fsanitize=thread). Насколько мне известно, текущая реализация tsan (2021 год) не дружит с asan (address sanitized). Так что не выйдет махом искать и race сondition, и обычные ошибки доступа к памяти с нарушением lifetime.

В Rust нельзя создать data race и вызвать неопределенное поведение в безопасном подмножестве языка. Однако, неаккуратно используя unsafe, и в нем можно устроить себе проблемы. И будет неопределенное поведение. На то оно и unsafe.

Полезные ссылки

  1. https://clang.llvm.org/docs/ThreadSanitizer.html
  2. https://en.cppreference.com/w/cpp/thread/mutex
  3. https://en.cppreference.com/w/cpp/atomic
  4. https://devblogs.microsoft.com/cppblog/using-c17-parallel-algorithms-for-better-performance/