Статьи

Перевод: Николай Мациевский aka sunnybear
Опубликована: 21 февраля 2008

Практический JS: избавляемся от утечек памяти в IE

Примечание: ниже находится перевод статьи Understanding and Solving Internet Explorer Leak Patterns", в которой автор рассматривает некоторые характерные случаи утечек памяти в IE и предлагает методы для их избежания и устранения. Рассмотренные проблемы не являются чем-то новым или революционным, однако, знать об их существовании должен любой уважающий себя программист клиентских интерфейсов. Мои комментарии далее курсивом.

Опубликована: июнь 2005

Развитие веб-разработок

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

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

Большим плюсом в данной ситуации будет то, что шаблоны утечек памяти могут быть легко обнаружены, если вы знаете, где их искать. Наиболее тяжелые из них, с которыми, возможно, вам довелось столкнуться, имеют подробно описанные методы устранения, которые, скорее всего, в вашем случае потребуют лишь небольшого количества дополнительной работы. Хотя некоторые страницы могут по-прежнему «падать» из-за небольших утечек, самые значительные могут быть легко удалены.

Шаблоны утечек

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

Давайте теперь обратимся к следующим шаблонам:

  1. Циклические ссылки, когда существует взаимная ссылка (mutual references) между объектом COM-объектом Internet Explorer'а и любым скриптовым движком, такие объекты могу приводить к утечкам памяти. Это самый распространенный шаблон.
  2. Замыкания являются наиболее значимым шаблоном для существующих архитектур веб-приложений. Замыкания довольно легко зафиксировать, потому что они зависят от ключевого слова, относящегося к используемому языку, и могут быть по нему, в общем случае, обнаружены.
  3. Постраничные утечки (Cross-Page) зачастую представляют собой очень маленькие утечки, которые возникают из-за учета (book-keeping) объектов при перемещении от элемента к элементу. Мы рассмотрим порядок добавления DOM-объектов, а заодно, и характерные примеры, которые демонстрируют, как небольшое изменение вашего кода может предотвратить создание таких учитываемых объектов.
  4. Псевдо-утечки, по существу, не являются утечками, но могут вызывать некоторое беспокойство, если вы не понимаете, куда расходуется ваша память. Мы рассмотрим перезапись объекта скрипта, и как она проявляется в расходовании очень малого количества памяти, если работает так, как требуется.

Циклические ссылки

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

Рисунок 1. Основной шаблон циклической ссылки

Утечка в таком шаблоне происходит из-за особенностей учета COM-ссылок. Объекты скриптового движка удерживают ссылку на DOM-элемент и ожидают, пока будут освобождены все внешние ссылки, чтобы освободить, в свою очередь, этот указатель на DOM-элемент. В нашем случае у нас две ссылки на объект скрипта: внутри области видимости скриптового движка и от расширенного свойства DOM-элемента. По окончанию своей работы, скрипт освободит первую ссылку, но ссылка из DOM-элемента никогда не будет освобождена, потому что ждет, что это сделает объект скрипта! Наверное, вы подумали, что такой сценарий развития событий легко обнаружить и устранить, однако, на практике, представленный базовый случай является только вершиной айсберга. Может оказаться так, что циклическая ссылка находится в конце цепочки из 30 объектов, обнаружить ее при этом крайне тяжело.

Если вы хотите посмотреть, как данный шаблон будет выглядеть в HTML, то можно вызвать утечку, используя глобальную переменную и DOM-объект, как показано ниже.

<html>
    <head>
        <script language="JScript">

	    var myGlobalObject;

	    function SetupLeak()
	    {
		// Для начала создадим ссылку из скрипта на DOM-элемент
		myGlobalObject =
		    document.getElementById("LeakedDiv");

		// Потом установим ссылку из DOM на глобальную переменную
		document.getElementById("LeakedDiv").expandoProperty =
		    myGlobalObject;
	    }

	    function BreakLeak()
	    {
		document.getElementById("LeakedDiv").expandoProperty =
		    null;
	    }

	</script>
    </head>

    <body onunload="BreakLeak()">
        <div id="LeakedDiv"></div>
    </body>
