Pro C#10 CHAPTER 14 Processes, AppDomains, and Load Contexts

PART V

Programming with .NET Core Assemblies

CHAPTER 14

Processes, AppDomains, and Load Contexts

In this chapter, you’ll drill deep into the details of how an assembly is hosted by the runtime and come to understand the relationship between processes, application domains, and object contexts.
In a nutshell, application domains (or simply AppDomains) are logical subdivisions within a given process that host a set of related .NET Core assemblies. As you will see, an AppDomain is further subdivided into contextual boundaries, which are used to group like-minded .NET Core objects. Using the notion of context, the runtime can ensure that objects with special requirements are handled appropriately.
While it is true that many of your day-to-day programming tasks might not involve directly working with processes, AppDomains, or object contexts, understanding these topics is important when working with numerous .NET Core APIs, including multithreading, parallel processing, and object serialization.

The Role of a Windows Process
The concept of a “process” existed within Windows-based operating systems well before the release of the
.NET/.NET Core platforms. In simple terms, a process is a running program. However, formally speaking, a process is an operating system–level concept used to describe a set of resources (such as external code libraries and the primary thread) and the necessary memory allocations used by a running application.
For each .NET Core application loaded into memory, the OS creates a separate and isolated process for use during its lifetime.
Using this approach to application isolation, the result is a much more robust and stable runtime environment, given that the failure of one process does not affect the functioning of another. Furthermore, data in one process cannot be directly accessed by another process, unless you use specific tools such as System.IO.Pipes or the MemoryMappedFile class. Given these points, you can regard the process as a fixed, safe boundary for a running application.
Every Windows process is assigned a unique process identifier (PID) and may be independently loaded and unloaded by the OS as necessary (as well as programmatically). As you might be aware, the Processes tab of the Windows Task Manager utility (activated via the Ctrl+Shift+Esc keystroke combination on Windows) allows you to view various statistics regarding the processes running on a given machine. The Details tab allows you to view the assigned PID and image name (see Figure 14-1).

© Andrew Troelsen, Phil Japikse 2022
A. Troelsen and P. Japikse, Pro C# 10 with .NET 6, https://doi.org/10.1007/978-1-4842-7869-7_14

551

Figure 14-1. The Windows Task Manager

The Role of Threads
Every Windows process contains an initial “thread” that functions as the entry point for the application. Chapter 15 examines the details of building multithreaded applications under the .NET Core platform; however, to facilitate the topics presented here, you need a few working definitions. First, a thread is a path of execution within a process. Formally speaking, the first thread created by a process’s entry point is termed the primary thread. Any .NET Core program (console application, Windows service, WPF application, etc.) marks its entry point with the Main() method or a file containing top-level statements (which gets converted to a Program class and Main() method, as demonstrated earlier in this book). When this code is invoked, the primary thread is created automatically.
Processes that contain a single primary thread of execution are intrinsically thread-safe, given that there is only one thread that can access the data in the application at a given time. However, a single-threaded process (especially one that is GUI based) will often appear a bit unresponsive to the user if this single thread is performing a complex operation (such as printing out a lengthy text file, performing a mathematically intensive calculation, or attempting to connect to a remote server located thousands of miles away).
Given this potential drawback of single-threaded applications, the operating systems that are supported by .NET Core (as well as the .NET Core platform) make it possible for the primary thread to spawn additional

secondary threads (also termed worker threads) using a handful of API functions such as CreateThread(). Each thread (primary or secondary) becomes a unique path of execution in the process and has concurrent access to all shared points of data within the process.
As you might have guessed, developers typically create additional threads to help improve the program’s overall responsiveness. Multithreaded processes provide the illusion that numerous activities are happening at the same time. For example, an application may spawn a worker thread to perform a labor- intensive unit of work (again, such as printing a large text file). As this secondary thread is churning away, the main thread is still responsive to user input, which gives the entire process the potential of delivering greater performance. However, this may not actually be the case: using too many threads in a single process can actually degrade performance, as the CPU must switch between the active threads in the process (which takes time).
On some machines, multithreading is most commonly an illusion provided by the OS. Machines that host a single (nonhyperthreaded) CPU do not have the ability to literally handle multiple threads at the same time. Rather, a single CPU will execute one thread for a unit of time (called a time slice) based in part on the thread’s priority level. When a thread’s time slice is up, the existing thread is suspended to allow another thread to perform its business. For a thread to remember what was happening before it was kicked out of the way, each thread is given the ability to write to Thread Local Storage (TLS) and is provided with a separate call stack, as illustrated in Figure 14-2.

