Customizing the .NET Common Object Runtime - Part 2

Dan Buskirk
June 11, 2007

kick it on DotNetKicks.com 

Overview of AppDomains

In the first part of this series, we created a small custom host for the .NET CLR. Our goal is to include custom loading of AppDomains as a part of the host's function, but first we must take a little time to review the fundamentals of the AppDomain itself.

I doubt that anyone would like to return to the days of Windows 3.1, when any errant application would crash the entire operating environment. In 32-bit Windows, applications run in their own separate process, and the operating system prevents an action in one process from inadvertently stomping on something in another. An AppDomain is very much an extension of this notion. As several processes run within the operating system, the CLR controls one or more AppDomains within a single process. An AppDomain is the unit of isolation with the CLR just as the process is the unit of isolation within the OS. The .NET runtime takes this further, however. AppDomains are not just about memory access, they are also the unit of isolation for security as well. The most common application of multiple AppDomains within a single process is the ASP.NET engine. Individual web applications run in separate AppDomains. As you know, the web application is the unit of security within ASP.NET; this is easy for the CLR to enforce via AppDomains. Our goal is similar; we wish to isolate unpredicatble add-ins from each other and from the application itself.

Another very valuable characteristic of AppDomains is that they can be unloaded. Once loaded, the CLR itself can never be unloaded, except by shutting down the process itself. Neither can individual assemblies be unloaded. Therefore, if we are loading a large assembly and wish to unload it to recover memory, the only alternative is to load that assembly into a separate AppDomain. Once we are finished with the code, the AppDomain, and the managed code within it, can be unloaded.

When the default host is asked to load managed .NET code into the CLR, an AppDomain, not surprisingly called the default AppDomain, is created. The host creates an AppDomainManager, which loads security information, which then loads and runs the managed code. Additional AppDomains can be created entirely within managed code. Each one gets an instance of the AppDomainManager which then loads more security information and more managed code.

Before we continue the development of our custom host, we will build two small applications to illustrate the creation of AppDomains in managed code. The first will simply load and run some user code in a new AppDomain. The second will create a managed AppDomainManager to provide more fine-grained control over this process.

Creating an Application that Compiles and Runs User C# Code

Much of the available sample code that illustrates th creation of new AppDomains illustrates how to load code from an assembly file into that new AppDomain. This, of course, is the practical task most developers will need to perform. For illustrative purposes, and perhaps a little fun, we will take a slightly different tack. Our application will accept a C# script from the user, compile it, and then run it in a new AppDomain.

Using CodeDom to Compile C# Code at Runtime

The .NET Framework has always provided mechanisms for compiling VB and C# code at runtime. Indeed, code written for this purpose in versions 1.0 and 1.1 will still continue to work in .NET 2.0. However, Microsoft is in the process of redesigning some of the key classes in the CodeDom namespace, so we will make a short detour to review them.

In earlier versions, to get a compiler you would declare a variable of type ICodeCompiler, instantiate an object of VBCodeProvider or CSharpCodeProvider class, and then call the CreateCompiler method of the appropriate object. The process is more direct with .NET 2.0. We simply call the new CompileAssemblyFromSource method of the CSharpCodeProvider class and we are all set. In the example code which follows, we will accept a string as input, then wrap that string in a class and function declaration. After this "on-the-fly" class is compiled, we will load its assembly and execute our method. The net effect, from the user's perspective, will be the execution of his or her code snippet immediately. If you are using the Visual Studio, create a Class Libbrary project called "AppDomainTestLibrary" and define the following class:

    1 using System;

    2 using System.Text;

    3 using System.Windows.Forms;

    4 using System.Collections;

    5 using System.Collections.Generic;

    6 using Microsoft.CSharp;

    7 using System.CodeDom.Compiler;

    8 using System.Reflection;

    9 

   10 

   11 public class CompileInvoke

   12 {

   13     public void RunIt(string CodeSnippet)

   14     {

   15 

   16         // (1)

   17         CSharpCodeProvider cscp = new CSharpCodeProvider();

   18         CompilerParameters cp = new CompilerParameters();

   19 

   20         // (2) We are goind to let users create message boxes if they like

   21         cp.ReferencedAssemblies.Add("System.Windows.Forms.dll");

   22         cp.GenerateInMemory = true; // false means create an output file

   23 

   24         // (3) We can save intermediate files if we like

   25         //cp.TempFiles = new TempFileCollection(".", true);

   26 

   27 

   28         //List<string> al = new List<string>();

   29 

   30         // (4) our source code will be in one big string

   31         StringBuilder sb = new StringBuilder();

   32 

   33         sb.Append("using System;\n");

   34         sb.Append("using System.Windows.Forms;\n");

   35         sb.Append("public class hola\n");

   36         sb.Append("{\n");

   37         sb.Append("public void f()\n");

   38         sb.Append("{\n");

   39         sb.Append(CodeSnippet);

   40         sb.Append("\n}\n}\n");

   41 

   42         // one array element is like one source file

   43         string[] theCode = { sb.ToString() };

   44 

   45 

   46         // (5) Where the action is

   47         CompilerResults cr = cscp.CompileAssemblyFromSource(cp, theCode);

   48         if (cr.Errors.Count == 0)

   49         {

   50             Assembly a = cr.CompiledAssembly;

   51             Type HolaType;

   52             HolaType = a.GetType("hola");

   53             Object it;

   54             it = Activator.CreateInstance(HolaType);

   55             MethodInfo mi;

   56             mi = HolaType.GetMethod("f");

   57             try

   58             {

   59                 mi.Invoke(it, null);

   60                 // (6)The following lines report where the code is running, but otherwise add nothing

   61                 Console.ForegroundColor = ConsoleColor.Blue;

   62                 Console.WriteLine(AppDomain.CurrentDomain.FriendlyName);

   63                 Console.ForegroundColor = ConsoleColor.Gray;

   64 

   65             }

   66             catch (Exception Ex)

   67             {

   68                 MessageBox.Show(Ex.Message);

   69             }

   70         }

   71         else

   72         {

   73             Console.ForegroundColor = ConsoleColor.Red;

   74             foreach (CompilerError ce in cr.Errors)

   75             {

   76                 Console.WriteLine(ce.ErrorText);

   77             }

   78             Console.ForegroundColor = ConsoleColor.Gray;

   79         }

   80     }//RunIt

   81 

   82 

   83 

   84 }//CompileInvoke Class

   85 

   86 

   87 

   88 

