Содержание


Методы отладки использования памяти

Разоблачение величайшего мифа о проблеме языка С

Comments

Ошибки памяти в программах, написанных на С и С++, плохи тем, что они повсеместны и могут иметь серьезные последствия. Множество серьезных предупреждений о проблемах безопасности от Computer Emergency Response Team (см. Ресурсы) и разработчиков программного обеспечения являются одним из следствий простых ошибок памяти. Программисты на С обсуждали этот тип ошибок с конца 70-х годов, но они часто встречались и в 2007 году. Ситуация усугубляется тем, что множество программистов С и С++ рассматривают ошибки памяти как неконтролируемые и роковые несчастья, которые можно только исправлять по мере их возникновения, но не предотвращать.

Это совсем не так. Эта статья покажет, что возможно понять все особенности грамотного программирования распределения памяти:

Важность правильного управления памятью

Программы с ошибками памяти, написанные на С и С++, являются причиной возникновения проблем. Если в них происходит утечка памяти, то они выполняются постепенно все медленнее и медленнее и в конечном итоге зависают; если они переписывают содержимое памяти - они не защищены и легко взламываются . Действие известного червя Морриса в 1988 году было основано на переполнении буфера, а недавно обнаруженные бреши в системе безопасности Flash Player'а и других распространенных программ также связаны с переполнением буфера. "Самая главная брешь в защите компьютера - переполнение буфера", - писал Родни Бейтс (Rodney Bates) в 2004 году.

Многие другие универсальные языки, такие как Java™, Ruby, Haskell, C#, Perl, Smalltalk и тому подобные, широко применяются в ситуациях, где могли бы использоваться С или С++, и каждый из них имеет энтузиастов и значительные выгоды. В компьютерном фольклоре бытует мнение, что большинство преимуществ в удобстве и простоте использования других языков по сравнению с С и С++ - это простота управления памятью. Программирование, связанное с управлением памяти, очень важно, но его правильное применение весьма трудно на практике из-за того, что сейчас доминируют другие подходы в программировании, такие как объектно-ориентированный, функциональный, высокоуровневый, декларативный и другие.

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

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

Какие встречаются ошибки в управлении памятью

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

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

Это полный список. Даже с учетом объектно-ориентированного С++ список возможных проблем сильно не изменится: модель управления памятью и ссылки в С и С++ одинаковы, независимо от того, является ли тип данных простым, структурой языка C или классом С++. Большинство кода ниже написано на чистом С, а код для С++ в большинстве случаев оставлен для самостоятельных упражнений.

Утечки памяти

Утечки памяти происходят, когда ресурсы системы выделяются для программы, но последняя никогда не возвращает их. Ниже представлен пример такой утечки: (см. листинг 1):

Листинг 1. Простая потенциальная потеря памяти в "куче" и перезапись буфера.
	void f1(char *explanation)
	{
	    char *p1;

	    p1 = malloc(100);
            (void) sprintf(p1,
                           "The f1 error occurred because of '%s'.",
                           explanation);
            local_log(p1);
	}

В чем тут кроется проблема? До тех пор пока local_log() несет необычную для себя ответственность за освобождение памяти (free()), каждый вызов f1 приводит к утечке 100 байт. 100 байт кажется достаточно незначительной потерей памяти, однако через несколько часов, в течение которых будет вызываться этот метод, программа может зависнуть.

В программировании на С и С++ недостаточно следить за использованием malloc() или new. ВВ начале этого раздела были упомянуты именно средства, а не конкретно память из-за примеров, подобных следующему (см. листинг 2). Файловые дескрипторы (FILE handles) могут не быть похожими на блоки памяти, но работать с ними нужно так же аккуратно, как и с памятью:

Листинг 2. Возможная потеря памяти в "куче" из-за неправильного управления ресурсами
	int getkey(char *filename)
	{
	    FILE *fp;
	    int key;

	    fp = fopen(filename, "r");
	    fscanf(fp, "%d", &key);
	    return key;
        }

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

Неправильное выделение памяти

Проще бороться с неправильным выделением памяти (см. пример из листинга 3):

Пример 3. Неинициализированный указатель
	void f2(int datum)
	{
	    int *p2;

                /* ошибка!  никто не инициализировал указатель p2. */
            *p2 = datum;
	       ...
        }

Подобные ошибки имеют печальные последствия. Если программа выше будет запущена на операционной системе AIX®, присвоение неинициализированному указателю в большинстве случаев вызовет немедленную ошибку в сегментации ( segmentation fault). Эти ошибки легко обнаружить и исправить их гораздо проще, чем те ошибки, которые трудно повторно воспроизвести и и на идентификацию которых может уйти несколько месяцев.

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