Figure 14-2. The Windows process/thread relationship

If the subject of threads is new to you, don’t sweat the details. At this point, just remember that a thread is a unique path of execution within a Windows process. Every process has a primary thread (created via the executable’s entry point) and may contain additional threads that have been programmatically created.

Interacting with Processes Using .NET Core
Although processes and threads are nothing new, the way you interact with these primitives under the
.NET Core platform has changed quite a bit (for the better). To pave the way to understanding the world of building multithreaded assemblies (see Chapter 15), let’s begin by checking out how to interact with processes using the .NET Core base class libraries.
The System.Diagnostics namespace defines several types that allow you to programmatically interact with processes and various diagnostic-related types such as the system event log and performance counters. In this chapter, you are concerned with only the process-centric types defined in Table 14-1.

Table 14-1. Select Members of the System.Diagnostics Namespace

Process-Centric Types of the
System.Diagnostics Namespace Meaning in Life
Process The Process class provides access to local and remote processes and allows you to programmatically start and stop processes.
ProcessModule This type represents a module (.dll or .exe) that is loaded into a process. Understand that the ProcessModule type can represent any module—COM-based, .NET-based, or traditional C-based binaries.
ProcessModuleCollection This provides a strongly typed collection of ProcessModule objects.
ProcessStartInfo This specifies a set of values used when starting a process via the
Process.Start() method.
ProcessThread This type represents a thread within a given process. Be aware that ProcessThread is a type used to diagnose a process’s thread set and is not used to spawn new threads of execution within a process.
ProcessThreadCollection This provides a strongly typed collection of ProcessThread objects.

The System.Diagnostics.Process class allows you to analyze the processes running on a given machine (local or remote). The Process class also provides members that allow you to programmatically start and terminate processes, view (or modify) a process’s priority level, and obtain a list of active threads and/or loaded modules within a given process. Table 14-2 lists some of the key properties of System.
Diagnostics.Process.
Table 14-2. Select Properties of the Process Type

Property Meaning in Life
ExitTime This property gets the timestamp associated with the process that has terminated (represented with a DateTime type).
Handle This property returns the handle (represented by an IntPtr) associated to the process by the OS. This can be useful when building .NET applications that need to communicate with unmanaged code.
Id This property gets the PID for the associated process.
MachineName This property gets the name of the computer the associated process is running on.
MainWindowTitle MainWindowTitle gets the caption of the main window of the process (if the process does not have a main window, you receive an empty string).
Modules This property provides access to the strongly typed ProcessModuleCollection type, which represents the set of modules (.dll or .exe) loaded within the current process.
ProcessName This property gets the name of the process (which, as you would assume, is the name of the application itself).
Responding This property gets a value indicating whether the user interface of the process is responding to user input (or is currently “hung”).
StartTime This property gets the time that the associated process was started (via a DateTime type).
Threads This property gets the set of threads that are running in the associated process (represented via a collection of ProcessThread objects).

In addition to the properties just examined, System.Diagnostics.Process also defines a few useful methods (see Table 14-3).

Table 14-3. Select Methods of the Process Type

Method Meaning in Life
CloseMainWindow() This method closes a process that has a user interface by sending a close message to its main window.
GetCurrentProcess() This static method returns a new Process object that represents the currently active process.
GetProcesses() This static method returns an array of new Process objects running on a given machine.
Kill() This method immediately stops the associated process.
Start() This method starts a process.

Enumerating Running Processes
To illustrate the process of manipulating Process objects (pardon the redundancy), create a C# Console Application project named ProcessManipulator. Next, define the following static helper method within the Program.cs file:

static void ListAllRunningProcesses()
{
// Get all the processes on the local machine, ordered by
// PID.
var runningProcs = from proc
in Process.GetProcesses(".") orderby proc.Id
select proc;

// Print out PID and name of each process. foreach(var p in runningProcs)
{
string info = $"-> PID: {p.Id}\tName: {p.ProcessName}"; Console.WriteLine(info);
}
Console.WriteLine("****\n");
}

The static Process.GetProcesses() method returns an array of Process objects that represent the running processes on the target machine (the dot notation shown here represents the local computer). After you have obtained the array of Process objects, you are able to invoke any of the members listed in Tables 14-2 and 14-3. Here, you are simply displaying the PID and the name of each process, ordered by PID. Update the top-level statements as follows:

using System.Diagnostics;
Console.WriteLine(" Fun with Processes \n"); ListAllRunningProcesses();
Console.ReadLine();

When you run the application, you will see the names and PIDs for all processes on your local computer. Here is some partial output from my current machine (your output will most likely be different):

Fun with Processes
-> PID: 0 Name: Idle
-> PID: 4 Name: System
-> PID: 104 Name: Secure System
-> PID: 176 Name: Registry
-> PID: 908 Name: svchost
-> PID: 920 Name: smss
-> PID: 1016 Name: csrss
-> PID: 1020 Name: NVDisplay.Container
-> PID: 1104 Name: wininit
-> PID: 1112 Name: csrss


Investigating a Specific Process
In addition to obtaining a complete list of all running processes on a given machine, the static Process. GetProcessById() method allows you to obtain a single Process object via the associated PID. If you request access to a nonexistent PID, an ArgumentException exception is thrown. For example, if you were interested in obtaining a Process object representing a process with the PID of 30592, you could write the following code:

// If there is no process with the PID of 30592, a runtime exception will be thrown. static void GetSpecificProcess()
{
Process theProc = null; try
{
theProc = Process.GetProcessById(30592); Console.WriteLine(theProc?.ProcessName);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
}
}

At this point, you have learned how to get a list of all processes, as well as a specific process on a machine via a PID lookup. While it is somewhat useful to discover PIDs and process names, the Process class also allows you to discover the set of current threads and libraries used within a given process. Let’s see how to do so.

Investigating a Process’s Thread Set
The set of threads is represented by the strongly typed ProcessThreadCollection collection, which contains some number of individual ProcessThread objects. To illustrate, add the following additional static helper function to your current application:

static void EnumThreadsForPid(int pID)
{
Process theProc = null; try
{
theProc = Process.GetProcessById(pID);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message); return;
}

// List out stats for each thread in the specified process. Console.WriteLine(
"Here are the threads used by: {0}", theProc.ProcessName); ProcessThreadCollection theThreads = theProc.Threads;

foreach(ProcessThread pt in theThreads)
{
string info =
$"-> Thread ID: {pt.Id}\tStart Time: {pt.StartTime.ToShortTimeString()}\tPriority:
{pt.PriorityLevel}"; Console.WriteLine(info);
}
Console.WriteLine("****\n");
}

As you can see, the Threads property of the System.Diagnostics.Process type provides access to the ProcessThreadCollection class. Here, you are printing the assigned thread ID, start time, and priority level of each thread in the process specified by the client. Now, update your program’s top-level statements to prompt the user for a PID to investigate, as follows:


// Prompt user for a PID and print out the set of active threads. Console.WriteLine(" Enter PID of process to investigate "); Console.Write("PID: ");
string pID = Console.ReadLine(); int theProcID = int.Parse(pID);

EnumThreadsForPid(theProcID); Console.ReadLine();

When you run your program, you can now enter the PID of any process on your machine and see the threads used in the process. The following output shows a partial list of the threads used by PID 3804 on my machine, which happens to be hosting Edge:

Enter PID of process to investigate PID: 3804
Here are the threads used by: msedge
-> Thread ID: 3464 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 19420 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 17780 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 22380 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 27580 Start Time: 01:20 PM Priority: -4


The ProcessThread type has additional members of interest beyond Id, StartTime, and PriorityLevel.
Table 14-4 documents some members of interest.

Table 14-4. Select Members of the ProcessThread Type

