4. В рамках процедуры очистки уничтожить состояние ресурса.

5. Освободить память. За этот этап отвечает исключительно уборщик мусора.

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

Если вы пишете код, безопасный по отношению к типам (без использования ключевого слова C# unsafe), повреждение памяти в ваших приложениях невозможно. Утечки памяти остаются теоретически возможными, но они не происходят в стандартной ситуации. Как правило, утечки памяти возникают из-за того, что приложение хранит объекты в коллекции, но не удаляет их, когда они становятся ненужными.

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

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

Выделение ресурсов из управляемой кучи

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

По мере заполнения области объектами CLR выделяет новые области, вплоть до заполнения всего адресного пространства. Таким образом, память приложения ограничивается виртуальным адресным пространством процесса. Для 32-разрядных процессов можно выделить до 1,5 гигабайта памяти, а для 64-разрядных процессов — около 8 терабайт памяти.

При выполнении оператора C# new среда CLR:

1) подсчитывает количество байтов, необходимых для размещения полей типа (и всех полей, унаследованных от базового типа);

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

указатель на объект-тип и индекс блока синхронизации. В 32-разрядных приложениях для каждого из этих полей требуется 32 бита, что увеличивает размер каждого объекта на 8 байт, а в 64-разрядных приложениях каждое поле занимает 64 бита, добавляя к каждому объекту 16 байт;

3) проверяет, хватает ли в зарезервированной области байтов на выделение памяти для объекта (при необходимости передает память). Если в управляемой куче достаточно места для объекта, ему выделяется память, начиная с адреса, на который ссылается указатель NextObjPtr, а занимаемые им байты обнуляются. Затем вызывается конструктор типа (передающий NextObjPtr в качестве параметра this), и оператор new возвращает ссылку на объект. Перед возвратом этого адреса NextObjPtr переходит на первый адрес после объекта, указывая на адрес, по которому в куче будет помещен следующий объект.

На рис. 21.1 изображена управляемая куча с тремя объектами: А, В и С. Новый объект размещается по адресу, заданному указателем NextObjPtr (сразу после объекта С).

А В С  

 

 

NextObjPtr

 


 

Рис. 21.1. Только что инициализированная управляемая куча стремя объектами

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

Итак, пока складывается впечатление, что управляемая куча обладает превосходными характеристиками быстродействия. И все же это описание предполагает, что память всегда бесконечна, a CLR всегда может выделить блок для нового объекта. Конечно, это не так, поэтому управляемой куче необходим механизм уничтожения объектов, которые перестали быть нужными приложению. Таким механизмом является уборка мусора (Garbage Collection, GC).

Алгоритм уборки мусора

Когда приложение вызывает оператор new для создания объекта, оставшегося адресного пространства может не хватить для выделения памяти под объект. В таком случае CLR выполняет уборку мусора.

ВНИМАНИЕ

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

Для управления сроком жизни объектов в некоторых системах используется алгоритм подсчета ссылок. Например, он используется в модели Microsoft СОМ (Component Object Model). В системах с подсчетом ссылок каждый объект в куче; содержит внутреннее поле с информацией о том, сколько «частей» программы в настоящее время используют данный объект. Когда каждая «часть» переходит к точке кода, в которой объект становится недоступным, она уменьшает поле счетчика объекта. Когда значение счетчика уменьшается до 0, объект удаляется из памяти. К сожалению, в системах с подсчетом ссылок возникают серьезные проблемы с циклическими ссылками. Например, в графическом приложении окно содержит ссылку на дочерний элемент пользовательского интерфейса, а дочерний элемент пользовательского интерфейса содержит ссылку на свое родительское окно. Эти ссылки не позволяют счетчикам двух объектов уменьшиться до 0, поэтому оба объекта остаются в памяти даже тогда, когда окно перестало быть нужным приложению.

Из-за проблем с алгоритмами, основанными на подсчете ссылок, CLR вместо этого использует алгоритм отслеживания ссылок. Алгоритм отслеживания ссылок работает только с переменными ссылочного типа, потому что только эти переменные могут ссылаться на объекты в куче; переменные значимых типов просто содержат данные экземпляра значимого типа. Ссылочные переменные могут использоваться во многих контекстах: статические и экземплярные поля классов, аргументы методов, локальные переменные. Все переменные ссылочных типов называются корнями (roots).

Когда среда CLR запускает уборку мусора, она сначала приостанавливает все программные потоки в процессе. Тем самым предотвращается обращение к объектам и возможное изменение состояния во время их анализа CLR. Затем CLR переходит к этапу уборки мусора, называемому маркировкой (marking). CLR перебирает все объекты в куче, задавая биту в поле индекса блока синхронизации значение 0. Это означает, что все эти объекты могут быть удалены. Затем CLR проверяет все активные корни и объекты, на которые они ссылаются. Если корень содержит null, CLR игнорирует его и переходит к следующему корню.

Если корень ссылается на объект, в поле индекса блока синхронизации устанавливается бит — это и есть признак маркировки объекта. После маркировки объекта

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

На рис. 21.2 показана куча с несколькими объектами, в которой корни приложения напрямую ссылаются на объекты А, С, D и F. Все эти объекты маркируются. При маркировке объекта D уборщик мусора обнаруживает, что в этом объекте есть поле, ссылающееся на объект Н, поэтому объект Н также помечается. Затем уборщик продолжает рекурсивный просмотр всех достижимых объектов.

Рис. 21.2. Управляемая куча до уборки мусора


 

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

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

После перемещения в памяти все ссылки на «выжившие» объекты из корней указывают на прежнее местонахождение объекта в памяти, а не на тот адрес, по которому объект был перемещен. Если возобновить выполнение потоков на этой стадии, потоки обратятся по старым адресам, что приведет к некорректному использованию памяти. Разумеется, этого допускать нельзя, поэтому в фазе сжатия CLR вычитает из каждого корня количество байт, на которое объект был сдвинут вниз в памяти. Тем самым гарантируется, что каждый корень будет ссылаться на тот же объект, что и прежде; просто сейчас этот объект оказался в другом месте памяти.

После сжатия памяти кучи в указатель NextObjPtr управляемой кучи заносится первый адрес за последним объектом, не являющимся мусором. По этому адресу следующий новый объект будет размещен в памяти. На рис. 21.3 показана управляемая куча после сжатия. После завершения фазы сжатия CLR возобновляет выполнение потоков приложения, а они обращаются к объектам так, словно никакой уборки мусора и не было.

NextObjPtr

Рис. 21.3. Управляемая куча после уборки мусора


 

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

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

ВНИМАНИЕ

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

Уборка мусора и отладка

Как только объект становится недостижимым, он превращается в кандидата на удаление — объекты далеко не всегда «доживают» до завершения работы метода. Для приложения эта особенность может иметь интересные последствия. Например, рассмотрим следующий код:

using System;

using System.Threading;

public static class Program { public static void Main() {

// Создание объекта Timer, вызывающего метод TimerCallback // каждые 2000 миллисекунд

Timer t = new Timer(TimerCallback, null, 0, 2000);

// Ждем, когда пользователь нажмет Enter Console.ReadLine();

}

private static void TimerCallback(Object o) {

// Вывод даты/времени вызова этого метода

Console.WriteLine("In TimerCallback: " + DateTime.Now);

// Принудительный вызов уборщика мусора в этой программе

GC. CollectQ;

}

}

Откомпилируйте этот код из командной строки, не используя никаких специальных параметров компилятора. Затем, запустив полученный исполняемый файл, вы увидите, что метод TimerCallback вызывается всего один раз!

После изучения приведенного кода складывается впечатление, что метод TimerCallback будет вызываться каждые 2000 миллисекунд. В конце концов, мы создаем объект Тimer, на который ссылается переменная t. Поскольку таймер существует, он должен срабатывать. Но обратите внимание, что в методе TimerCallback процедура уборки мусора вызывается принудительно методом GC .Collect().

После запуска уборщик мусора предполагает, что все объекты в куче недостижимы (то есть являются мусором), в том числе объект Тimer. Затем уборщик проверяет корни приложения и видит, что метод Main не использует переменную t после присвоения ей значения. Поэтому в приложении нет переменной, ссылающейся на объект Timer, и уборщик мусора освобождает занятую им память. В итоге таймер останавливается, а метод TimerCallback вызывается всего один раз.

Допустим, вы используете отладчик для метода Main, а уборка мусора происходит сразу после присвоения переменной t адреса нового объекта Тimer. Что случится, если затем вы попытаетесь просмотреть объект, на который ссылается t, в окне Quick Watch отладчика? Отладчик не сможет показать объект, потому что тот был удален уборщиком мусора. Для многих разработчиков такой вариант развития событий стал бы очень неприятным сюрпризом, поэтому специалисты Microsoft предложили другое решение.

При компиляции сборки с ключом /debug компилятора C# компилятор применяет к полученной сборке атрибут System.Diagnostics.DebuggableAttribute

с установленным флагом DisableOptimizations. При компиляции метода во время выполнения JIT-компилятор видит, что этот атрибут задан, и искусственно продлевает время жизни всех корней до завершения метода. В моем примере JIT- компилятор считает, что переменная t в Main должна существовать до конца метода. Таким образом, если происходит уборка мусора, уборщик теперь считает, что t остается корнем, а объект Тimer, на который ссылается t, по-прежнему достижим. Объект Timer переживет уборку мусора, а метод TimerCallbacк будет вызываться многократно вплоть до выхода из Main.

Чтобы убедиться в этом, перекомпилируйте программу из командной строки, но на этот раз укажите ключ компилятора C# /debug. Теперь при выполнении полученного исполняемого файла метод TimerCallback будет вызываться многократно! Учтите, что ключ /optimize+ компилятора C# снова включает оптимизации, поэтому он не должен использоваться при проведении эксперимента.

JIT-компилятор делает это, чтобы помочь вам в процессе отладки. Теперь можно запустить приложение в обычном режиме (без отладчика), и если метод будет вызван, JIT-компилятор искусственно увеличит время жизни переменных до его окончания. Затем, если к процессу будет добавлен отладчик, можно вставить точку останова в ранее скомпилированный метод и изучить переменные.

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

Можно попробовать изменить метод Main следующим образом:

public static void Main() {

// Создание объекта Timer, вызывающего метод TimerCallback каждые 2000 мс Timer t = new Timer(TimerCallback, null, 0, 2000);

// Ждем, когда пользователь нажмет Enter Console.ReadLine();

// Создаем ссылку на t после ReadLine

// (в ходе оптимизации эта строка удаляется)

t = null;

}

Все равно после компиляции этого кода (без параметра /debug+) и запуска полученного исполняемого файла (без отладчика) выяснится, что метод Timer_Callback вызывается всего раз. Дело здесь в том, что JIT-компилятор является оптимизирующим, а приравнивание локальной переменной или переменной-параметра к null равнозначно отсутствию ссылки на эту переменную. Иначе говоря, JIT-компилятор в ходе оптимизации полностью убирает строку t = null) из программы, из-за

этого она работает не так, как хотелось бы. Вот как правильно следовало изменить метод Main:

public static void Main() {

// Создание объекта Timer, вызывающего метод TimerCallback каждые 2000 мс Timer t = new Timer(TimerCallback, null, 0, 2000);

// Ждем, когда пользователь нажмет Enter Console.ReadLine();

// Создаем ссылку на переменную t после ReadLine // (t не удаляется уборщиком мусора //до возвращения управления методом Dispose) t. DisposeQ;

}

Теперь, скомпилировав этот код (без параметра /debug+) и запустив полученный исполняемый файл (без отладчика), вы увидите, что метод TimerCallback вызывается несколько раз, и программа работает корректно. Это объясняется тем, что объект, на который ссылается переменная t, не должен удаляться, чтобы для него можно было вызвать метод Dispose (значение t нужно передать методу Dispose как аргумент this). Парадокс: явно указывая, в каком месте таймер должен быть уничтожен, мы продлеваем его жизнь до этой точки.

ПРИМЕЧАНИЕ

После всего сказанного не стоит преждевременно беспокоиться о том, что ваши собственные объекты могут быть уничтожены раньше времени. Класс Timer использовался в обсуждении только из-за своего специфического отсутствующего удругих классов поведения. Дело в том, что присутствие в куче объекта Timer приводит к периодическому вызову метода. Другие типы не в состоянии так себя вести. К примеру, наличие в памяти объекта String не имеет никаких последствий. Строка просто находится в куче. Именно поэтому, чтобы продемонстрировать, как работают корни и как время жизни объекта связано с отладчиком, я использовал объект Timer. Но при этом основной вопрос состоял не в том, как растянуть время жизни объекта. Время жизни остальных объектов определяется приложением автоматически.

Поколения

Уборщик мусора с поддержкой поколений (generational garbage collector), который также называют эфемерным уборщиком мусора (ephemeral garbage collector), хотя я не использую такой термин в своей книге, работает на основе следующих предположений:

□ чем младше объект, тем короче его время жизни;

□ чем старше объект, тем длиннее его время жизни;

□ уборка мусора в части кучи выполняется быстрее, чем во всей куче.

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

Сразу после инициализации в управляемой куче нет объектов. Говорят, что создаваемые в куче объекты составляют поколение 0. Проще говоря, к нулевому поколению относятся только что созданные объекты, которых не касался уборщик мусора. Рисунок 21.4 демонстрирует только что запущенное приложение, разместившее в памяти пять объектов (А-Е). Через некоторое время объекты С и Е становятся недоступными.

А В С D Е  

---------------------------------------------------- ►

Поколение О

Рис. 21.4. Вид кучи сразу после инициализации: все объекты в ней относятся к поколению 0, уборка мусора еще не выполнялась

 


 

При инициализации CLR выбирает пороговый размер для поколения 0. Если в результате выделения памяти для нового объекта размер поколения 0 превышает пороговое значение, должна начаться уборка мусора. Допустим, объекты А Е относятся к поколению 0. Тогда при размещении объекта F должна начаться уборка мусора.

Уборщик мусора определяет, что объекты С и Е — это мусор, и выполняет сжатие памяти для объекта D, перемещая его вплотную к объекту В. Объекты, пережившие уборку мусора (Л, В и D), становятся поколением 1. Объекты из поколения 1 были проверены уборщиком мусора один раз. Теперь куча выглядит так, как показано на рис. 21.5.

А в D

 

_______ II____________________ ^

Поколение 1

Поколение 0
         

Рис. 21.5. Вид кучи после одной уборки мусора: выжившие объекты из поколения О переходят в поколение 1, поколение 0 пустует

 


 

После уборки мусора объектов в поколении 0 не остается. Туда помещаются новые объекты. Как показано на рис. 21.6, приложение продолжает работу и размещает объекты F-K. Также в ходе работы приложения становятся недоступными объекты В, HnJ, поэтому занятая ими память должна рано или поздно освободиться.

А В D F G Н I J К  

-------------------------- II----------------------------------------------------------- ►

Поколение 1


Поколение О


Рис. 21.6. В поколении 0 появились новые объекты, в поколении 1 — мусор


 

А теперь представьте, что при попытке размещения объекта L размер поколения О превысил пороговое значение, поэтому должна начаться уборка мусора. При этом уборщик мусора решает, какие поколения следует обработать. Я уже упоминал, что при инициализации CLR выбирает пороговый размер поколения 0; CLR также выбирает пороговый размер для поколения 1.