Листинг 4. Два ошибочно выполненных высвобождения памяти
	/* выделяем память один раз, освобождаем дважды. */
	void f3()
	{
	    char *p;

	    p = malloc(10);
	     ...
            free(p);
	     ...
            free(p);
        }

        /* выделяем ноль раз, освобождаем один раз. */
	void f4()
	{
	    char *p;

                /* внимание! на этой строчке указатель p все еще не инициализирован. */
	    free(p);
	}

Эти ошибки также не угрожают тяжелыми последствиями. Хотя стандарт С не определяет поведение в этих ситуациях, обычно в готовых продуктах эти ошибки либо игнорируются, либо помечаются быстро и четко; как упомянутые ранее, эти ошибки безвредны.

Указатели, указывающие на несуществующие объекты

Указатели, указывающие на несуществующие объекты (далее - зависшие указатели) вызывают больше неприятностей. Зависшие указатели возникают, когда программист использует ресурсы памяти после того, как их освободили (см. листинг 5):

Листинг 5. Зависшие указатели
       void f8() 
       {
	   struct x *xp;

	   xp = (struct x *) malloc(sizeof (struct x));
	   xp.q = 13;
	   ...
	   free(xp);
	   ...
	       /* проблема!  нет никакой гарантии, что
		  блок памяти, на который ссылается указатель xp,
		  не будет перезаписан. */
	   return xp.q;
       }

Традиционной отладкой зависшие указатели трудно локализовать. Их трудно повторно воспроизвести по следующим причинам:

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

Зависшие указатели являются постоянной проблемой программ, написанных на С или С++.

Нарушение размерности массива

Не такой уж безопасной ошибкой является нарушение размерности массива – ошибка, стоящая последней в списке главных ошибок в управлении памятью. Посмотрите снова листинг 1, что случится, если длина explanation превысит 80? Трудно однозначно сказать, что случится, но в любом случае от этого не стоит ждать ничего хорошего. Более определенно, С копирует строку, которая не умещается в 100 символов, под которые отведена память. В любых общих реализациях не влезающие в отведенные 100 байт символы перепишут другие данные в памяти. Размещение данных в памяти достаточно сложное и трудно воспроизвести, поэтому любые признаки, симптомы ошибки в реализации было бы трудно связать с определенной ошибкой в исходном коде. Нарушение размерности массивов - ошибка, входящая в число ошибок, причиняющих миллионные убытки.

Стратегии программирования использования памяти

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

Стиль программирования

Самое важное и, как я заметил, единственное, чему не придают особого значения другие авторы - общепринятые стандарты программирования. Функции и методы, которые влияют на ресурсы, особенно память, необходимо подробно комментировать. Вот некоторые примеры (см. листинг 6).

Листинг 6. Пример кода, в котором подробно описано управление ресурсами памяти
        /********
        * ...
        * каждая функция, вызывающая protected_file_read(), принимает на себя
        * ответственность рано или поздно закрыть возвращенное значение, 
		* если оно не NULL, с помощью fclose().
        *
        ********/
        FILE *protected_file_read(char *filename)
        {
        FILE *fp;

        fp = fopen(filename, "r");
	    if (fp) {
		...
	    } else {
		...
	    }
	    return fp;
	}

        /*******
	 * ...
	 * возвращенное из get_message значение ведет на фиксированную область
	 * памяти. НЕ НУЖНО её освобождать через free(), не забудьте создать копию,
	 * если нужно сохранить значение...
	 *
	 ********/
	char *get_message()
	{
	    static char this_buffer[400];

            ...
	    (void) sprintf(this_buffer, ...);
	    return this_buffer;
        }


        /********
	 * хотя эта функция использует память из "кучи"
	  и может временно
	 * увеличить общий объем используемой памяти,
	  она аккуратно убирает за собой.
	 * 
	 ********/
        int f6(char *item1)
	{
	    my_class c1;
	    int result;
            ...
	    c1 = new my_class(item1);
	    ...
            result = c1.x;
	    delete c1;
	    return result;
	}
	/********
	 * ...
	 * так как f8() согласно документации 
	 возвращает значение, которое необходимо
	 * вернуть в кучу, а метод f7() внутри 
	 себя вызывает f8(), то любой код, вызывающий
	 * f7() должен вызвать free() 
	 чтобы освободить память, 
	 * используемую возвращенным значением.
	 *
	 ********/
	int *f7()
	{
	    int *p;

	    p = f8(...);
	    ...
	    return p;
	}

