Нельзя писать безопасный код на C++ без санитайзеров

Kate

Administrator
Команда форума
С++ видится мне огромным франкенштейном: очень уж много разнообразных способов описать свои намерения. В добавок к этому язык пропагандирует политику zero-cost abstractions, из которой следует (помимо прочего), что программист в ответе за все свои действия. Однако, работая с большими кодовыми базами, становится крайне тяжело держать в уме различные тонкости языка, которые держать в уме нужно — иначе Undefined Behavior.

В данной статье хочу рассказать о трех интересных случаях UB, с которыми столкнулся при разработке на С++. Не думаю, что опытным разработчикам примеры из статьи будут полезны, но, полагаю, что начинающим разработчикам смогу показать на своем примере, как не стоит писать код на C++.

#1 Lifetime Extension не сработал​

Я писал модуль, который должен был выводить некоторую информацию о системе (плата, чип, номер и тип сборки). В коде модуля были функции, которые эту информацию получали и возвращали в виде std::string. Выглядели они примерно так:

std::string GetBoard()
{
return std::string{BOARD};
}
Здесь BOARD - макрос, определяемый на этапе сборки компонента.

Также была функция, которая возвращала так называемый заголовок (тоже в виде std::string), который содержал отформатированную информацию о системе. Интересующая нас здесь часть функции выглядела так:

auto args = fmt::make_format_args(
fmt::arg("board", GetBoard()),
fmt::arg("soc", GetSoC()),
...
);
Однако вместо системной информации на выходе был случайно выглядевший текст. То есть было понятно, что происходит неправильное обращение с памятью. Тут подозрение пало только на dangling reference.

Запуск санитайзера сразу указал на проблему: stack-use-after-free. То есть где-то в коде была ссылка на уже не существующий объект. Так как единственное место, где создавались временные объекты, - функция fmt::make_format_args - то стало очевидно, что проблема кроется именно там.

Я пошел смотреть документацию и исходный код библиотеки fmt. Функция fmt::make_format_args создает контейнер ссылок на объекты. Ее исходный код в текущем контексте неинтересен. В моем случае такой контейнер содержал сслыки на объекты, возвращаемые из функции fmt::arg . Посмотрим на ее код:

template <typename Char, typename T>
inline auto arg(const Char* name, const T& arg) -> detail::named_arg<Char, T>
{
...
return {name, arg};
}
Единственное назначение функции - создавать объект класса detail::named_arg. Взглянем на его код:

template <typename Char, typename T> struct named_arg : view
{
const Char* name;
const T& value;
named_arg(const Char* n, const T& v) : name(n), value(v) {}
};
Выходит, что detail::named_arg, помимо имени аргумента, хранит константную ссылку на него. Когда я смотрел на данный код, я не понимал, почему он не работал должным образом. Мне казалось, что наличие константной ссылки на временный объект продлевает время его жизни (процесс, известный как lifetime extension). Но оказалось, что моих знаний на эту тему было недостаточно, так как я не знал о некоторых ситуациях, когда данный процесс не должен работать.


Стандарт говорит следующее:

A temporary bound to a reference member in a constructor’s ctor-initializer (§12.6.2) persists until the constructor exits.

То есть несмотря на то, что внутри списка инициализации происходит привязка временного объекта к константной ссылке на этот объект, являющейся членом класса, данный временный объект живет столько, сколько выполняется конструктор класса. В моем конкретном случае это значит, что время жизни объекта std::string не продлевается внутри вызова fmt::arg, потому что данный объект перестает существовать после того, как отработает конструктор detail::named_arg.

#2 Гонка при инициализации объектов​

Наблюдаемое поведение заключалось в следующем: раз в несколько запусков программа падала с необработанным исключением. Текст исключения выглядел примерно так:

mutex lock failed: Invalid argument
Сам код не делал ничего сверхестественного. В конструкторе некоторого класса (в списке инициализации, если быть точнее) инициализировался объект класса std::thread некоторым функтором. Как только данный функтор запускался, он почти сразу захватывал мьютекс, инициализируя объект класса std::lock_guard:

MyClass::MyClass()
: m_worker{std::thread{[this]{
std::lock_guard l{m_mutex};
...
}}} {}
Здесь с толку сбивал именно незнакомый и малопонятный текст исключения. Первое, о чем я подумал: "Что я мог написать в коде не так, что каким-то образом передал некорректный аргумент метода lock у мьютекса?". Но ведь аргумент метода lock у мьютекса и есть сам мьютекс. Но что я мог не так сделать с мьютексом, что он стал "некорректным"?

На самом деле, я довольно скоро обнаружил проблему. Взгляните на код, расположенный чуть выше, и на этот код и, думаю, вы тоже поймете, в чем была причина UB в данном случае:

...
private:
...
std::thread m_worker;
...
std::mutex m_mutex;
...
Здесь показан примерный участка кода, который объявлял поля вышеописанного класса.

В стандарте есть следующее высказывание:

...nonstatic data members shall be initialized in the order they were declared in the class definition (again regardless of the order of the mem-initializers).
Это значит, что поля класса (или структуры) инициализируются в порядке их объявления. Иначе говоря, сверху вниз по коду. Это значит, что поле m_mutex будет инициализировано после поля m_worker. Мой случай был еще интересен тем, что в баге присутствовал фактор случайности - иногда поток инициализировался до инициализации мьютекста, в результате чего бросалось исключение при попытке вызвать на нем метод lock, а иногда поток запускался "достаточно долго", чтобы мьютекс успел инициализироваться и все проходило "хорошо".

#3 Метод std::unordered_map::find перестал работать​

Этот случай показался мне наиболее интересным. После внесения некоторых изменений начал периодически падать один из тестов компонента, за который отвечает моя команда. Спустя некоторое время исследования выяснили, что падение происходило из-за отсутствия элемента в хеш-таблице (std::unordered_map). Интересный момет здесь заключался в том, что элемент на самом деле присутствовал, что подтверждал поэлементный перебор коллекции.

Так как код, который оперировал таблицей, был асинхронный, то сначала пришлось потратить время на поиск логической ошибки в доступе к общим данным. Ничего найти не удалось. Тогда я применил адресный санитайзер. Ответ - UB вследствие использования инвалидированного итератора. Вот сам код, который содержал проблемные инструкции:

std::vector<std::thread> threads;

threads.emplace_back([&threads]{
for (int i = 0; i < NumOfWorkingThreads; ++i)
{
threads.emplace_back([]{ WorkingFunc(); });
}
});

for (auto& t : threads)
{
t.join();
}
Здесь намеревалось создать поток, который в фоне создаст еще несколько потоков. Но так как иногда код, который зовет метод std:🧵:join, выполнялся в то же время, что и код, призванный порождать новые потоки и класть объекты класса std::thread в std::vector, то возникала гонка данных: один поток менял содержимое std::vector, а второй его читал. Как следствие читающий поток пытался в цикле (range-based for loop) обращаться к итератору, который инвалидировался пишущим потоком. А это, согласно стандарту, неопределенное поведение.

Вывод​

Для повышения уверенности в коде при разработке на C++ необходимо выполнять как минимум следующее:

  • Использовать санитайзеры и различные анализаторы
  • Применять политику zero-warnings
  • Следовать лучшим практикам разработки

 
Сверху