Lessons Learned: .NET Framework Assembly Loading, Memory Optimization

Several of my colleges and I recently began looking into how assemblies were loaded in the .NET CLR for a web application that we're working on. The application runs several instances within each of several application pools that all used the same set of large assemblies. What we found was, that since we'd paid little attention to how assemblies were loaded to that point, we had assemblies being loaded multiple times contributing to a huge memory footprint for the application.

To fully understand the problem and the partial solution we found, I'll review how an application in IIS and the .NET CLR is logically structured.

Logical Runtime Structure of IIS Hosted Websites
Global Assembly Cache (GAC)

The GAC is a cache of Common Language Infrastructure assemblies available be shared by multiple applications. Assemblies loaded from the GAC are loaded with Full Trust, note that in .NET 4 Code Access Security (CAS) has changed significantly and permissions are determined largely by the permissions of the executing account.

Application Pools
Application Pools (App Pools) are grouped sets of Web Applications under IIS that share the same W3WP worker process. Each application in an app pool runs under the same service account and as a result with the same account permissions. Application pools can be viewed as a mechanism for creating isolation between applications in IIS. Often sys admins create a separate application pool for each web application. Generally you'll have a single worker process for an application in IIS though you can configure multiple; this is refereed to as a Web Garden. Just a Note: Generally you want to avoid Web Gardens as they can create all sorts of problems for you. Their purpose is intended to be supporting long running requests without creating a blocking situation for your application.

Within a worker process, one or more threads, configurable in IIS, are available. Threads are like the workers on an assembly line with App Domains being the different stations for a worker to do their work. A thread does all the work inside of an application and they can only work on one App Domain at any given time. A thread can however work for multiple domains over the life cycle of the application bouncing back and forth between as work needs to be done.

An application domain is a unit of isolation within the .NET framework. Every application can have one or more application domains. By default, every application has 1 AppDomain however you can create AppDomains within your code to manage assembly dependencies. Within an AppDomain, there are three contexts for assemblies that are loaded within that domain. These contexts are a further isolation unit within an AppDomain into which assemblies are loaded based on the method of loading.
  • default-load context
    • Assemblies resolved by the CLR from the Global Assembly Cache (GAC) or the private application bin.
  • load-from
  • reflection-only
    • Assemblies loaded only for reflection.
The permissions for assemblies are associated with the context they are loaded into. In addition to the AppDomain(s) within an Application, the CLR maintains a SharedDomain at the worker process level into which it loads assemblies that it determines to be domain-neutral. The CLR determines if an assembly can be loaded as domain-neutral automatically and as a developer you have limited ability to control how the CLR loads your assembly. The caveat to that is that you can increase the chances of it happening by where you place your assembly to be loaded from. If it were an application where you can control the entry point (console, windows forms, etc) you can add the LoaderOptimizationAttribute to the main method of your application.  An assembly loaded from the GAC will generally be loaded as domain-neutral if all of it's dependencies can also be loaded as domain-neutral.

Now with all that background information out of the way, we found that placing those large assemblies I mentioned above, along with all of their references in the GAC caused the CLR to load them as domain-neutral and thus share the same assemblies in memory across applications in the same application pool. This reduced our memory footprint per application pool somewhat and with several application pools running several applications made an impact to resource demands on our servers.

So what you might be asking, the moral of this story and the reason I decided to blog about it is that all too often we as developers or systems administrators don't pay enough attention to how our applications are deployed and configured. We take for granted all the things that the .NET framework does for us and our application performance and users suffer because of it. Now, deploying assemblies in the GAC isn't what i'm recommending for every solution and we didn't end up putting all of our application assemblies in the GAC. You should however take some of the information i've provided above, research a bit for yourself and evaluate your application to optimize it's use of resources which can translate to a big performance impact for your end users.

Reference Links: