Сообщений 1    Оценка 600 [+2/-0]         Оценить  
Система Orphus

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

Автор: Андрей Корявченко
The RSDN Group

Источник: RSDN Magazine #1-2003
Опубликовано: 12.06.2003
Исправлено: 10.12.2016
Версия текста: 1.0
Теория
Что такое домены приложений
Работа с доменами
Практика
Утилита запуска приложений в домене
Сравнение расхода памяти
Тестирование времени доступа
Выводы

Теория

Что такое домены приложений

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

В отличие от обычного, управляемый (managed) код может проверяться (верифицироваться), в том числе и на наличие потенциально небезопасных операций. Для верифицированного кода можно обеспечить гарантированную безопасность. Но верификация всего лишь проверяет код на наличие некоторых запрещенных операций, в то время как вполне безобидные внешние вызовы могут серьезно повредить другим приложениям или компонентам. Чтобы избежать этого, нужно организовать выполнение кода в некой изолированной среде, не допускающей подобных обращений. Для неуправляемого кода роль изолированной среды выполняет исключительно процесс. Однако, с точки зрения скорости, взаимодействие между процессами требует относительно больших затрат времени. Чтобы минимизировать потери времени и ресурсов, в .NET был включён механизм доменов. Этот механизм позволяет запустить группу приложений в одном процессе, обеспечивая относительную изоляцию их друг от друга, в то же время позволяя им взаимодействовать друг с другом значительно быстрее, чем в случае отдельных процессов. Этот механизм в платформе .NET получил название доменов. Как и в случае процессов для неуправляемого кода, домен позволяет повысить устойчивость системы к сбоям, так как сбой внутри одного из доменов не приводит к сбоям внутри хост-процесса и других приложений.

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

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

ПРИМЕЧАНИЕ

Для взаимодействия между доменами используется инфраструктура Remoting. При этом данные передаются по специализированному каналу, предназначенному для обмена между доменами. Этот канал недокументирован и используется только для внутренних целей. Имя класса канала System.Runtime.Remoting.Channels.CrossAppDomainChannel. Обратите внимание на то, что в случае передачи по значению сборка с кодом класса должна быть доступна в обоих доменах, а при передаче по ссылке в вызывающем домене должны быть доступны метаданные.

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

Загрузка сборок в каждый домен происходит независимо. Таким образом, в каждом домене будет свой собственный экземпляр класса Assembly, даже если они используют одну и ту же сборку. Однако существует способ добиться того, чтобы код загружаемых сборок совместно использовался (share) разными доменами. Сборка, используемая совместно несколькими доменами, называется нейтральной по отношению к домену (domain-neutral). Режим доступа к сборкам может быть одним из трех:

ПРИМЕЧАНИЕ

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

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

Указать режим доступа можно при создании домена, передав в качестве параметра экземпляр класса AppDomainSetup, у которого свойство LoaderOptimization установлено определенным образом. Это свойство может принимать следующие значения:

Значение Описание
NotSpecified Использовать оптимизацию, заданную хост-процессом или оптимизацию по умолчанию (SingleDomain).
SingleDomain Указывает, что приложение, скорее всего, будет содержать только один домен, и в разделении кода сборок нет необходимости.
MultiDomain Указывает, что приложение содержит много доменов с одинаковым кодом, и загрузчик будет пытаться сделать по возможности общими все ресурсы.
MultiDomainHost Указывает, что, скорее всего, приложение будет содержать несколько доменов с уникальным кодом, и загрузчик обеспечит разделяемый доступ к сборкам со строгим именем.

Не существует какой-либо определенной связи между потоками операционной системы (threads) и доменами. Домен может порождать любое количество потоков, так же как поток может перейти к выполнению кода не в том домене, который его создал. Для каждого потока можно определить, в каком домене он в текущий момент выполняется, вызвав Thread.GetDomain(). Внутри потока текущий домен можно получить из свойства AppDomain.CurrentDomain.

Работа с доменами

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

Создание домена

