Как я спорил с преподом об "утечке памяти"

8 сентября 2024 г.

Ура-ура, я все-таки поступил. Поздравьте меня. На полном серьезе, я давно не чувствовал себя так хорошо, как в университете. Это все то, о чем я мечтал и чего я хотел. А самое главное — это общение с такими людьми, которых я до универа не мог бы днем с огнем отыскать.

И все-таки как бы в универе ни было замечательно, какая-то неприятная ситуация гарантированно случится везде. И универ не стал исключением. В первую неделю я познакомился с преподавателем, который будет вести семинары по C++. Я не буду сильно задерживаться на том, чем лично меня — привередливого меня — препод не устраивал.

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

Итак, рассмотрим следующий код:

int main() {

    for (int i = 0; i < 1000; i++) {

        int number = 42;
    }

    return 0;
}

Эта простая программа, которая функционально ничего не делает, состоит из цикла for, который выполняется для чисел от 0 до 999, в теле которого, соответственно, сто раз объявляется переменная numbers, после чего ей присваивается значение 42.

А шокировал меня преподаватель тем, что по его мнению этот цикл небезопасен (!!!) и может привести к утечке памяти, поскольку память, занятая переменой number в таком случае не освобождалась бы. Так давайте же разберем по частям им сказанное.

Стек, куча и указатели (поинтеры)

Сначала немного теории.

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

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

Что же такое утечка памяти? Это такая ситуация, когда программа выделяет какую-то память под свои нужды, забывает о том, что она выделила эту память, не освобождает ее после того, как она перестала быть нужна, и память сидит, неиспользуемая, занимает место, ведь никто не сказал ей "ты свободна, память, можешь теперь хранить другую полезную информацию". В итоге — ни себе, ни людям. Неиспользуемую память нельзя отдать другим процессам, потому что она не помечена как освобожденная. В результате такая неиспользуемая память накапливается, происходит "утечка" — программа занимает все больше и больше памяти, а что она с ней делает — непонятно.

Для управления памятью в C++ предусмотрены два механизма — стек и куча. Начнем со стека:

Любая переменная в C++ имеет начало своего существования и конец. Переменные, объявленные вне функций, существуют до тех пор, пока выполняется программа. Переменные, объявленные внутри функций — до тех пор, пока выполняется функция.

Рассмотрим следующий код:

int foo = 42;

void function() {

    bar = 43;

    // ...
}

int main() {

    // ...

    return 0;
}

Переменная foo будет существовать с момента запуска программы и до ее завершения. Переменная bar будет создаваться с момента вызова функции function и до окончания ее работы. Внутри функций создается так называемая область видимости — контекст, в котором переменные существуют, но которым они ограничены. Проще говоря, то, что происходит в фигурных скобках, остается в фигурных скобках.

Когда компилятор анализирует программу, он создает каждой функции свою область видимости, и делает так, чтобы переменные, объявленные в этой области, освобождались, покидая ее. Этот механизм называется СТЕК.

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

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

* — спорный момент, см. конец поста за подробностями.

Однако возникают ситуации, когда нам нужно выделить какой-то кусок памяти под что-то, размер чего заранее неизвестен. На помощь приходит КУЧА. В ней можно в любой момент выделить сколько угодно памяти под любые нужды и она даст пользоваться этой памятью как угодно, с той поправкой, что потом эту память надо освободить вручную.

Тут нужно ввести еще одно очень важное понятие — УКАЗАТЕЛЬ. Это такой специальный объект, который хранит адрес в памяти, выделенной в куче. Когда мы выделяем память на куче, мы получаем указатель. Когда мы обращаемся к этой памяти, мы это делаем через указатель. Когда мы хотим от нее избавиться, мы это делаем с помощью указателя.

Я думаю, станет понятнее на примере. Рассмотрим следующий код:

int main() {

    int* number = new int(42);

    std::cout << *number << std::endl;

    delete number;

    return 0;
}

М, незнакомый синтаксис. Теперь разберем его по частям, и я попробую сделать так, чтобы он стал понятен.

  • int* number — объявление переменной типа указатель на тип int
  • new int(42) — выделение памяти на куче с помощью ключевого слова new (в эту память записывается объект int(42)). Памяти выделяется столько, сколько нужно, чтобы этот объект поместился
  • std::cout << *number << std::endl — с помощью оператора (в этот раз это оператор) * мы берем указатель number и получаем то, на что он указывает (т.е. число 42)
  • delete number — с помощью ключевого слова delete мы берем указатель number и освобождаем память, на которую он указывает. Теперь он указывает на неразмеченную память и им нельзя пользоваться

К любому типу данных можно приписать звездочку (а можно даже несколько!) и тогда это будет указатель, указывающий на тип данных. К самим же указателям звездочка применяется как оператор, который возвращает значение, хранящееся в памяти, на которую указатель указывает. Наконец, по-секрету, на уже существующую память можно получить указатель с помощью оператора &.

Теперь должно стать яснее, как пользоваться указателями.

  1. Выделяем на куче память под объект с помощью new
  2. Когда нам нужно воспользоваться объектом, память под который мы выделили, мы используем оператор * на указателе
  3. Когда мы закончили и хотим освободить память, мы используем delete для этого

