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

Проще говоря: нарушение доступа к памяти — это какие-то действия с некорректными данными, а утечка памяти — это отсутствие определённых действий с корректными данными. В табличной форме:


                    Корректные данные     Некорректные данные
Используются        OK                    Ошибка доступа к памяти
Не используются     Утечка памяти         OK

Лучшие программы выполняют только действия из ОК-ячеек: они манипулируют корректными данными и не манипулируют некорректными. Приемлемые программы могут также содержать некоторые корректные, но неиспользуемые данные (утечки памяти), а плохие пытаются использовать некорректные данные.

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


Последствия


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

Безопасность работы с памятью является ключевым элементом любой другой формы безопасности/корректности. Если программа допускает ошибки с памятью, о ее поведении сложно давать какие-либо гарантии, поскольку не исключается возможность повреждения памяти. Злоумышленники смогут воспользоваться ошибками доступа к памяти в такой программе для считывания конфиденциальных ключей непосредственно из памяти сервера или выполнения произвольного кода на вашем компьютере.

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

В итоге, большинство языков программирования предпочитают мириться с утечками памяти (допуская отсутствие освобождения или очистки данных после последнего использования), но не с ошибками доступа к памяти. Таким образом, большинство "безопасных" языков гарантируют, что программы, написанные на них, не содержат таких ошибок, если только вы сознательно не решите обойти ограничения (например, используя модуль ctypes в Python или ключевое слово unsafe в Rust). Что касается утечек, то с ними пытаются (как правило, сильно) бороться, но не дают никаких гарантий.


delete free


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


Ошибки работы с динамической памятью — некорректное использование динамической памяти и указателей:

  • Висячие указатели — указатели, хранящие адрес объекта, который был удалён.
  • Двойное освобождение — повторный вызов free может преждевременно удалить новый объект, располагающийся по тому же адресу. Если же адрес не был повторно использован, тогда могут возникнуть другие проблемы, особенно в аллокаторах, которые используют свободные списки (free lists).
  • Некорректное освобождение — передача некорректного адреса в функцию free может испортить кучу.
  • Обращение по нулевому указателю приведёт к исключению или аварийному завершению программы в большинстве окружений, но может и вызвать порчу данных в ядре операционной системы или в системах без защиты памяти или при применении большого отрицательного смещения.


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

Конечно, простой запрет на вызов free имеет свои недостатки (хотя имеет и преимущества, помимо отсутствия проблем с освобождением памяти: не возникает сложностей с пониманием времени жизни данных, что упрощает написание многих параллельных алгоритмов). В частности, становится проблематично писать программы так, чтобы они не исчерпали всю доступную память. Однако, компьютеры, в отличии от людей, не ошибаются, так что, возможно, мы можем переложить вызов free на их плечи…


Оптимизация утечек


Значительная часть современного кода написана на языках, направленных на обеспечение безопасности работы с памятью, таких как Java, Javascript, Python или Ruby. Они обходятся без явного вызова free и автоматически управляют памятью (отсюда и название "управляемые языки") при помощи "сборщика мусора" (garbage collector), встроенного в среду выполнения языка.

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

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

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

Тем не менее, цель достигается даже с учётом возникающих на практике проблем: отсутствие вызовов free гарантирует отсутствие (некоторых) проблем работы с памятью.


Меньше абстракций


Не могу не упомянуть альтернативу автоматическому управлению памятью: вместо того, чтобы пытаться избавиться от колонки "Некорректные данные" целиком, можно гарантировать только отсутствие проблем безопасности работы с памятью. Язык программирования Rust делает именно это.

Такой подход избавляет от необходимости ручного освобождения ресурсов, хотя функция drop и позволяет вызвать деструктор раньше времени. В отличии от С и С++, на этапе компиляции Rust запрещает дальнейшее использование таких ставших некорректными данных и предотвращает ошибки.

Однако, эта модель не гарантирует отсутствие утечек: в пересмотренной таблице для Rust (и языков с аналогичным принципом) всё ещё имеется ячейка "Утечка памяти".


                    Корректные данные     Некорректные данные
Используются        OK                    Невозможно
Не используются     Утечка памяти         OK

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


std::mem::forget


И, наконец, в Rust присутствует функция forget, которая помечает данные как освобождённые, предотвращая дальнейший доступ к ним, но не вызывает деструктор, что потенциально ведёт к утечке памяти. Долгое время эта функция была помечена как небезопасная (unsafe), то есть Rust неявно подразумевал, что утечки памяти — это то, что программист должен выбирать сознательно, аналогично безопасности работы с памятью. Однако, на практике указатели с подсчётом ссылок или взаимная блокировка потоков могут приводить к утечкам. В итоге функцию forget сделали безопасной, сфокусировавшись на предотвращении ошибок доступа к памяти, хотя и прилагая все возможные усилия в борьбе с утечками, впрочем, как и все остальные языки.

Как и современный С++, Rust довольно неплохо справляется с задачей: управление ресурсами, основанное на RAII, а именно деструкторы, являются мощным средством для управления памятью (и не только), особенно в комбинации с семантикой перемещения по умолчанию, используемой в Rust. Отсутствие утечек не гарантируется по двум причинам:


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