Figure 1.

In section (1), we create our two fundamental objects, the CSharpCodeProvider which will supply the compiler and the CompilerParameters object which we will use to control the conditions of compilation. In section (2) we set a parameter which adds System.Windows.Forms.dll as a reference. Obviously we can add whatever we need. For example, we could add a reference to the application itself, and allow users to control the program with .NET scripts. In this case, we tell the set the GenerateInMemory parameter to true, indicating that we do not wish to create an actual exe or dll file.

In section (4), we use a StringBuilder to create the actual source code for out class. When the code string is finished, we assign it to the first element of a string array called theCode. The compiler expects a string array as an argument, even if we are only compiling a single string as we are here. A multiple element string array would be the equivalent of compiling a project with multiple C# source files. Each element of the array must therefore have the appropriate namespace declarations etc.

In section (5) we actually do the work of compilation. We obtain a CompilationResults object from the CompilaAssemblyFromSource method, and then assign the CompiledAssembly reference to a variable of type assembly. This is a full-fledged assembly and contains our new class and method. However, the code which will run this new assembly does not know about the class or method, so we must do some more work. We call GetType to obtain the type of our "hola" class and we then use Activator.CreateInstance to instantiate it. A MethodInfo object is then used to invoke the "f" mthod which was defined in out source code. In this example we are keeping things simple, but in practice the function we are invoking might require parameters. In this case, the second argument of Invoke would be an Object array holding the parameters. After Invoke executes (and we're certain not to get an error, aren;t we?) a few lines of code print to the console the name of the AppDomain in which this code is executing. At present, this code does not provide us with any useful information. Later, if we run this code from a different AppDomain, this code will provide confirmation of that fact.

We will need a simple user interface to test this code. If you like, you can create a console application which loads a Windows Form with a RichTextBox control serving as a simple source code editor. Or, if you prefer, you can use the form found in the collector01.zip file. The form must contain a button or a menu item which calls our RunIt method and passes the contents of the RichTextBox containing the C# source code. The code might look something like this:

   12     internal static void RunItHere(string CodeSnippet)

   13     {

   14         (new CompileInvoke()).RunIt(CodeSnippet);

   15     }

Figure 2.

Loading .NET Assemblies into a New AppDomain

Loading .NET assemblies into a new AppDomain is more difficult to understand than it is to actually program. The conceptual difficulty lies in the fact that the code which gets the ball rolling runs in the first AppDomain, so the code loaded into the new AppDomain will not be directly accessible. The alternative method to RunItHere (figure 2) is RunItInNewAppDomain which we will design to create a new AppDomain and then load our CompileInvoke object into that new AppDomain. We will then have to find some way of calling the RunIt method, which as we have said cannot be called directly. Here is our alternative to the code in Figure 2:

   17     internal static void RunItInNewAppDomain(string CodeSnippet)

   18     {

   19         // (1) Create the new AppDomain

   20         AppDomain AD = AppDomain.CreateDomain("HeyThereBuddy"); // It's a friendly name

   21         // (2) load our class into the AppDomain, instantiate CompileInvoke,

   22         //  and get a reference to a proxy for the CompileInvoke object

   23         CompileInvoke mbro = (CompileInvoke)AD.CreateInstanceAndUnwrap("AppDomainTestLibrary", "CompileInvoke");

   24         mbro.RunIt(CodeSnippet);

   25     }

Figure 3.

Section (1) creates the AppDomain with a friendly name of our choosing (as opposed to a full name).  Section (2) does quite a bit. It tells the new AppDomain to load AppDomainTestLibrary.dll and to instantiate a CompileInvoke object. We will need to cast the return of CreateInstanceAndUnwrap as the correct type. But the return value is not actually a reference to our new object. Since the code in the new AppDomain cannot be directly accessed, what we get is actually a reference to a proxy which will marshal any calls to the code in the new AppDomain. But how did the proxy get created? The answer is "It didn't!". If we tried to run this code, we would get a runtime error. In order to be accessed across AppDomains, a class must either be serializable or it must inherit from MarshalByRefObject. My CompileInvoke method is neither, but you probably already noticed that. Let's modify the CompileInvoke class:

   10 // must either be serializable or inherit from MarshalByRefObject

   11 public class CompileInvoke : MarshalByRefObject

   12 {

   13     public void RunIt(string CodeSnippet)

   14     {...

Figure 4.

Now we should be able to test our application.

Of course, up till now we have been letting the AppDomain management tasks to the CLR itself. Now we will turn our attention to designing our own AppDomain manager. In .NET versions prior to 2.0, this usually involved writing unmanaged code in our host. Now, however, .NET 2.0 has provided a toolset to accomplish this task entirely in managed code. Appropriately enough, the new class provided for this purpose is called the AppDomainManager class.

Next:  Page 2