Создать домен можно при помощи вызова AppDomain.CreateDomain(). Этот метод перегружен, и позволяет задавать помимо имени домена права доступа и настройки. В настройках можно указать файл конфигурации, разрешить теневое копирование сборок, определить каталог запуска приложения и многое другое. Пример создания домена приведён ниже:

AppDomain newDomain = AppDomain.CreateDomain("NewDomain");

Кроме того, домен можно создать из неуправляемого приложения. Для этого используется основанный на COM API, предоставляемый CLR.

Создание объектов

Создать объект внутри домена можно при помощи вызова AppDomain.CreateInstanceFrom(). Метод возвращает ссылку на специальный объект-обертку. Пользоваться такой оберткой напрямую нельзя. Для этого сначала нужно получить ссылку на прокси. Сделать это можно, вызвав ObjectHandle.Unwrap или сразу использовав метод CreateInstanceFromAndUnwrap.

AppDomain newDomain = AppDomain.CreateDomain("NewDomain");
MBRClass mbrcls = 
  (MBRClass)newDomain.CreateInstanceFromAndUnwrap("myasm.dll", "MBRClass");

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

AppDomain newDomain = AppDomain.CreateDomain("NewDomain");
newDomain.CreateInstanceFromAndUnwrap("myapp.exe");

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

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

Потоки внутри домена при его выгрузке будут завершены при помощи метода Thread.Abort().

События, генерируемые доменом

У домена есть несколько очень важных событий. Вот их краткое описание:

Событие Описание
AssemblyLoad Вызывается при загрузке каждой сборки.
AssemblyResolve Вызывается, если стандартный алгоритм не смог найти сборку. Это событие позволяет загрузить сборку самостоятельно из любого хранилища. Следует быть внимательным, так как повторная загрузка одной и той же сборки не контролируется.
ResourceResolve Вызывается, если в ресурсах не обнаружен требуемый ресурс. Позволяет загрузить ресурс самостоятельно
TypeResolve Вызывается, если не удалось определить, в какой сборке находится запрашиваемый тип, и позволяет загрузить его самостоятельно.
UnhandledException Вызывается в случае необработанного исключения (не перехваченного прикладным кодом).
ProcessExit Вызывается, если завершается процесс. Позволяет принудительно освободить используемые неуправляемые ресурсы, удалить временные файлы и т.д.
DomainUnload Вызывается, если произошла выгрузка домена.

Теневое копирование файлов

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

Включить этот механизм можно только до загрузки первой сборки. Для этого нужно установить свойство AppDomain.ShadowCopyFiles в true, или, при создании, передать в качестве параметра экземпляр класса AppDomainSetup с соответствующим свойством, установленным в true.

Практика

Утилита запуска приложений в домене

Давайте попробуем создать утилиту, которая будет запускать .NET-приложения в отдельном домене, но внутри одного процесса.

Для начала создадим класс DomainManager, который будет заниматься управлением доменами. За запуск приложения будет отвечать его метод RunApp(string fname), которому нужно передать имя файла сборки, содержащей запускаемое приложение.

Создадим домен и добавим его во внутренний список:

AppDomain dom = AppDomain.CreateDomain(fname);
domains.Add(dom);

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

        using System;

namespace RSDN.NETShell
{
  publicclass DomainRunner : MarshalByRefObject
  {
    privatestring executeAssembly;
    publicstring ExecuteAssembly
    {
      get {return executeAssembly;}
      set {executeAssembly = value;}
    }

    publicvoid Run()
    {
      //Запускаем приложение
      AppDomain.CurrentDomain.ExecuteAssembly(ExecuteAssembly);
    }
  }
}

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

Создадим экземпляр этого класса и укажем ему сборку с приложением:

DomainRunner dr = (DomainRunner)dom.CreateInstanceFromAndUnwrap(
  "netshell.exe", "RSDN.NETShell.DomainRunner");
dr.ExecuteAssembly = fname;

Метод AppDomain.CreateInstanceFromAndUnwrap создает экземпляр запрошенного класса, находящегося в сборке с указанным именем, и возвращает ссылку на его прокси.

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

        private
        class AppRunControl
{
  private DomainManager parent;
  private DomainRunner domainRunner;
  private AppDomain domain;