Начиная уборку мусора, уборщик определяет, сколько памяти занято поколением 1. Пока поколение 1 занимает намного меньше отведенной памяти, поэтому уборщик проверяет только объекты поколения 0. Еще раз просмотрите предположения, на которых базируется работа уборщика мусора. Первое допущение гласит, что у новых объектов время жизни короче. Поэтому в поколении 0, скорее всего, окажется много мусора, и очистка этого поколения освободит много памяти. А поскольку уборщик игнорирует объекты поколения 1, уборка мусора значительно ускоряется.

Ясно, что игнорирование объектов поколения 1 повышает быстродействие уборщика. Однако его производительность растет еще больше благодаря выборочной проверки объектов в управляемой куче. Если корень или объект ссылается на объект из старшего поколения, уборщик игнорирует все внутренние ссылки старшего объекта, сокращая время построения графа доступных объектов. Конечно, возможна ситуация, когда старый объект ссылается на новый. Чтобы не пропустить обновленные поля этих старых объектов, уборщик использует внутренний механизм JIT-компилятора, устанавливающий флаг при изменении ссылочного поля объекта. Он позволяет уборщику выяснить, какие из старых объектов (если они есть) были изменены с момента последней уборки мусора. Остается проверять только старые объекты с измененными полями, чтобы выяснить, не ссылаются ли они на новые объекты из поколения О[21].

ПРИМЕЧАНИЕ

Тесты быстродействия, проведенные Microsoft, показали, что уборка мусора в поколении 0 занимает меньше 1 мс. Microsoft стремится к тому, чтобы уборка мусора занимала не больше времени, чем обслуживание обычной страничной ошибки.

Уборщик мусора с поддержкой поколений также предполагает, что объекты, прожившие достаточно долго, продолжат жить и дальше. Так что велика вероятность, что объекты поколения 1 и впредь останутся доступными в приложении. То есть проверив объекты поколения 1, уборщик нашел бы мало мусора и не смог бы освободить много памяти. Следовательно, уборка мусора в поколении 1, скорее всего, окажется пустой тратой времени. Если в поколении 1 появляется мусор, он просто остается там. Сейчас куча выглядит, как показано на рис. 21.7.

А В D F G I К  

----------------------------------------------- II------------------------------------------ ►

Поколение 1


Поколение О


Рис. 21.7. Вид кучи после двух операций уборки мусора: выжившие объекты из поколения 0 переходят в поколение 1 (увеличивая его размер), поколение 0 пустует


 

Как видите, все объекты из поколения 0, пережившие уборку мусора, перешли в поколение 1. Так как уборщик не проверяет поколение 1, память, занятая объектом В, не освобождается, даже если этот объект на момент уборки мусора недоступен. И в этот раз после уборки мусора поколение 0 пустеет, в это поколение попадут новые объекты. Допустим, приложение работает дальше и выделяет память под объекты L-0. Во время работы приложение прекращает использовать объекты G, L и М, и они становятся недоступными. В результате куча выглядит так, как показано на рис. 21.8.

А В D F G I К L М N 0  

Поколение 1 Поколение О

Рис. 21.8. В поколении 0 созданы новые объекты, количество мусора в поколении 1 увеличилось

 


 

Допустим, в результате размещения объекта Р размер поколения 0 превысил пороговое значение, что инициировало уборку мусора. Поскольку все объекты поколения 1 занимают в совокупности меньше порогового уровня, уборщик вновь решает собрать мусор только в поколении 0, игнорируя недоступные объекты в поколении 1 (В и G). Куча после уборки мусора показана на рис, 21.9.

А В D F G I К N 0  

------------------------------------------------------------- 1

----------------------------- ►

Поколение 1 Поколение О

Рис. 21.9. Вид кучи после трех операций уборки мусора: выжившие объекты из поколения 0 переходят в поколение 1 (увеличивая его размер); поколение 0 пустеет

 

 

На рисунке видно, что поколение 1 постепенно растет. Допустим, поколение 1 выросло до таких размеров, что все его объекты в совокупности превысили пороговое значение. В этот момент приложение продолжает работать (потому что уборка мусора только что завершилась) и начинает размещение в памяти объектов P-S, которые заполняют поколение 0 до его порогового значения (рис. 21.10).

А В D F G 1 К N 0 Р Q R S  

---------------------------------------------- II----------------- ►

Поколение 1


Поколение 0


Рис. 21.10. Новые объекты размещены в поколении 0, в поколении 1 появилось больше мусора


 

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

D F 1 N 0 Q S  

------------------------------- 1

1------------ 1

------------------------------------------- ►

Поколение 2 Поко- Поколение О

ление 1

Рис. 21.11. Вид кучи после четырех операций уборки мусора: выжившие объекты из поколения 1 переходят в поколение 2, выжившие объекты из поколения 0 переходят в поколение 1, поколение 0 снова пусто

 


 

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

Управляемая куча поддерживает только три поколения: 0, 1 и 2. Поколения 3 не существует1. При инициализации в CLR устанавливается пороговое значение для всех трех поколений. Уборщик мусора CLR является самонастраивающимся, то есть в процессе работы он анализирует функциональность приложения и адаптируется. Например, если приложение создает множество объектов и пользуется ими очень

Статический метод Max Generation класса System.GC возвращает 2.

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

Если уборщик видит, что после уборки мусора в поколении 0 остается очень мало выживших объектов, он может снизить порог для поколения 0. В этом случае уборка мусора будет выполняться чаще, но это меньше загрузит уборщик, поэтому рабочий набор процесса останется небольшим. В сущности, если все объекты поколения 0 станут мусором, уборщику не придется даже дефрагментировать память — достаточно будет вернуть указатель NextOb jPtr в начало поколения 0, чтобы посчитать уборку мусора законченной. Замечательный способ освобождения памяти!

ПРИМЕЧАНИЕ

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

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

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

Я привел пример того, как уборщик динамически может изменять порог поколения 0, но сходным образом могут меняться пороги для поколений 1 и 2. При уборке мусора в этих поколениях уборщик определяет, сколько памяти было освобождено и сколько объектов осталось. В зависимости от полученных данных он может увеличить или уменьшить пороги для этих поколений, чтобы повысить производительность работы приложения. В итоге уборщик мусора автоматически адаптируется к загрузке памяти, необходимой для конкретного приложения!

Показанный далее класс GCNotification выдает событие при уборке мусора в поколении 0 или поколения 2. По этому событию можно подать звуковой сигнал или вычислить, сколько времени прошло между уборками, какой объем памяти был выделен и т. д. Данный класс позволяет проанализировать код приложения, чтобы лучше понять, каким образом оно использует память:

public static class GCNotification {

private static Action<Int32> s_gcDone = null; // Поле события

public static event Action<Int32> GCDone { add {

// Если зарегистрированные делегаты отсутствуют, начинаем оповещение if (s_gcDone == null) { new GenObJect(0); new Gen0bject(2); } s_gcDone += value;

}

remove { s_gcDone -= value; }

}

private sealed class GenObJect {
private Int32 regeneration;

public Gen0bJect(Int32 generation) { regeneration = generation; }

~GenObJect() { // Метод финализации

// Если объект принадлежит нужному нам поколению (или выше),

// оповещаем делегат о выполненной уборке мусора Action<Int32> temp = Volatile.Read(ref s_gcDone); if (temp != null) temp(mgeneration);

}

// Продолжаем оповещение, пока остается хоть один зарегистрированный // делегат, домен приложений не выгружен и процесс не завершен if ((s_gcDone != null)

&& !AppDomain.CurrentDomain.IsFinalizingForUnload()

&& !Environment.HasShutdownStarted) {

// Для поколения 0 создаем объект; для поколения 2 воскрешаем // объект и позволяем уборщику вызвать метод финализации // при следующей уборке мусора для поколения 2 if (regeneration == 0) new GenObject(0); else GC.ReRegisterForFinalize(this);

} else { /* Позволяем объекту исчезнуть */ }

>

>

}

Запуск уборки мусора

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

□ Вызов статического метода Collect объекта System .GC. Код явно указывает, в какой момент должна быть выполнена уборка мусора. Хотя Microsoft решительно не рекомендует использовать этот метод, иногда принудительная уборка мусора в приложении может быть оправдана. Этот способ рассматривается позднее в этой главе.

□ Windows сообщает о нехватке памяти. CLR использует функции Win32 Сге- ateMemoryResourceNotification и QueryMemoryResourceNotification для контроля состояния памяти системы. Если Windows сообщает о недостаточном объеме свободной памяти, CLR запускает уборку мусора, чтобы избавиться от неиспользуемых объектов и сократить размер рабочего набора процесса.

□ Выгрузка домена приложения. При выгрузке домена приложения CLR выполняет полную уборку мусора для всех поколений. Домены приложений рассматриваются в главе 22.

□ Завершение работы CLR. CLR завершает работу при нормальном завершении процесса (по сравнению, например, с внешним завершением работы из Диспетчера задач). Во время заверения CLR считает, что в процессе нет корневых ссылок; объектам предоставляется возможность выполнить очистку, но CLR не пытается дефрагментировать или освобождать память, потому что после завершения всего процесса Windows автоматически освобождает всю его память.

Большие объекты

Существует еще один путь повышения быстродействия, о котором стоит рассказать. CLR делит объекты на малые и большие. До настоящего момента рассматривались только малые объекты. Любые объекты размером 85 000 байт и более считаются большими[22]. CLR работает с большими объектами по несколько отличающимся правилам:

□ Память для них выделяется в отдельной части адресного пространства процесса.

□ К большим объектам не применяется сжатие, так как на их перемещение в памяти потребуется слишком много процессорного времени. Возможная фрагментация адресного пространства между большими объектами может привести к выдаче исключения OutOfMemoryException. В будущих версиях CLR большие объекты могут участвовать в сжатии.

□ Большие объекты всегда считаются частью поколения 2, поэтому их следует создавать лишь для ресурсов, которые должны жить долго. Размещение в памяти короткоживущих больших объектов приведет к необходимости частой уборки мусора в поколении 2, что снижает производительность. Обычно в больших объектах хранятся большие строки (например, XML или JSON) или массивы байтов, используемые в операциях ввода/вывода — например, при чтении данных из файла или сети в буфер для последующей обработки.

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

Режимы уборки мусора

При запуске CLR выбирается один из режимов уборки мусора, который не может быть изменен до завершения процесса. Существует два основных режима уборки мусора:

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

□ Режим сервера. Этот режим оптимизирует уборку мусора для приложений на стороне сервера. Уборщик предполагает, что на машине не запущено никаких сторонних приложений (клиентских или серверных), поэтому все ресурсы процессора можно бросить на уборку мусора. В этом режиме управляемая куча разбирается на несколько разделов — по одному на процессор. Изначально уборщик мусора использует один поток на один процессор. Каждый поток выполняется в собственном разделе одновременно с другими потоками. Такой подход хорошо работает в случае приложений с единообразным поведением рабочих потоков. Функция работает на компьютерах с несколькими процессорами; только в этом случае параллельная обработка потоков позволяет получить прирост производительности.

По умолчанию приложения запускаются в режиме рабочей станции с включенным режимом параллельной уборки мусора. А серверные приложения (например, ASP.NET или SQL Server), обеспечивающие хостинг CLR, могут потребовать загрузки режима сервера. Однако если серверное приложение запускается на однопроцессорной машине, CLR всегда использует режим рабочей станции. Автономное приложение может приказать CLR использовать серверный уборщик мусора путем создания конфигурационного файла (о том, как это сделать, рассказывалось в главах 2 и 3), содержащего элемент gcServer. Вот пример конфигурационного файла:

configuration)

<runtime>

<gcServer enabled="true"/>

</runtime>

</configuration>

Узнать, запущена ли среда CLR в серверном GC-режиме, можно при помощи логического свойства IsServerGC класса GCSettings, предназначенного только для чтения: using System;

using System.Runtime; // GCSettings находится в этом пространстве имен

public static class Program { public static void Main() {

Console.WriteLIne(

"Application Is running with server GC=" + GCSettlngs.IsServerGC);

}

}

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

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

Можно запретить CLR использовать режим параллельной уборки мусора, создав конфигурационный файл приложения, содержащий элемент gcConcurrent (см. главы 2 и 3). Вот пример такого файла:

<configuration>

<runtime>

<gcConcurrent enabled="false"/>

</runtime>

</configuration>

Хотя конфигурация GC-режима не может быть изменена до завершения процесса, приложение может контролировать уборку мусора при помощи свойства GCLatencyMode класса GCSettings. Этому свойству могут присваиваться любые значения из перечисления GCLatencyMode. Варианты перечислены в табл. 21.1.

Таблица 21.1. Значения, определенные в перечислении GCLatencyMode

Значение Описание
Batch (по умолчанию используется для серверного режима) Отключает параллельную уборку мусора
Interactive (по умолчанию используется для режима рабочей станции) Включает параллельную уборку мусора
LowLatency В режиме рабочей станции этот скрытый режим используется для кратковременных, критичных по времени операций (например, анимации), для которых уборка мусора в поколении 2 из-за снижения производительности может оказаться неприемлемой
Sustained LowLatency Используется для предотвращения долгих пауз уборки мусора во время выполнения приложения. Блокирующая уборка мусора поколения 2 запрещается при наличии свободной памяти. Пользователи таких приложений скорее предпочтут установить на компьютере дополнительную память, чтобы избежать пауз. Пример приложения такого рода — приложение для торговли на бирже, которое должно немедленно реагировать на изменение цены


 


 

Режим LowLatency требует дополнительных пояснений. Обычно его включают для реализации операций, для которых важно время выполнения, а затем возвращают режим Batch или Interactive. Однако в режиме LowLatency уборщик мусора действительно обходит вниманием поколение 2, так как это может занять много времени. Разумеется, если вы вызовете метод GC.Collect(), поколение 2 также отправится в мусор. То же самое произойдет, если Windows «пожалуется» CLR на недостаток системной памяти (этот вопрос обсуждался ранее в этой главе).

В режиме LowLatency приложение может выдавать исключение OutOfMemory- Exception. Соответственно, можно порекомендовать включать этот режим на максимально короткое время, избегать размещения в памяти многих объектов, а также больших объектов и возвращаться к режимам Batch и Interactive при помощи области ограниченного выполнения (см. главу 20). Также помните, что режим LowLatency является настройкой уровня процесса и потоки могут быть запущены параллельно. Эти потоки могут даже менять данную настройку в процессе ее использования другим потоком. В этом случае вы можете добавить обновляющийся счетчик (управляемый при помощи методов Interlocked). Вот пример корректного использования режима LowLatency:

private static void LowLatencyDemoQ {

GCLatencyMode oldMode = GCSettings.LatencyMode;

System.Runtime.CompilerServices.RuntimeHelpers.PrepareConstrainedRegionsQ; try {

GCSettings.LatencyMode = GCLatencyMode.LowLatency;

// Здесь выполняется код

}

finally {

GCSettings.LatencyMode = oldMode;

}

}

Программное управление уборщиком мусора

Тип System.GC позволяет приложению напрямую управлять уборщиком мусора. Для начала замечу, что узнать максимальное поколение, поддерживаемое управляемой кучей, можно, прочитав значение свойства GC .MaxGeneration. Это свойство всегда возвращает 2.