Member Meaning in Life
CurrentPriority Gets the current priority of the thread
Id Gets the unique identifier of the thread
IdealProcessor Sets the preferred processor for this thread to run on
PriorityLevel Gets or sets the priority level of the thread
ProcessorAffinity Sets the processors on which the associated thread can run
StartAddress Gets the memory address of the function that the operating system called that started this thread
StartTime Gets the time that the operating system started the thread
ThreadState Gets the current state of this thread
TotalProcessorTime Gets the total amount of time that this thread has spent using the processor
WaitReason Gets the reason that the thread is waiting

Before you read any further, be aware that the ProcessThread type is not the entity used to create, suspend, or kill threads under the .NET Core platform. Rather, ProcessThread is a vehicle used to obtain diagnostic information for the active Windows threads within a running process. Again, you will investigate how to build multithreaded applications using the System.Threading namespace in Chapter 15.

Investigating a Process’s Module Set
Next up, let’s check out how to iterate over the number of loaded modules that are hosted within a given process. When talking about processes, a module is a general term used to describe a given .dll (or the
.exe itself) that is hosted by a specific process. When you access the ProcessModuleCollection via the
Process.Modules property, you can enumerate over all modules hosted within a process: .NET Core-based,

COM-based, or traditional C-based libraries. Ponder the following additional helper function that will enumerate the modules in a specific process based on the PID:

static void EnumModsForPid(int pID)
{
Process theProc = null; try
{
theProc = Process.GetProcessById(pID);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message); return;
}

Console.WriteLine("Here are the loaded modules for: {0}", theProc.ProcessName);
ProcessModuleCollection theMods = theProc.Modules; foreach(ProcessModule pm in theMods)
{
string info = $"-> Mod Name: {pm.ModuleName}"; Console.WriteLine(info);
}
Console.WriteLine("****\n");
}

To see some possible output, let’s check out the loaded modules for the process hosting the current example program (ProcessManipulator). To do so, run the application, identify the PID assigned to ProcessManipulator.exe (via the Task Manager), and pass this value to the EnumModsForPid() method. Once you do, you might be surprised to see the list of *.dlls used for a simple Console Application project (GDI32.dll, USER32.dll, ole32.dll, etc.). The following output is a partial listing of modules loaded (edited for brevity):

Here are (some of) the loaded modules for: ProcessManipulator Here are the loaded modules for: ProcessManipulator
-> Mod Name: ProcessManipulator.exe
-> Mod Name: ntdll.dll
-> Mod Name: KERNEL32.DLL
-> Mod Name: KERNELBASE.dll
-> Mod Name: USER32.dll
-> Mod Name: win32u.dll
-> Mod Name: GDI32.dll
-> Mod Name: gdi32full.dll
-> Mod Name: msvcp_win.dll
-> Mod Name: ucrtbase.dll
-> Mod Name: SHELL32.dll
-> Mod Name: ADVAPI32.dll
-> Mod Name: msvcrt.dll
-> Mod Name: sechost.dll
-> Mod Name: RPCRT4.dll

-> Mod Name: IMM32.DLL
-> Mod Name: hostfxr.dll
-> Mod Name: hostpolicy.dll
-> Mod Name: coreclr.dll
-> Mod Name: ole32.dll
-> Mod Name: combase.dll
-> Mod Name: OLEAUT32.dll
-> Mod Name: bcryptPrimitives.dll
-> Mod Name: System.Private.CoreLib.dll


Starting and Stopping Processes Programmatically
The final aspects of the System.Diagnostics.Process class examined here are the Start() and Kill() methods. As you can gather by their names, these members provide a way to programmatically launch and terminate a process, respectively. For example, consider the following static StartAndKillProcess() helper method.

■ Note Depending on your operating system’s security settings, you might need to be running with administrator rights to start new processes.