  public AppRunControl(DomainManager prnt, DomainRunner dr, AppDomain dm)
  {
    parent = prnt;
    domainRunner = dr;
    domain = dm;
  }

  publicvoid Run()
  {
    domainRunner.Run();
    parent.TerminateApp(domain);
  }
}

В методе Run этого класса мы вызываем метод Run экземпляра DomainRunner, а по его завершении выгружаем домен.

Создадим и запустим поток.

AppRunControl rc = new AppRunControl(this, dr, dom);
Thread rt = new Thread(new ThreadStart(rc.Run));
rt.Start();

Для оповещения при изменении списка доменов создадим событие, и метод, вызывающий это событие.

        public
        event EventHandler DomainListChanged;

protectedvirtualvoid OnDomainListChanged()
{
  if(DomainListChanged != null)
    DomainListChanged(this, EventArgs.Empty);
}

По завершению запуска приложения вызовем метод OnDomainListChanged().

Для остановки приложения создадим метод TerminateApp().

        public
        void TerminateApp(AppDomain domain)
{
  domains.Remove(domain);
  AppDomain.Unload(domain);
  OnDomainListChanged();
}

Сравнение расхода памяти

Сравним расход памяти при запуске приложений внутри домена и отдельными процессами. Будем запускать последовательно экземпляры приложения, и сравнивать занимаемую ими память по показаниям Task Manager.

Кол-во экз.\Способ запуска Отдельный процесс Один процесс
1 9.1 9.1
2 18.2 16.8
3 27.2 18.4
4 36.2 19.9
5 45.2 21.9

Как мы видим, при запуске 1-2 приложений расход памяти почти одинаков, однако далее разница становится все больше и больше, и уже при 5 запущенных экземплярах расход уменьшается более чем в 2 раза.

Тестирование времени доступа

Давайте сравним время доступа при прямом вызове, вызове через границу домена и при вызове через границу процесса.

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

        using System;

namespace RSDN.NETShell
{
  publicclass TestClass : MarshalByRefObject
  {
    publicint TestMethod(int par1, byte[] par2, string par3)
    {
      return 0;
    }
  }
}

Потом сделаем нашу утилиту remoting-сервером.

ChannelServices.RegisterChannel(new TcpChannel(888));
RemotingConfiguration.RegisterWellKnownServiceType (
    typeof(TestClass),"TestClass",WellKnownObjectMode.Singleton);

Затем напишем тест, последовательно измеряющий скорость трех вариантов доступа.

        string[] res = newstring[4];
res[0] = "Running test ...";
constint iterCount = 100000;
int st;

TestClass tc = new TestClass();
st = Environment.TickCount;
for(int i = 0; i < iterCount; i++)
  tc.TestMethod(55, newbyte[20], "Test string");
res[1] = "Direct call " + (Environment.TickCount - st).ToString() + " ms";

AppDomain dom = AppDomain.CreateDomain("test");
tc = (TestClass)dom.CreateInstanceAndUnwrap("netshell", "RSDN.NETShell.TestClass");
tc.TestMethod(0, null, null);
st = Environment.TickCount;
for(int i = 0; i < iterCount; i++)
  tc.TestMethod(55, newbyte[20], "Test string");
res[2] = "Crossdomain call " + (Environment.TickCount - st).ToString() + " ms";

tc = (TestClass) Activator.GetObject(typeof(TestClass),
    "tcp://localhost:888/TestClass");
tc.TestMethod(0, null, null);
st = Environment.TickCount;
for(int i = 0; i < iterCount; i++)
  tc.TestMethod(55, newbyte[20], "Test string");
res[3] = "Crossprocess call " + (Environment.TickCount - st).ToString() + " ms";

textBox.Lines = res;

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

Результаты работы теста.

Running test ...
Direct call 20 ms
Crossdomain call 5649 ms
Crossprocess call 66866 ms

Выводы

Из проведенных тестов можно сделать следующие выводы:

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


Эта статья опубликована в журнале RSDN Magazine #1-2003. Информацию о журнале можно найти здесь
    Сообщений 1    Оценка 600 [+2/-0]         Оценить