AppDomain это набор сборок. Изначально при запуске приложения создан один дефолтный домен приложения. Вообще изначально домены создавались для разделения выполняемого в одном windows процессе кода. Можно было бы выполнять код в разных процессах, но само создание нового процесса - дорого по времени. Поэтому CLR позволяет создавать домены приложений в одном процессе. При этом домену не выделяются какие-то дополнительные потоки(насколько я понимаю).
Из кода можно создать домен так:
var ad = AppDomain.CreateDomain("new created domain", null, null); // name , securityInfo, appDomainInfo
Далее в этом домене можно создавать типы, существующие в текущем домене вызывом метода:
var createdInAnotherDomainType = ad.CreateInstanceAndUnwrap("assemblyWhichContainsSomeTypeName","someTypeName")
Тогда в только что созданном домене будет создан тип c именем "someType" и возвращен назад текущему домену, но на самом деле не всегда. Для того чтобы объект смог пересечь границу доменов он должен быть помечен атрибутом SerializableAttribute
или унаследован от MarshalByRefObject
. В первом случае объект someType будет создан в новом домене, потом сериализован в байтовый массив передан текущему домену и десериализован. Во втором случае он будет создан в новом домене, а далее исходному домену будет передан не сам объект, а его прокси, который будет транслировать вызовы всех методов или обращение ко всем свойствам в домен, в котором был создан реальный объект. Такие обращения происходят с помощью GCHandle Table, которая создается одна на весь CLR хост(предположительно).
Понятно, что это не дешево, например если написать такой бенчмарк, то можно увидеть насколько это недешево:
public class ObjectWithMarshal : MarshalByRefObject
{
public int Q;
}
public class ObjectWithoutMarshal
{
public int Q;
}
internal class Program
{
private static Action _l;
public static void Main(string[] args)
{
var testWith = new ObjectWithMarshal();
var testWithout = new ObjectWithoutMarshal();
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000_000; i++)
{
testWith.Q++;
}
Console.WriteLine("with "+sw.Elapsed); // with 00:00:05.6907908
sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000_000; i++)
{
testWithout.Q++;
}
Console.WriteLine("without "+sw.Elapsed); // 00:00:01.6633009
}
}
Рихтер объясняет это тем, что при MarshalByRef при изменении поля на самом деле вызывает метод Object.FieldSetter, который использует рефлексию. C вызовами методов ситуация даже хуже:
public class ObjectWithMarshal : MarshalByRefObject
{
public int Q;
public void Test()
{
}
}
public class ObjectWithoutMarshal
{
public int Q;
public void Test()
{
}
}
internal class Program
{
public static void Main(string[] args)
{
var testWith = new ObjectWithMarshal();
var testWithout = new ObjectWithoutMarshal();
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000_000; i++)
{
testWith.Test();
}
Console.WriteLine("with " + sw.Elapsed); // 00:00:03.0445448
sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000_000; i++)
{
testWithout.Test();
}
Console.WriteLine("without " + sw.Elapsed); // 00:00:00.3003106
}
}
Под капотом в этот момент происходит следующее, идет обращение к прокси, который проверяет находится ли объект в том же домене, если находится, то происходит простой вызов метода, если нет, то вызов метода адресуется объекту другого домена, при этом параметры сериализуются.
Если у класса есть атрибут Serializable, то каждое возвращаемое из метода значение будет сериализоваться и в исходном домене будет копией, а не прокси. Если класс унаследован от MarshalByObjectRef, тогда каждое возвращаемое из его метода значение должно само определить как передаваться через границу контекстов, в виде прокси(достаточно наследования от MarshalByObjectRef) или в сериализованном виде повесив на себя атрибут Serializable.
Каждый домен имеет свои загруженные dll и они могут повторяться в разных доменах одного процесса. Есть общие dll https://hsto.org/files/82c/e6e/b1d/82ce6eb1d4874fd2a092751fd4f0b739.gif Я немного перефразирую вопрос, тип А определенный в AppDomain1 будет ссылаться на ту же память, что и тип A определенный в AppDomain2?
За загрузку типов в каждом домене отвечает Loader Heap, он состоит из High-Frequency Heap, Low-Frequency Heap, and Stub Heap и для каждого домена он разный Loader Heap хранит все информацию о типах + статики, object heap информацию о созданных объектах, также в Loader хип не заходит сборщик мусора.
Хорошая картинка на эту тему:
Получается, что все типы разрешаются индивидуально в каждом домене(jit'тятся также)? Не совсем так. Как можно заметить есть еще SharedDomain, поэтому вполне возможно, что сборка будет загружена туда, так и происходит для mscorlib.dll(информация об этом).При этом скорее всего уже mscorlib уже преджиттен. Это базовая библиотека, в ней содержатся таким типы как Object,Int,String,Double,Decimal,Task,Task и так далее. Код методов этих классов будет jitt'иться один раз. Dll которые шарятся между всеми доменами в процессе называются Domain-Neutral Assemblies. Они загружаются на старте CLR в loader heap Shared Domain'a. У них есть минус, если эти сборки загружены в CLR, их уже нельзя выгрузить
Да, Рихтер пишет, что шарится, поэтому метод который был скомпилирован при вызове из одного домена, будет использоваться при последующих вызовах из других доменов. На самом деле в этом есть проблема, доступ к статическим полям/свойствам сборок код которых расшарен более долгий, чем к явно загружаемым в домен, поскольку статики для каждого домена свои и в случае обращения к статику в расшаренной сборке происходит дополнительный вызов, который идет в loader heap app domain'a из которого происходит вызов, ищет там именно тот статик который нужен и происходит обращение к нему(это логично, ведь код джиттиться только один раз для всех доменов). А в случае явно загруженной в домен сборки, на этапе JIT компиляции в момент обращения к статику точно известен его адрес, он и проставляется
Одна из причин выполнение недоверенного кода. AppDomain'у можно выдать определенные разрешения(например, на выполнение кода) и запретить что-то(например, трогать файловую систему). Это делается при создании домена, ему можно передать параметр csharp PermissionSet
, который позволяет конфигурировать настройки доступа:
PermissionSet permSet = new PermissionSet(PermissionState.None);
permSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution)); // даем права только на выполнение кода
Также при создании домена в него можно передать параметр AppDomainSetup
, он уже позволяет конфигурировать более сложные вещи, например AppDomainManager, который может переопределить все параметры при создании нового домена в том AppDomain'e(если в только что созданном AppDomain'e будут еще создаваться домены, тогда перед созданием будет вызываться переопределенный в сборке класс AppDomainManager, который сможет перехватить создание сборки и добавить или удалить у сборки некоторые разрешения)
Рихтер предполагает, что SQL сервер запускает недоверенную процедуру, тогда он берет поток из пула потоков, и вызывает недоверенный код в таком блоке:
public void ExecuteUntrustedCode(){
// disables thread control
var appDomain = GetCreatedAppDomainWithMinimumPermissions();
var getSandboxFromCreatedAppDomain = (Sanbox)Activator
.CreateInstance(appDomain,typeof(Sandboxer).Assembly.ManifestModule.FullyQualifiedName,typeof(Sandbox).FullName);
var thread = new Thread(()=>{
getSandboxFromCreatedAppDomain.ExecuteCode(untrustedCode)
}
catch(ThreadAbortException threadAborException){
Thread.ResetAbort();
}
}
ThreadPool.QueueUserWorkItem(_=>ExecuteUntrustedCode());
- Берется поток из пула
- Туда отправляется делегат
- В делегате создание нового домена с минимальными правами
- Далее создание класса песочницы(класса, который вызывает недоверенных код)
- Далее вызов этого кода
- Обработка ThreadAbortException в блоке catch, а именно вызов thread.ResetAbort();
Тут важно отметить, что CLR сконфигурирован вручную, так, чтобы прерывать потоки, которые выполняются дольше определенного времени и также жестко выгружать домен, если блок catch выполняется дольше определенного времени. Рассмотрим как недоверенных код может пытаться нас хакнуть:
try{
while(true){}
}
catch(ThreadAbortException ex){
while(true){}
}
Как работает thread.Abort()
. Потоку проставляется флажок Thread.Aborted
, и далее CLR ждет пока сможет остановить этот поток, CLR не может остановить поток, тогда, когда выполняется конструктор типа, код в catch или finally(т.e пока код исполняется в finally, его невозможно прервать ThreadAbortExeption'ом) блоках или CER(Constrained Execution Region).
Получается хакнуть можно еще и так:
try{}
finally{
while(true){}
}
Это решается введением escalation policy
, хост CLR можно сконфигурировать так, что он жестко выгрузит домен, если не сможет прервать поток определенное время, также такой способ может убить весь процесс. Т.Е задание escalation policy решает эту проблему.
Пример взятый с msdn и немного доработанный, это простейшая реализация песочницы:
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Remoting;
using System.Security;
using System.Security.Permissions;
using System.Threading;
//The Sandboxer class needs to derive from MarshalByRefObject so that we can create it in another
// AppDomain and refer to it from the default AppDomain.
class Sandboxer : MarshalByRefObject
{
const string pathToUntrusted = @"C:\Users\evgeniy\RiderProjects\ThreadAbortTest\ThreadAbortTest1\bin\Debug";
const string untrustedAssembly = "ThreadAbortTest1";
const string untrustedClass = "ThreadAbortTest1.ProgramClass";
const string entryPoint = "Print";
private static Object[] parameters = {45};
static void Main()
{
//Setting the AppDomainSetup. It is very important to set the ApplicationBase to a folder
//other than the one in which the sandboxer resides.
AppDomainSetup adSetup = new AppDomainSetup();
adSetup.ApplicationBase = Path.GetFullPath(pathToUntrusted);
//Setting the permissions for the AppDomain. We give the permission to execute and to
//read/discover the location where the untrusted code is loaded.
PermissionSet permSet = new PermissionSet(PermissionState.None);
permSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
//Now we have everything we need to create the AppDomain, so let's create it.
AppDomain newDomain = AppDomain.CreateDomain("Sandbox", null, adSetup, permSet);
//Use CreateInstanceFrom to load an instance of the Sandboxer class into the
//new AppDomain.
ObjectHandle handle = Activator.CreateInstanceFrom(
newDomain, typeof(Sandboxer).Assembly.ManifestModule.FullyQualifiedName,
typeof(Sandboxer).FullName
);
//Unwrap the new domain instance into a reference in this domain and use it to execute the
//untrusted code.
Sandboxer newDomainInstance = (Sandboxer) handle.Unwrap();
bool isExecuted = false;
var thread = new Thread(() =>
{
newDomainInstance.ExecuteUntrustedCode(untrustedAssembly, untrustedClass, entryPoint, parameters);
isExecuted = true;
});
thread.Start();
for (int i = 0; i <= 10_000; i += 10)
{
Thread.Sleep(1);
if (isExecuted)
{
break;
}
if (i == 10_000)
{
thread.Abort();
}
}
}
public void ExecuteUntrustedCode(string assemblyName, string typeName, string entryPoint, Object[] parameters)
{
//Load the MethodInfo for a method in the new Assembly. This might be a method you know, or
//you can use Assembly.EntryPoint to get to the main function in an executable.
MethodInfo target = Assembly.Load(assemblyName).GetType(typeName).GetMethod(entryPoint);
target.Invoke(null, parameters);
}
}
Создается домен, в нем экземпляр класса-песочницы, которому позволено только вызывать методы и все. ему запрещено,например, писать в файл, поэтому такой код метода Print
не пройдет, будет сгенерирован SecurityException
, потому что ему запрещено писать в файл
public static bool Print(int q)
{
File.WriteAllText("file.txt","text");
}
Также я добавил защиту от while(true){};
недоверенный код запускается в новом потоке и если работает слишком долго, вызывается thread.Abort()
, на самом деле ничего не мешает недоверенному коду в блоке catch или finally снова сделать while(true);
, тогда уже нужно разбираться с escalation policy(т.e хостить clr вручную используя unsafe)
Тут есть особенность. В .net core не работают thread.Abort кидает PlatformNotSupportedException
. Более того, я не смог найти кейса, когда RuntimeHelpers.PrepareRegions
реально отрабатывает. Ощущение что его выпилили также. Я тестировал на примере, приведенном у Рихтера.
public void Main(){
try{
Console.Write("Method");
}
finally{
Class.Method();
}
}
public class Class{
static Class(){
Console.WriteLine("ctor");
}
[ReliabilityContract(Consistency.WillNotCorruptState,Cer.Success)]
public static void Method(){
}
}
В .net framework вывод :
ctor
Method
В .net core:
Method
ctor