Чтобы заставить уборщика мусора провести уборку, следует вызвать метод Collect класса GC. При вызове можно указать поколение, в котором нужно выполнить уборку мусора, параметр GCCollectionMode и логический признак выполнения блокирующей (непараллельной) или фоновой (параллельной) уборки мусора. Сигнатура самой сложной перегруженной версии Collect выглядит так:

void Collect(Int32 generation, GCCollectionMode mode, Boolean blocking)

Различные значения параметра GCCollectionMode описаны в табл. 21.2.

Таблица 21.2. Значения параметра GCCollectionMode

Значение Описание
Default Аналогично вызову метода GC.Collect без флагов. В настоящее время эквивалентно передаче параметра Forced, но в следующих версиях CLR ситуация может измениться
Forced Инициирует уборку мусора для всех поколений вплоть до указанного вами, включая и само это поколение
Optimized Уборка мусора осуществляется только при условии качественного конечного результата, выражающегося либо в освобождении большого объема памяти, либо в уменьшении фрагментации. В противном случае вызов метода в этом режиме не дает никакого эффекта


 


 

Обычно следует избегать вызова любых методов Collect: лучше не вмешиваться в работу уборщика мусора и позволить ему самостоятельно настраивать пороговые значения для поколений, основываясь на реальном поведении приложения. Однако при написании приложения с консольным или графическим интерфейсом его код «владеет» процессом и CLR в этом процессе. В подобных приложениях порой следует собирать мусор принудительно во вполне определенное время. Это можно сделать при помощи метода GCCollectionMode в режиме Optimized. Режимы Default и Forced обычно используют для отладки и тестирования.

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

В некоторых приложениях (особенно это касается серверных приложений, хранящих в памяти множество объектов) время на полную уборку мусора (до второго поколения) оказывается слишком большим. Более того, если уборка мусора длится слишком долго, может завершиться время ожидания клиентских запросов. Чтобы избежать подобных ситуаций, в классе GC имеется метод RegisterForFullGCNotification. С его помощью и при использовании дополнительных вспомогательных методов (WaitForFullGCApproach, WaitForFullGCComplete и CancelFullGCNotification) можно оповестить приложение о том, что уборщик мусора близок к выполнению полной уборки. В результате приложение сможет вызвать метод GC.Collect для принудительной уборки мусора в более подходящее время или свяжется с другими серверами, чтобы лучше распределить клиентские запросы. Дополнительную информацию об этих методах вы можете найти в документации на .NET Framework SDK. Имейте в виду, что методы WaitForFullGCApproach и WaitForFullGCComplete всегда вызываются вместе, так как CLR обрабатывает их попарно.

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

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

Int32 CollectionCount(Int32 generation);

Int64 GetTotalMemory(Boolean forceFullCollection);

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

Можно также узнать, сколько памяти расходуется отдельным доменом приложений. О том, как это сделать, вы узнаете в главе 22.

В ходе установки .NET Framework устанавливается также набор счетчиков производительности, которые позволяют собирать в реальном времени самые разнообразные статистические данные о CLR. Эти данные можно просматривать с помощью утилиты PerfMon.exe или системного монитора из состава Windows. 11роще всего получить доступ к системному монитору, запустив утилиту PerfMon. ехе и щелкнув на кнопке + панели инструментов; на экране появляется диалоговое окно Add Counters, показанное на рис. 21.12.

Рис. 21.12. Счетчики памяти .NET CLR в окне PerfMon.exe


 

Для мониторинга уборки мусора в CLR выберите объект производительности .NET CLR Memory, затем укажите в списке нужное приложение. В завершение выберите набор счетчиков для мониторинга, щелкните на кнопке Add. затем - на кнопке ОК. Теперь системный монитор будет в реальном времени строить график выбранного статистического показателя. Чтобы узнать, что означает счетчик, выделите его и установите флажок Show Description.

Еще один замечательный инструмент для анализа использования памяти и производительности приложения называется PerfView. Он позволяет собирать журналы ETW (Event Tracing for Windows) и обрабатывать их. Вы можете найти его и Интернете по строке поиска «PerfView». Наконец, можно воспользоваться отладочным расширением (SOS.dll), помогающим при проблемах с памятью и других проблемах CLR. Это расширение позволяет узнать, сколько памяти выделено для процесса и управляемой куче, вывести все объекты, зарегистрированные для финализации и помещенные в очередь, просмотреть записи в таблице GCHandle как

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

Освобождение ресурсов при помощи механизма финализации

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

Например, типу System. 10.FileStream нужно открыть файл (системный ресурс) и сохранить его дескриптор. Затем при помощи этого дескриптора методы Read и Write данного типа работают с файлом. Аналогично, тип System. Threading. Mutex открывает мьютекс, являющийся объектом ядра Windows (системный ресурс), и сохраняет его дескриптор, который использует при вызове методов объекта Mutex.

Если тип, использующий системный ресурс, будет уничтожен в ходе уборки мусора, занимаемая объектом память вернется в управляемую кучу; однако системный ресурс, о котором уборщику мусора ничего не известно, будет потерян. Разумеется, это нежелательно, поэтому CLR поддерживает механизм финализации (finalization), позволяющий объекту выполнить корректную очистку, прежде чем уборщик мусора освободит занятую им память. Любой тип, использующий системный ресурс (файл, сетевое соединение, сокет, мьютекс и т. д.), должен поддерживать финализацию. Когда CLR определяет, что объект стал недоступным, ему предоставляется возможность выполнить финализацию с освобождением всех задействованных системных ресурсов, после чего объект будет возвращен в управляемую кучу.

Всеобщий базовый класс System.Object определяет защищенный виртуальный метод с именем Finalize. Когда уборщик мусора определяет, что объект подлежит уничтожению, он вызывает метод Finalize этого объекта (если он переопределен). Группа проектировщиков C# из Microsoft посчитала, что метод финализации отличается от остальных и требует специального синтаксиса в языке программирования (по аналогии с тем, как в C# специальный синтаксис используется для определения конструктора). Поэтому для определения метода финализации в C# перед именем класса нужно добавить знак тильды (~):

internal sealed class SomeType {

// Метод финализации ~SomeType() {

// Код метода финализации

}

}

Скомпилировав этот код и проверив полученную сборку с помощью утилиты ILDasm.exe, вы увидите, что компилятор C# внес в метаданные этого модуля защи-

щенный метод с именем Finalize. При изучении IL-кода метода Finalize также становится ясно, что код в теле метода генерируется в блок try, а вызов метода base .Finalize — в блок finally.

ВНИМАНИЕ

Разработчики с опытом программирования на C++ заметят, что специальный синтаксис, используемый в C# для определения метода финализации, напоминает синтаксис деструктора C++. Действительно, в предыдущих версиях спецификации C# этот метод назывался деструктором (destructor). Однако метод финализации работает совсем не так, как неуправляемый деструктор C++, что сбивает с толку многих разработчиков, переходящих с одного языка на другой.

Беда в том, что разработчики ошибочно полагают, что использование синтаксиса деструктора означает в C# детерминированное уничтожение объектов типа, как это происходит в C++. Flo CLR не поддерживает детерминированное уничтожение, поэтому C# не может предоставить этот механизм.

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

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

Для вызова методов Finalize CLR использует специальный высокоприоритетный поток. Таким образом предотвращаются ситуации взаимной блокировки, возможные в обычных условиях[23]. Если метод Finalize блокируется (например, входит в бесконечный цикл или ожидает объекта, который никогда не будет освобожден), специальный поток не сможет вызывать методы Finalize. Данная ситуация крайне нежелательна, потому что приложение не сможет освободить память, занимаемую финализируемыми объектами. Если метод Finalize выдает необработанное исключение, процесс завершается; перехватить такое исключение невозможно.

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

Если вы создаете управляемый тип, использующий системный ресурс, создайте класс, производный от специального базового класса System.Runtime. InteropServices. SafeHandle, который выглядит примерно так (комментарии

в коде метода мои):

public abstract class SafeHandle : CriticalFinalizerObject, IDisposable {

// Это дескриптор системного ресурса protected IntPtr handle;

protected SafeHandle(IntPtr InvalldHandleValue, Boolean ownsHandle) { this.handle = InvalldHandleValue;

// Если значение ownsHandle равно true, то системный ресурс закрывается // при уничтожении объекта, производного от SafeHandle,

// уборщиком мусора

}

protected void SetHandle(IntPtr handle) { this.handle = handle;

}

// Явное освобождение ресурса выполняется вызовом метода Dispose public void DisposeQ { Dispose(true); }

// Здесь подойдет стандартная реализация метода Dispose // Настоятельно не рекомендуется переопределять этот метод! protected virtual void Dispose(Boolean disposing) {

// В стандартной реализации аргумент, вызывающий метод // Dispose, игнорируется

// Если ресурс уже освобожден, управление возвращается коду // Если значение ownsHandle равно false, управление возвращается // Установка флага, означающего, что этот ресурс был освобожден

// Вызов виртуального метода ReleaseHandle

// Вызов GC.SuppressFinalize(this), отменяющий вызов метода финализации // Если значение ReleaseHandle равно true, управление возвращается коду // Если управление передано в эту точку,

// запускается ReleaseHandleFailed Managed Debugging Assistant (MDA)

}

// Здесь подходит стандартная реализация метода финализации // Настоятельно не рекомендуется переопределять этот метод!

~SafeHandle() { Dispose(false); }

// Производный класс переопределяет этот метод,

// чтобы реализовать код освобождения ресурса protected abstract Boolean ReleaseHandleQ;

public void SetHandleAsInvalid() {

// Установка флага, означающего, что этот ресурс был освобожден // Вызов GC.SuppressFinalize(this), отменяющий вызов метода финализации

}

public Boolean IsClosed { get {

// Возвращение флага, показывающего, был ли ресурс освобожден

}

}

public abstract Boolean Islnvalid {

// Производный класс переопределяет это свойство // Реализация должна вернуть значение true, если значение // дескриптора не представляет ресурс (обычно это значит,

// что дескриптор равен 0 или @1) get

}

// Эти три метода имеют отношение к безопасности и подсчету ссылок

// Подробнее о них рассказывается в конце этого раздела

public void DangerousAddRef(ref Boolean success)

public IntPtr DangerousGetHandleQ {...}

public void DangerousRelease() {...}

}

Рассматривая класс SafeHandle, прежде всего нужно отметить, что он наследует от класса CriticalFinalizerObject, определенного в пространстве имен System. Runtime.ConstrainedExecution. Это гарантирует «особое обращение» со стороны CLR к этому классу и другим, производным от него классам. В частности, CLR наделяет этот класс тремя интересными особенностями:

□ При первом создании любого объекта, производного от типа CriticalFinalizerObject, CLR автоматически запускает JIT-компилятор, компилирующий все методы финализации в иерархии наследования. Компиляция этих методов после создания объекта гарантирует, что системные ресурсы освободятся, как только объект станет мусором. Без немедленной компиляции метода финализации может оказаться, что ресурс будет выделен и использован, но не освобожден. При недостатке памяти CLR может не хватить памяти для компиляции метода финализации; в этом случае метод не будет исполнен, что приведет к утечке системных ресурсов. Также ресурсы не будут освобождены, если код в методе финализации содержит ссылку на тип в другой сборке, которая не была обнаружена CLR.

□ CLR вызывает метод финализации для типов, производных от CriticalFinalizer- Object, после вызова методов финализации для типов, непроизводных от CriticalFinalizerObject. Благодаря этому классы управляемых ресурсов, имеющие метод финализации, могут успешно обращаться к объектам, производным от CriticalFinalizerObject, в их методах финализации. Так, метод финализации класса FileStream может сбросить данные из буфера памяти на диск в полной уверенности, что дисковый файл еще не был закрыт.

□ CLR вызывает метод финализации для типов, производных от CriticalFinalizerObject, если домен приложения был аварийно завершен управляющим приложением (например, Microsoft SQL Server или Microsoft ASRNET). Это гарантирует освобождение системных ресурсов даже в том случае, когда управляющее приложение больше не доверяет работающему внутри него управляемому коду.

Также следует отметить, что класс SafeHandle является абстрактным: предполагается, что разработчик создаст класс, производный от SafeHandle, который переопределит защищенный конструктор, абстрактный метод ReleaseHandle и абстрактное свойство Islnvalid метода доступа get.

В Windows для операций с системными ресурсами обычно используются дескрипторы (32-разрядные в 32-разрядных системах, 64-разрядные в 64-разрядных системах). В классе SafeHandle определяется защищенное поле IntPtr с именем handle. Большинство дескрипторов считаются недействительными при равенстве их значения 0 или-1. Пространство имен Microsoft. Win32. SafeHandles содержит еще один вспомогательный класс SafeHandleZeroOrMinusOnelsInvald вида:

public abstract class SafeHandleZeroOrMinusOnelsInvalid : SafeHandle {

protected SafeHandleZeroOrMinusOneIsInvalid(Boolean ownsHandle)

: base(IntPtr.Zero, ownsHandle) {

>

public override Boolean Islnvalid { get {

if (base.handle == IntPtr.Zero) return true; if (base.handle == (IntPtr) (-1)) return true; return false;

>

>

Обратите внимание, что класс SafeHandleZeroOrMinusOnelsInvalid является абстрактным, поэтому надо создать дочерний класс, который переопределит защищенный конструктор и абстрактный метод ReleaseHandle. На платформе Microsoft .NET Framework есть несколько открытых классов, производных от SafeHandleZeroOrMunusOnelsInvalid, в числе которых SafeFileHandle, SafeRegistryHandle, SafeWaitHandle и SafeMemoryMappedViewHandle.А так выглядит класс SafeFileHandle:

public sealed class SafeFileHandle : SafeHandleZeroOrMinusOnelsInvalid {

public SafeFileHandle(IntPtr preexistingHandle, Boolean ownsHandle)

: base(ownsHandle) {

base.SetHandle(preexistingHandle);

}

protected override Boolean ReleaseHandle() {

// Сообщить Windows, что системный ресурс нужно закрыть return Win32Native.CloseHandle(base.handle);

}

}

Класс SafeWaitHandle реализован сходным образом. Единственной причиной для создания разных классов с похожими реализациями является обеспечение безопасности типов: компилятор не позволит использовать файловый дескриптор в качестве аргумента метода, принимающего дескриптор блокировки, и наоборот. Метод ReleaseHandle класса SafeRegistryHandle вызывает 1\^п32-функцию RegCloseKey.

Жаль, что на платформе .NET Framework отсутствуют дополнительные классы, служащие оболочкой различных системных ресурсов, например таких, как SafeProcessHandle,SafeThreadHandle, SafeTokenHandle, SafeLibraryHandle (его метод ReleaseHandle вызывал бы 1¥т32-функцию FreeLibrary), SafeLocalAl- locHandle (его метод ReleaseHandle вызывал бы Win32-функцию LocalFree) и т. п.

Все эти классы (а также некоторые другие) есть в библиотеке FCL. Однако они не предоставляются для открытого использования, являясь внутренними классами сборок, в которых они определяются. Microsoft не афиширует эти классы, чтобы не выполнять их полное тестирование и не тратить время на их документирование. Если же вам придется с ними столкнуться, рекомендую воспользоваться утилитой ILDasm.exe или другим IL-декомпилятором, чтобы извлечь код этих классов и интегрировать его в исходный текст программы. Все эти классы тривиально реализуются и их несложно написать самостоятельно.

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

с неуправляемым кодом, им гарантирован особый подход со стороны CLR. Вот пример:

using System;

using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles;

internal static class SomeType {

[DllImport("Kernel32", CharSet=CharSet.Unicode, EntryPoint=nCreateEvent")]

// Этот прототип неустойчив к сбоям private static extern IntPtr CreateEventBad(

IntPtr pSecurityAttributes, Boolean manualReset,

Boolean initialstate, String name);

// Этот прототип устойчив к сбоям

[DllImport("Kernel32", CharSet=CharSet.Unicode, EntryPoint="CreateEvent")] private static extern SafeWaitHandle CreateEventGood(

IntPtr pSecurityAttributes, Boolean manualReset,

Boolean initialstate. String name);

public static void SomeMethodQ {

IntPtr handle = CreateEventBad(IntPtr.Zero, false, false, null);

SafeWaitHandle swh = CreateEventGood(IntPtr.Zero, false, false, null);

}

Обратите внимание, что прототип метода CreateEventBad возвращает IntPtr, то есть он возвращает дескриптор в управляемый код. Подобного рода взаимодействия с неуправляемым кодом неустойчивы к сбоям. После вызова метода CreateEventBad (создающего системный ресурс события) возможна ситуация, когда исключение ThreadAbortException появляется до присвоения дескриптора переменной handle. В таких редких случаях в управляемом коде образуется утечка системного ресурса. И событие можно закрыть только одним способом — завершив процесс.

Класс SafeHandle устраняет эту потенциальную утечку ресурсов. Обратите внимание, что прототип метода CreateEventGood возвращает SafeWaitHandle, а не IntPtr. При вызове метода CreateEventGood CLR вызывает \¥т32-функцию CreateEvent. Когда эта функция возвращает управление управляемому коду, CLR «знает», что SafeWaitHandle является производным от SafeHandle. Поэтому CLR автоматически создает экземпляр класса SafeWaitHandle, передавая ему полученное от метода CreateEvent значение дескриптора. Обновление объекта SafeWaitHandle и присвоение дескриптора происходят в неуправляемом коде, который не может быть прерван исключением ThreadAbortException. В результате в управляемом коде не может возникнуть утечка этого системного ресурса. А в итоге объект SafeWaitHandle удаляется уборщиком мусора и вызывается его метод финализации, обеспечивающий освобождение памяти.

И наконец, классы, производные от SafeHandle, гарантируют, что никто не сможет воспользоваться возможными брешами в системе безопасности. Проблема в том, что один из потоков может попытаться использовать системный ресурс, освобождаемый другим потоком. Это называется атакой с повторным использованием дескрипторов. Класс Saf eHandle предотвращает это нарушение безопасности благодаря подсчету ссылок. В нем определено закрытое поле, исполняющее роль счетчика. Когда производному от Saf eHandle объекту присваивается корректный дескриптор, счетчик приравнивается к 1. Всякий раз, когда производный от Saf eHandle объект передается как аргумент неуправляемому методу, CLR автоматически увеличивает значение счетчика на единицу. Когда неуправляемый метод возвращает управление управляемому коду, CLR уменьшает значение счетчика на ту же величину. Например, вот как выглядит прототип \\йп32-функции SetEvent:

[DllImport("Kernel32", Exactspelling=true)]

private static extern Boolean SetEvent(SafeWaitHandle swh);

При вызове этого метода и передаче ему ссылки на объект SafeWaitHandle CLR увеличивает значение счетчика перед вызовом и уменьшает значение счетчика сразу после вызова. Разумеется, операции со счетчиком выполняются способом, безопасным по отношению к потокам. Как это повышает безопасность? Если другой поток попытается освободить системный ресурс, оболочкой которого является объект SafeHandle, CLR узнает, что это ему не разрешено, потому что данный ресурс используется неуправляемой функцией. Когда функция вернет управление программе, значение счетчика будет приравнено к 0 и ресурс освободится.

При написании или вызове кода, работающего с дескриптором (например, IntPtn), к нему можно обратиться из объекта SafeHandle, но подсчет ссылок придется выполнять явно с помощью методов DangerousAddRef и DangerousRelease объекта SafeHandle. Обращение к исходному дескриптору происходит через метод DangenousGetHandle.

Нельзя не упомянуть о классе CriticalHandle, также определенном в пространстве имен System.Runtime.InteropServices. Он работает точно так же, как и SafeHandle, но не поддерживает подсчет ссылок. В CriticalHandle и производных от него классах безопасность принесена в жертву повышению производительности (за счет отказа от счетчиков). Как и у SafeHandle, у CriticalHandle есть два производных типа — CriticalHandleMinusOnelsInvalid и CriticalHand leZeroOrMinusOnelsInvalid. Так как Microsoft отдает предпочтение безопасности, а не производительности системы, в библиотеке классов нет типов, производных от этих двух классов. Используйте типы, производные от CriticalHandle, только когда высокая производительность необходима и оправдывает некоторое ослабление защиты.

Типы, использующие системные ресурсы

Итак, теперь вы знаете, как определить производный от SafeHandle класс, инкапсулирующий системный ресурс. Давайте посмотрим, как разработчики используют подобные типы. Начнем с более распространенного класса System. 10. FileStream. Класс FileStream позволяет открыть файл, прочитать из него и записать в него байты, а затем закрыть его. При создании объекта FileStream вызывается Win32-4>ymaHM CreateFile, возвращаемый дескриптор сохраняется в объекте SafeFileHandle, а ссылка на этот объект сохраняется как закрытое поле в объекте FileStream. Класс FileStream также поддерживает ряд дополнительных свойств (например, Length, Position, CanRead) и методов (Read, Write, Flush).

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

using System; using System.10;

public static class Program { public static void Main() {

// Создание байтов для записи во временный файл Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };

// Создание временного файла

FileStream fs = new FileStream("Temp.dat", FileMode.Create);

// Запись байтов во временный файл fs.Write(bytesToWrite, 0, bytesToWrite.Length);

// Удаление временного файла

File.Delete("Temp.dat"); // Генерируется исключение IOException

}

}

К сожалению, если скомпоновать и запустить этот код, работать он, скорее всего, не будет. Дело в том, что вызов статического метода Delete объекта File заставляет Windows удалить открытый файл, поэтому метод Delete генерирует исключение System .10 .IOException с таким сообщением (процесс не может обратиться к файлу Temp.dat, потому что он используется другим процессом):

The process cannot access the file "Temp.dat" because it is being used by another process

Однако в некоторых случаях файл все же удаляется! Если другой поток инициировал уборку мусора между вызовами методов Write и Delete, поле SafeFileHandle объекта FileStream вызывает свой метод финализации, который закрывает файл и разрешает выполняться методу Delete. Однако вероятность данной ситуации крайне мала, поэтому в 99 случаях из 100 приведенный код работать не будет.

Классы, позволяющие пользователю управлять жизненным циклом инкапсулированных системных ресурсов, реализуют интерфейс IDisposable, который выглядит так:

public interface IDisposable { void Dispose();

}

ВНИМАНИЕ

Если класс определяет поле типа, реализующего паттерн dispose, сам класс тоже должен реализовать этот паттерн. Метод Dispose должен уничтожать объект, на который ссылается поле. Это позволит пользователю класса вызвать для него Dispose, что в свою очередь приведет к освобождению ресурсов, используемых самим объектом.

К счастью, класс FileStream реализует интерфейс IDisposable, а его реализация вызывает Dispose для приватного поля SafeFileHandle объекта FileStream.

Теперь мы можем изменить свой код так, чтобы файл явно закрывался в нужный момент (вместо того, чтобы дожидаться уборки мусора когда-нибудь в будущем). Измененный исходный код выглядит так:

using System; using System.10;

public static class Program { public static void Main() {

// Создание байтов для записи во временный файл Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };

// Создание временного файла

FileStream fs = new FileStream("Temp.dat", FileMode.Create);

// Запись байтов во временный файл fs.Write(bytesToWritej 0, bytesToWrite.Length);

// Явное закрытие файла после записи f s. DisposeQ;

// Удаление временного файла

File.Delete("Temp.dat“); // Теперь эта инструкция

// всегда остается работоспособной

}

}

Теперь при вызове метода Delete объекта File Windows видит, что файл не открыт, и успешно удаляет его.

Учтите, что гарантированное освобождение системных ресурсов возможно и без вызова Dispose. Рано или поздно оно все равно будет выполнено; вызов Dispose позволяет вам управлять тем, когда это произойдет. Кроме того, вызов Dispose не удаляет управляемый объект из управляемой кучи. Единственный способ освобождения памяти в управляемой куче — уборка мусора. Это означает, что методы управляемого объекта могут вызываться даже после освобождения всех системных ресурсов, которые им могли использоваться.

Следующий код вызывает метод Write после закрытия файла и пытается дописать в файл несколько байтов. Разумеется, новые байты записаны не будут, а при выполнении кода второй вызов метода Write выдает исключение System.

ObjectDisposedException со следующим сообщением (нет доступа к закрытому файлу):

message: Cannot access a closed file, using System; using System.10;

public static class Program { public static void Main() {

// Создание байтов для записи во временный файл Byte[] bytesToWrite = new Byte[] { 1, 2, Ъ, A, 5 };

// Создание временного файла

FileStream fs = new FileStream("Temp.dat"j FileMode.Create);

11 Запись байтов во временный файл fs.Write(bytesToWrite, 0, bytesToWrite.Length);

// Явное закрытие файла после записи fs.Dispose();

// Попытка записи в файл после закрытия fs.Write(bytesToWrite, 0, bytesToWrite.Length); // Исключение

ObjectDisposedException

// Удаление временного файла File.Delete("Temp.dat");

}

}

В данном случае содержимое памяти не повреждается, так как область, выделенная для объекта FileStream, все еще существует; просто после явного освобождения объект не может успешно выполнять свои методы.

ВНИМАНИЕ

Определяя собственный тип, реализующий интерфейс IDisposable, обязательно сделайте так, чтобы все методы и свойства в случае явной очистки объекта генерировали исключение System.ObjectDisposedException. При повторных вызовах методы Dispose никогда не должны выдавать исключение ObjectDisposedException — они должны просто возвращать управление.

ВНИМАНИЕ

Настоятельно рекомендую в общем случае отказаться от применения методов Dispose. Уборщик мусора из CLR достаточно хорошо написан, и пусть он делает свою работу сам. Он определяет, когда объект более недоступен коду приложения, и только тогда уничтожает его. Вызывая метод Dispose, код приложения фактически заявляет, что сам «знает», когда объект становится ненужным приложению. Но зачастую приложение не может достоверно судить об этом.

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

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

Приведенные примеры кода демонстрируют методику явного вызова метода Dispose. Если вы решили воспользоваться явным вызовом, настоятельно рекомендую поместить его в блок обработки исключений finally, так как это гарантирует исполнение кода очистки. Соответственно код из предыдущего примера лучше переписать так:

using System; using System.10;

public static class Program { public static void Main() {

// Создание байтов для записи во временный файл Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };

// Создание временного файла

FileStream fs = new FileStream("Temp.dat", FileMode.Create); try {

// Запись байтов во временный файл fs.Write(bytesToWrite, 0, bytesToWrite.Length);

>

finally {

// Явное закрытие файла после записи

if (fs != null)

fs.DisposeQ;

>

// Удаление временного файла File.Delete("Temp.dat");

}

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

using System; using System.10;

public static class Program { public static void Main() {

// Создание байтов для записи во временный файл Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };

// Создание временного файла

using (FileStream fs = new FileStream("Temp.dat", FileMode.Create)) {

// Запись байтов во временный файл fs.Write(bytesToWrite, в, bytesToWrite.Length);

}

// Удаление временного файла File.Delete("Temp.dat");

}

}

Инструкция using инициализирует объект и сохраняет в переменной ссылку на него. После этого к этой переменной можно обращаться из кода, расположенного в скобках в инструкции using. При компиляции этого кода автоматически создаются блоки try и finally. Внутрь блока finally компилятор помещает код, выполняющий приведение типа объекта к интерфейсу ([Disposable, и вызывает метод Dispose. Ясно, что компилятор позво.ляет использовать инструкцию using только с типами, в которых реализован интерфейс IDisposable.

ПРИМЕЧАНИЕ

Инструкция using языка C# позволяет инициализировать несколько переменных одного типа или использовать переменную, инициализированную ранее. За дополнительной информацией по этой теме обращайтесь к разделу «Using statements» в справочнике C# Programmer's Reference.

Интересные аспекты зависимостей

Тип System. 10.FileStream позволяет пользователю открыть файл для чтения и записи. Для повышения быстродействия реализация типа задействует буфер в памяти. Содержимое буфера сбрасывается в файл только после его заполнения. Тип FileStream поддерживает только запись байтов — для записи символов или строк требуется тип System. 10. St reamWrite г, как показано в следующем примере:

FileStream fs = new FileStream("DataFile.dat", FileMode.Create);

StreamWriter sw = new StreamWriter(fs); sw.Write("Hi there");

// Следующий вызов метода Dispose обязателен sw.DisposeQ;

// ПРИМЕЧАНИЕ. Метод StreamWriter.Dispose закрывает объект FileStream // Вручную закрывать объект FileStream не нужно

Обратите внимание: конструктор StneamWniten принимает в качестве параметра ссылку на объект Stream, тем самым позволяя передать как параметр ссылку на объект FileStream. Внутренний код объекта StreamWriter сохраняет ссылку на объект Stream. При записи в объект StreamWriter он выполняет внутреннюю буферизацию данных в свой буфер в памяти. После заполнения буфера StreamWriter записывает данные в Stream.

После записи данныхчерез объект BinaryWriter следует вызвать метод Dispose (так как в типе StreamWriter реализован интерфейс IDisposable, его можно использовать с инструкцией using языка С#). Вызов заставляет BinaryWriter сбросить данные в объект Stream и закрыть его[24].

ПРИМЕЧАНИЕ

Вручную вызывать метод Dispose для объекта FileStream не обязательно: BinaryWriter сделает это сам. Если же этот метод все-таки вызван явно, FileStream обнаружит, что очистка объекта уже выполнена, и вызванный метод просто вернет управление.

Как вы думаете, что было бы, не будь кода, явно вызывающего метод Dispose? Уборщик мусора однажды правильно определил бы, что эти объекты стали мусором, и финализировал их. Но он не может гарантировать определенной очередности вызова методов финализации. Поэтому если объект FileStream завершится первым, он закроет файл. Затем после финализации объекта StreamWriter он попытается записать данные в закрытый файл, что вызовет исключение. В то же время, если StreamWriter завершается первым, данные благополучно записываются в файл.

Как с этой проблемой справились в Microsoft? Заставить уборщик мусора финализировать объекты в определенном порядке нельзя, так как объекты могут содержать ссылки друг на друга, и тогда уборщик не сможет определить правильную очередность их финализации. В Microsoft нашиг выход: тип StreamWriter не поддерживает финализацию, поэтому этот тип не может сбросить данные из своего буфера в базовый объект FileStream. Таким образом, если вы забыли вручную закрыть объект StreamWriter, данные гарантированно будут потеряны. В Microsoft считают, что разработчики не смогут не заметить этой повторяющейся потери данных и исправят код, вставив явный вызов Dispose.

ПРИМЕЧАНИЕ

В .NET Framework поддерживаются управляемые расширения отладки (Managed Debugging Assistants, MDA). Когда они включены, .NET Framework выполняет поиск некоторых распространенных ошибок в программах и запускает соответствующий отладчик. В отладчике все это выглядит как генерирование исключения. MDAумеет определять ситуации, когда объект StreamWriter удален уборщиком мусора до своего закрытия. Чтобы включить данный управляемый отладчик в Visual Studio, откройте проект и выберите в меню команду Debug ► Exceptions. В диалоговом окне Exceptions раскройте узел Managed Debugging Assistants, прокрутите страницу вниз до элемента StreamWriterBufferedDataLost и установите флажок Thrown, чтобы заставить отладчик Visual Studio останавливаться при каждой потере данных объекта StreamWriter.

Другие возможности уборщика мусора для работы с системными ресурсами

Иногда системный ресурс требует много памяти, а управляемый объект, являющийся его «оберткой», занимает очень мало памяти. Наиболее типичный пример — растровое изображение. Оно может занимать несколько мегабайтов системной памяти, а управляемый объект может быть очень небольшим, так как содержит только 4- или 8-байтовое значение. С точки зрения CLR до уборки мусора процесс может выделять сотни растровых изображений (которые займут мало управляемой памяти). Однако если процесс манипулирует множеством изображений, расходование памяти процессом начнет расти с огромной скоростью. Для исправления ситуации в классе GC предусмотрены два статических метода следующего вида:

public static void AddMemoryPressure(Int64 bytesAllocated); public static void RemoveMemoryPressure(Int64 bytesAllocated);

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

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

Для таких ситуаций в пространстве имен System.Runtime.InteropServices предусмотрен класс HandleCollecton:

public sealed class HandleCollector {

public HandleCollector(String name, Int32 initialThreshold);

public HandleCollector(

String name^ Int32 initialThreshold, Int32 maximumThreshold); public void Add(); public void Remove();

public Int32 Count { get; } public Int32 InitialThreshold { get; } public Int32 MaximumThreshold { get; } public String Name { get; }

}

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

ПРИМЕЧАНИЕ

Код методов GC.AddMemoryPressure и HandleCollector.Add вызывает GC.Collect для запуска уборщика мусора до достижения поколением 0 своего предела. Обычно настоятельно не рекомендуется принудительно вызывать уборщик мусора, потому что это отрицательно сказывается на производительности приложения. Однако вызов этих методов в классах призван обеспечить доступ приложения кограниченному числу системных ресурсов. Если системных ресурсов окажется недостаточно, произойдет сбой приложения. Для большинства приложений лучше работать медленнее, чем не работать вообще.

Следующий код иллюстрирует использование и результат работы методов сжатия памяти и класса HandleCollecton:

using System;

using System.Runtime.InteropServices;

public static class Program { public static void Main() {

MemoryPressureDemo(0); // 0 вызывает нечастую уборку мусора MemoryPressureDemo(10 * 1024 * 1024); // 10 Мбайт вызывают частую

// уборку мусора

HandleCollectorDemoQ;

}

private static void MemoryPressureDemo(Int32 size) {

Console.WriteLine();

Console.WriteLine("MemoryPressureDemo, size={0}", size);

// Создание набора объектов с указанием их логического размера for (Int32 count = 0; count < 15; count++) { new BigNativeResource(size);

}

// В демонстрационных целях очищаем все GC.Collect();

У

private sealed class BigNativeResource { private Int32 m_size;

public BigNativeResource(Int32 size) { m_size = size;

// Пусть уборщик думает, что объект занимает больше памяти if (m_size > 0) GC.AddMemoryPressure(m_size);

Console.WriteLine("BigNativeResource create.");

} ~BigNativeResource() {

// Пусть уборщик думает, что объект освободил больше памяти if (m_size > 0) GC.RemoveMemoryPressure(m_size);

Console.WriteLine("BigNativeResource destroy.");

}

private static void HandleCollectorDemoQ {

Console.WriteLine();

Console.WriteLine("HandleCollectorDemo");

for (Int32 count = 0; count < 10; count++) new LimitedResource();

// В демонстрационных целях очищаем все GC.Collect();

}

private sealed class LimitedResource {

// Создаем объект HandleCollector и передаем ему указание // перейти к очистке,когда в куче появится два или более // объекта LimitedResource private static HandleCollector she =

new HandleCollector("LimitedResource", 2);

public LimitedResourceQ {

// Сообщаем HandleCollector, что в кучу добавлен еще

// один объект LimitedResource

shc.AddQ;

Console.WriteLine("LimitedResource create. Count={0}", she.Count);

}

~LimitedResource() {

// Сообщаем HandleCollector, что один объект LimitedResource

// удален из кучи

s_hc.Remove();

Console.WriteLine("LimitedResource destroy. Count={0}", she.Count)

}

>

>

После компиляции и запуска этого кода результат будет выглядеть примерно так:

MemoryPressureDemo, size=0 BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource create.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

BigNativeResource destroy.

MemoryPressureDemo, size=10485760 BigNativeResource create. BigNativeResource create. BigNativeResource create. BigNativeResource create. BigNativeResource create. BigNativeResource create. BigNativeResource create. BigNativeResource create. BigNativeResource destroy. BigNativeResource destroy. BigNativeResource destroy. BigNativeResource destroy. BigNativeResource destroy. BigNativeResource create. BigNativeResource create. BigNativeResource destroy. BigNativeResource destroy.

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

BigNativeResource

HandleCollectorDemo LimitedResource create. LimitedResource create. LimitedResource create. LimitedResource destroy. LimitedResource destroy. LimitedResource destroy. LimitedResource create. LimitedResource create. LimitedResource destroy. LimitedResource create. LimitedResource create. LimitedResource destroy. LimitedResource destroy. LimitedResource destroy. LimitedResource create. LimitedResource create. LimitedResource destroy. LimitedResource create. LimitedResource destroy. LimitedResource destroy.

Внутренняя реализация финализации

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

Когда приложение создает новый объект, оператор new выделяет для него память из кучи. Если в типе объекта определен метод финализации, непосредственно перед вызовом конструктора экземпляра типа указатель на объект помещается в список финализации (finalization list) — внутреннюю структуру данных, находящуюся под управлением уборщика мусора. Каждая запись этого списка указывает на объект, для которого нужно вызвать метод финализации, прежде чем освободить занятую им память.

На рис. 21.13 показана куча с несколькими объектами. Одни достижимы из корней приложения, другие — нет. При создании объектов С, Е, F, I и/ система, обнаружив в их типах методы финализации, добавила указатели на эти объекты в список финализации.

Рис. 21.13. Управляемая куча с указателями в списке финализации


 

ПРИМЕЧАНИЕ

Хотя в System.Object определен метод финализации, CLR его игнорирует. То есть если при создании экземпляра типа метод финализации этого типа унаследован от System.Object, созданный объект не считается подлежащим финализации. Метод финализации объекта Object должен переопределяться в одном из производных типов.

Сначала уборщик мусора определяет, что объекты В, Е, G, Н, I и/ — это мусор. Уборщик сканирует список финализации в поисках указателей на эти объекты. Обнаружив указатель, он извлекает его из списка финализации и добавляет в конец очереди на финализацию (freachable queue) — еще одной внутренней структуры данных уборщика мусора. Каждый указатель в этой очереди идентифицирует объект, готовый к вызову своего метода финализации. Вид управляемой кучи после уборки мусора показан на рис. 21.14.

На рисунке видно, что занятая объектами В, Gm Н память была освобождена, поскольку у них нет метода финализации. Однако память, занятую объектами Е, I и J, освободить нельзя, так как их методы финализации еще не вызывались.

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

вызывая соответствующие методы финализации. Особенности работы данного потока запрещают исполнять в методе финализации любой код, имеющий какие- либо допущения о потоке, исполняющем код. Например, в методе финализации следует избегать обращения к локальной памяти потока.

Список финализации Очередь на финализацию

Рис. 21.14. Управляемая куча с указателями, перемещенными из списка финализации в очередь на финализацию


 

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

Взаимодействие списка финализации и очереди на финализацию само по себе замечательно, но сначала я расскажу, почему эта очередь получила свое оригинальное название. Очевидно, буква «f» означает «finalization», то есть «финализация»: каждая запись в очереди — это ссылка на объект в управляемой куче;, для которого должен быть вызван метод финализации. Вторая часть оригинального имени, «reachable», означает, что эти объекты доступны. То есть ее можно было бы назвать очередью ссылок на объекты, доступные для финализации, но для краткости мы будем называть ее очередью на финализацию. Эту очередь можно рассматривать и просто как корень, подобно статическим полям, которые являются корнями. Таким образом, находящийся в очереди на финализацию объект доступен и не является мусором.

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

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

 


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

Список финализации Очередь на финализацию

Рис. 21.15. Состояние управляемой кучи после второй уборки мусора

Мониторинг и контроль времени жизни объектов

Для каждого домена приложения CLR поддерживает таблицу GC-дескрипторов (GC handle table), с помощью которой приложение отслеживает время жизни объекта или позволяет управлять им вручную. В момент создания домена приложения таблица пуста. Каждый элемент таблицы состоит из указателя на объект в управляемой куче и флага, задающего способ мониторинга или контроля объекта. Приложение добавляет в таблицу и удаляет из таблицы элементы с помощью показанного далее типа System.Runtime.IntenopSenvices.GCHandle.

// Тип определяется в пространстве имен System.Runtime.InteropServices public struct GCHandle {

// Статические методы, создающие элементы таблицы public static GCHandle Alloc(object value);

public static GCHandle Alloc(object value, GCHandleType type);

// Статические методы, преобразующие GCHandle в IntPtr public static explicit operator IntPtr(GCHandle value); public static IntPtr ToIntPtr(GCHandle value);

// Статические методы, преобразующие IntPtr в GCHandle public static explicit operator GCHandle(IntPtr value); public static GCHandle FromIntPtr(IntPtr value);

// Статические методы, сравнивающие два типа GCHandles public static Boolean operator ==(GCHandle a, GCHandle b); public static Boolean operator !=(GCHandle a, GCHandle b);

// Экземплярный метод, освобождающий элемент таблицы (индекс равен 0) public void Free();

// Экземплярное свойство, извлекающее/назначающее // для элемента ссылку на объект public object Target { get; set; }

// Экземплярное свойство, равное true при отличном от 0 индексе public Boolean IsAllocated { get; }

// Для элементов с флагом pinned возвращается адрес объекта public IntPtr AddrOfPinnedObject();

public override Int32 GetHashCodeQ; public override Boolean Equals(object o);

}

В сущности, для контроля или мониторинга времени жизни объекта вызывается статический метод Alloc объекта GCHandle, передающий ссылку на этот объект, и тип GCHandleType, который представляет собой флаг, задающий способ мониторинга/ контроля объекта. Перечислимый тип GCHandleType определяется так:

public enum GCHandleType {

Weak = 0, // Мониторинг существования объекта

WeakTrackResurrection = 1 // Мониторинг существования объекта Normal = 2, // Управление временем жизни объекта

Pinned =3 // Управление временем жизни объекта

}

Вот что означают эти флаги.

□ Weak — мониторинг времени жизни объекта. Флаг позволяет узнать, когда уборщик мусора обнаруживает, что объект более недоступен коду приложения. Учтите, что метод финализации объекта мог как выполниться, так и не выполниться, поэтому объект может по-прежнему оставаться в памяти.

□ WeakTrackResurrection — мониторинг времени жизни объекта. Флаг позволяет узнать, когда уборщик мусора обнаруживает, что объект более недоступен коду приложения. Учтите, что метод финализации объекта (если таковой имеется) уже точно был выполнен, то есть память, занятая объектом, была освобождена.

□ Normal — контроль времени жизни объекта. Флаг заставляет уборщик мусора оставить объект в памяти, даже если в приложении нет переменных (корней), ссылающихся на него. В ходе уборки мусора память, занятая этим объектом, может быть сжата (перемещена). Метод Alloc, не принимающий флаг GCHandleType, предполагает, что задано значение GCHandleType. Normal.

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

При вызове статический метод Alloc объекта GCHandle сканирует таблицу GC- дескрипторов домена приложения в поисках элемента, в котором хранится адрес объекта, переданного ему в качестве параметра. При этом устанавливается флаг, переданный в качестве параметра типу GCHandleType. Затем метод Alloc возвращает экземпляр GCHandle. Тип GCHandle — это «облегченный» значимый тип, содержащий одно экземплярное поле IntPtr, ссылающееся на индекс элемента в таблице. Чтобы освободить этот элемент в таблице GC-дескрипторов, нужно взять экземпляр GCHandle и вызвать метод Fгее (который также объявляет недействительным экземпляр, обнуляя поле IntPtr).

Вот как уборщик мусора работает с таблицей GC-дескрипторов.

1. Уборщик мусора маркирует все доступные объекты (как описано в начале этой главы). Затем он сканирует таблицу GC-дескрипторов, все объекты с флагом Normal или Pinned считаются корнями и также маркируются (в том числе все объекты, на которые они ссылаются через свои поля).

2. Уборщик мусора сканирует таблицу GC-дескрипторов в поисках всех записей с флагом Weak. Если такая запись ссылается на немаркированный объект, указатель относится к недоступному объекту (мусору) и приравнивается к null.

3. Уборщик мусора сканирует список финализации. Если указатель из списка ссылается на немаркированный объект, этот объект начинает считаться недоступным и перемещается из списка финализации в очередь на финализацию. В этот момент объект маркируется, потому что начинает считаться доступным.

4. Уборщик мусора сканирует таблицу GC-дескрипторов в поисках всех элементов с флагом WeakTrackResurrection. Если такой элемент ссылается на немаркированный объект (теперь это объект, на который указывает элемент из очереди на финализацию), указатель считается относящимся к недоступному объекту (мусором) и приравнивается к null.

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

Разобравшись в логике работы уборщика, попробуем использовать эти знания на практике. Наиболее понятны флаги Normal и Pinned, поэтому начнем с них. Обычно они применяются при взаимодействии с неуправляемым кодом.

Флаг Normal используется для передачи ссылки на управляемый объект неуправляемому коду при условии, что позже неуправляемый код выполнит обратный вызов управляемого кода, передав ему эту ссылку. В общем случае невозможно передать ссылку на управляемый объект неуправляемому коду, потому что при уборке мусора адрес объекта в памяти может измениться, и указатель станет недействительным. Чтобы решить эту проблему, можно вызвать метод Alloc объекта GCHandle и передать ему ссылку на объект и флаг Normal. Затем возвращенный экземпляр GCHandle нужно привести к типу IntPtr и передать полученный результат в неуправляемый код. Когда неуправляемый код выполнит обратный вызов управляемого кода, последний приведет передаваемый тип IntPtr обратно к GCHandle, после чего запросит свойство Target, чтобы получить ссылку на управляемый объект (или его текущий адрес). Когда неуправляемому коду эта ссылка будет больше не нужна, следует вызывать метод Free объекта GCHandle, который позволит очистить объект при следующей уборке мусора (при условии, что для этого объекта нет других корней).

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

При использовании для вызова метода механизма P/Invoke CLR автоматически устанавливает для аргументов флаг Pinned и сбрасывает его, когда неуправляемый метод возвращает управление. Поэтому чаще всего в типе GCHandle не приходится самостоятельно явно устанавливать флаг Pinned для каких-либо управляемых объектов. Тип GCHandle нужно явно использовать для передачи адреса управляемого объекта неуправляемому коду. Затем неуправляемая функция возвращает управление, а неуправляемому коду этот объект может потребоваться позднее. Чаще всего такая ситуация возникает при асинхронных операциях ввода-вывода.

Допустим, вы выделяете память для байтового массива, который должен заполняться данными по мере их поступления из сокета. Затем вызывается метод Alloc типа GCHandle, передающий ссылку на массив и флаг Pinned. Далее при помощи возвращенного экземпляра GCHandle вызывается метод AddrOfPinnedObject. Он возвращает IntPtn — действительный адрес объекта с флагом Pinned в управляемой куче. Затем этот адрес передается неуправляемой функции, которая сразу передает управление управляемому коду. В процессе поступления данных из сокета буфер байтового массива не должен перемещаться в памяти, что и обеспечивается флагом Pinned. По завершении операции асинхронного ввода-вывода вызывается метод Free объекта GCHandle, который разрешает перемещения в буфер при следующей уборке мусора. В управляемом коде по-прежнему должна присутствовать ссылка на этот буфер, обеспечивая разработчику доступ к данным. Эта ссылка не позволит уборщику полностью убрать буфер из памяти.

Следует упомянуть и о таком средстве фиксации объектов внутри кода, как инструкция fixed языка С#. Вот пример ее применения:

unsafe public static void Go() {

// Выделение места под объекты, которые немедленно превращаются в мусор for (Int32 х = 0; х < 10000; х++) new ObjectQ;

IntPtr originalMemoryAddress;

Byte[] bytes = new Byte[1000]; // Располагаем этот массив

// после мусорных объектов

// Получаем адрес в памяти массива Byte[]

fixed (Byte* pbytes = bytes) { originalMemoryAddress = (IntPtr) pbytes; }

// Принудительная уборка мусора

// Мусор исчезает, позволяя сжать массив Byte[]

GC.CollectQ;

// Повторное получение адреса массива Byte[] в памяти // и сравнение двух адресов fixed (Byte* pbytes = bytes) {

Console.Writeline("The Byte[] did{0} move during the GC",

(originalMemoryAddress == (IntPtr) pbytes) ? " not" : null);

}

}

Инструкция fixed языка C# работает эффективней, чем выделение в памяти фиксированного GC-дескриптора. В данном случае она заставляет установить специальный «блокирующий» флаг на локальную переменную pbytes. Уборщик мусора, исследуя содержимое этого корня и обнаруживая отличные от null значения, понимает, что во время сжатия перемещать объект, на который ссылается эта переменная, нельзя. Компилятор C# создает IL-код, присваивающий локальной переменной pbytes адрес объекта из начала блока fixed. При достижении конца блока компилятор создает IL-инструкцию, возвращающую переменной pbytes значение null. Она перестает ссылаться на объект, позволяя удалить этот объект в ходе следующей уборки мусора.

Флаги Weak и WeakTrackResurrection могут применяться как в сценариях взаимодействия с неуправляемым кодом, так и при использовании только управляемого кода. Флаг Weak указывает, что объект уже помечен как мусор, но занимаемая им память пока может оказаться невостребованной. А вот флаг WeakTrackResurrection указывает на необходимость возвращения памяти. В то время как флаг Weak применяется повсеместно, я еще ни разу не видел применения флага WeakTrackResurrection в реальных приложениях.

Предположим, что объект А периодически вызывает метод для объекта В. Но наличие ссылки на объект В со стороны объекта А защищает его от уборщика мусора, и вполне возможны ситуации, в которых такое поведение нежелательно. Предположим, что вместо этого нам нужно, чтобы объект А вызывал метод объекта В при условии, что последний находится в управляемой куче;. Для решения этой задачи объекту А следует вызвать метод Alloc класса GCHandle, передав этому методу ссылку на объект В и флаг Weak. В результате в объекте А будет храниться возвращенная ссылка на экземпляр GCHandle, а не реальная ссылка на объект В.

При отсутствии других корней, сохраняющих объект В, теперь он может отправляться в мусорную корзину. Если объекту А понадобится вызвать метод объекта В, он обратится к предназначенному только для чтения свойству Тa rget класса GCHandle. Возвращение этим свойством значения, отличного от null, указывает, что объект В еще существует. После этого код объекта А сможет привести возвращенную ссылку к типу объекта В и вызвать метод. Если же свойство Target возвращает значение null, значит, объект В уничтожен уборщиком мусора, и объект А не будет пытаться вызвать метод объекта В. Впрочем, код объекта А может вызвать метод Free типа GCHandle, чтобы разорвать связь с экземпляром GCHandle.

Поскольку из-за высоких требований к безопасности при фиксации или сохранении объекта в памяти работать с типом GCHandle не просто, в пространство имен System был включен вспомогательный класс WeakReference:

public sealed class WeakReference<T> : ISerializable where T : class { public WeakReference(T target);

public WeakReference(T target, Boolean trackResurrection);

public void SetTarget(T target);

public Boolean TryGetTarget(out T target);

}

Это не более чем объектно-ориентированная обертка для экземпляра GCHandle: конструктор этого класса вызывает метод Alloc класса GCHandle, его свойство TryGetTarget читает свойство Target класса GCHandle, метод SetTarget задает свойство Target класса GCHandle, аметод Finalize (выше не показан, поскольку является защищенным) — метод Free класса GCHandle. Для работы с классом WeakReference коду не требуется специального разрешения, так
как этот класс поддерживает только слабые ссылки; поведение, предоставляемое экземплярами GCHandle, размещенными в режимах Normal или Pinned, не поддерживается. Недостатком класса WeakReference<T> является необходимость пребывания объекта в куче. Из-за этого объекты WeakReference «весят» больше экземпляров GCHandle.

ВНИМАНИЕ

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

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

Слабые ссылки могут быть очень эффективными при кэшировании, но очень сложно создать хороший алгоритм кэширования, обеспечивающий нужное равновесие между расходованием памяти и быстродействием. В сущности, необходимо, чтобы в кэше были строгие ссылки на все объекты, а затем, когда памяти становится мало, строгие ссылки должны превращаться в слабые. На сегодняшний момент CLR не поддерживает механизм, позволяющий уведомлять приложение об исчерпании ресурсов памяти. Тем не менее некоторые разработчики приспособились периодически вызывать \ЛАп32-функцию GlobalMemoryStatusEx и проверять поле dwMemoryLoad возвращенной структуры MEMORYSTATUSEX. Его значение, превышающее 80, означает, что память на исходе и настало время преобразовывать строгие ссылки в слабые по выбранному алгоритму: по давности, частоте, времени использования объектов или по другим алгоритмам.

Разработчикам часто требуется связать фрагмент данных с каким-нибудь другим элементом. Например, можно связать данные с потоком или с доменом приложений. Класс System.Runtime.CompilerServices.ConditionalWeakTable<TKey,TValue> позволяет связать данные с объектом. Вот как он выглядит:

public sealed class ConditionalWeakTable<TKey, TValue> where TKey : class where TValue : class { public ConditionalWeakTableQ; public void Add(TKey key, TValue value); public TValue GetValue(

TKey key, CreateValueCallback<TKey, TValue> createValueCallback); public Boolean TryGetValue(TKey key, out TValue value); public TValue GetOrCreateValue(TKey key); public Boolean Remove(TKey key);

public delegate TValue CreateValueCallback(TKey key); // Вложенное

// определение делегата

 


Для связи произвольных данных с одним или несколькими объектами класса для начала вам потребуется экземпляр этого класса. Затем следует вызвать метод Add, передав параметру key ссылку на объект, а параметру value — данные, которые вы хотите связать с этим объектом. При попытке повторного добавления ссылки на тот же самый объект метод Add выдаст исключение ArgumentException. Чтобы изменить связанное с объектом значение, нужно удалить ключ и добавить его снова уже с другим значением. Имейте в виду, что так как класс является безопасным в отношении потоков, его могут в конкурентном режиме использовать другие потоки, хотя это не лучшим образом сказывается на производительности. Производительность класса нужно проверять, чтобы узнать, насколько она достаточна именно для вашего сценария.

Разумеется, во внутренней реализации таблицы хранится ссылка WeakReference на объект, переданный им в качестве ключа; это гарантирует, что таблица не будет принудительно увеличивать время жизни объекта. Но особенность класса ConditionalWeakTable состоит в том, что он гарантирует наличие в памяти значения до тех пор, пока объект идентифицируется в памяти по ключу. Это превосходит способности обычного класса WeakReference, в котором значение уничтожается уборщиком мусора, хотя ключ еще существует. Класс ConditionalWeakTable может применяться для реализации механизма свойств зависимости, используемого в XAML. Он может также внутренне использоваться в динамических языках для динамической связи данных с объектами.

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

internal static class CondltlonalWeakTableDemo { public static void Main() {

Object о = new ObjectQ. GCWatch("My Object created at " + DateTime.Now);

GC. CollectQ; // Оповещение об отправке в мусор не выдается

GC.KeepAlive(o); // Объект, на который ссылается о, должен существовать о = null; // Объект, на который ссылается о, можно уничтожать

GC. CollectQ; // Оповещение об отправке в мусор

Console.Read Line();

}

}

Internal static class GCWatcher {

// ПРИМЕЧАНИЕ. Аккуратнее обращайтесь с типом String

// из-за интернирования и объектов-представителей MarshalByRefObject

private readonly static CondltlonalWeakTablecObject,

NotifyWhenGCd<String>> s_cwt =

new CondltlonalWeakTablecObject, NotlfyWhenGCd<String>>();

private sealed class NotifyWhenGCd<T> { private readonly T mvaluej

internal NotifyWhenGCd(T value) { m_value = value; }

public override string ToStringQ { return m_value.ToString(); }

~NotifyWhenGCd() { Console.WriteLine("GC'd: " + m_value); }

}

public static T GCWatch<T>(this T ^object, String tag) where T : class { s_cwt.Add(@ob]ectj new NotifyWhenGCd<String>(tag)); return ^object;

}

Глава 22. Хостинг CLR и домены приложений

В этой главе обсуждаются две темы, позволяющие по-настоящему оценить достоинства Microsoft .NET Framework, — хостинг (hosting) и домены приложений (AppDomains). Благодаря хостингу любое приложение может использовать возможности общеязыковой среды CLR, в частности существующие приложения можно, по крайней мере, частично, переписать при помощи управляемого кода. Кроме того, хостинг позволяет настраивать и дополнять приложения на программном уровне.

Поддержка дополнений означает возможность включения в свои программы кода сторонних разработчиков. В Microsoft Windows загрузка чужих DLL-библиотек была исключительно рискованным мероприятием. В такой библиотеке очень легко мог оказаться код, разрушающий структуры данных, и код приложений. Кроме того, библиотека могла использовать контекст безопасности приложений для получения доступа к ресурсам, к которым в обычных условиях доступа у нее нет. Домены приложений позволили решить эти проблемы. Именно они дают возможность запускать не пользующийся доверием код сторонних разработчиков, a CLR гарантирует безопасность и целостность структур данных и кода, а также невозможность использовать в неблаговидных целях контекст безопасности.

Обычно хостинг и домены приложений используют наряду с загрузкой сборок и отражением. Совместное применение этих четырех технологий превращает CLR в невероятно богатую и мощную платформу. Эта глава посвящена в основном хостингу и доменам приложений, а о загрузке сборок и отражении рассказывается в следующей главе. Изучив и освоив эти технологии, вы узнаете, почему усилия, затраченные на освоение .NET Eramework, с лихвой окупятся в будущем.

Хостинг CLR

Платформа .NET Framework работает поверх Microsoft Windows. Это значит, что в ее основе должны лежать технологии, с которыми Windows может взаимодействовать. Для начала все файлы управляемых модулей и сборок должны иметь формат РЕ (portable executable), являться исполняемыми файлами Windows (EXE) или динамически подключаемыми библиотеками (DLL).

В Microsoft разработали CLR в виде COM-сервера, содержащегося в DLL. То есть разработчики определили для CLR стандартный COM-интерфейс и присвоили этому интерфейсу и COM-серверу глобально уникальные идентификаторы (GUID).

При установке .NET Framework COM-сервер, представляющий CLR, регистрируется в реестре Windows как любой другой COM-сервер. Подробнее см. заголовочный файл C++ MetaHost.h из .NET Framework SDK — в этом файле определены все GUID-идентификаторы и неуправляемый интерфейс ICLRMetaHost.

Любое Windows-приложение может стать хостом (управляющим приложением) для CLR. Однако не следует создавать экземпляры COM-сервера CLR функцией CoCreatelnstance; вместо этого неуправляемый хост должен вызывать функцию CLRCreatelnstance, объявленную в файле MetaHost.h. Эта функция реализована в библиотеке MSCorEE.dll, которая обычно расположена в каталоге C:\Windows\ System32. Обычно эту библиотеку называют оболочкой совместимости (shim) — она не содержит COM-сервер CLR, а только определяет, какую версию CLR следует создать.

На одной машине допускается установка нескольких версий CLR, но может быть только одна версия файла MSCorEE.dll (оболочка совместимости)1. Версия библиотеки MSCorEE.dll совпадает с версией самой последней установленной среды CLR, поэтому эта версия MSCorEE.dll «знает», как найти любые более ранние версии CLR, которые устанавливались на машине.

Код CLR содержится в файле, имя которого зависит от версии. Для версий 1.0, 1.1 и 2.0 это файл MSCorWks.dll, а для версии 4.0 — файл Clr.dll. Так как на один компьютер можно установить несколько версий CLR, эти файлы располагаются в разных папках[25] [26]:

□ версия 1.0 — в папке C:\Windows\Microsoft.NET\Framework\v1.0.3705;

□ версия 1.1 — в папке C:\Windows\Microsoft.NET\Framework\v1.0.4322;

□ версия 2.0 — в папке C:\Windows\Microsoft.NET\Framework\v2.0.50727;

□ версия 4.0 — в папке C:\Windows\Microsoft.NET\Framework\v4.0.21006.

Функция CLRCreatelnstance возвращает интерфейс ICLRMetaHost. Хост- приложение может вызывать функцию GetRuntime этого интерфейса, указывая ту версию CLR, которую следует создать. После этого оболочка совместимости загружает эту версию в текущий процесс.

По умолчанию оболочка совместимости анализирует управляемый исполняемый файл и извлекает из него сведения о версии CLR, с которой было построено и протестировано приложение. Однако приложение может переопределить заданные по умолчанию сведения, записав элементы requiredRuntime и supportedRuntime в конфигурационный XML-файл (см. главы 2 и 3).

Функция Get Runtime возвращает указатель на неуправляемый интерфейс ICLRRuntimelnfo, из которого при помощи метода Getlnterface получается интерфейс ICLRRuntimeHost. Вызывая методы этого интерфейса, хост-приложение может выполнять следующие операции:

□ Устанавливать хост-диспетчеры (host managers), то есть сообщать CLR, что хост должен участвовать в решениях, связанных с выделением памяти, планированием и синхронизацией потоков, загрузкой сборок и т. п. Кроме того, хосту могут понадобиться уведомления о начале и окончании уборки мусора, а также о завершении определенных операций.

□ Получать информацию о CLR-диспетчерах, то есть запрещать CLR использовать определенные классы или члены. Кроме того, хост может указать подлежащий и неподлежащий отладке код, а также методы, вызываемые при наступлении определенных событий, таких как выгрузка домена приложения, остановка CLR или исключение, вызванное переполнением стека.

□ Инициализировать и запускать CLR.

□ Загружать сборку и исполнять ее код.

□ Останавливать CLR, предотвращая дальнейшее исполнение управляемого кода в Windows-процессе.

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

□ программирование на любом языке;

□ код может компилироваться (а не интерпретироваться) JIT-компилятором, что обеспечивает максимальное быстродействие;

□ поддержка уборки мусора, предотвращающей утечки и повреждение памяти;

□ выполнение кода в безопасной изоляции;

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

Тем, кто интересуется подробностями хостинга CLR, я настоятельно рекомендую отличную книгу Стивена Претчнера (Steven Pratschner) «Customizing the Microsoft .NET Framework Common Language Runtime» (Microsoft Press, 2005), несмотря на то, что в ней рассматриваются более ранние, чем 4.0, версии CLR.

ПРИМЕЧАНИЕ

Конечно, Windows-процессы могут обойтись и без CLR. Эта среда нужна только для исполнения в процессе управляемого кода. До появления .NET Framework 4.0 внутри Windows-процесса допускался только один экземпляр CLR. То есть процесс мог не содержать CLR вообще или же содержать какую-нибудь из имеющихся версий — 1.0, 1.1 или 2.0. Это было серьезное ограничение. Например, в Microsoft Office Outlook было невозможно загрузить двадополнительных компонента, если они создавались и тестировались на разных версиях .NET Framework.

К счастью, начиная с .NET Framework 4.0, поддерживается возможность загрузки версий 2.0 и 4.0 в один Windows-процесс, позволяя компонентам, написанным для .NET Framework 2.0 и 4.0, работать параллельно, не испытывая проблем совместимости. Эта без преувеличения фантастическая возможность позволяет применять компоненты .NET Framework в самых разных сценариях. Узнать, какая версия или версии CLR загружены в определенный процесс, можно с помощью утилиты ClrVer.exe.

Загруженную в Windows-процесс среду CLR выгрузить уже нельзя. Методы AddRef и Release не влияют на интерфейс ICLRRuntimeHost. Вы можете только завершить процесс, вынудив Windows очистить все занятые в нем ресурсы.

Домены приложений

В ходе инициализации COM-сервер CLR создает домен приложений (AppDomain), представляющий собой логический контейнер для набора сборок. Первый из созданных доменов называют основным (default AppDomain), он уничтожается только при завершении Windows-процесса.

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

□ Объекты, созданные одним доменом приложений, недоступны для кода других доменов. Когда код домена приложений создает объект, домен становится «хозяином» этого объекта. Иначе говоря, время жизни объекта ограничивается временем существования самого домена. Код другого домена может получить доступ к объекту, только используя семантику продвижения по ссылке (marshal- by-reference) или по значению (marshal-by-value). Тем самым обеспечивается четкое разделение кода в домене приложений, так как код в одном домене не может напрямую ссылаться на объект, созданный в другом домене. Такая изоляция позволяет легко выгружать домены приложений из процесса без влияния на работу других доменов.

□ Домены приложений можно выгружать. CLR не поддерживает выгрузку отдельных сборок. Однако можно заставить CLR выгрузить домен приложений со всеми содержащимися в нем в данный момент сборками.

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

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

ВНИМАНИЕ

Windows предоставляет замечательную возможность запускать каждое приложение в собственном адресном пространстве. Это гарантирует, что код одного приложения не получит доступа к коду и данным другого. Изоляция процессов предотвращает появления брешей в системе безопасности, повреждение данных и другие неприятности, обеспечивая надежность Windows и работающих в этой операционной системе приложений. К сожалению, создание процесса в Windows — операция очень ресурсоемкая. Win32^yHKHMB CreateProcess выполняется медленно, а виртуализация адресного пространства процесса требует много памяти.

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

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

Кроме того, в каждый домен приложений загружены сборки. В первый (он же основной) загружены три сборки: MyApp.exe, Typel_ib.dll и System.dll, во второй две сборки: Wintellect.dll и System.dll.

Обратите внимание, что сборка System.dll загружается в оба домена. Если в обоих доменах используется один тип из System.dll, в их кучах загрузчика будут размещены объекты одинаковых типов; память, выделенная под эти объекты, не используется доменами совместно. Более того, когда код домена вызывает определенные в типе методы, IL-код метода JIT-компилируется, а результирующий машинный код привязывается к каждому домену в отдельности, то есть он не используется ими совместно.

 


Windows-процесс

Рис. 22.1. Windows-процесс, являющийся хостом для CLR
и двух доменов приложений

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

Некоторые сборки предназначены для совместного использования разными доменами. Лучший пример — сборка MSCorLib.dll, созданная в Microsoft. Именно ей принадлежат типы System.Object, System.Int32 и другие типы, неотделимые от .NET Framework. Эта сборка автоматически загружается при инициализации CLR, и домены приложений совместно используют ее типы. Для экономии ресурсов MSCorLib.dll загружается как сборка, не связанная с конкретным доменом. Объекты всех типов в этой куче загрузчика и весь машинный код методов этих типов совместно используются всеми доменами процесса. К сожалению, для достижения всех преимуществ от совместного использования ресурсов приходится кое-чем жертвовать: сборки, загруженные без привязки к доменам, нельзя выгружать до завершения процесса. Единственный способ вернуть ресурсы — завершить процесс.

Доступ к объектам из других доменов

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

private static void Marshalling() {

// Получаем ссылку на домен, в котором исполняется вызывающий поток AppDomain adCallingThreadDomain = Thread.GetDomainQ;

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

String callingDomainName = adCallingThreadDomain.FriendlyName;

Console.WriteLine(

"Default AppDomain's friendly name={0}", callingDomainName);

// Получаем и выводим сборку в домене, содержащем метод Main.

String exeAssembly = Assembly.GetEntryAssembly().FullName;

Console.WriteLine("Main assembly={0}", exeAssembly);

// Определяем локальную переменную, ссылающуюся на домен AppDomain ad2 = null;

// ПРИМЕР 1. Доступ к объектам другого домена приложений // с продвижением по ссылке

Console.WriteLine("{0}Demo #1", Environment.NewLine);

// Создаем новый домен (с теми же параметрами защиты и конфигурирования) ad2 = AppDomain.CreateDomain("AD #2", null, null);

MarshalByRefType mbrt = null;

// Загружаем нашу сборку в новый домен, конструируем объект // и продвигаем его обратно в наш домен

// (в действительности мы получаем ссылку на представитель) mbrt = (MarshalByRefType)

ad2.CreateInstanceAndUnwrap(exeAssembly, "MarshalByRefType");

Console.WriteLine("Type={0}", mbrt.GetType()); // CLR неверно

// определяет тип

// Убеждаемся, что получили ссылку на объект-представитель Console.WriteLine(

"Is proxy={0}", RemotingServices.IsTransparentProxy(mbrt));

// Все выглядит так, как будто мы вызываем метод экземпляра // MarshalByRefType, но на самом деле мы вызываем метод типа // представителя. Именно представитель переносит поток в тот домен,

II в котором находится объект, и вызывает метод для реального объекта mbrt.SomeMethod();

II Выгружаем новый домен AppDomain.Unload(ad2);

II mbrt ссылается на правильный объект-представитель;

// объект-представитель ссылается на неправильный домен

try {

// Вызываем метод, определенный в типе представителя // Поскольку домен приложений неправильный, появляется исключение mbrt.SomeMethod( );

Console.WriteLine("Successful call.");

}

catch (AppDomalnllnloadedExceptlon) {

Console.WriteLine("Failed call.");

}

// ПРИМЕР 2. Доступ к объектам другого домена // с продвижением по значению

Console.WriteLine("{0}Demo #2", Environment.NewLine);

// Создаем новый домен (с такими же параметрами защиты

// и конфигурирования, как в текущем)

ad2 = AppDomain.CreateDomain("AD #2", null, null);

// Загружаем нашу сборку в новый домен, конструируем объект // и продвигаем его обратно в наш домен

// (в действительности мы получаем ссылку на представитель) mbrt = (MarshalByRefType)

ad2.CreateInstanceAndUnwrap(exeAssembly, "MarshalByRefType");

// Метод возвращает КОПИЮ возвращенного объекта;

// продвижение объекта происходило по значению, а не по ссылке MarshalByValType mbvt = mbrt. MethodWithReturnQ ;

// Убеждаемся, что мы НЕ получили ссылку на объект-представитель Console.WriteLine(

"Is proxy={0}", RemotingServices.IsTransparentProxy(mbvt));

// Кажется, что мы вызываем метод экземпляра MarshalByRefType,

// и это на самом деле так

Console.WriteLine("Returned object created " + mbvt.ToStrlng());

// Выгружаем новый домен AppDomain.Unload(ad2);

// mbrt ссылается на действительный объект;

// выгрузка домена не имеет никакого эффекта try {

// Вызываем метод объекта; исключение не генерируется Console.WriteLine("Returned object created " + mbvt.ToStringQ); Console.WrlteLlne("Successful call.");

}

catch (AppDomalnUnloadedException) {

Console.WriteLine("Failed call.");

}

// ПРИМЕР 3. Доступ к объектам другого домена // без использования механизма продвижения Console.WriteLine("{0}Demo #3", Environment.NewLine);

// Создаем новый домен (с такими же параметрами защиты

// и конфигурирования, как в текущем)

ad2 = AppDomain.CreateDomain("AD #2", null, null);

// Загружаем нашу сборку в новый домен, конструируем объект // и продвигаем его обратно в наш домен

// (в действительности мы получаем ссылку на представитель) mbrt = (MarshalByRefType)

ad2.CreatelnstanceAndllnwrap(exeAssembly, "MarshalByRefType");

// Метод возвращает объект, продвижение которого невозможно // Генерируется исключение

NonMarshalableType nmt = mbrt.MethodArgAndReturn(callingDomainName);

// До выполнения этого кода дело не дойдет...

}

// Экземпляры допускают продвижение по ссылке через границы доменов public sealed class MarshalByRefType : MarshalByRefObject { public MarshalByRefTypeQ {

Console.WriteLine("{0} ctor running in {1}",

this.GetType(). ToStringQ, Thread.GetDomainQ . FriendlyName);

}

public void SomeMethod() {

Console.WriteLine("Executing in " + Thread.GetDomainQ.FriendlyName);

>

public MarshalByValType MethodWithReturn() {

Console.WriteLine("Executing in " + Thread.GetDomainQ.FriendlyName); MarshalByValType t = new MarshalByValTypeQ; return t;

>

public NonMarshalableType MethodArgAndReturn(String callingDomainName) { // ПРИМЕЧАНИЕ: callingDomainName имеет атрибут [Serializable]

Console.WriteLine("Calling from '{0}' t° "{1}".", callingDomainName, Thread.GetDomainQ.FriendlyName);

NonMarshalableType t = new NonMarshalableTypeQ j return tj

}

}

// Экземпляры допускают продвижение по значению через границы доменов [Serializable]

public sealed class MarshalByValType : Object { private DateTime mcreationTime = DateTime.Now;

// ПРИМЕЧАНИЕ: DateTime помечен атрибутом [Serializable]

public MarshalByValType() {

Console.WriteLine("{0} ctor running in {1}, Created on {2:D}“, this.GetType() .ToStringQ,

Thread.GetDomainQ. FriendlyName, m_creationTime);

>

public override String ToStringQ {

return m creationTime.ToLongDateString();

>

>

// Экземпляры не допускают продвижение между доменами // [Serializable]

public sealed class NonMarshalableType : Object { public NonMarshalableType() {

Console.WriteLine("Executing in " + Thread.GetDomainQ .FriendlyName);

}

}

Собрав и выполнив это приложение, мы получим следующее:

Default AppDomain's friendly name= Ch22-l-AppDomains.exe Main assembly=Ch22-l-AppDomains, Version=0.0.0.0,

CuIture=neutral, PublicKeyToken=nu11

Demo #1

MarshalByRefType ctor running in AD #2

Type=MarshalByRefType

Is proxy=True

Executing in AD #2

Failed call.

Demo #2

MarshalByRefType ctor running in AD #2 Executing in AD #2

MarshalByValType ctor running in AD #2, Created on Friday, August 07, 2009 Is proxy=False

Returned object created Friday, August 07, 2009 Returned object created Friday, August 07, 2009 Successful call.

Demo #3

MarshalByRefType ctor running in AD #2

Calling from 'Ch22-l-AppDomains.exe' to 'AD #2'.

Executing in AD #2

Unhandled Exception: System.Runtime.Serialization.SerializationException:

Type 'NonMarshalableType' in assembly 'Ch22-l-AppDomains, Version=0.0.0.0,

Culture=neutral, PublicKeyToken=null‘ is not marked as serializable.

at MarshalByRefType.MethodArgAndReturn(String callingDomainName)

at Program.Marshalling()

at Program.Main()

is not marked as serializable.

at MarshalByRefType.MethodArgAndReturn(String callingDomainName) at Program.Marshalling() at Program.Main()

А теперь поговорим о том, что делает этот код и как работает CLR.

В методе Marshalling я первым делом получаю ссылку на объект AppDomain, который идентифицирует домен приложений, где в данный момент выполняется вызывающий поток. В Windows поток всегда создается в контексте одного процесса и проводит в нем всю свою жизнь. Однако между потоками и доменами приложений отсутствует однозначное соответствие. Домены приложений относятся к функциональности CLR, Windows о них ничего не «знает». Так как в одном Windows-процессе может существовать несколько доменов приложений, поток может в разное время выполнять код разных доменов. С точки зрения CLR в каждый момент времени поток выполняет код только в одном из доменов приложений. Поток может запросить у CLR, код какого домен в нем выполняется в текущий момент, вызвав статический метод GetDomain класса System. Threading. Thread или запросив статическое, предназначенное только для чтения свойство CurrentDomain класса System.AppDomain.

Создаваемому домену можно присвоить значимое имя — строку типа String, используемую затем для идентификации. Обычно это оказывается полезным при отладке. Так как среда CLR создает основной домен до выполнения какого-либо кода, в качестве имени по умолчанию берется имя исполняемого файла. Мой метод Marshalling запрашивает имя основного домена через предназначенное только для чтения свойство FriendlyName класса System.AppDomain.

Далее, метод Marshalling запрашивает строгое имя сборки (загруженной в основной домен), которое определяет точку входа в метод Main, вызывающий мой метод Marshalling. В этой сборке определено несколько типов: Program, MarshalByRefType, MarshalByValType и NonMarshalableType. Теперь рассмотрим три во многом сходных друг с другом примера.

Пример 1. Междоменное взаимодействие с продвижением по ссылке

В первом примере статический метод CreateDomain типа System .AppDomain вызывается, чтобы заставить CLR создать новый домен приложений в том же Windows- процессе. Тип AppDomain предоставляет несколько перегруженных версий метода

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

□ Строка String содержит значимое имя для нового домена. Я передаю ей значение "AD #2".

□ Аргумент System.Security.Policy.Evidence содержит политику, которую должна использовать среда CLR для вычисления набора разрешений для домена. В этом аргументе я передаю значение null, чтобы новый домен приложений наследовал тот же набор разрешений, что и родительский. Обычно для создания защитной границы вокруг кода домена приложений конструируют объект System. Security. PermissionSet, создают в нем необходимые объекты разрешений (экземпляры типов, которые реализуют интерфейс IPermission), а затем передают ссылку на результирующий объект PermissionSet перегруженной версии метода CreateDomain, принимающего PermissionSet.

□ Аргумент System.AppDomainSetup задает параметрыконфигурирации, которые

среда CLR должна применить к новому домену. И здесь я передаю значение null, чтобы новый домен приложения наследовал конфигурацию родительского. Чтобы домен получил особую конфигурацию, надо создать объект AppDomainSetup, задать его свойства требуемым образом, а затем передать в метод CreateDomain ссылку на результирующий объект AppDomainSetup.

Код метода CreateDomain создает новый домен в процессе. Этому домену присваивается значимое имя, а также параметры защиты и конфигурации. У нового домена есть собственная куча загрузчика, которая пока пуста, потому что в него не загружено ни одной сборки. При создании домена среда CLR не создает в нем никаких потоков; там не выполняется никакой код, пока вы явно не заставите поток вызвать код домена приложений.

Теперь, чтобы получить экземпляр объекта в новом домене, надо сначала загрузить туда сборку, а затем создать экземпляр определенного в этой сборке типа. Именно эти операции и выполняет открытый экземплярный метод CreatelnstanceAndUnwrap класса AppDomain. Этому методу передаются два аргумента: строка String, идентифицирующая сборку, которую следует загрузить в новый домен (на нее ссылается переменная ad2), и строка String, содержащая имя типа, экземпляр которого надо создать. Метод CreatelnstanceAndUnwrap заставляет вызывающий поток перейти из текущего домена в новый. Теперь поток (который выполняет вызов CreatelnstanceAndUnwrap) загружает указанную сборку в новый домен, а затем просматривает таблицу метаданных с определениями типов сборки в поисках указанного типа (MarshalByRefType). Обнаружив нужный тип, поток вызывает конструктор MarshalByRefType без параметров и возвращается обратно в основной домен, чтобы метод CreatelnstanceAndUnwrap мог вернуть ссылку на новый объект MarshalByRefType.

ПРИМЕЧАНИЕ

Существуют перегруженные версии CreatelnstanceAndllnwrap, которые позволяют вызывать конструктор типа с передачей аргументов.

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

Обратите внимание, что тип MarshalByRefType наследует от специального базового класса System .MarshalByRefObject. Обнаружив, что метод CreatelnstanceAndllnwrap выполняет продвижение объекта типа, производного от MarshalByRefObject, CLR выполняет продвижение объекта по ссылке в другой домен. Поясню, что означает продвижение объекта по ссылке из одного домена (в котором объект был создан) в другой (в котором вызывается метод CreatelnstanceAndllnwrap).

Когда домену-источнику нужно передать ссылку на объект в целевой домен приложений или вернуть ее обратно, CLR определяет в куче загрузчика этого домена тип представителя (proxy). Этот тип определяется посредством метаданных исходного типа, представителем которого он является, поэтому выглядит он в точности как исходный тип — у него совпадают все экземплярные члены (свойства, события и методы). Экземплярные поля к типу не относятся, но об этом мы поговорим чуть позже. В новом типе действительно определяются некоторые экземплярные поля, но они не идентичны полям исходного типа данных. Вместо этого эти поля указывают, который из доменов «владеет» реальным объектом и как найти этот объект в домене-владельце. (Внутренне объект-представитель использует экземпляр GCHandle, который ссылается на реальный объект. Тип GCHandle обсуждается в главе 21).

После определения этого типа в целевом домене метод CreatelnstanceAndllnwrap создает экземпляр типа представителя, инициализирует его поля таким образом, чтобы они указывали на домен-источник и реальный объект, и возвращает в целевой домен ссылку на объект-представитель. В моем варианте кода на этот представитель ссылается переменная mbrt. При этом возвращенный методом CreatelnstanceAndllnwrap объект не является экземпляром типа MarshalByRefType. Обычно CLR не разрешает приведение к несовместимым типам, однако в этой ситуации преобразование выполняется, потому что члены экземпляров нового и исходного типов совпадают. В сущности, если вы используете объект-представитель для вызова метода GetType, представитель вводит вас в заблуждение, представляясь объектом MarshalByRefType.

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

Для этого мы используем открытый статический мете>д IsTransparentProxy типа System.Runtime.Remoting.RemotingService,которому в качестве параметра передается ссылка, возвращенная методом CreatelnstanceAndllnwrap. Если метод IsTnanspanentPnoxy возвращает значение true, значит, объект является представителем.

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

 


Есть два способа узнать, что залрамм тающий поток перешел от основного к новому домену. Во-первых, в методе SomeMethod я вызываю метод Thread.GetDomain(). FriendlyName. В результате возвращается AD #2 (что подтверждается выходными данными), так как поток теперь выполняется в новом домене, созданном методом AppDomain. CreateDomain с параметром AD #2 в качестве значимого имени. Во- вторых, при пошаговом выполнении кода в отладчике с открытым окном Call Stack строка [AppDomain Transition] отмечает переход потока через границу между доменами (рис, 22.2).

* * |

ft I««tiiKfi can b* iirihiM-by-r(l#rm4 aersu ЛдаОш*1.п soandirSvi

g**llc H»M clH.* I (

pubLit Her-iKilBrUefГурс{) (

Ccnul».U-19«U.Kf(~(a) etor глл1л| In fiy%

}

»» -1-r

 


CTtfJ-l-*gECMWf»t«Hf43g—Ha4bjlnqi;i i»-U + lit I u

Рис. 22.2. Переход между доменами в окне отладчика Call Stack

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

ПРИМЕЧАНИЕ

Когда поток в одном домене вызывает метод другого домена приложений, поток переходит от домена кдомену. Это означает, что вызовы метода через границу между доменами приложения выполняются синхронно. Однако считается, что в каждый момент времени поток находится только в одном домене. Для выполнения кода в нескольких доменах приложений одновременно придется создавать дополнительные потоки и заставлять их выполнять нужный код в нужных доменах.

Далее мое приложение вызывает открытый статический метод Unload типа AppDomain, чтобы заставить CLR выгрузить указанный домен вместе со всеми загруженными в него сборками. Одновременно инициируется уборка мусора для освобождения всех объектов выгружаемого домена. На этом этапе переменная mbnt основного домена все еще ссылается на нужный объект-представитель; однако сам объект-представитель больше не ссылается на нужный домен приложений (поскольку тот уже выгружен). Когда основной домен пытается использовать объект-представитель для вызова метода SomeMethod, вызывается реализация этого метода, определенная в представителе. Реализация представителя выясняет, что домен приложений, в котором находился реальный объект, уже выгружен, и метод SomeMethod генерирует исключение AppDomainUnloadedException, информируя вызывающий код о невозможности выполнения операции.

Как видите, команда разработчиков CLR в Microsoft проделала огромную работу, чтобы обеспечить изоляцию доменов приложений. И это дело исключительной важности, так как данная функциональность все активнее используется при решении повседневных задач. Ясно, что доступ к объектам через границы доменов посредством продвижения по ссылке связан с некоторой потерей производительности, поэтому использование таких операций надо сводить к минимуму.

Я обещал подробнее рассказать об экземплярных полях. Они определяются в типе, производном от ManshalByRefOb ject, однако не являются частью представителя и отсутствуют в объекте-представителе. Когда вы пишете код, читающий и изменяющий значения полей экземпляров типа, производного от MarshalByRefOb ject, JIT-компилятор генерирует код, использующий объект-представитель (чтобы найти реальные домен и объект), вызывая соответственно метод FieldGetter или FieldSetten класса System. Object. Это закрытые и недокументированные методы; в сущности, для считывания и записи значений в поле в них используется отражение. Так что хотя вы и имеете доступ к полям типа, производного от MarshalByRefOb ject, производительность от этого страдает особенно сильно, потому что для такого доступа среде CLR приходится вызывать методы. Производительность значительно снижается, даже если объект, к которому происходит обращение, находится в локальном домене приложений[27].

ПРИМЕЧАНИЕ

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

private sealed class NonMBRO : Object { public Int32 x; }

private sealed class MBRO : MarshalByRefObject { public Int32 x; }

private static void FieldAccessTiming(){ const Int32 count = 100000000;

NonMBRO nonMbro = new NonMBROQ;

MBRO mbro = new MBRO();

Stopwatch sw = Stopwatch.StartNew();

for (Int32 c = 0; c < count; C++) nonMbro.x++;

Console.WriteLine(n{0}nj sw.Elapsed); // 00:00:00.4073560

sw = Stopwatch. StartNewQ;

for (Int32 c = 0; c < count; C++) mbro.x++;

Console.WriteLine(n{0}nj sw.Elapsed); // 00:00:02.5388665

Доступ к экземплярному полю класса NonMBRO, производного от класса Object, занял около 0,4 секунды, в то время как для доступа к классу MBRO, производному от MarshalByRefObject, потребовалось 2,54 секунды. Как видите, во втором случае процесс занял в 6 раз больше времени!

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

Так как во втором домене отсутствуют корни, исходный объект, на который ссылался представитель, может быть отправлен в мусор. Разумеется, это — не идеальный подход. Однако если бесконечно хранить исходный объект в памяти, он останется там даже после удаления представителя, что тоже не очень хорошо. В CLR эта проблема решается при помощи диспетчера аренды (lease manager). Создав для объекта представитель, CLR сохраняет объект в течение 5 минут. Если за это время через представитель не последовало ни одного вызова, объект деактивируется (deactivated) и освобождает память при следующей уборке мусора. После каждого вызова объекта диспетчер обновляет его срок аренды, в результате объект гарантированно остается в памяти еще 2 минуты и только потом деактивируется. При попытке вызвать через представитель объект с истекшим сроком аренды CLR генерирует исключение System.Runtime.Remoting.RemotingException.

Для переопределения заданного по умолчанию времени в 5 и 2 минуты используйте виртуальный метод InitializeLif etimeSenvices типа ManshalByRefOb ject. Дополнительную информацию по данной теме вы можете найти в SDK-документации на .NET Framework.

Пример 2. Междоменное взаимодействие с продвижением по значению

Этот пример похож на предыдущий. В нем точно так же создается второй домен приложений. Затем вызывается метод CneatelnstanceAndUnwnap для загрузки той же сборки в новый домен и создания в нем экземпляра объекта ManshalByRefType. Далее CLR создает для объекта представитель, и переменной mbnt (в основном домене) присваивается ссылка на него. Теперь при помощи созданного представителя я вызываю метод MethodWithRetunn. Этот метод без параметров будет выполнен в новом домене, а перед тем как вернуть ссылку на объект основному домену, он создаст экземпляр типа ManshalByValType.

Тип ManshalByValType не является производным от System. ManshalByRefOb ject, а значит, CLR не может определить тип представителя для создания его экземпляра; то есть объект нельзя продвинуть по ссылке через границу домена.

Однако благодаря наличию у типа ManshalByValType настраиваемого атрибута [Senializable] метод MethodWithRetunn может выполнить продвижение объекта по значению. Сейчас мы поговорим о том, что происходит при продвижении объекта по значению из одного домена (исходного) в другой (целевой). А дополнительную информацию о механизмах сериализации и десериализации вы найдете в главе 24.

Когда исходному домену нужно передать ссылку на объект в целевой домен (или возвратить ссылку из целевого домена), CLR сериализует экземплярные поля объекта в байтовый массив, который затем копируется. После этого CLR десериализует байтовый массив в целевом домене. Это вынуждает CLR загрузить в целевой массив сборку (если она еще не загружена), в которой определен десериализованный тип. Далее CLR создает экземпляр типа и использует значения из байтового массива для инициализации полей объекта так, чтобы они полностью совпадали со значениями исходного объекта. Иначе говоря, CLR делает точную копию исходного объекта в целевом домене. Затем метод MethodWithRetunn возвращает ссылку на эту копию; в результате наш объект оказывается продвинутым по значению через границу домена.

ВНИМАНИЕ

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

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

Чтобы убедиться, что объект, возвращенный методом MethodWithReturn, не является ссылкой на объект-представитель, мое приложение вызывает открытый статический метод IsTrasparentProxy типа System.Runtime.Remoting. RemotingService, передавая ему в качестве параметра ссылку, возвращенную методом MethodWithReturn. Как видно из результатов работы программы, метод IsTrasparentProxy возвращает значение false, означающее, что объект является реальным объектом, не представителем.

Итак, моя программа использует реальный объект для вызова метода ToString. Так как переменная mbvt ссылается на реальный объект, вызывается реальная реализация этого метода и никаких переходов между доменами приложений не происходит. Это легко проверить, проанализировав информацию в окне Call Stack отладчика: строка [AppDomain Transition] там не появится.

Чтобы более убедительно доказать, что это не представитель, мое приложение выгружает новый домен, после чего пытается снова вызвать метод ToString. В отличие от примера 1 на сей раз запрос успешно выполняется, потому что выгрузка нового домена никак не влияет на объекты, расположенные в основном домене, в том числе на объект, продвинутый по значению.

Пример 3. Междоменное взаимодействие без продвижения

Этот пример очень похож на описанные ранее примеры 1 и 2. Точно так же создается новый домен, после чего вызывается метод CreatelnstanceAndUnwrap для загрузки той же сборки в новый домен приложений и создания в нем объекта MarshalByValType. Переменная mbrt ссылается на представитель этого объекта.

Затем через этого представителя я вызываю метод MethodArgAndReturn, принимающий один аргумент. Так как среда CLR должна контролировать изоляцию домена, она не может просто передать ссылку на аргумент в новый домен приложений. Для объекта, принадлежащего к типу, производному от MarshalByRefOb ject, CLR создает представитель и выполняет продвижение объекта по ссылке. Если тип объекта помечен атрибутом [Serializable], CLR сериализует объект (и его потомков) в байтовый массив, пакует этот массив в новый домен и десериализует его в граф объекта, передав корень графа методу MethodArgAndReturn.

В этом примере я передаю объект System.String через границы домена. Тип System .String не является производным от класса MarshalByRefOb ject, а значит, CLR не может создать представителя. К счастью, объект System.String помечен атрибутом [Serializable], поэтому CLR в состоянии продвинуть его по значению, и код будет работать. Обратите внимание, что для типа String CLR выполняет специальную оптимизацию. Продвигая объект String через границу домена, CLR просто передает ссылку на него; копию объекта она не делает. Подобная оптимизация оказывается возможной благодаря неизменности объектов типа String, что не дает коду из одного домена повредить символы объекта String из другого. Дополнительную информацию о неизменности объектов данного типа вы найдете в главе 14[28].

Внутри метода MethodArgAndReturn я вывожу передаваемую в него строку, чтобы показать ее переход через границу домена. Затем я создаю экземпляр типа NonMarshalableType и возвращаю ссылку на этот объект в основной домен. Так как тип NonMarshalableType не является производным от System. MarshalByRefObject и не помечен атрибутом [Serializable], метод MethodArgAndReturn не может продвинуть объект по ссылке или по значению. То есть у нас нет способа передать объект через границы домена. Чтобы указать на этот факт, метод MethodArgAndReturn генерирует в основном домене исключение Serialization Exception. Так как моя программа не умеет его перехватывать, она просто прекращает свою работу.

Выгрузка доменов

Одна из замечательных особенностей CLR — возможность выгрузки доменов приложений. При этом выгружаются и все загруженные в них сборки, а также освобождается куча загрузчика доменов. Провести эту процедуру легко: достаточно вызвать статический метод Unload класса AppDomain (как показано в моем приложении в начале главы). Это заставляет CLR выполнить набор операций по корректной выгрузке указанного домена.