Отладка и профилирование

Золотое правило программирования гласит: код делает не то, чего вы от него ожидаете, а то, что вы ему сказали. Преодолеть этот разрыв порой бывает совсем непросто. В этой лекции мы рассмотрим полезные приёмы работы с глючным и жадным до ресурсов кодом: отладку и профилирование.

Отладка

Отладка через printf и логирование

“Самым эффективным инструментом отладки остаётся внимательное размышление в сочетании с разумно расставленными операторами print” – Брайан Керниган, Unix for Beginners.

Первый подход к отладке программы – добавить операторы print вокруг того места, где вы обнаружили проблему, и повторять это до тех пор, пока не соберёте достаточно информации, чтобы понять, что именно вызывает проблему.

Второй подход – использовать в программе логирование вместо разовых операторов print. Логирование – это, по сути, «более аккуратная печать», и обычно оно делается через фреймворк для логирования, в который уже встроена поддержка таких вещей, как:

К тому же операторы логирования вы, как правило, расставляете заранее, ещё во время написания программы, так что нужные для отладки данные, возможно, уже будут на месте! И действительно: когда вы нашли и исправили проблему с помощью операторов print, часто имеет смысл превратить эти print’ы в полноценные операторы логирования, а не просто удалить их. Тогда, если похожие ошибки возникнут в будущем, у вас уже будет нужная диагностическая информация без правки кода.

Логи сторонних программ: многие программы поддерживают флаг -v или --verbose, чтобы выводить больше информации во время работы. Это помогает выяснить, почему та или иная команда падает. Некоторые даже позволяют указать флаг несколько раз, чтобы получить ещё больше подробностей. При отладке проблем с сервисами (базами данных, веб-серверами и т. д.) смотрите их логи – в Linux они часто лежат в /var/log/. Для просмотра логов сервисов systemd используйте journalctl -u <service>. Для сторонних библиотек проверьте, поддерживают ли они отладочное логирование через переменные окружения или конфигурацию.

Отладчики

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

Отладчики – это инструменты, которые позволяют взаимодействовать с выполнением программы прямо по ходу дела, давая возможность:

Большинство языков программирования поддерживают ту или иную форму отладчика (или поставляются с ним). Самые универсальные – это отладчики общего назначения вроде gdb (GNU Debugger) и lldb (LLVM Debugger), которые умеют отлаживать любой нативный бинарник. У многих языков есть также специализированные отладчики, теснее интегрированные со средой выполнения (например, pdb в Python или jdb в Java).

gdb – де-факто стандартный отладчик для C, C++, Rust и других компилируемых языков. Он позволяет заглянуть практически в любой процесс и получить его текущее машинное состояние: регистры, стек, счётчик команд и многое другое.

Несколько полезных команд GDB:

Попробуйте TUI-режим GDB (gdb -tui или нажмите Ctrl-x a внутри GDB): экран делится на две части, и исходный код отображается рядом с командной строкой.

Отладка с записью и воспроизведением

Одни из самых изматывающих багов – гейзенбаги (Heisenbugs): баги, которые словно исчезают или меняют поведение, как только вы пытаетесь за ними наблюдать. Сюда относятся состояния гонки, ошибки, зависящие от тайминга, и проблемы, которые проявляются только при определённых условиях в системе. Традиционная отладка здесь часто бесполезна, потому что при повторном запуске программа ведёт себя иначе (например, отладочные print’ы могут замедлить код настолько, что гонка перестанет возникать).

Отладка с записью и воспроизведением (record-replay debugging) решает эту проблему: выполнение программы записывается, и затем его можно детерминированно воспроизводить столько раз, сколько нужно. Более того, по записанному выполнению можно двигаться в обратном направлении и найти, где именно всё пошло не так.

rr – мощный инструмент для Linux, который записывает выполнение программы и позволяет детерминированно воспроизводить его с полноценными возможностями отладки. Он работает с GDB, так что интерфейс вам уже знаком.

Базовое использование:

# Record a program execution
rr record ./my_program

# Replay the recording (opens GDB)
rr replay

Вся магия происходит при воспроизведении. Поскольку выполнение детерминировано, можно использовать команды обратной отладки:

Для отладки это невероятно мощно. Допустим, программа падает – вместо того чтобы гадать, где баг, и расставлять точки останова, вы можете:

  1. Дойти до падения
  2. Изучить повреждённое состояние
  3. Поставить точку наблюдения (watchpoint) на повреждённую переменную
  4. Выполнить reverse-continue, чтобы найти, где именно она была повреждена

Когда использовать rr:

Примечание: rr работает только на Linux и требует аппаратных счётчиков производительности. Он не работает в виртуальных машинах, которые не пробрасывают эти счётчики (например, на большинстве инстансов AWS EC2), и не поддерживает доступ к GPU. Для macOS посмотрите на Warpspeed.

rr и конкурентность: поскольку rr записывает выполнение детерминированно, он сериализует планирование потоков. Это значит, что некоторые состояния гонки под rr могут не проявиться, если они зависят от конкретного тайминга. rr всё равно полезен для отладки гонок – как только вы поймали неудачный запуск, его можно надёжно воспроизводить, – но, чтобы поймать плавающий баг, может понадобиться несколько попыток записи. А для багов, не связанных с конкурентностью, rr раскрывается во всей красе: вы всегда можете воспроизвести точно то же выполнение и с помощью обратной отладки выследить, где именно повредились данные.

Трассировка системных вызовов

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

strace (Linux) и dtruss (macOS)

strace позволяет наблюдать за каждым системным вызовом, который делает программа:

# Trace all system calls
strace ./my_program

# Trace only file-related calls
strace -e trace=file ./my_program

# Follow child processes (important for programs that start other programs)
strace -f ./my_program

# Trace a running process
strace -p <PID>

# Show timing information
strace -T ./my_program

На macOS и BSD для тех же целей используйте dtruss (обёртку над dtrace):

Чтобы глубже разобраться в strace, загляните в отличный зин про strace Джулии Эванс.

bpftrace и eBPF

eBPF (extended Berkeley Packet Filter) – мощная технология Linux, позволяющая запускать изолированные (sandboxed) программы внутри ядра. bpftrace предоставляет высокоуровневый синтаксис для написания eBPF-программ. Это произвольные программы, работающие в ядре, а потому у них огромная выразительная сила (хотя и несколько неуклюжий синтаксис в духе awk). Чаще всего их используют, чтобы выяснить, какие системные вызовы выполняются, – включая агрегацию (например, подсчёт или статистику задержек) или изучение аргументов системных вызовов (или даже фильтрацию по ним).

# Trace file opens system-wide (prints immediately)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'

# Count system calls by name (prints summary on Ctrl-C)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'

Впрочем, eBPF-программы можно писать и напрямую на C с помощью такого набора инструментов, как bcc, – в комплекте с ним также идёт много удобных утилит, например biosnoop для вывода распределения задержек дисковых операций или opensnoop для вывода всех открываемых файлов.

Если strace хорош тем, что его легко «просто взять и запустить», то за bpftrace стоит браться, когда вам нужны меньшие накладные расходы, хочется трассировать вызовы вглубь функций ядра, требуется какая-либо агрегация и так далее. Учтите, впрочем, что bpftrace должен запускаться от root и что он, как правило, наблюдает за всем ядром, а не за отдельным процессом. Чтобы нацелиться на конкретную программу, можно фильтровать по имени команды или по PID:

# Filter by command name (prints summary on Ctrl-C)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /comm == "bash"/ { @[probe] = count(); }'

# Trace a specific command from startup using -c (cpid = child PID)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid == cpid/ { @[probe] = count(); }' -c 'ls -la'

Флаг -c запускает указанную команду и записывает её PID в cpid – это удобно, чтобы трассировать программу с самого момента запуска. Когда трассируемая команда завершается, bpftrace выводит агрегированные результаты.

Отладка сети

Для сетевых проблем есть tcpdump и Wireshark, которые позволяют перехватывать и анализировать сетевые пакеты:

# Capture packets on port 80
sudo tcpdump -i any port 80

# Capture and save to file for Wireshark analysis
sudo tcpdump -i any -w capture.pcap

С HTTPS-трафиком tcpdump менее полезен из-за шифрования. Такие инструменты, как mitmproxy, могут работать как перехватывающий прокси и позволяют заглянуть в зашифрованный трафик. Для отладки HTTPS-запросов из веб-приложений зачастую проще всего воспользоваться инструментами разработчика в браузере (вкладка Network) – они показывают расшифрованные данные запросов и ответов, заголовки и тайминги.

Отладка памяти

Ошибки работы с памятью – переполнения буфера, use-after-free, утечки памяти – одни из самых опасных и трудных в отладке. Часто они не приводят к падению сразу, а портят память так, что проблемы проявляются гораздо позже.

Санитайзеры

Один из способов находить ошибки работы с памятью – использовать санитайзеры (sanitizers): это возможности компилятора, которые инструментируют ваш код, чтобы обнаруживать ошибки во время выполнения. Например, широко используемый AddressSanitizer (ASan) обнаруживает:

# Compile with AddressSanitizer
gcc -fsanitize=address -g program.c -o program
./program

Полезных санитайзеров существует немало:

Санитайзеры требуют перекомпиляции, но при этом достаточно быстры, чтобы использовать их в CI-конвейерах и в повседневной разработке.

Valgrind: когда перекомпилировать нельзя

Valgrind идёт другим путём: он запускает вашу программу в чём-то вроде виртуальной машины и так обнаруживает ошибки работы с памятью. Он медленнее санитайзеров, зато не требует перекомпиляции:

valgrind --leak-check=full ./my_program

Используйте Valgrind, когда:

На самом деле Valgrind – очень мощная среда контролируемого выполнения, и мы ещё вернёмся к нему, когда дойдём до профилирования!

ИИ для отладки

Большие языковые модели (LLM) неожиданно оказались весьма полезными помощниками в отладке. Есть отладочные задачи, в которых они особенно сильны и хорошо дополняют традиционные инструменты.

Где LLM особенно сильны:

Замечание об отладочных символах: чтобы трассировки стека и отладка были осмысленными, убедитесь, что ваши бинарники (и все подключаемые библиотеки) скомпилированы с отладочными символами (флаг -g). Отладочная информация обычно хранится в формате DWARF. Кроме того, компиляция с указателями кадров (-fno-omit-frame-pointer) делает трассировки стека надёжнее, что особенно важно для инструментов профилирования. Без этого трассировки стека могут показывать только адреса памяти или быть неполными. Для программ, компилируемых в машинный код (C++, Rust), это важнее, чем для Python или Java.

Ограничения, о которых стоит помнить:

Это не то же самое, что общие возможности ИИ для программирования, о которых шла речь в лекции «Среда разработки и инструменты». Здесь мы говорим именно об использовании LLM как подспорья в отладке.

Профилирование

Даже если ваш код функционально ведёт себя так, как вы ожидаете, этого может быть недостаточно, если по пути он съедает весь ваш CPU или всю память. На курсах по алгоритмам часто учат нотации «O большое», но не тому, как находить горячие точки (hot spots) в ваших программах. Поскольку преждевременная оптимизация – корень всех зол, вам стоит познакомиться с профилировщиками и инструментами мониторинга. Они помогут понять, какие части вашей программы отнимают больше всего времени и/или ресурсов, чтобы вы могли сосредоточиться именно на их оптимизации.

Замер времени

Самый простой способ измерить производительность – засечь время. Во многих случаях достаточно просто вывести, сколько времени ваш код выполнялся между двумя точками.

Однако астрономическое время (wall clock time) может вводить в заблуждение: компьютер параллельно может выполнять другие процессы или ждать каких-то событий. Команда time различает время Real, User и Sys:

$ time curl https://missing.csail.mit.edu &> /dev/null
real	0m0.272s
user	0m0.079s
sys	    0m0.028s

Здесь запрос занял почти 300 миллисекунд (время real), но процессорного времени (user + sys) набежало лишь 107 мс. Остальное – ожидание сети.

Мониторинг ресурсов

Иногда первый шаг к анализу производительности программы – понять, сколько ресурсов она на самом деле потребляет. Программы часто работают медленно, когда им не хватает ресурсов.

Визуализация данных о производительности

Люди замечают закономерности на графиках гораздо быстрее, чем в таблицах с числами. При анализе производительности построение графика по вашим данным часто выявляет тренды, всплески и аномалии, которые в сырых числах были бы незаметны.

Делайте данные пригодными для графиков: добавляя отладочный вывод или логирование, сразу форматируйте его так, чтобы потом по нему легко было построить график. Простую пару «метка времени, значение» в формате CSV (1705012345,42.5) нанести на график куда проще, чем предложение в свободной форме. Логи со структурой JSON тоже можно распарсить и визуализировать с минимальными усилиями. Иными словами, ведите логи в опрятном виде.

Быстрые графики с gnuplot: для простых графиков из командной строки gnuplot умеет строить графики прямо из файлов с данными:

# Plot a simple CSV with timestamp,value
gnuplot -e "set datafile separator ','; plot 'latency.csv' using 1:2 with lines"

Итеративное исследование с matplotlib и ggplot2: для более глубокого анализа matplotlib в Python и ggplot2 в R позволяют исследовать данные итеративно. В отличие от разовых графиков, эти инструменты дают возможность быстро нарезать и преобразовывать данные, чтобы проверять гипотезы. Особенно мощны facet-графики в ggplot2 – один набор данных можно разбить на несколько подграфиков по категории (например, разложить задержку запросов по эндпоинтам или по времени суток), чтобы вытащить закономерности, которые иначе остались бы скрытыми.

Примеры применения:

CPU-профилировщики

Чаще всего, говоря о профилировщиках, люди имеют в виду CPU-профилировщики. Есть два основных типа:

У сэмплирующих профилировщиков меньше накладных расходов, и для продакшена обычно предпочитают именно их.

perf: сэмплирующий профилировщик

perf – стандартный профилировщик в Linux. Он умеет профилировать любую программу без перекомпиляции:

perf stat даёт быстрый обзор того, на что уходит время:

$ perf stat ./slow_program

 Performance counter stats for './slow_program':

         3,210.45 msec task-clock                #    0.998 CPUs utilized
               12      context-switches          #    3.738 /sec
                0      cpu-migrations            #    0.000 /sec
              156      page-faults               #   48.587 /sec
   12,345,678,901      cycles                    #    3.845 GHz
    9,876,543,210      instructions              #    0.80  insn per cycle
    1,234,567,890      branches                  #  384.532 M/sec
       12,345,678      branch-misses             #    1.00% of all branches

Вывод профилировщика для реальных программ содержит огромное количество информации. Люди – существа визуальные и довольно плохо справляются с чтением больших массивов чисел. Flame graphs (флейм-графики) – это визуализация, которая делает данные профилирования намного понятнее.

Flame graph показывает иерархию вызовов функций по оси Y, а затраченное время откладывает пропорционально по оси X. Флейм-графики интерактивны – можно кликнуть и приблизить интересующую вас часть программы.

FlameGraph

Чтобы построить flame graph из данных perf:

# Record profile
perf record -g ./my_program

# Generate flame graph (requires flamegraph scripts)
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg

Для интерактивного просмотра flame graph в браузере попробуйте Speedscope, а для комплексного анализа на уровне всей системы – Perfetto.

Callgrind из Valgrind: трассирующий профилировщик

callgrind – это инструмент профилирования, который записывает историю вызовов и число выполненных инструкций вашей программы. В отличие от сэмплирующих профилировщиков, он даёт точное число вызовов и умеет показывать связь между вызывающими и вызываемыми функциями:

# Run with callgrind
valgrind --tool=callgrind ./my_program

# Analyze with callgrind_annotate (text) or kcachegrind (GUI)
callgrind_annotate callgrind.out.<pid>
kcachegrind callgrind.out.<pid>

Callgrind работает медленнее сэмплирующих профилировщиков, зато даёт точное число вызовов и при желании может симулировать поведение кэша (с флагом --cache-sim=yes), если вам нужна такая информация.

Если вы пишете на каком-то конкретном языке, для него могут существовать более специализированные профилировщики. Например, у Python есть cProfile и py-spy, у Go – go tool pprof, а у Rust – cargo-flamegraph (который на самом деле работает для любой скомпилированной программы!).

Профилировщики памяти

Профилировщики памяти помогают понять, как ваша программа использует память с течением времени, и найти утечки памяти.

Massif из Valgrind

massif профилирует использование памяти в куче:

valgrind --tool=massif ./my_program
ms_print massif.out.<pid>

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

Для Python есть memory-profiler, который показывает использование памяти построчно.

Бенчмаркинг

Когда нужно сравнить производительность разных реализаций или инструментов, для бенчмарков программ командной строки отлично подходит hyperfine:

$ hyperfine --warmup 3 'fd -e jpg' 'find . -iname "*.jpg"'
Benchmark #1: fd -e jpg
  Time (mean ± σ):      51.4 ms ±   2.9 ms    [User: 121.0 ms, System: 160.5 ms]
  Range (min … max):    44.2 ms …  60.1 ms    56 runs

Benchmark #2: find . -iname "*.jpg"
  Time (mean ± σ):      1.126 s ±  0.101 s    [User: 141.1 ms, System: 956.1 ms]
  Range (min … max):    0.975 s …  1.287 s    10 runs

Summary
  'fd -e jpg' ran
   21.89 ± 2.33 times faster than 'find . -iname "*.jpg"'

Для веб-разработки отличные профилировщики встроены в инструменты разработчика браузеров. См. документацию Firefox Profiler и Chrome DevTools.

Упражнения