</html>

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

Хотя это только базовый шаблон, для более сложных ситуаций может быть достаточно сложно выяснить первопричину утечки. Распространенной практикой по написанию объектно-ориентированного JScript является расширение DOM-элементов путем инкапсуляции их внутри JScript-объекта. В процессе создания такого объекта вы, в большинстве случаев, получаете ссылку на желаемый DOM-элемент, а затем сохраняете эту ссылкой в только что созданном объекте, при этом экземпляр этого объекта оказывается прикрепленным к DOM-элементу. Таким способом ваша модель приложения всегда получает доступ ко всему, что нужно. Проблема заключается в том, что это явная циклическая ссылка, но из-за использования других аспектов языка она может остаться незамеченной. Устранение шаблонов такого рода может быть весьма затруднительным, но вы вполне можете использовать простые методы, обсужденные ранее.

<html>
    <head>
	<script language="JScript">

	    function Encapsulator(element)
	    {
		// Создаем элемент
		this.elementReference = element;

		// Создаем циклическую ссылку
		element.expandoProperty = this;
	    }

	    function SetupLeak()
	    {
		// Утечка: все в одном
		new Encapsulator(document.getElementById("LeakedDiv"));
	    }

	    function BreakLeak()
	    {
		document.getElementById("LeakedDiv").expandoProperty =
		    null;
	    }

	</script>
    </head>

    <body onunload="BreakLeak()">
	<div id="LeakedDiv"></div>
    </body>
</html>

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

Замыкания

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

Рисунок 2. Циклические ссылки с замыканиями

