Иногда после внесения изменений в исходный код и запуска программы появляются странные ошибки. Например, программа зависает, не реагируя на действия пользователя, и аварийно завершается(падает). Либо зависания не происходит, но программа падает.
Иногда даже анализ логов не добавляет ясности, так как падение происходит в том месте исходного кода, которое не вызывает подозрений (по крайней мере на первый взгляд). Некоторые ошибки можно выявить при прогоне тестов. Но они не всегда помогают. Теоретически для выявления проблем можно призвать на помощь весь свой опыт и применить метод пристального взгляда. Но данный способ часто требует большое количество времени и сил, которые могут быть на исходе.
Поэтому гораздо продуктивнее использовать инструменты, позволяющее автоматизировать поиск ошибок. Одним из таких инструментов в ОС Linux и MacOS является Valgrind. Его мы сегодня и рассмотрим в связке с CMake.
Рассмотрим работу с Valgrind на примере проекта, являющего развитием калькулятора, описанного в одной из предыдущих статей. Добавим в директорию arithmetic файлы calculator.h и calculator.cpp с описанием простого класса-обертки, вызывающего разработанные ранее арифметические функции.
calculator.h
- #pragma once
- class calculator {
- public:
double sum(double op1, double op2);
double sub(double op1, double op2);
double mult(double op1, double op2);
double divide(double op1, double op2); - };
calculator.cpp
- #include "calculator.h"
- #include "arithmetic.h"
- double calculator::sum(double op1, double op2)
- { return sum(op1, op2); }
- double calculator::sub(double op1, double op2)
- { return ::sub(op1, op2); }
- double calculator::mult(double op1, double op2)
- { return ::mult(op1, op2); }
- double calculator::divide(double op1, double op2)
- { return ::divide(op1, op2); }
В одном из методов специально допущена ошибка! Напишем тесты и посмотрим что они покажут. Забегая немного вперед скажу, что в тестах тоже есть проблема.
- #include <gtest/gtest.h>
- #include "arithmetic.h"
- #include "calculator.h"
- TEST(ArithmeticTest, Sum) {
- calculator* pCalc = new calculator;
- int a = 10;
- int b = 20;
- EXPECT_EQ(pCalc->sum(a, b), 30);
- }
- TEST(ArithmeticTest, Sub) {
- calculator* pCalc = new calculator;
- int a = 10;
- int b = 20;
- EXPECT_EQ(pCalc->sub(a, b), -10);
- }
- TEST(ArithmeticTest, Mult) {
- calculator* pCalc = new calculator;
- int a = 10;
- int b = 20;
- EXPECT_EQ(pCalc->mult(a, b), 200);
- }
Для того чтобы программы анализа билда могли выдать полезную информацию необходимо сделать отладочную сборку. Находясь в корневой директории проекта введем команду
cmake -B ./build -DCMAKE_BUILD_TYPE=Debug
Перейдя в директорию build cоберем и запустим цель unit_tests, введя команду
cmake --build . -t unit_tests
Получим следующий, на первый взгляд, странный результат.
[==========] Running 7 tests from 3 test suites.
[----------] Global test environment set-up.
[----------] 3 tests from ArithmeticTest
[ RUN ] ArithmeticTest.Sum
Ошибка сегментирования (стек памяти сброшен на диск)
При запуске первого теста ArithmeticTest.Sum получили ошибку сегментирования и , как следствие, аварийное завершение тестирующей программы.
Смотрим исходный код теста TEST(ArithmeticTest, Sum) и не понимаем что же в такой простой функции привело к появлению непонятной ошибки сегментирования. Нам явно нужно больше информации об ошибке. Для ее получения подключим к проекту фреймворк Valgrind.
Согласно Википедии, Valgrind — инструментальное программное обеспечение, предназначенное для отладки использования памяти, обнаружения утечек памяти, проверки потокобезопасности, а также профилирования.
В состав пакета Valgrind входит множество инструментов (также существуют сторонние дополнительные инструменты). Инструмент по умолчанию (и наиболее используемый) — Memcheck. Принцип его работы основан на том, что вокруг почти всех инструкций вставляется дополнительный код инструментирования, который отслеживает законность (вся невыделенная память изначально помечается как некорректная или «неопределенная», пока не будет инициализирована одним из определенных состояний, вероятно, из другой памяти) и адресуемость.
Проблемы, которые может обнаружить Memcheck, включают в себя:
- попытки использования неинициализированной памяти;
- чтение/запись в память после её освобождения;
- чтение/запись за границами выделенного блока;
- утечки памяти.
Ценой этого является потеря производительности. Программы, запущенные под Memcheck, как правило, выполняются в 5-12 раз медленнее, чем при выполнении без Valgrind, а также используют больший объём памяти (за счет значительных дополнительных расходов памяти). Поэтому код запускают под Memcheck / Valgrind перед комитом в репозиторий с целью убедиться в отсутствии проблем при работе с памятью.
В дополнение к Memcheck Valgrind имеет и другие инструменты:
- Helgrind и DRD — инструменты, способные отслеживать состояние гонки и другие ошибки в многопоточном коде.
Установим Valgrind в Ubuntu, введя команду sudo apt-get install valgrind . После чего интегрируем в существующий проект CMake. Для этого создадим файл Valgrind.cmake в директории modules, где уже находится файл с описанием цели покрытия coverage.cmake. Добавим в него функцию AddMemeoryCheck, которая позволит запускать Valgrind после построения программы.
Valgrind.cmake
- function(AddMemeoryCheck target)
- find_program(VALGRIND_PATH valgrind REQUIRED)
- add_custom_target(valgrind
- COMMAND ${VALGRIND_PATH} --leak-check=yes
- $<TARGET_FILE:${target}>
- WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
- endfunction()
Вначале функции вызывается find_program, которая проверяет установлен ли Valgrind в системе и прекращает построение программы, если фреймворк не установлен. Далее при помощи add_custom_target добавим цель valgrind. Целью фактически является запуск программы ОС Linux, расположенной по пути, задаваемой переменной VALGRIND_PATH . leak-check - это флаг командной строки Valgrind, говорящий о том, что необходимо проверять утечки памяти. При помощи TARGET_FILE и WORKING_DIRECTORY задается целевой файл для отслеживания утечек памяти и путь к нему.
Добавим вызов функции AddMemeoryCheck в файл test/CMakeLists.txt сразу после строки gtest_discover_tests(unit_tests) следующим образом:
include(Valgrind) #говорит в каком файле искать функцию AddMemeoryCheck
AddMemeoryCheck(unit_tests) # запуск функции с аргументом - целью прогона тестов
Находясь в директории build, cобираем проект с целью valgrind командой cmake --build . -t valgrind .
При этом тестовая программа будет автоматически запущена и проверена на ошибки при работе с памятью. Получим довольно длинный вывод, часть которого показана на скриншоте ниже.
Среди этой информации наиболее интересными для нас являются следующие строки:
==14034== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==14034== Process terminating with default action of signal 11 (SIGSEGV)
==14034== at 0x484C7A6: calculator::sum(double, double) (calculator.cpp:5)
==14034== The main thread stack size used in this run was 8388608.
Первая строка говорит о том, что невозможно увеличить размер сегмента стека до заданной величины. Вторая сообщает об уничтожении процесса при получении сигнала SIGSEGV. Третья строка говорит о том, в какой функции скрывается проблема - calculator::sum . Последняя строка указывает размер стекового сегмента - 8 МБ что соответствует размеру стека по умолчанию. Программа хочет намного его увеличить. Таким образом мы выяснили причину проблему - увеличение размера стека, вызванного функцией calculator::sum. Обычно причина такого поведения - использование бесконечной рекурсии, когда метод класса или функция вызывает себя до тех пор, пока не исчерпает размеры выделенного сегмента стека. После этого происходит аварийное завершение.
Анализируя код calculator::sum находим причину:
double calculator::sum(double op1, double op2)
{ return sum(op1, op2); }
Метод sum вызывает рекурсивно сам себя, хотя должен вызывать функцию из внешней области видимости - sum из arithmetic.cpp. Устраняем проблему, добавляя символ ::
double calculator::sum(double op1, double op2)
{ return ::sum(op1, op2); }
Перестраиваем и запускаем программу. Тесты запускаются и успешно проходят, НО Valgrind снова сигнализирует об ошибках. В этот раз - об утечках памяти. Часть отчета приведена ниже:
==14928== LEAK SUMMARY:
==14928== definitely lost: 3 bytes in 3 blocks
Если перейти к началу отчета, то можно увидеть такие строки:
==14928== 1 bytes in 1 blocks are definitely lost in loss record 1 of 3
==14928== at 0x483BE63: operator new(unsigned long) (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==14928== by 0x113F23: ArithmeticTest_Sum_Test::TestBody() (arithmetic_test.cpp:6)
Видим что Valgrind указывает нам на место утечки:
TEST(ArithmeticTest, Sum) {
auto pCalc = new calculator; # выделили память оператором new, но не освободили при помощи delete.
Так как всевозможные руководства и "лучшие практики" по использованию языка С++ призывают нас отказаться от работы с "сырой" памятью посредством new/delete, исправим проблему при помощи unique_ptr.
- TEST(ArithmeticTest, Sum) {
- int a = 10;
- int b = 20;
- EXPECT_EQ(std::make_unique<calculator>()->sum(a, b), 30);
- }
Внесем аналогичные исправления и в остальные методы для класса calculator.
После перестроения и запуска тестов Valgrind, наконец, сообщит нам об отсутствии ошибок.
==15172== HEAP SUMMARY:
==15172== in use at exit: 0 bytes in 0 blocks
==15172== total heap usage: 298 allocs, 298 frees, 129,494 bytes allocated
==15172== All heap blocks were freed -- no leaks are possible
==15172== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
Таким образом все тесты проходят и утечек памяти больше нет. Как видно из описанного примера использование Valgrind помогло устранить не только причину падения программы, но и утечки памяти.
Далее рассмотрим как Valgrind помогает с решением проблем многопоточности. Для этого рассмотрим классическую проблему модификации переменной из разных потоков. В разработке приложений на C++ под Linux, MacOS для создания потоков можно пользоваться как библиотекой pthread, так и параллельными алгоритмами, доступными в стандартной библиотеке, начиная с C++ 17.
Вначале рассмотрим тест с pthread. Добавим в Valgrind.cmake функцию AddThreadCheck для запуска Valgrind c инструментом Helgrind, выявляющим ошибки многопоточности. Создадим также новую цель threadcheck.
- function(AddThreadCheck target)
- find_program(VALGRIND_PATH valgrind REQUIRED)
- add_custom_target(threadcheck
- COMMAND ${VALGRIND_PATH} --tool=helgrind
- $<TARGET_FILE:${target}>
- WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
- endfunction()
Логика аналогична описанной выше для AddMemeoryCheck. Различие состоит только в указании флага tool=helgrind, задающим Helgrind в качестве инструмента поиска проблем вместо использовавшегося ранее MemCheck.
Добавим в проект файл variable_test.cpp со следующим содержимым:
- #include <gtest/gtest.h>
- #include <pthread.h>
- int var = 0;
- void* child_fn ( void* arg ) {
- var++;
- return NULL;
- }
- TEST(EqualityTest, Equal) {
- pthread_t child;
- pthread_create(&child, NULL, child_fn, NULL);
- var++;
- pthread_join(child, NULL);
- EXPECT_EQ(var, 2);
- }
Логика программы предельно проста. В начале объявляем глобальную переменную var и функцию child_fn, выполняющую инкремент этой переменной. Далее в тесте создаем поток вызовом pthread_create, в который в качестве аргумента передаем child_fn. Далее в главном потоке выполняется инкремент переменной var и ожидание завершения второго потока, запущенного вызовом pthread_create.
Соберем и запустим проект с целью threadcheck: cmake --build . -t threadcheck
Тесты проходят без ошибок, но Valgrind не доволен и выдает нам длинную тираду сообщений. Внимательно проанализировав центнер текста, выделим главное:
==18758== Thread #1 is the program's root thread
==18758== Thread #2 was created
==18758== Possible data race during write of size 4 at 0x1A2318 by thread #1
==18758== Locks held: none
==18758== at 0x1177BE: EqualityTest_Equal_Test::TestBody() (variable_test.cpp:14)
=18758== This conflicts with a previous write of size 4 by thread #2
==18758== Locks held: none
==18758== at 0x11776B: child_fn(void*) (variable_test.cpp:7)
==18758== Address 0x1a2318 is 0 bytes inside data symbol "var"
В строках 1 и 2 Valgrind сообщает о том, что вначале был создан главный поток Thread #1 и дополнительный Thread2. В третьей строке говорится о возможном состоянии гонки при записи потоком Thread #1 в 4 байтовую переменную. Четвертая строка говорит о причине гонки - отсутствии блокировки при записи в переменную. Далее идет указание на функцию child_fn, в которой возможно состояние гонки. В последней строке указывается название переменной - var.
Как можно понять из описания, в программе присутствует классическая ситуация гонки за данными (data races), ведущая к неопределенному поведению (UB). UB в данном случае состоит в том, что результат выполнения двух операций инкремента, выполняемых в двух разных потоках, не всегда будет одним и тем же. Даже если текущая проверка завершилась без ошибок, такой тест может совершенно неожиданной не пройти в будущем.
Для устранения состояния гонки нужно ввести блокировку для предотвращения неупорядоченного доступа к переменной из двух потоков. Такую блокировку обычно рекомендуют делать при помощи мьютекса, хотя это не лучшее решение. Но пока остановимся на нем, а усовершенствование оставим до следующего примера. В библиотеке pthread для мьютекса предусмотрен тип данных pthread_mutex_t. А за его блокировку/разблокировку отвечают функции pthread_mutex_lock/pthread_mutex_unlock. Для упрощения кода создадим структуру для захвата и освобождения мьютекса в соответствии с идиомой RAII. В конструкторе захватываем мьютекс, а в деструкторе - освобождаем. Переработанный тест будет иметь следующий вид:
- pthread_mutex_t lock;
- struct raimutext {
raimutext(pthread_mutex_t* theLock)
:lock_(theLock)
{
pthread_mutex_lock(lock_);
}
~raimutext()
{
pthread_mutex_unlock(lock_);
}
pthread_mutex_t* lock_{}; }; - int var = 0;
- void* child_fn ( void* arg ) {
- raimutext mut(&lock);
- var++;
- return NULL;
- }
- TEST(EqualityTest, Equal) {
- pthread_t child;
- pthread_create(&child, NULL, child_fn, NULL);
- {
- struct raimutext mut(&lock);
- var++;
- }
- pthread_join(child, NULL);
- EXPECT_EQ(var, 2);
- }
Теперь после прогона тестов Valgrind сообщит об отсутствии ошибок. Для того чтобы тестовая программа с данным примером собиралась без ошибок нужно добавить библиотеку pthread в target_link_libraries. Кроме того, для сборки следующего примера сюда же необходимо добавить библиотеку tbb.
Посмотрим корректно ли отлавливает Valgrind ту же проблему в случае потоков из стандартной библиотеки C++. Напишем еще один тест.
- TEST(EqualityTestStdLibrary, Equal) {
- var = 0;
- std::thread other([&var]() {
var++; - });
- var++;
- other.join();
- EXPECT_EQ(var, 2);
- }
Запускаем сборку и видим все то же сообщение об ошибках.
==18956== Possible data race during read of size 4 at 0x1A5348 by thread #1
==18956== Locks held: none
Исправим их введением мьютекса.
- std::mutex std_mutex;
- TEST(EqualityTestStdLibrary, Equal) {
- var = 0;
- std::thread other([&var]() {
- std::lock_guard<std::mutex> lock(std_mutex);
var++; - });
- std::lock_guard<std::mutex> lock(std_mutex);
- var++;
- other.join();
- EXPECT_EQ(var, 2);
- }
На этот раз получим сообщение об отсутствии ошибок.
Теперь рассмотрим проблему гонки за данными применительно к параллельным алгоритмам из стандартной библиотеки C++. Допустим мы хотим написать код нахождения суммы всех элементов вектора, содержащего миллион элементов при помощи алгоритма std::for_each.
Добавим в проект файл algo_test.cpp.
- #include <gtest/gtest.h>
- #include <vector>
- #include <execution>
- #include <algorithm>
- TEST(EqualityTest, Equal) {
- constexpr int vec_size{1'000'000};
- std::vector<int> acc{vec_size};
- for(int i = 0; i < vec_size; ++i)
- acc[i] = i;
- long long sum = 0;
std::for_each(std::execution::par, acc.begin(), acc.end(), [&sum](int x){
sum += x;
}); - EXPECT_EQ(sum, 499'999'500'000);
- }
Логика программы предельно проста. Объявляем вектор, содержащий миллион элементов, в цикле инициализируем элементы последовательными числами от 0 до 999999. Далее в std::for_each осуществляем параллельное суммирование (так как первый аргумент имеет значение std::execution::par) элементов вектора.
После запуска тестов видим очень длинную историю сообщений об ошибках следующего вида:
==23036== Possible data race during write of size 8 at 0x6547D38 by thread #5
==23036== Locks held: none
==23036== Possible data race during write of size 8 at 0x6547D38 by thread #1
==23036== Locks held: none
Как можно понять из анализа сообщений, проблема опять кроется в неупорядоченном доступе к общей переменной. Нетрудно догадаться, что это переменная sum. Из этого примера следует крайне важный вывод.
Несмотря на то, что стандартная библиотека предоставляют возможность выполнять алгоритмы параллельно, она не избавляет от необходимости защиты общих данных от неупорядоченного доступа. Эта задача, по-прежнему, лежит на плечах программиста. Упорядочим доступ при помощи атомарной переменной, так как данный способ зачастую выигрывает в скорости у мьютекса.
Для этого нужно изменить объявление sum следующим образом: std::atomic<long long> sum(0) . Переменная становится атомарной и не может одновременно модифицироваться несколькими потоками. Однако, при прогоне этого кода опять возникает масса ошибок вида:
==16180== Possible data race during write of size 1 at 0x5AF4F18 by thread #4
==16180== Locks held: none
Долго пытался понять причину такого поведения Valgrind, пока не выяснил что алгоритмы типа std::for_each реализуются при помощи библиотеки tbb от Intel. Данная библиотека использует примитивы синхронизации собственной разработки. В тоже время в руководстве по Valgrind сказано следующее.
Убедитесь, что ваше приложение и все его библиотеки используют примитивы потоков POSIX. Helgrind должен иметь возможность видеть все события, относящиеся к созданию потоков, выходу, блокировке и другим событиям синхронизации. Для этого он перехватывает множество функций потоков POSIX.
Не создавайте собственные примитивы потоков (мьютексы и т. д.) из комбинаций системных вызовов Linux futex, атомарных счетчиков и т. д. Это сбивает внутренние модели Helgrind с толку и дает ложные результаты (так называемые ложно-положительные срабатывания).
То есть использование самописных примитивов синхронизации в библиотеке Threading Building Blocks (tbb) ведет к нахождению Valgrind несуществующих ошибок. Данная проблема обсуждалась здесь. Для проверки кода, использующего параллельные алгоритмы из STL, приходится отказаться от использования Valgrind и собрать проект, используя thread sanitizer (TSan) компилятора Clang. После прогона теста получаем отчет об отсутствии ошибок.
Подводя итоги, можно сделать следующие выводы:
- Расшифровка и анализ сообщений об ошибках прогона программы с использованием Valgrind является совершенно необходимым навыком, позволяющим отлавливать проблемы, которые не всегда можно выявить при помощи тестов;
- Метод пристального взгляда все еще нужен в тех случаях, когда есть подозрения на ложные срабатывания;
- Использование одного инструмента анализа программы в некоторых случаях оказывается недостаточным и ведет к ложно-положительным срабатываниям. Поэтому необходимо анализировать отчеты, получившиеся в результате работы, хотя бы двух инструментов.
Код, приведенный в статье, можно посмотреть в репозитории.
Продолжение следует...