Эти элементы стиля должны стать частью стиля написания программ. Вот еще несколько подходов к проблеме памяти:

  • специализированные библиотеки;
  • языки;
  • утилиты;
  • аппаратный контроль.

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

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

Проверка

В дополнение к стандартам программирования существует проверка (inspection). Любые методики самостоятельны, но особенно они действенны в комплексе. Внимательный профессиональный программист на С или С++ может проанализировать даже незнакомый исходный код и быстро увидеть проблемы в управлении памятью. После небольшой практики и с соответствующими навыками текстового поиска любой программист сможет быстро научиться оценивать правильность тела программы для сбалансированного использования *alloc() и free() или new и delete. При таком просмотре кода часто вылезают проблемы как в листинге 7.

Листинг 7. Проблемная утечка памяти
	static char *important_pointer = NULL;
	void f9()
	{
	    if (!important_pointer) 
		important_pointer = malloc(IMPORTANT_SIZE);
            ...
	    if (condition)
		    /* ошибка!  мы только что потеряли ссылку на значение, 
		       уже находящееся по указателю important_pointer. */
		important_pointer = malloc(DIFFERENT_SIZE);
            ...
        }

Поверхностное использование автоматических инструментов во время выполнения программы не обнаружит утечку памяти, которая происходит в случае, если условие condition окажется истинным. Тщательное исследование кода может учитывать подобные условия и позволяет обоснованно корректировать предположения. Я повторю то, что писал о стиле: в то время как в большинстве описания проблем, связанных с памятью, в качестве решения предлагается использовать инструменты для обнаружения ошибок и средства языков программирования, я считаю, что наибольшие выгоды приносят мягкие изменения, вносимые разработчиками. Любые улучшения в стиле и проверка помогают программисту понять диагностику, производимую автоматическими инструментами.

Статический автоматический анализ синтаксиса кода

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

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

Библиотеки для управления памятью

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

Из-за всех этих причин я призываю С и С++ программистов начинать с поиска в своем собственном коде проблем с использованием памяти. Сделав это, можно переходить к использованию библиотек.

Несколько библиотек делают возможным написание стандартного С или С++ кода с гарантированным улучшением управления памятью. Джонатан Бартлет (Jonathan Bartlett) описывает главных кандидатов в обзоре 2004 года для developerWorks, доступном в разделе Ресурсы ниже. Библиотеки предназначены для работы с различными проблемами памяти, так что очень трудно сравнить их непосредственно; общими рубриками являются собирание "мусора" ( garbage collection), интеллектуальные указатели (smart pointers) и интеллектуальные контейнеры (smart containers). Грубо говоря, библиотеки автоматизируют большую часть управления памятью, поэтому программист делает меньше ошибок

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

Инструментальные средства для управления памятью.

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

Это введение рассматривает только программные инструментальные средства для управления памятью. Аппаратные отладчики работы памяти также существуют. Я считаю, что они нужны только в исключительных ситуациях - в основном, когда надо работать со специализированными компьютерами, которые не поддерживают другие типы инструментальных средств.

Рынок программных инструментальных средств включает в себя IBM Rational® Purify, Electric Fence и другие инструментальные средства с открытым исходным кодом. Некоторые их них хорошо работают с AIX и другими операционными системами.

Все инструментальные средства для работы с памятью работают приблизительно одинаково: сначала программист создает специальную версию исполняемого кода (хотя сгенерировать отладочную версию можно с помощью флага -g при компиляции), затем запускает приложение и изучает отчеты, автоматически сгенерированные инструментарием. Рассмотрим программу из листинга 8.

Листинг 8. Эталонная ошибка
	int main()
	{
	    char p[5];
	    strcpy(p, "Hello, world.");
	    puts(p);
	}

Во многих средах разработки эта программа компилируется, работает и печатает "Hello, world" на экране. Запуск того же приложения с инструментарием по управлению памятью создаст отчет о нарушении размерности массива в четвертой строке. Узнать об ошибке в программном обеспечении подобным образом (в нашем случае ошибка заключается в том, что четырнадцать символов были скопированы в место, отведенное только для пяти) значительно дешевле, нежели получить от заказчика жалобы на сбои. В этом и заключается польза инструментальных средств для работы с памятью.

Заключение

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


Ресурсы для скачивания


Похожие темы


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=AIX и UNIX
ArticleID=285483
ArticleTitle=Методы отладки использования памяти
publish-date=02012008