Вебинар: Использование статических анализаторов кода при разработке безопасного ПО - 19.12
Вашему вниманию предлагается одиннадцатая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.
Что вообще такое указатель? Когда их пытаются объяснить новичкам в C++, часто говорят, что это число, адрес, указывающий на номер ячейки в памяти, где что-то лежит.
Это в каком-то смысле справедливо на очень низком уровне — в ассемблере, в машинных кодах. Но в C и С++ указатель — это не просто адрес. И тем более не число, которое как-то просто по-особому используется. Более того, в C++ (не в C) есть указатели, которые вообще не являются адресами в памяти — указатели на поля и методы классов. Но о них мы говорить сейчас не будем.
Указатель — это ссылочный тип данных. Нечто, с помощью чего можно получить доступ к другим объектам. И, в отличие от ссылок C++, объекты-указатели являются настоящими объектами, а не странными псевдонимами для существующих значений. С числами и адресами в памяти указатели связаны только деталями реализации.
Для указателей в стандарте C++ подробно расписано, откуда они могут появляться. Если коротко, то:
Все остальные источники указателей — implementation defined или вообще undefined.
Главная операция, выполняемая над указателями, это разыменование, то есть получение доступа к объекту, на который этот указатель ссылается. И вместе с этой операцией приходит главная проблема — её не ко всем указателям применять можно. Есть и другие операции, которые также применимы не к любому указателю. Но, конечно, есть единственная операция, допустимая почти всегда — сравнение на равенство (неравенство).
В идеальном светлом мире от типа объекта зависит множество допустимых над ним операций. Но в случае указателей (и это очень печально) применимость или неприменимость зависит не только от значения указателя, но ещё и от того, откуда этот указатель взялся, а также откуда взялись другие указатели!
int x = 5;
auto x_ptr = &x; // валидный указатель, его МОЖНО разыменовывать
auto x_end_ptr = (&x) + 1; // валидный указатель,
// но его НЕЛЬЗЯ разыменовывать
auto x_invalid_ptr = (&x) + 2; // невалидный указатель,
// само его существование недопустимо.
Сравнение указателей с помощью операторов больше / меньше определено только для указателей на элементы одного и того же массива. Для произвольных указателей — unspecified.
Арифметика указателей допустима только в пределах одного и того же массива (от указателя на первый элемент до указателя на элемент за последним). Иначе — undefined behavior. Особый только случай (&x) + 1 — любой объект считается массивом из одного элемента.
Пример кода, который валится с UB именно на арифметике указателей, найти сложно, зато можно привести пример с итераторами (которые разворачиваются в указатели).
std::string str = "hell";
str.erase(str.begin() + 4 + 1 - 3);
Этот код упадёт в отладочной сборке под msvc. str.begin() + 4 — указатель на элемент за последним. И ещё +1 выводит за пределы строки. Это UB. И неважно, что дальше вычитание возвращает внутренний указатель обратно в границы строки.
Не стоит выполнять сложные вычисления с указателями. Прибавлять к ним или вычитать лучше всегда конечный числовой результат. В конкретном примере расчёт отступа (4 + 1 - 3) нужно выполнить отдельно — расставить скобки. Ещё лучше, безопаснее и понятнее — вынести в отдельную переменную.
Помимо выхода за границы объектов, невалидные указатели могут появляться после отрабатывания некоторых функций. Наиболее яркий пример такого UB представил Ник Левицки (Nick Lewycky) для Undefined Behavior Consequences Contest. Немного переделанная под C++ версия (чтобы в ней было только одно UB, а не два) выглядит так:
int* p = (int*)malloc(sizeof(int));
int* q = (int*)realloc(p, sizeof(int));
if (p == q) {
new(p) int (1);
new(q) int (2);
std::cout << *p << *q << "\n"; // print 12
}
Этот код, собранный Clang 18.1.0 (-O3 -std=c++20), выводит результат "12", противоречащий здравому смыслу (если вы не знаете, что в коде UB!). И этот же пример демонстрирует, что указатели — это не просто число-адрес.
Указателем, переданным в "сишную" функцию realloc, при успешной реаллокации пользоваться более нельзя. Его можно только перезаписать (а потом уже использовать).
Этот пример, конечно, искусственный, но в него можно легко влететь, если, например, по какой-то причине писать свою версию вектора, используя realloc, и захотеть немного "соптимизировать".
template <class T>
struct Vector {
static_assert(std::is_trivially_copyable_v<T>);
size_t size() const {
return end_ - data_;
}
private:
T* data_;
T* end_;
size_t capacity_;
void reallocate(size_t new_cap){
auto ndata = realloc(data_, new_cap * sizeof(T));
if (!ndata) {
throw std::bad_alloc();
}
capacity_ = new_cap;
if (ndata != data_) {
const auto old_size = size(); // !access to invalidated data_!
data_ = ndata;
end_ = data_ + old_size;
} // else — "ok", noop
}
}
Этот код с неопределённым поведением. Скорее всего, оно никак не проявится сейчас, но это не значит, что так будет и в будущем. Возможно, вызов reallocate заинлайнится в неподходящем месте, и всё пойдёт вверх дном.
Однако, начиная любые попытки реализовать свой собственный вектор (стандартный вполне может кого-то не устраивать тем, что он по умолчанию инициализирует память), надо учитывать следующий печальный факт: это невозможно сделать без неопределённого поведения (формального или реального). Основная причина — арифметика указателей внутри сырой памяти. В сырой памяти формально нет C++ массивов, только внутри которых арифметика и определена.
Вам посчастливилось добыть новую суперэффективную библиотеку для управления памятью? Вы хотите пользоваться ею в C++ и не сталкиваться с надуманным UB из-за проблем с лайфтаймами?
Вам повезло! Просто выделяйте память своей библиотекой, создавайте в выделенном буфере объекты с помощью placement new и забот не знайте!
void* buffer = my_external_malloc(sizeof(T), alignof(T));
auto pobj = new (buffer) T();
Красиво, просто, здорово!
А что, если мы захотим выделить память и разместить в ней массив?
Нет ничего проще!
void* buffer = my_external_malloc(n * sizeof(T), alignof(T));
auto pobjarr = new (buffer) T[n];
Всё, можно идти пить чай. Задача решена. Мы молодцы. Как похорошел C++ с 11-го стандарта!
Но не может же быть всё так просто?
Конечно же нет! До C++20 вариант placement new для массивов имеет полное право испоганить вашу память.
Конструкция new (buffer) T[n];, согласно примерам (§ 8.5.2.4 (15.4)) из стандарта C++17, переводится в:
operator new[](sizeof(T) * n + x, buffer);
// или operator new[](sizeof(T) * n + x,
// std::align_val_t(alignof(T)), buffer);
Где x — никак не специфицируемое неотрицательное число, предназначенное, например, чтобы застолбить место под какую-либо метаинформацию о выделенном массиве: засунуть число элементов в начало области памяти или расставить маркеры начала/конца, или ещё что-нибудь, что обычно делают аллокаторы.
То есть placement new для массива вполне может полезть за пределы предоставленного вами буфера. Очень удобно!
В C++20 восхитительную формулировку изменили.
Теперь же, если конструкция new (arg1, arg2...) T[n]; соответствует вызову стандартного void* operator new[](std::size_t count, void* ptr);, то все будет хорошо. Никаких магических сдвигов на +x не возникнет.
Но если же какой-то доброжелатель определил свой собственный operator placement new... Впрочем, это уже совсем другая история.
Я не встречал ни одного компилятора и ни одной поставки стандартной библиотеки, в которых бы стандартный placement new как-либо двигал указатель на пользовательский буфер. Реальную угрозу трудноотлавливаемого UB в большей степени представляют user-defined версии placement new.
Чтобы обезопасить себя и вызвать настоящий стандартный placement new, нужно использовать ::new и кастить указатель на буфер к void*. Либо положиться на алгоритмы std::uninitialized_default_construct_n и подобные ему.
Также нужно отметить, что в C++ нет placement delete синтаксиса. Мы можем только явно вызвать operator delete[](void* ptr, void* place), стандартная версия которого ничего не делает.
Тут, конечно, нужно понимать разницу между самим operator delete и синтаксическими конструкциями delete p и delete [] p. Первый занимается только управлением памятью, последние же ещё и вызывают деструкторы.
В C++ нет отдельной синтаксической конструкции, чтобы махом вызывать деструкторы элементов массива, созданного с помощью placement new. Это нужно делать вручную или использовать алгоритм std::destroy.
Ни в коем случае не стоит использовать delete [] против указателя, полученного с помощью placement new []. Будет плохо.
Разработка многопоточных приложений — это всегда сложно. Проблема синхронизации доступа к разделяемым данным — вечная головная боль. Хорошо, если у вас уже есть оттестированная и проверенная временем библиотека контейнеров, высокоуровневых примитивов, параллельных алгоритмов, берущих на себя контроль за всеми инвариантами. Очень хорошо, если статические проверки компилятора не позволят вам использовать все это добро неправильно. Ах, как было бы хорошо...
До 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). Но имейте в виду, что из-за особенности реализации санитайзеров ASan и TSan не могут работать одновременно. Так что не выйдет махом искать ими и race сondition, и обычные ошибки доступа к памяти с нарушением lifetime.
В Rust нельзя создать data race и вызвать неопределённое поведение в безопасном подмножестве языка. Однако, неаккуратно используя unsafe, и в нём можно устроить себе проблемы. И будет неопределённое поведение. На то оно и unsafe.
Пожалуй, это самый популярный вопрос для собеседования на позицию разработчика на C++. И не без причины: этим прекрасным умным указателем так просто пользоваться (в сравнении с его собратом std::unique_ptr), что легко не заметить подвох. В его названии есть shared. Да он и спроектирован так, чтобы его можно было разделять между потоками. Что может пойти не так?
Всё.
Новички довольно быстро обнаруживают первую линию костыльно-грабельной обороны бастиона сложности shared_ptr: если доступ к самому указателю shared_ptr<T> "безопасен", то к объекту T его всё равно надо синхронизировать. Это очевидно, это заметно, это понятно. Но дальше ведь всё просто?
Нет.
Дальше притаились волчьи ямы с отравленными копьями. Сам объект-указатель shared_ptr не является потокобезопасным. И доступ к самому указателю тоже надо синхронизировать!
Как же так? Мы никогда не синхронизировали, и у нас всё работало.
Поздравляю, у вас одно из двух:
1. Либо все доступы к указателю из разных потоков осуществляют только чтение. И тогда проблем действительно нет.
2. Программа работает по воле случая.
using namespace std::literals::chrono_literals;
std::shared_ptr<std::string> str = nullptr;
std::jthread t1 { [&]{
std::size_t cnt_miss = 0;
while (!str) {
++cnt_miss;
}
std::cout << "count miss: " << cnt_miss << "\n";
std::cout << *str << "\n";
} };
std::jthread t2 { [&] {
std::this_thread::sleep_for(500ms);
str = std::make_shared<std::string>("Hello World");
}
};
Аналогично другим примерам с race condition код выше перестаёт работать при изменении уровня оптимизации.
Но ведь вы наверняка что-то слышали: всё-таки есть в shared_ptr кое-что потокобезопасное...
Да. Есть. Счётчик ссылок. Больше ничего потокобезопасного в std::shared_ptr нет. Атомарный счётчик ссылок как раз и позволяет без проблем копировать один и тот же указатель (увеличивая счётчики) в разные потоки и не синхронизировать вручную вызовы деструкторов (уменьшающих счётчики) в разных потоках.
Если вам надо менять указатель из разных потоков, то вам нужен std::atomic<std::shared_ptr<T>> (C++20). Либо использовать функции std::atomic_load/std::atomic_store и прочие — у них есть специальные перегрузки для shared_ptr.
С std::weak_ptr всё то же самое.
А вы уже заметили, что в предыдущих главах я использую std::jthread из C++20 вместо std::thread? И зачем?
А всё очень просто: деструктор std::thread дурной.
Везде, где может начать вызываться деструктор std::thread, нужно втыкать:
// std::thread t1;
if (t1.joinable()) { // Если вы не уверены в богатой жизненной
// истории объекта t1,
// обязательно выполняйте эту проверку.
t1.join(); // или t1.detach()
}
Чтобы ознаменовать своё желание (или нежелание) дожидаться окончания выполнения потока. Иначе деструктор потока повалит вашу программу, вызвав std::terminate. Очень удобно и очень по RAII-шному, неправда ли?
Конечно, совсем везде втыкать приведённый фрагмент не надо. Он лишний, если:
И тем более не надо просто так втыкать этот код, обращающийся к одному и тому же объекту std::thread из разных потоков. Иначе — race condition. Надо синхронизировать.
И, конечно же, убедитесь, что этот код ни в коем случае не будет выполняться параллельно с вызовом деструктора t1. Деструктор тоже вызывает joinable, а это опять race condition.
Собираетесь сделать обёртку над std::thread, чтобы вызывать join в её деструкторе? Спешу порадовать: join/detach кидают исключения. Со всеми вытекающими проблемами.
Здорово, да? Поэтому в примерах был и будет std::jthread. Его деструктор сам выполняет join и снимает хотя бы часть головной боли.
А если вас join не устраивает, не хотите ждать и пользуетесь detach... Ну что ж. Ваше право. Только помните, что все потоки резко и внезапно помрут, когда закончится main.
Deadlock это, конечно, печально. Система завязалась в узел и никогда не развяжется. А сколько мьютексов нужно, чтобы уйти в deadlock?
Немного подумав, можно решить, что одного достаточно — просто захвати его два раза подряд, не отпуская, в одном и том же потоке.
Возможно, под какой-то платформой это и так. Но в C++ это неопределённое поведение, и для красивого показательного дедлока нужно два мьютекса. А с одним ваш фокус не удастся и превратится в фокус от мира UB.
struct Test{
std::mutex mutex;
std::vector<int> v = { 1,2,3,4,5};
auto fun(int n){
mutex.lock(); // Захватываем.
return
std::shared_ptr<int>(v.data() + n,
[this](auto...){mutex.unlock();});
// Освободим при смерти указателя.
}
};
int main(){
Test tt;
auto a = tt.fun(1); // Захватили первый раз.
std::cout << *a << std::endl;
// Указатель жив.
auto b = tt.fun(2); // Захватили второй раз. UB.
std::cout << *b << std::endl;
return 0;
}
Этот пример даёт разные результаты на одном и том же компиляторе, на одной и той же платформе, на одном и том же уровне оптимизаций. Всё зависит от того, подключили pthread или нет.
Кто в здравом уме будет такое делать-то? Никто же никогда не захватывает один и тот же мьютекс два раза подряд.
Даже не знаю... Зачем-то же существуют рекурсивные мьютексы, которые можно захватывать по нескольку раз.
Да и сводить задачу к уже решённой и переиспользовать написанный код любят:
template <class T>
struct ThreadSafeQueue<T> {
bool empty() const {
std::scoped_lock lock { mutex_ };
....
}
void push(T x) {
std::scoped_lock lock { mutex_ };
....
}
std::optional<T> pop() {
std::scoped_lock lock { mutex_ };
if (empty()) { // ! ПОВТОРНЫЙ ЗАХВАТ !
return std::nullopt;
}
....
}
....
std::mutex mutex_;
};
Чтобы исправить, нужно либо подумать, либо использовать рекурсивный мьютекс. Но лучше подумать.
Методов у объекта может быть много. Разработчиков тоже. Они могут не помнить, где есть блокировка, а где нет. Могут засунуть блокировку в один метод, забыв про другие. Так что от повторного захвата мьютекса в одном и том же потоке никто не застрахован.
Разработчик любого сколько-нибудь серьёзного приложения рано или поздно вынужден озаботиться вопросами поведения программы в различных краевых и внештатных ситуациях: запрос досрочного завершения, внезапное закрытие терминала, обработка маловероятных ошибочных состояний. Во многих этих случаях приходится иметь дело с довольно примитивным механизмом межпроцессного взаимодействия — с обработкой сигналов.
Программист регистрирует обработчики нужных ему сигналов и забот не знает, очень часто допуская серьёзную ошибку — выполняет в обработчике сигналов код, который там выполнять небезопасно: выделяет память, делает I/O, захватывает блокировки...
Сигналы прерывают нормальный ход исполнения программы, и могут быть обработаны в произвольном потоке. Поток мог начать выделять память, захватить блокировку в аллокаторе и в этот момент быть прерванным сигналом. Если обработчик сигнала в свою очередь запросит выделение памяти... будет повторный захват блокировки в одном и том же потоке. Неопределённое поведение.
И результат может быть самым неожиданным. Например, в OpenSSH в 2006 году была обнаружена критическая уязвимость с возможностью удалённо получить root-доступ к системам с запущенным SSHD сервером. Баг непосредственно связан с кодом, вызывавшим malloc и free при обработке сигналов. Уязвимость исправили, но в 2020, спустя 14 лет, eё случайно занесли обратно. Ошибку снова обнаружили и исправили лишь в 2024 году, и кто знает, сколько раз и кто воспользовался этой RegreSSHion за четыре года!
Упрощённо можно продемонстрировать проблему с сигналонебезопасным кодом на следующем примере:
std::mutex global_lock;
int main() {
std::signal(SIGINT, [](int){
std::scoped_lock lock {global_lock};
printf("SIGINT!\n");
});
{
std::scoped_lock lock {global_lock};
printf("start long job\n");
sleep(10);
printf("end long job\n");
}
sleep(10);
}
Если мы скомпилируем эту программу под Linux (не забыв указать -pthread), запустим и нажмём Ctrl+C, то она навсегда зависнет из-за повторного захвата мьютекса одним и тем же потоком. Если же забудем -pthread, то программа не зависнет и отработает "ожидаемым" образом.
Под Windows эта программа также работает "ожидаемо" из-за специфики обработки сигналов — там для обработки SIGINT/SIGTERM всегда неявно порождается новый поток.
В любом случае этот код некорректен из-за использования сигналонебезопасной функции внутри обработчика сигналов.
Обработка сигналов — вопрос крайне платформоспецифичный и зависит от конкретной прикладной задачи и архитектуры вашего приложения. Также это довольно сложный вопрос, если учитывать, что во время обработки одного сигнала нас могут прервать для обработки другого.
Наиболее часто встречаемое использование обработки сигналов — корректное завершение приложения с очисткой ресурсов и закрытием соединений. В общем, Graceful Shutdown (изящное завершение работы). В таком случае обычно обработка сигналов сводится к выставлению и проверке некоторого глобального флага.
Стандарты C и C++ описывают специальный целочисленный тип — sig_atomic_t. При доступе к переменным этого типа гарантируется сигналобезопасность. На практике он может оказаться просто алиасом для int или long. volatile sig_atomic_t можно использовать в качестве глобального флага, выставляемого в обработчике сигналов. Но только в однопоточной среде. Тут volatile необходим только для предотвращения нежелательной оптимизаций — компилятор не делает предположений о возможной обработке сигналов и прерывании нормального потока выполнения программы.
Нужно помнить, что volatile не даёт гарантий потокобезопасности. И в многопоточной среде необходимо использовать настоящие атомарные типы, поддерживаемые на вашей платформе. Например, std::atomic<int>. Если, конечно, std::atomic<T>::is_lock_free истинно.
Синхронизация потоков — это сложно, хотя у нас и есть примитивы. Такой себе каламбур. Хорошо, если есть готовые высокоуровневые абстракции в виде очередей или каналов. Но иногда приходится мастерить их самому с использованием более низкоуровневых вещей: мьютексов, атомарных переменных и обвязки вокруг них.
condition_variable — примитив синхронизации, позволяющий одному или нескольким потокам ожидать сообщений от других потоков. Ожидать пассивно, не тратя время CPU впустую на постоянные проверки чего-то в цикле. Поток просто снимается с исполнения, ставится в очередь операционной системой, а по наступлении определённого события (уведомления) от другого потока пробуждается. Всё замечательно и удобно.
Сам по себе примитив condition_variable не передаёт никакой информации, а только служит для пробуждения или усыпления потоков. Причём пробуждения из-за особенностей реализации блокировок могут случаться ложно, самопроизвольно (spurious), а не только по непосредственной команде через condition_variable.
Потому типичное использование требует дополнительной проверки условий и выглядит как-то так.
std::condition_variable cv;
std::mutex event_mutex;
bool event_happened = false;
// исполняется в одном потоке
void task1() {
std::unique_lock lock { event_mutex };
// Предикат гарантированно проверяется под
// захваченной блокировкой
cv.wait(lock, [&] { return event_happened; });
// Беcпредикатная версия wait ждёт только уведомления,
// но может произойти ложное пробуждение
// (обычно если кто-то отпускает этот же мьютекс)
....
// Дождались — событие наступило.
// Выполняем что нужно.
}
// Исполняется в другом потоке.
void task2() {
....
{
std::lock_guard lock {event_mutex};
event_happened = true;
}
// Обратите внимание: вызов notify не обязан быть
// под захваченной блокировкой. Однако в ранних
// версиях msvc, а также в очень старой версии из
// boost были баги, требующие удерживать мьютекс
// захваченным во время вызова notify().
// Но есть случай, когда делать вызов notify
// под блокировкой необходимо — если другой тред
// может вызвать, например, завершаясь,
// деструктор объекта cv.
cv.notify_one(); // notify_all()
}
Хм, внимательный читатель может сообразить, что в функции task2 мьютекс используется только для защиты булевого флага. Невиданное расточительство! Целых два системных вызова в худшем случае. Давайте лучше флаг сделаем атомарным!
std::atomic_bool event_happened = false;
std::condition_variable cv;
std::mutex event_mutex;
void task1() {
std::unique_lock lock { event_mutex };
cv.wait(lock, [&] { return event_happened; });
....
}
void task2() {
....
event_happened = true;
cv.notify_one(); // notify_all()
....
}
Компилируем, запускаем, работает. Классно, срочно в релиз!
Но однажды приходит пользователь и говорит, что запустил task1 и task2 как обычно одновременно, но сегодня внезапно task1 не завершается, хотя task2 отработал! Вы идёте к пользователю, смотрите — висит. Перезапускаете — не зависает. Ещё перезапускаете — опять не зависает. Перезапускаете 50 раз — всё равно не зависает. "Сбой какой-то железный, разовый" — думаете вы.
Уходите. Через месяц пользователь опять приходит с той же проблемой. И опять не воспроизводится. Ну точно железный сбой, космическая радиация битик какой-то в локальном кэше треда выбивает. Ничего страшного...
На самом деле в программе ошибка, приводящая к блокировке при редком совпадении в порядке инструкций. Чтобы понять её, нужно посмотреть внимательнее на то, как устроен метод wait с предикатом.
// thread a
std::unique_lock lock {event_mutex}; // a1
// cv.wait(lock, [&] { return event_happened; }) это
while (![&] { return event_happened; }()) { // a2
cv.wait(lock); // a3
}
// -------------------------------
// thread b
event_happened = true; // b1
cv.notify_one(); // b2
Рассмотрим следующую последовательность исполнения строчек в двух потоках:
Отоптимизировали? Возвращайте захват мьютекса обратно.
Захват мьютекса в уведомляющем потоке гарантирует, что ожидающий уведомления поток либо ещё не начал ждать и проверять событие, либо уже ждёт его. Если же мьютекс не захвачен, мы можем попасть в промежуточное состояние.
Осторожнее с примитивами!
Автор — Дмитрий Свиридкин
Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.
Редактор — Андрей Карпов
Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.
0