Отладка

  1. Отладьте алгоритм сортировки: следующий псевдокод реализует сортировку слиянием, но содержит ошибку. Реализуйте его на языке по вашему выбору, а затем с помощью отладчика (gdb, lldb, pdb или отладчика вашей IDE) найдите и исправьте ошибку.

    function merge_sort(arr):
        if length(arr) <= 1:
            return arr
        mid = length(arr) / 2
        left = merge_sort(arr[0..mid])
        right = merge_sort(arr[mid..end])
        return merge(left, right)
    
    function merge(left, right):
        result = []
        i = 0, j = 0
        while i < length(left) AND j < length(right):
            if left[i] <= right[j]:
                append result, left[i]
                i = i + 1
            else:
                append result, right[i]
                j = j + 1
        append remaining elements from left and right
        return result
    

    Тестовый пример: merge_sort([3, 1, 4, 1, 5, 9, 2, 6]) должен вернуть [1, 1, 2, 3, 4, 5, 6, 9]. Расставьте точки останова и пошагово пройдите функцию merge, чтобы найти место, где выбирается не тот элемент.

  2. Установите rr и с помощью обратной отладки найдите ошибку, портящую данные. Сохраните эту программу как corruption.c:

    #include <stdio.h>
    
    typedef struct {
        int id;
        int scores[3];
    } Student;
    
    Student students[2];
    
    void init() {
        students[0].id = 1001;
        students[0].scores[0] = 85;
        students[0].scores[1] = 92;
        students[0].scores[2] = 78;
    
        students[1].id = 1002;
        students[1].scores[0] = 90;
        students[1].scores[1] = 88;
        students[1].scores[2] = 95;
    }
    
    void curve_scores(int student_idx, int curve) {
        for (int i = 0; i < 4; i++) {
            students[student_idx].scores[i] += curve;
        }
    }
    
    int main() {
        init();
        printf("=== Initial state ===\n");
        printf("Student 0: id=%d\n", students[0].id);
        printf("Student 1: id=%d\n", students[1].id);
    
        curve_scores(0, 5);
    
        printf("\n=== After curving ===\n");
        printf("Student 0: id=%d\n", students[0].id);
        printf("Student 1: id=%d\n", students[1].id);
    
        if (students[1].id != 1002) {
            printf("\nERROR: Student 1's ID was corrupted! Expected 1002, got %d\n",
                   students[1].id);
            return 1;
        }
        return 0;
    }
    

    Скомпилируйте командой gcc -g corruption.c -o corruption и запустите. ID студента 1 оказывается испорчен, хотя портит данные функция, которая работает только со студентом 0. С помощью rr record ./corruption и rr replay найдите виновника. Поставьте точку наблюдения (watchpoint) на students[1].id и после порчи выполните reverse-continue, чтобы точно узнать, какая строка кода перезаписала значение.

  3. Отладьте ошибку работы с памятью с помощью AddressSanitizer. Сохраните этот код как uaf.c:

    #include <stdlib.h>
    #include <string.h>
    #include <stdio.h>
    
    int main() {
        char *greeting = malloc(32);
        strcpy(greeting, "Hello, world!");
        printf("%s\n", greeting);
    
        free(greeting);
    
        greeting[0] = 'J';
        printf("%s\n", greeting);
    
        return 0;
    }
    

    Сначала скомпилируйте и запустите без санитайзеров: gcc uaf.c -o uaf && ./uaf. Может показаться, что всё работает. Теперь скомпилируйте с AddressSanitizer: gcc -fsanitize=address -g uaf.c -o uaf && ./uaf. Прочитайте отчёт об ошибке. Какую ошибку находит ASan? Исправьте проблему, на которую он указывает.

  4. С помощью strace (Linux) или dtruss (macOS) отследите системные вызовы, которые делает какая-нибудь команда вроде ls -l. Какие системные вызовы она выполняет? Попробуйте оттрассировать программу посложнее и посмотрите, какие файлы она открывает.

  5. Используйте LLM, чтобы разобраться с непонятным сообщением об ошибке. Попробуйте скопировать ошибку компилятора (особенно из шаблонов C++ или из Rust) и попросить объяснить её и предложить исправление. Попробуйте скормить модели часть вывода strace или AddressSanitizer.

Профилирование

  1. С помощью perf stat соберите базовую статистику производительности для любой программы на ваш выбор. Что означают разные счётчики?

  2. Проведите профилирование с помощью perf record. Сохраните этот код как slow.c:

    #include <math.h>
    #include <stdio.h>
    
    double slow_computation(int n) {
        double result = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < 1000; j++) {
                result += sin(i * j) * cos(i + j);
            }
        }
        return result;
    }
    
    int main() {
        double r = 0;
        for (int i = 0; i < 100; i++) {
            r += slow_computation(1000);
        }
        printf("Result: %f\n", r);
        return 0;
    }
    

    Скомпилируйте с отладочными символами: gcc -g -O2 slow.c -o slow -lm. Запустите perf record -g ./slow, а затем perf report, чтобы увидеть, на что уходит время. Попробуйте построить flame graph с помощью скриптов flamegraph.

  3. С помощью hyperfine сравните производительность двух разных реализаций одной и той же задачи (например, find и fd, grep и ripgrep или двух версий вашего собственного кода).

  4. Понаблюдайте в htop за системой во время работы ресурсоёмкой программы. Попробуйте ограничить с помощью taskset набор процессоров, доступных процессу: taskset --cpu-list 0,2 stress -c 3. Почему stress не использует три процессора?

  5. Частая проблема: порт, который вы хотите слушать, уже занят другим процессом. Научитесь находить этот процесс: сначала выполните python -m http.server 4444, чтобы запустить минимальный веб-сервер на порту 4444. В другом терминале выполните ss -tlnp | grep 4444, чтобы найти процесс. Завершите его командой kill <PID>.


Редактировать страницу.

Лицензия CC BY-NC-SA.