For better or for worse, a senior .NET developer needs to understand how the .NET runtime loads assemblies. We are constantly dealing with libraries and NuGet packages. These libraries depend on other popular libraries and there are a lot of shared dependencies. With a large enough web of dependencies, you’ll eventually get into conflicts or hard situations.
The best way to deal with such issues is to understand how the mechanism works internally. In this article, you’ll see how and when a .NET process loads a referenced assembly. You’ll understand which library version was loaded, what happens when there are several versions available, and why problems sometimes occur due to version conflicts. You’ll see how to debug these types of problems, see assembly binding logs (fusion logs), and see some ways to resolve conflicts.
Assembly, Module, and Reference
Let’s start with some basic terms around a .NET process.
An Assembly in .NET is a DLL or EXE file. Each project in your Visual Studio solution is compiled into an assembly. Each assembly can contain multiple Modules, but in practice, we’ll almost always have one module in an assembly, which will have the same name as the assembly.
When starting a process or hitting F5 in Visual Studio, your startup project assembly is going to be executed. It’s going to be the first assembly loaded except for .NET Framework or .NET Core assemblies. Afterward, the process is going to load other assemblies at runtime according to its need. It will load assemblies lazily, only when it needs to call a method or use a type from that assembly.
Here are the modules loaded for a simple “Hello World” .NET Framework project (Modules and Assemblies are the same for all our intents and purposes). MyStartup.dll is the startup project here:
When you reference a project from another project, at build time, the referenced project’s DLL or EXE are copied to the Bin folder of the startup project. It will usually be Bin\Debug or Bin\Release. At runtime, when you use a type from a referenced project for the first time, the CLR looks in the application directory for the DLL file with the same name and version it expects. It then loads that assembly into the process. This is also called binding to the assembly.
Here’s an example:
Let’s say that we have a simple Console application called MyStartup that referenced another project called Lib1. MyStartup uses some classes from the Lib1 assembly.
In MyStartup:
class Program
{
static void Main(string[] args)
{
int a = int.Parse(Console.ReadLine());
int b = int.Parse(Console.ReadLine());
Console.WriteLine("A + B = " + Add(a, b));
}
private static int Add(int a, int b)
{
var calculator = new Lib1.Calculator();
return calculator.Sum(a, b);
}
}
In Lib1:
public class Calculator
{
public int Sum(int a, int b)
{
return a + b;
}
}
When entering Main
method, the Lib1 assembly isn’t loaded yet. But when entering the Add
method, the CLR tries to resolve the Calculator
type, figures out it’s in a referenced assembly Lib1 and then tries to load that assembly.
Assemblies Binding in .NET
When the CLR needs to load an assembly, the logic is actually a bit more complicated than looking in the Bin folder. Here’s the actual logic executed (see Microsoft’s documentation for elaboration):
-
Determine the version of the assembly that needs to load according to configuration files (app.config or web.config). That configuration file name will be (after the build)
[executable name].exe.config
orweb.config
. Here binding redirects come into play (more on that later). -
See if the assembly is already loaded. If a different version is loaded, a FileLoadException will be thrown, unless it’s a strongly-named assembly that can be loaded in several versions side-by-side.
-
If it’s a strongly-named assembly, check the Global Assembly Cache (GAC). The GAC is a place on the machine to share assemblies for multiple applications. An assemblies cache if you will. It can store only strongly-named assemblies. It can store different versions of the same assembly. You can install it to the GAC yourself with gacutil.exe .
-
If it’s a strongly-named assembly and configuration files include
<codeBase>
nodes, it checks for the assembly location there. If the<codeBase>
node exists and the assembly is not found, aFileNotFoundException
will be thrown. -
Checks for the assembly DLL or EXE according to a heuristic algorithm. This process is called Probing. The algorithm is as follows:
- Check the folder
[application base] / [assembly name].dll
. The application base is where the application executable is. Usually your Bin\Debug or Bin\Release folders. - Check
[application base] / [assembly name] / [assembly name].dll
- If culture information is specified for the referenced assembly, only the following directories are checked:
[application base] / [culture] / [assembly name].dll``[application base] / [culture] / [assembly name] / [assembly name].dll
- If the
<probing>
node exists in configuration files, then it looks for the assembly in the folder specified by theprivatePath
attribute of that node.
- Check the folder
Why do they have to make everything so difficult, right?
Actually, this logic is very much to help us develop, and not to make things difficult. It exists to achieve a few important goals:
- To make sure that if you reference a specific assembly and version, then that exact version will be loaded. Otherwise, an exception will be thrown. And if you know what you’re doing, then you can specify override rules in configuration files (binding redirects).
- To allow flexibility in which assembly you want to load. For example, if you want to load different assemblies according to different cultures (languages), then you can do so easily. Or if you want to load different assemblies according to customer configuration, that’s also OK.
- For security, we have strongly-named assemblies. They make sure that you can’t “fake” assemblies. For example, if a process expects to load Lib1 v4.5, then you won’t be able to load a malware assembly with that same name and version. An exception will be thrown while loading it. That’s why the GAC, which is shared for all processes on the machine, only accepts strongly-named assemblies.
In most applications, you don’t need to keep in mind the complex logic of assembly loading and probing. You don’t need to know or think about the GAC, strongly named assemblies, or manipulating configuration files. You mostly don’t need to think about library versions at all because possible conflicts are automatically solved with a mechanism called Binding Redirects.
Binding Redirects
If there’s one thing that’s really important to understand about this whole deal, it’s binding redirects. That’s the ability to tell the runtime which version it should actually load, regardless of the version it has a reference to.
Here’s an example: Your process has two projects (modules): Project A and Project B. Project A references log4net.dll v1.1 and project B references log4net.dll v1.2. Both log4net DLL files are copied to the output folder, but there can be only one log4net.dll file. Let’s say the file that was copied to the output folder was log4net.dll v1.2. Let’s say that the first code reached was the one in Project A, which references log4net v1.1. The runtime will look in the output folder, find a different version of log4net, and fail with a FileLoadException
.
There’s another scenario possible. Suppose the code from Project B was executed first and when trying to use log4net, it successfully loaded log4net.dll v1.2. After a while, the code from Project A will try to use log4net v1.1, see that the assembly is already loaded with a different version, and throw a FileLoadException
.
What you can do in this scenario, if you know which log4net version is going to be in the output folder, is to tell the runtime which version it should use. Simply add the following lines to your app.config
file in the runtime
section:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
...
<runtime>
...
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="log4net"
publicKeyToken="669e0ddf0bb1aa2a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-5.0.0.0" newVersion="1.2.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
...
</configuration>
This means that whenever the runtime wants to bind to the assembly log4net with version range from 0.0.0.0
to 5.0.0.0
, it will instead try to bind to version 1.2.0
.
In practice, you don’t have to add these redirects manually because they are added automatically. If you go to the Properties of your startup project, you’ll see the following setting:
This option is checked by default. It automatically detects version conflicts and generates binding redirects in the .config
file.
When Problems Start Happening
Binding redirects might appear like the answer to all your problems at first glance, but it’s far from the truth. When you’re using binding redirects, you’re basically using a different library version than intended. What if a method was removed? Or a method’s signature changed? In that case, when that method is called, the program will fail with a runtime error. After all, versions were created for a reason.
If you do have these kinds of problems, there are ways to deal with them. Check out my article: How to resolve .NET reference and NuGet package version conflicts .
Troubleshooting
When you have a FileLoadException
or something similar, the first thing I recommend doing is look at your Modules window in Visual Studio. In there, you’ll see all the loaded modules and find out if the assembly that you’re trying to load is already loaded, with what version, and from which path.
Besides that, you can view assembly binding logs, also called Fusion logs. These logs will show what exactly happened during the assembly binding attempts. You’ll see what assembly version the runtime looked for, which folders the runtime looked in, and the points of failure.
There are several ways to see fusion logs. To start, you’ll have to enable them because they are disabled by default. You can enable them manually in the registry by setting the HKLM\Software\Microsoft\Fusion\ForceLog
value to 1 and HKLM\Software\Microsoft\Fusion\LogPath
value to C:\FusionLogs
. The logs will appear automatically. Alternatively, you can use Fusion Log Viewer, which should be installed on your PC as fuslogvw.exe
. I suggest using a program like Everything windows search
to find it. Make sure to run fusion log viewer with administrator privileges to be able to enable and disable logs. A more modern tool to do this that’s becoming popular recently is Fusion++
.
Side note
Don’t know about you, but I used to hate having to deal with these kinds of issues. Give me a logical problem, let me build something, or even solve a production bug… but never this. Having no choice in the matter, I had to learn the inner works of assembly bindings the hard way. I discovered that like with everything else, once you understand something, it becomes less terrible and even enjoyable. So I hope this article made sense for you and will help you quickly along the path that I went through.