Как я спорил с преподом об "утечке памяти"
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
— объявление переменной типа указатель на тип intnew int(42)
— выделение памяти на куче с помощью ключевого словаnew
(в эту память записывается объектint(42)
). Памяти выделяется столько, сколько нужно, чтобы этот объект поместилсяstd::cout << *number << std::endl
— с помощью оператора (в этот раз это оператор)*
мы берем указательnumber
и получаем то, на что он указывает (т.е. число 42)delete number
— с помощью ключевого словаdelete
мы берем указательnumber
и освобождаем память, на которую он указывает. Теперь он указывает на неразмеченную память и им нельзя пользоваться
К любому типу данных можно приписать звездочку (а можно даже несколько!) и тогда это будет указатель, указывающий на тип данных. К самим же указателям звездочка применяется как оператор, который возвращает значение, хранящееся в памяти, на которую указатель указывает. Наконец, по-секрету, на уже существующую память можно получить указатель с помощью оператора &
.
Теперь должно стать яснее, как пользоваться указателями.
- Выделяем на куче память под объект с помощью
new
- Когда нам нужно воспользоваться объектом, память под который мы выделили, мы используем оператор
*
на указателе - Когда мы закончили и хотим освободить память, мы используем
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 — такая вещь, которая до сих пор вызывает споры. Я отношусь к ней скептически, хотя я согласен с тем, что она имеет свои случаи использования.
Тем не менее, я стараюсь использовать стек именно как статичную память, и всем это советую. Тогда ни у кого точно оттуда ничего не утечет :)