Стандартная библиотека Rust ожидает, что утечки безопасны, хотя и могут приводить к некорректной работе. Другими словами, вы можете получить нежелательное поведение, если данные не будут освобождены, но последствия будут менее разрушительны, чем ошибки сегментации (segmentation fault) или повреждения памяти.

Комментарии (25)


  1. DrZlodberg
    25.04.2016 10:05
    -2

    В теории всё это звучит хорошо, однако как показал опыт самый простой способ контролировать память как ни странно старый добрый malloc/free.
    В режиме отладки добавляется глобальный дефайн malloc -> malloc_log и аналогичные для остальных и, в зависимости от режима отладки, либо всё пишется в лог, либо просто считается баланс выделения-освобождения. Если баланс на выходе не сходится — переключаем в полный лог, в который пишем размер, адрес, файл и строку. Дальше тупой скрипт на perl выдаёт статистику — что не было освобождено или не дай бог освобождено без выделения. Утечки ловятся на раз, т.к. в режиме баланса на отладке он работает всегда. Правда с хитрыми утечками, которые возникают только при редких стечениях обстоятельств работает плохо. Логи, правды, бывают большими. Порой по пол-гига набегало.
    Как сделать аналогичное с new придумать не удалось.


    1. ProstoTyoma
      25.04.2016 10:17
      +3

      > Как сделать аналогичное с new придумать не удалось.
      Хороший детектор для Visual Studio, в т.ч. для new. Можно подсмотреть как сделано.
      http://www.codeproject.com/Articles/9815/Visual-Leak-Detector-Enhanced-Memory-Leak-Detectio


      1. DrZlodberg
        25.04.2016 10:41

        Спасибо, весьма любопытная штука. Как и то, как именно она это делает.Правда один минус — не ловит «излишки», т.е. попытки освободить то, что не было выделено (случается про повреждении указателей). Очень мерзкая вещь, поскольку обычно вызывает падение программы без каких-либо явных симптомом.
        Еще не очень понял — оно работает только с VS или можно куда угодно прикрутить?


        1. ProstoTyoma
          25.04.2016 10:57

          Не знаю, пробовал только в VS. В остальных местах же есть Valgrind?


          1. DrZlodberg
            25.04.2016 11:45

            Valgrind судя по вики есть только в Linux. Под Windows список компиляторов VS не ограничивается. Сам, например, сейчас пишу под mingw.


    1. DarkEld3r
      25.04.2016 10:42
      +6

      однако как показал опыт самый простой способ контролировать память как ни странно старый добрый malloc/free.

      Вы описываете не способ контролировать, а способ бороться с последствиями такого "контроля". Не говоря уже о том, что вся статья посвящена как раз тому что утечки и расстрел памяти — разные вещи и раст обещает именно отсутствие второго.

      В моей практике (С++) весьма неплохо работают умные указатели в связке с валгриндом — как раз чтобы уменьшить человеческий фактор. Подозреваю, что достаточного опыта для полноценного сравнения с Rust у вас нет. Впрочем, я и сам не сильно дальше "хелоу ворлдов" ушёл, так что спорить не буду. Хотя растовые гарантии мне кажутся весьма полезными.


      1. DrZlodberg
        25.04.2016 10:50

        Увы, да. Хотя Rust очень заинтересовал своим подходом.
        А что такое «валгринд»?


        1. DarkEld3r
          25.04.2016 10:53
          +4

          А что такое «валгринд»?

          Процитирую википедию: "Valgrind — инструментальное программное обеспечение, предназначенное для отладки использования памяти, обнаружения утечек памяти, а также профилирования." Правда под Windows им пользоваться не получится.


    1. MikalaiR
      25.04.2016 11:12
      +5

      >> Как сделать аналогичное с new придумать не удалось.
      void* operator new(std::size_t n)
      void* operator new[]( std::size_t count )
      void operator delete(void* ptr)
      void operator delete[]( void* ptr )
      Ну и placement new, new(nothrow) если они вам нужны.


    1. chersanya
      25.04.2016 13:32
      +4

      Чем городить велосипед можно использовать готовое решение — в clang есть address sanitizer и leak sanitizer. Включаются тривиально — просто опцией при компиляции, работают быстро (относительно валгринда), сразу выдают читаемые сообщения об ошибках.


      1. ZyXI
        25.04.2016 15:40
        +1

        Для более корректной работы нужно так же компилировать все сторонние библиотеки, кроме системных вроде libc. Можно и без этого, но он начнёт пропускать ошибки. Так что работают?то sanitizer’ы быстро, но всё имеет свою цену.


        1. chersanya
          25.04.2016 17:20
          +2

          Я когда использую его подключаю не инструментированные clang'ом libstdc++ и boost, остальные используемые библиотеки относительно небольшие и они и так компилируются clang в рамках одного проекта и cmake файла, поэтому для них добавить ключ компиляции не проблема. При этом sanitizer помогает почти мгновенно найти как обращение к «чужой» памяти (это меньшая проблема т.к. обычно отлавливается и просто при запуске в дебаггере — однако sanitizer даёт дополнительную полезную инфу) так и утечки памяти.


      1. DrZlodberg
        25.04.2016 15:48

        Занятно. А можно где-нибудь про него почитать нормальны доки? Что-то находятся только какие-то обрезки.
        Ну и, собственно, та же проблема (как я понял по обрывкам). Он попытки освобождения повреждённого указателя не видит.


        1. chersanya
          25.04.2016 17:24

          А какие собственно доки нужны, кроме того что есть на http://clang.llvm.org/docs/AddressSanitizer.html? Если просто добавить указанный ключ и ASAN_SYMBOLIZER_PATH=/usr/local/bin/llvm-symbolizer при запуске, то в случае ошибки обращения к памяти или утечки он выдаст подробную информацию. Так что ничего настраивать не надо особо — просто в системе сборки указать дополнительный ключ компиляции и всё (конечно, если у вас clang используется; и обнаружение утечек работает только на linux).


          1. DrZlodberg
            26.04.2016 08:18

            Вообще я с mingw работаю, но в gcc похоже эта фишка тоже есть. Так что есть надежда, что и в mingw. Хотя если только на linux…


      1. dpantele
        26.04.2016 02:37

        В gcc они же есть с 4.8: https://gcc.gnu.org/gcc-4.8/changes.html


        1. chersanya
          26.04.2016 19:22

          Спасибо за информацию, не знал — всегда clang использую. Не нашёл по вашей ссылке детектор утечек правда — судя по гуглу, его тоже добавили, но совсем недавно.


          1. dpantele
            26.04.2016 22:28
            +1

            Leak sanitizer встроен в Address sanitizer, причём судя по этому issue он там появился раньше чем сам Address Sanitizer стал встроен в gcc. В любом случае, он есть и работает, с gcc 5 точно. Например, недавно благодаря ему узнал про mpfr_free_cache.


            1. dpantele
              26.04.2016 23:49

              Все ссылки оказались съедены…

              Leak sanitizer всроен в Address: https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html#index-fsanitize_003dleak-950
              Issue: https://github.com/google/sanitizers/issues/118
              mpfr_free_cache: http://www.mpfr.org/mpfr-current/mpfr.html#index-mpfr_005ffree_005fcache


    1. 0xd34df00d
      25.04.2016 19:21
      +3

      Это всё, конечно, очень хорошо, но до тех пор, пока счёт аллокаций не идёт на сотни миллионов, и сама программа не начинает занимать под сотню гигабайт в памяти.

      Ну и да, valgrind, asan, google perftools.


      1. DrZlodberg
        26.04.2016 08:16

        Ну да, это уже другой уровень. Хотя метод вполне модифицируется и под такой вариант. Только вместо лога придётся отслеживать всё в рилтайме. С памятью тут проблем явно не будет под это. По сути это уже будет примерно то же самое, что и другие решения. Примерно.


  1. DrZlodberg
    25.04.2016 11:38

    Это же вроде как всем прописывать придётся, операторы у каждого объекта свои. Если у тебя их штук так дофига — это уже проблема, и отключить их в релизе так просто не получится. В теории можно переопределить у корневого (при наследовании) но смысла нет, т.к. не будет понятно откуда он вызван.
    Хотя возможно тут я не прав, голый C мне ближе чем ++
    (пардон, это было к комментарию про «void* operator new(std::size_t n)»)


    1. MikalaiR
      25.04.2016 12:00
      +1

      Нет, их можно переопределить глобально.


      1. 0xd34df00d
        25.04.2016 19:23
        +2

        А можно просто переопределить через LD_PRELOAD malloc/free и glibc'шными же средствами получать стектрейс каждой аллокации или освобождения. Заодно решается проблема с инструментацией сторонних библиотек, не собираемых в рамках проекта.

        Собственно, google perftools примерно так и поступает.

        Конечно, это всё под Linux. Может, под Windows есть аналогичные методы, не знаю.


        1. hdfan2
          26.04.2016 07:26
          +1

          Под Windows для поиска утечек памяти есть утилиты gflags и umdh из Debugging Tools for Windows. Сначала делаете:

          gflags -i program.exe +ust

          Подставив вместо program.exe имя своей программы, естественно. Это включает для program.exe логирование выделения/удаления блоков в куче со стеком. Это нужно делать ТОЛЬКО перед поиском утечек, т.к. всё будет работать существенно медленней. Потом запускаете программу и выполняете:

          umdh -pn:program.exe -f:dump1.txt

          Делается первый слепок памяти (все выделенные блоки со стеками). Дали поработать, поутекать памяти, потом делаете:

          umdh -pn:program.exe -f:dump2.txt
          umdh dump1.txt dump2.txt > diff.txt

          В файл diff.txt выводятся все блоки памяти (количество, объём, стек), сгруппированные по стеку, которые либо был выделены и удалены, либо выделены до первого вызова umdh и удалены до второго. В конце нужно не забыть сделать:

          gflags -i program.exe -ust