Собственно, утечки памяти происходят если с последним пунктом проблемы: если не delete-ать указатели, то они теряются, и память, на которую они указывали, уже не удалить, а она все еще занята :(

Про тот кусочек кода, который был в начале статьи

Повторюсь, дана программа:

int main() {

    for (int i = 0; i < 1000; i++) {

        int number = 42;
    }

    return 0;
}

Во-первых,

теперь мы, отталкиваясь от свежеусвоенной теории, знаем, что никакой утечки памяти попросту не будет. Как минимум потому что переменная объявляется на стеке, а не в куче, и ее банально не надо освобождать.

Во-вторых,

попробуем убедиться в том, что это не так, и что память выделяется все-таки в куче. Для этого давайте скомпилируем два варианта этой программы с флагом -fstack-usage:

program_a.cpp:

int main() {

    for (int i = 0; i < 1; i++) {
            
        int a[10];
    }
}

program_b.cpp:

int main() {

    for (int i = 0; i < 1; i++) {
            
        //int a[10];
    }
}

Компиляция:

g++ program_a.cpp -fstack-usage -o program_a.o
g++ program_b.cpp -fstack-usage -o program_b.o

Сгенерируются два файла: program_a.su и program_b.su

program_a.su:

program_a.cpp:1:5:int main()    80  static

program_b.su:

program_b.cpp:1:5:int main()    16  static

Как мы видим, под программу program_a на СТЕКЕ выделяется 80 байт (количество байт, выделяемых под функцию на стеке всегда кратно 16), тогда как под program_b — только 16. Массивы в данном примере используются из-за того что неиспользуемые переменные g++ как будто бы не включает в программу. Для таких же программ, в которых вместо int a[10]; написано int a;, файлы .su вообще не отличаются, сколько бы переменных не объявлялось (если их не использовать).

То есть, массив a выделяется именно на СТЕКЕ, а не в куче. Подробнее про расшифровку файлов .su можно почитать здесь.

В-третьих,

Давайте на секундочку еще раз допустим, что все-таки память выделяется именно в куче. Тогда нам наверняка скажет об этом утилита для отладок утечек памяти в программах на C++, известная как valgrind. Было бы логично, не так ли?

Запустим с помощью valgrind program_a.o и program_b.o:

Вывод для program_a.o:

==2403044== Memcheck, a memory error detector
==2403044== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2403044== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==2403044== Command: ./program_a.o
==2403044== 
==2403044== 
==2403044== HEAP SUMMARY:
==2403044==     in use at exit: 0 bytes in 0 blocks
==2403044==   total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==2403044== 
==2403044== All heap blocks were freed -- no leaks are possible
==2403044== 
==2403044== For lists of detected and suppressed errors, rerun with: -s
==2403044== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Вывод для program_b.o:

==2403407== Memcheck, a memory error detector
==2403407== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==2403407== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==2403407== Command: ./program_b.o
==2403407== 
==2403407== 
==2403407== HEAP SUMMARY:
==2403407==     in use at exit: 0 bytes in 0 blocks
==2403407==   total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==2403407== 
==2403407== All heap blocks were freed -- no leaks are possible
==2403407== 
==2403407== For lists of detected and suppressed errors, rerun with: -s
==2403407== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Я думаю, total heap usage: 0 allocs, 0 frees, 0 bytes allocated ясно дает понять, что программой куча не использовалась,

В-четвертых,

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

Скомпилируем следующую программу:

int main() {

    for (int i = 0; i < 1; i++) {

        int a = 42;
        delete a;
    }
}

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

stopa@machine:~/cpp$ g++ program.cpp -o program.o
program.cpp: In function ‘int main()’:
program.cpp:7:10: error: type ‘int’ argument given to ‘delete’, expected pointer
    7 |   delete a;
      |          ^

Упс! Оператор delete, оказывается, работает именно с указателями. Что же тогда будет, если мы попытаемся скормить ему указатель на переменную a?

Давайте попробуем скомпилировать эту программу:

int main() {

    for (int i = 0; i < 1; i++) {
            
        int a = 42;
        delete &a;
    }
}

Компилятор не ругается. Что же будет, если запустить ее?

stopa@machine:~/cpp$ ./program.o
free(): invalid size
Aborted (core dumped)

Хм, интересно, почему бы так могло получиться. Давайте до кучи попробуем использовать переменную, прежде чем освобождать ее. Попробуем скомпилировать и запустить следующую программу:

#include <stdio.h>

int main() {

    for (int i = 0; i < 1; i++) {
            
        int a = 42;
        printf("%i", a);
        delete &a;
    }
}

Мы получим такой результат:

stopa@machine:~/cpp$ ./program.o
munmap_chunk(): invalid pointer
Aborted (core dumped)

Наконец,

Каждый желающий читатель может скомпилировать и запустить следующий код на своем устройстве:

int main() {

    while (true) {

        int number = 42;
    }
}

Запустить и заглянуть в диспетчер задач (или любой удобный менеджер процессов) и увидеть там, что использование памяти у программы почему-то не растет, в отличие от того, как росло бы оно если запустить следующую программу:

int main() {

    while (true) {

        int* number = new(42);
    }
}

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

А до тех пор, я думаю, я привел достаточно убедительных аргументов в пользу объявления переменных внутри циклов. Теперь, если кто-то будет Вам запрещать это делать, смело направляйте им эту статью — я буду готов лично защищать Ваш код, если они не окажутся убеждены после всего вышенаписанного.

Аппендикс (не относится к переменным внутри цикла)

"у каждой функции должно быть неменяющееся, постоянное количество памяти, которое она использует, которое всегда известно"

Я обещал вернуться к этому утверждению.

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

Например, открытый стандарт языка C C99 предусматривает существование такой вещи, как VLA — variable length array, или массив варьирующейся длины. Таким образом, на стеке можно, если компилятор того позволяет, выделить массив некоторой длины, которая получается извне. Такой код, например, я могу скомпилировать прямо сейчас, хотя он противоречит тому, что я сказал выше:

void function(int n) {

    int a[n];
}

int main() {

    // ...
}

Здесь массив a создается с длиной n, т.е. мы не знаем его длины к моменту компиляции. VLA — такая вещь, которая до сих пор вызывает споры. Я отношусь к ней скептически, хотя я согласен с тем, что она имеет свои случаи использования.

Тем не менее, я стараюсь использовать стек именно как статичную память, и всем это советую. Тогда ни у кого точно оттуда ничего не утечет :)

(этот сайт написал человек; код написал человек; текст написал тоже человек)