static void StartAndKillProcess()
{
Process proc = null;

// Launch Edge, and go to Facebook! try
{
proc = Process.Start(@"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", "www.facebook.com");
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

Console.Write("–> Hit enter to kill {0}…", proc.ProcessName);
Console.ReadLine();

// Kill all of the msedge.exe processes. try
{
foreach (var p in Process.GetProcessesByName("MsEdge"))
{
p.Kill(true);
}

}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
}

The static Process.Start() method has been overloaded a few times. At a minimum, you will need to specify the path and filename of the process you want to launch. This example uses a variation of the
Start() method that allows you to specify any additional arguments to pass into the program’s entry point, in this case, the web page to load.
After you call the Start() method, you are returned a reference to the newly activated process.
When you want to terminate the process, simply call the instance-level Kill() method. In this example, since Microsoft Edge launches a lot of processes, you are looping through to kill all the launched processes. You are also wrapping the calls to Start() and Kill() within a try/catch block to handle any InvalidOperationException errors. This is especially important when calling the Kill() method, as this error will be raised if the process has already been terminated prior to calling Kill().

■ Note When using the .net Framework (prior to .net Core), the Process.Start() method allowed for either the full path and filename or the operating system shortcut (e.g., msedge) of the process to start. With
.net Core and the cross-platform support, you must specify the full path and filename. operating system associations can be leveraged using the ProcessStartInfo, covered in the next two sections.

Controlling Process Startup Using the ProcessStartInfo Class
The Process.Start() method also allows you to pass in a System.Diagnostics.ProcessStartInfo type to specify additional bits of information regarding how a given process should come to life. Here is a partial definition of ProcessStartInfo (see the documentation for full details):

public sealed class ProcessStartInfo : object
{
public ProcessStartInfo();
public ProcessStartInfo(string fileName);
public ProcessStartInfo(string fileName, string arguments); public string Arguments { get; set; }
public bool CreateNoWindow { get; set; }
public StringDictionary EnvironmentVariables { get; } public bool ErrorDialog { get; set; }
public IntPtr ErrorDialogParentHandle { get; set; } public string FileName { get; set; }
public bool LoadUserProfile { get; set; } public SecureString Password { get; set; } public bool RedirectStandardError { get; set; } public bool RedirectStandardInput { get; set; }
public bool RedirectStandardOutput { get; set; } public Encoding StandardErrorEncoding { get; set; } public Encoding StandardOutputEncoding { get; set; } public bool UseShellExecute { get; set; }

public string Verb { get; set; } public string[] Verbs { get; }
public ProcessWindowStyle WindowStyle { get; set; } public string WorkingDirectory { get; set; }
}

To illustrate how to fine-tune your process startup, here is a modified version of StartAndKillProcess(), which will load Microsoft Edge and navigate to www.facebook.com, using the Windows association MsEdge:

static void StartAndKillProcess()
{
Process proc = null;

// Launch Microsoft Edge, and go to Facebook, with maximized window. try
{
ProcessStartInfo startInfo = new ProcessStartInfo("MsEdge", "www.facebook.com");
startInfo.UseShellExecute = true; proc = Process.Start(startInfo);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}

}

In .NET Core, the UseShellExecute property defaults to false, while in prior versions of .NET, the UseShellExecute property defaults to true. This is the reason that the previous version of Process.Start(), shown here, no longer works without using ProcessStartInfo and setting the UseShellExecute property to true:

Process.Start("msedge")

Leveraging OS Verbs with ProcessStartInfo
In addition to using the OS shortcuts to launch applications, you can also take advantage of file associations with ProcessStartInfo. On Windows, if you right-click a Word document, there are options to edit or print the document. Let’s use the ProcessStartInfo to determine the verbs available and then use them to manipulate the process.
Create a new method with the following code:

static void UseApplicationVerbs()
{
int i = 0;
//adjust this path and name to a document on your machine ProcessStartInfo si =
new ProcessStartInfo(@"..\TestPage.docx"); foreach (var verb in si.Verbs)

{
Console.WriteLine($" {i++}. {verb}");
}
si.WindowStyle = ProcessWindowStyle.Maximized; si.Verb = "Edit";
si.UseShellExecute = true; Process.Start(si);
}

When you run this code, the first part prints out all the available verbs for a Word document, as the following shows:

Fun with Processes
0.Edit
1.OnenotePrintto
2.Open
3.OpenAsReadOnly
4.Print
5.Printto
6.ViewProtected

After setting WindowStyle to maximized, the verb is set to Edit, which opens the document in edit mode. If you set the verb to Print, the document will be sent straight to the printer.
Now that you understand the role of Windows processes and how to interact with them from C# code, you are ready to investigate the concept of a .NET application domain.

■ Note the directory in which the application runs is dependent on how you run the sample application. if you use the CLi command dotnet run, the current directory is the same as where the project file is located. if you are using Visual studio, the current directory will be the directory of the compiled assembly, which is .\ bin\debug\net6.0. You will need to adjust the path to the Word document accordingly.

Understanding .NET Application Domains
Under the .NET and .NET Core platforms, executables are not hosted directly within a Windows process, as is the case in traditional unmanaged applications. Rather, .NET and .NET Core executables are hosted by
a logical partition within a process called an application domain. This partition of a traditional Windows process offers several benefits, some of which are as follows:
• AppDomains are a key aspect of the OS-neutral nature of the .NET Core platform, given that this logical division abstracts away the differences in how an underlying OS represents a loaded executable.
• AppDomains are far less expensive in terms of processing power and memory than a full-blown process. Thus, the CoreCLR can load and unload application domains much quicker than a formal process and can drastically improve scalability of server applications.

AppDomains are fully and completely isolated from other AppDomains within a process. Given this fact, be aware that an application running in one AppDomain is unable to obtain data of any kind (global variables or static fields) within another AppDomain, unless they use a distributed programming protocol.

■ Note support for appDomains is changed in .net Core. in .net Core, there is exactly one appDomain. Creation of new appDomains is no longer supported because they require runtime support and are generally expensive to create. the ApplicationLoadContext (covered later in this chapter) provides assembly isolation in .net Core.

The System.AppDomain Class
The AppDomain class is largely deprecated with .NET Core. While most of the remaining support is designed to make migrating from .NET 4.x to .NET Core easier, the remaining features can still provide value, as covered in the next two sections.

Interacting with the Default Application Domain
Your application has access to the default application domain using the static AppDomain.CurrentDomain property. After you have this access point, you can use the methods and properties of AppDomain to perform some runtime diagnostics.
To learn how to interact with the default application domain, begin by creating a new Console Application project named DefaultAppDomainApp and disable nullability in the project file. Now, update your Program.cs file with the following logic, which will simply display some details about the default application domain, using a number of members of the AppDomain class:

using System.Reflection; using System.Runtime.Loader;

Console.WriteLine(" Fun with the default AppDomain \n"); DisplayDADStats();
Console.ReadLine();

static void DisplayDADStats()
{
// Get access to the AppDomain for the current thread. AppDomain defaultAD = AppDomain.CurrentDomain;
// Print out various stats about this domain. Console.WriteLine("Name of this domain: {0}",
defaultAD.FriendlyName);
Console.WriteLine("ID of domain in this process: {0}", defaultAD.Id);
Console.WriteLine("Is this the default domain?: {0}", defaultAD.IsDefaultAppDomain());
Console.WriteLine("Base directory of this domain: {0}", defaultAD.BaseDirectory);
Console.WriteLine("Setup Information for this domain:"); Console.WriteLine("\t Application Base: {0}",

defaultAD.SetupInformation.ApplicationBase); Console.WriteLine("\t Target Framework: {0}",
defaultAD.SetupInformation.TargetFrameworkName);
}