В случае обычной циклической ссылки у нас есть 2 постоянных объекта, которые содержат ссылки друг на друга, но замыкания отличаются от этой модели. Вместо создания прямых ссылок, они наследуют информацию из пространства порождающих их объектов. В обычном случае локальные переменные в функции и используемые параметры существуют только в течение времени жизни самой функции. В случае замыканий, эти переменные и параметры имеют внешнюю ссылку на весь период жизни замыкания, которое может быть значительно дольше, чем породившей ее функции. В качестве примера можно рассмотреть Параметр 1, который был бы освобожден по окончанию вызова функции, в общем случае. Однако, после добавления замыкания была создана вторая ссылка на этот параметр, которая не может быть освобождена, пока не будет закрыто это замыкание. Если вы прикрепили замыкание к событию, то вам придется его у события, в конце концов, убрать. Если замыкание прикреплено к расширенному параметру (expando), вам нужно будет занулить (приравнять этот параметр к null.

Замыкания создаются по вызову функции, поэтому два различных вызова породят два независимых замыкания, каждое будет содержать ссылки на параметры своего вызова. Из-за такой внешней прозрачности очень легко, на самом деле, позволить замыканиям «течь». В следующем примере приводится один из базовых случаев возникновения утечек при замыканиях:

<html>
    <head>
        <script language="JScript">

	    function AttachEvents(element)
	    {
		// Эта структура создает у элемента ссылку на ClickEventHandler
		element.attachEvent("onclick", ClickEventHandler);

		function ClickEventHandler()
		{
		    // Это замыкание ссылается на элемент
		}
	    }

    	    function SetupLeak()
	    {
		// Происходит утечка
		AttachEvents(document.getElementById("LeakedDiv"));
	    }

    	    function BreakLeak()
	    {
	    }

	</script>
    </head\>

    <body onunload="BreakLeak()">
        <div id="LeakedDiv"></div>
    </body>
</html>

Если вы задумываетесь над тем, как устранить эту утечку, то это не так просто сделать, как в случае с обычной циклической ссылкой. «Замыкание» можно рассматривать как временный объект, который существует в области видимости функции. После завершения функции, вы теряете ссылку на само замыкание, поэтому встает вопрос, как же вызвать завершающий detachEvent? Один из наиболее интересных подходов рассмотрен в блоге MSN spaces, спасибо Scott Isaacs. В нем используется второе замыкание, которое цепляется на событие onUnload всего окна браузера. Поскольку оно имеет объекты из той же области видимости, то становится возможным снять обработку события, высвободить замыкание и завершить процесс очистки. Чтобы окончательно прояснить ситуацию, мы можем добавить в наш пример дополнительное свойство, в котором сохраним ссылку на замыкание, затем по ссылке освободим замыкание и обнулим само свойство, например, как в следующем образце кода.

<html>
    <head>
        <script language="JScript">

        function AttachEvents(element)
	{
	    // чтобы иметь возможность освободить замыкание
	    // мы должны где-то сохранить ссылку на него
	    element.expandoClick = ClickEventHandler;

	    // Эта структура создает у элемента ссылку на ClickEventHandler
	    element.attachEvent("onclick", element.expandoClick);

	    function ClickEventHandler()
	    {
		// Это замыкание ссылается на элемент
	    }
	}

        function SetupLeak()
	{
	    // Происходит утечка
	    AttachEvents(document.getElementById("LeakedDiv"));
	}

        function BreakLeak()
	{
	    document.getElementById("LeakedDiv").detachEvent("onclick",
	    document.getElementById("LeakedDiv").expandoClick);
	    document.getElementById("LeakedDiv").expandoClick = null;
	}

        </script>
    </head>

    <body onunload="BreakLeak()">
	<div id="LeakedDiv"></div>
    </body>
</html>

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

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

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

Постраничные утечки

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

Рисунок 3. Утечки, связанные с порядком добавления DOM-элементов

Далее мы собираемся проанализировать характерный пример, который обычно упускают из рассмотрения большинство алгоритмов по обнаружению утечек. Поскольку он не затрагивает публично доступных элементов и объекты, которые вызывают утечки, весьма невелики, возможно, вы никогда и не заметите этой проблемы. Чтобы заставить наш пример работать, нам нужно снабдить динамически создаваемые элементы указателем на какую-либо линейную (inline) функцию. Таким образом, при каждом таком вызове будет происходить утечка памяти на создание временного внутреннего объекта (например, обработчика событий), как только мы будет прикреплять создаваемый объект к общему дереву. Поскольку утечка весьма мала, нам придется запустить сотни циклов. Фактически, она составляет всего несколько байтов. Запуская пример и возвращаясь к пустой странице, можно замерить разницу в объеме памяти между этими двумя случаями. При использовании первой DOM-модели для прикрепления дочернего узла к родительскому, а затем родительский — к общему дереву, использование памяти немного возрастает. Это утечка использования перекрестных ссылок (cross-navigation), и память не высвобождается, если вы перезапустите IE-процесс. Если вы протестируете пример, используя вторую DOM-модель для тех же самых действий, то никакого изменения в размере памяти не последует. Таким образом, можно исправить утечки такого рода.

<html>
    <head>
	<script language="JScript">
	
	function LeakMemory()
	{
	    var hostElement = document.getElementById("hostElement");

	    // Давайте посмотрим, что происходит с памятью в Диспетчере Задач

            for(i = 0; i < 5000; i++)
	    {
		var parentDiv =
		    document.createElement("<div>");
		var childDiv =
		    document.createElement("<div>");

                // Здесь вызывается утечка на временном объекте
		parentDiv.appendChild(childDiv);
		hostElement.appendChild(parentDiv);
		hostElement.removeChild(parentDiv);
		parentDiv.removeChild(childDiv);
		parentDiv = null;
		childDiv = null;
            }
	    hostElement = null;
	}

        function CleanMemory()
	{
	    var hostElement = document.getElementById("hostElement");

	    // Опять смотрим в Диспетчере задач на использование памяти

	    for(i = 0; i < 5000; i++)
	    {
                var parentDiv =
		    document.createElement("<div>");
		var childDiv =
		    document.createElement("<div>");

                // Изменение порядка имеет значение. Теперь утечек нет
		hostElement.appendChild(parentDiv);
		parentDiv.appendChild(childDiv);
		hostElement.removeChild(parentDiv);
		parentDiv.removeChild(childDiv);
		parentDiv = null;
		childDiv = null;
            }
	    hostElement = null;
	}
	</script>

    </head>

    <body>
        <button>Memory Leaking Insert</button>
	<button>Clean Insert</button>

	<div id="hostElement"></div>
    </body>
</html>

Стоит немного прокомментировать приведенный пример, потому что он противоречит некоторым практическим советам, которые дают относительно написания скриптов для IE. Ключевым моментом в данном случае для осознания причины утечки является то, что DOM-элементы создаются с прикрепленными к ним обработчиками событий. Это является критичным для утечки, потому что в случае обычных DOM-элементов, которые не содержат никаких скриптов, их можно присоединять друг к другу в обычном режиме, не опасаюсь проблем, связанных с утечками. Это позволяет предложить второй метод решения поставленной проблемы, который может быть даже лучше для больших поддеревьев (в нашем примере мы работали только с двумя элементами, но работа с деревом, не используя первичный DOM-объект, может быть весьма далека от оптимальной производительности). Итак, второе решение заключается в том, что мы можем создать вначале все элементы без прикрепленных к ним скриптов, чтобы безопасно собрать все поддерево. После этого можно прикрепить полученную структуру к первичному DOM и перебрать все необходимые узлы и навесить на них требуемые обработчики событий. Помните об опасностях использования циклических ссылок и замыканий, чтобы не вызвать возможные дополнительные утечки памяти, связанные с обработчиками событий.

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

Псевдо-утечки

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

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

<html>
    <head>
        <script language="JScript">

	function LeakMemory()
	{
    	    // Посмотрим, что происходит с памятью в Диспетчере Задач
	    
	    for(i = 0; i < 5000; i++)
	    {
		hostElement.text = "function foo() { }";
	    }
    	}
	</script>
    </head>

    <body>
        <button>Memory Leaking Insert</button>
	<script id="hostElement">function foo() { }</script>
    </body>
</html>

Если вы запустите приведенный код и посмотрите в Диспетчере Задач, что происходит при переходе с «текущей» страницы на чистую, вы не увидите никаких утечек. Скрипт расходует память только внутри текущей страницы, и при перемещении на новую вся задействованная память разом освобождается. Вся ошибка заключается в неверном ожидании определенного поведения. Казалось бы, что переписывание некоторого скрипта приведет к тому, что предыдущий кусок будет бесследно исчезать, оставляя только дополнительные циклические ссылки или замыкания, однако, фактически, он не исчезает. Как вы можете видеть, это псевдо-утечка. В данном случае размер выделенной памяти выглядит устрашающе, однако, для этого совершенно законная причина.

Заключение

Каждый веб-разработчик составляет персональный список примеров кода, для которого известно, то он «течет», и пытается найти для каждого случая достойное решение, когда обнаруживает источник проблемы. Это весьма полезно, и именно по этой причине сейчас веб относительно свободен от утечек памяти. Размышляя о проблемах выделения памяти в терминах шаблонов, а не индивидуальных кусков кода, можно начать внедрять гораздо более продуктивные и более осмысленные решения. Идея заключается в том, чтобы уже на этапе проектирования вашего приложения вы имели представление о том, какие утечки возможно и как с ними будет лучше работать. Используйте «оборонительную» тактику при разработке и предполагайте, что вся задействованная приложением память должна быть освобождена. Хотя это и преувеличение действительной проблемы, потому что очень редко, когда действительно требует освободить всю память, однако, это становится существенным при наличии у переменных и расширяемых свойств потенциальной склонности к утечкам.

Если вам интересно дальнейшее изучение таких шаблонов, я бы настоятельно рекомендовал ознакомиться с короткой заметкой Scott'а, потому что в ней демонстрируется общий подход, позволяющий убрать разом все возможные утечки памяти, основанные на замыканиях. Хотя он требует большего объема кода, но является хорошей практикой в данной области и позволяет легко находить улучшенный шаблон и отлаживать его. Похожий алгоритм регистрации возможных утечек может быть использован для работы с основанным на дополнительных свойствах шаблоном утечек по циклическим ссылкам, если, конечно, сам этот метод не вызывает утечек (особенно при работе с замыканиями)!

Читать дальше

Все комментарии (habrahabr.ru)