The output of this example is shown here:

Fun with the default AppDomain Name of this domain: DefaultAppDomainApp ID of domain in this process: 1
Is this the default domain?: True
Base directory of this domain: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\ DefaultAppDomainApp\DefaultAppDomainApp\bin\Debug\net6.0\
Setup Information for this domain:
Application Base: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\DefaultAppDomainApp\ DefaultAppDomainApp\bin\Debug\net6.0\
Target Framework: .NETCoreApp,Version=v5.0

Notice that the name of the default application domain will be identical to the name of the executable that is contained within it (DefaultAppDomainApp.exe, in this example). Also notice that the base directory value, which will be used to probe for externally required private assemblies, maps to the current location of the deployed executable.

Enumerating Loaded Assemblies
It is also possible to discover all the loaded .NET Core assemblies within a given application domain using the instance-level GetAssemblies() method. This method will return to you an array of Assembly objects (covered in Chapter 17). To do this, you must have added the System.Reflection namespace to your code file (as you did earlier in this section).
To illustrate, define a new method named ListAllAssembliesInAppDomain() within the Program.cs
file. This helper method will obtain all loaded assemblies and print the friendly name and version of each.

static void ListAllAssembliesInAppDomain()
{
// Get access to the AppDomain for the current thread. AppDomain defaultAD = AppDomain.CurrentDomain;

// Now get all loaded assemblies in the default AppDomain. Assembly[] loadedAssemblies = defaultAD.GetAssemblies();
Console.WriteLine(" Here are the assemblies loaded in {0} \n", defaultAD.FriendlyName);
foreach(Assembly a in loadedAssemblies)
{
Console.WriteLine($"-> Name, Version: {a.GetName().Name}:{a.GetName().Version}" );
}
}

Assuming you have updated your top-level statements to call this new member, you will see that the application domain hosting your executable is currently using the following .NET Core libraries:

Here are the assemblies loaded in DefaultAppDomainApp
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Threading:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0

Now understand that the list of loaded assemblies can change at any time as you author new C# code. For example, assume you have updated your ListAllAssembliesInAppDomain() method to make use of a LINQ query, which will order the loaded assemblies by name, as follows:
static void ListAllAssembliesInAppDomain()
{
// Get access to the AppDomain for the current thread. AppDomain defaultAD = AppDomain.CurrentDomain;

// Now get all loaded assemblies in the default AppDomain. var loadedAssemblies =
defaultAD.GetAssemblies().OrderBy(x=>x.GetName().Name); Console.WriteLine(" Here are the assemblies loaded in {0} \n", defaultAD.FriendlyName);
foreach(Assembly a in loadedAssemblies)
{
Console.WriteLine($"-> Name, Version: {a.GetName().Name}:{a.GetName().Version}" );
}
}
If you were to run the program once again, you would see that System.Linq.dll has also been loaded
into memory.

Here are the assemblies loaded in DefaultAppDomainApp
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Linq:5.0.0.0
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0.0.0
-> Name, Version: System.Threading:5.0.0

Assembly Isolation with Application Load Contexts
As you have just seen, AppDomains are logical partitions used to host .NET Core assemblies. Additionally, an application domain may be further subdivided into numerous load context boundaries. Conceptually, a load context creates a scope for loading, resolving, and potentially unloading a set of assemblies. In a
nutshell, a .NET Core load context provides a way for a single AppDomain to establish a “specific home” for a given object.

■ Note While understanding processes and application domains is quite important, most .net Core applications will never demand that you work with object contexts. i’ve included this overview material just to paint a more complete picture.

The AssemblyLoadContext class provides the capability to load additional assemblies into their own contexts. To demonstrate, first add a class library project named ClassLibary1 and add it to your current solution. Using the .NET Core CLI, execute the following commands in the directory containing your current solution:

dotnet new classlib -lang c# -n ClassLibrary1 -o .\ClassLibrary1 -f net6.0 dotnet sln .\Chapter14_AllProjects.sln add .\ClassLibrary1

Next, add a reference from the DefaultAppDomainApp to the ClassLibrary1 project by executing the following CLI command:

dotnet add DefaultAppDomainApp reference ClassLibrary1

If you are using Visual Studio, right-click the solution node in Solution Explorer, select Add ➤ New Project, and add a .NET Core class library named ClassLibrary1. This creates the project and adds it to your solution. Next, add a reference to this new project by right-clicking the DefaultAppDomainApp project
and selecting Add ➤ Project Reference. Select the Projects ➤ Solution option in the left rail, and select the ClassLibrary1 check box, as shown in Figure 14-3.

Figure 14-3. Adding the project reference in Visual Studio

In this new class library, add a Car class, as follows:

namespace ClassLibrary1; public class Car
{
public string PetName { get; set; } public string Make { get; set; } public int Speed { get; set; }
}

With this new assembly in place, make sure the following using statements are at the top of the
Program.cs file in the DefaultAppDomainApp project:

using System.Reflection; using System.Runtime.Loader;

The next method in the DefaultAppDomainApp top level statements is the
LoadAdditionalAssembliesDifferentContexts() method, shown here:

static void LoadAdditionalAssembliesDifferentContexts()
{
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"ClassLibrary1.dll"); AssemblyLoadContext lc1 =
new AssemblyLoadContext("NewContext1",false); var cl1 = lc1.LoadFromAssemblyPath(path);
var c1 = cl1.CreateInstance("ClassLibrary1.Car");

AssemblyLoadContext lc2 =
new AssemblyLoadContext("NewContext2",false); var cl2 = lc2.LoadFromAssemblyPath(path);
var c2 = cl2.CreateInstance("ClassLibrary1.Car");
Console.WriteLine(" Loading Additional Assemblies in Different Contexts "); Console.WriteLine($"Assembly1 Equals(Assembly2) {cl1.Equals(cl2)}"); Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}"); Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}"); Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}

The first line uses the static Path.Combine method to build up the directory for the ClassLibrary1
assembly.

■ Note You might be wondering why you created a reference for an assembly that will be loaded dynamically. this is to make sure that when the project builds, the ClassLibrary1 assembly builds as well and is in the same directory as the DefaultAppDomainApp. this is merely a convenience for this example. there is no need to reference an assembly that you will load dynamically.

Next, the code creates a new AssemblyLoadContext with the name NewContext1 (the first parameter of the method) and does not support unloading (the second parameter). This LoadContext is used to load
the ClassLibrary1 assembly and then create an instance of a Car class. If some of this code is new to you, it will be explained more fully in Chapter 18. The process is repeated with a new AssemblyLoadContext, and then the assemblies and classes are compared for equality. When you run this new method, you will see the following output:

Loading Additional Assemblies in Different Contexts Assembly1 Equals(Assembly2) False

Assembly1 == Assembly2 False Class1.Equals(Class2) False Class1 ==8 Class2 False

This demonstrates that the same assembly has been loaded twice into the app domain. The classes are also different, as should be expected.
Next, add a new method that will load the assembly from the same AssemblyLoadContext.

static void LoadAdditionalAssembliesSameContext()
{
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"ClassLibrary1.dll"); AssemblyLoadContext lc1 =
new AssemblyLoadContext(null,false); var cl1 = lc1.LoadFromAssemblyPath(path);
var c1 = cl1.CreateInstance("ClassLibrary1.Car"); var cl2 = lc1.LoadFromAssemblyPath(path);
var c2 = cl2.CreateInstance("ClassLibrary1.Car");
Console.WriteLine(" Loading Additional Assemblies in Same Context "); Console.WriteLine($"Assembly1.Equals(Assembly2) {cl1.Equals(cl2)}"); Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}"); Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}"); Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}

The main difference in this code is that only one AssemblyLoadContext is created. Now, when the ClassLibrary1 assembly is loaded twice, the second assembly is simply a pointer to the first instance of the assembly. Running the code produces the following output:

Loading Additional Assemblies in Same Context Assembly1.Equals(Assembly2) True
Assembly1 == Assembly2 True Class1.Equals(Class2) False Class1 == Class2 False

Summarizing Processes, AppDomains, and Load Contexts
At this point, you should have a much better idea about how a .NET Core assembly is hosted by the runtime. If the previous pages have seemed to be a bit too low level for your liking, fear not. For the most part, .NET Core automatically deals with the details of processes, application domains, and load contexts on your behalf. The good news, however, is that this information provides a solid foundation for understanding multithreaded programming under the .NET Core platform.

Summary
The point of this chapter was to examine exactly how a .NET Core application is hosted by the .NET Core platform. As you have seen, the long-standing notion of a Windows process has been altered under the hood to accommodate the needs of the CoreCLR. A single process (which can be programmatically manipulated via the System.Diagnostics.Process type) is now composed of an application domain, which represents isolated and independent boundaries within a process.
An application domain is capable of hosting and executing any number of related assemblies.
Furthermore, a single application domain can contain any number of load contexts for further assembly isolation. Using this additional level of type isolation, the CoreCLR can ensure that special-need objects are handled correctly.

发表评论