Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Lazy Loading of Transitive Shared Types [See Comments] #164

Merged
merged 9 commits into from
Jan 31, 2021
65 changes: 55 additions & 10 deletions src/Plugins/Loader/AssemblyLoadContextBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class AssemblyLoadContextBuilder
private AssemblyLoadContext _defaultLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default;
private string? _mainAssemblyPath;
private bool _preferDefaultLoadContext;
private bool _lazyLoadReferences;

#if FEATURE_UNLOAD
private bool _isCollectible;
Expand Down Expand Up @@ -63,6 +64,7 @@ public AssemblyLoadContext Build()
resourceProbingPaths,
_defaultLoadContext,
_preferDefaultLoadContext,
_lazyLoadReferences,
#if FEATURE_UNLOAD
_isCollectible,
_loadInMemory,
Expand Down Expand Up @@ -148,21 +150,46 @@ public AssemblyLoadContextBuilder PreferLoadContextAssembly(AssemblyName assembl
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName)
{
if (assemblyName.Name == null || _defaultAssemblies.Contains(assemblyName.Name))
// Lazy loaded references have dependencies resolved as they are loaded inside the actual Load Context.
if (_lazyLoadReferences)
{
// base cases
if (assemblyName.Name != null && !_defaultAssemblies.Contains(assemblyName.Name))
{
_defaultAssemblies.Add(assemblyName.Name);
var assembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName);
natemcmaster marked this conversation as resolved.
Show resolved Hide resolved
foreach (var reference in assembly.GetReferencedAssemblies())
{
if (reference.Name != null)
{
_defaultAssemblies.Add(reference.Name);
}
}
}

return this;
}

_defaultAssemblies.Add(assemblyName.Name);

// Recursively load and find all dependencies of default assemblies.
// This sacrifices some performance for determinism in how transitive
// dependencies will be shared between host and plugin.
var assembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName);
foreach (var reference in assembly.GetReferencedAssemblies())
var names = new Queue<AssemblyName>();
names.Enqueue(assemblyName);
while (names.TryDequeue(out var name))
{
PreferDefaultLoadContextAssembly(reference);
if (name.Name == null || _defaultAssemblies.Contains(name.Name))
{
// base cases
continue;
}

_defaultAssemblies.Add(name.Name);

// Load and find all dependencies of default assemblies.
// This sacrifices some performance for determinism in how transitive
// dependencies will be shared between host and plugin.
var assembly = _defaultLoadContext.LoadFromAssemblyName(name);

foreach (var reference in assembly.GetReferencedAssemblies())
{
names.Enqueue(reference);
}
}

return this;
Expand All @@ -187,6 +214,24 @@ public AssemblyLoadContextBuilder PreferDefaultLoadContext(bool preferDefaultLoa
return this;
}

/// <summary>
/// Instructs the load context to lazy load dependencies of all shared assemblies.
/// Reduces plugin load time at the expense of non-determinism in how transitive dependencies are loaded
/// between the plugin and the host.
///
/// Please be aware of the danger of using this option:
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873">
/// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
/// </seealso>
/// </summary>
/// <param name="isLazyLoaded">True to lazy load, else false.</param>
/// <returns>The builder.</returns>
public AssemblyLoadContextBuilder IsLazyLoaded(bool isLazyLoaded)
{
_lazyLoadReferences = isLazyLoaded;
return this;
}

/// <summary>
/// Add a managed library to the load context.
/// </summary>
Expand Down
20 changes: 18 additions & 2 deletions src/Plugins/Loader/ManagedLoadContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ internal class ManagedLoadContext : AssemblyLoadContext
private readonly IReadOnlyDictionary<string, ManagedLibrary> _managedAssemblies;
private readonly IReadOnlyDictionary<string, NativeLibrary> _nativeLibraries;
private readonly IReadOnlyCollection<string> _privateAssemblies;
private readonly IReadOnlyCollection<string> _defaultAssemblies;
private readonly ICollection<string> _defaultAssemblies;
private readonly IReadOnlyCollection<string> _additionalProbingPaths;
private readonly bool _preferDefaultLoadContext;
private readonly string[] _resourceRoots;
private readonly bool _loadInMemory;
private readonly bool _lazyLoadReferences;
private readonly AssemblyLoadContext _defaultLoadContext;
#if FEATURE_NATIVE_RESOLVER
private readonly AssemblyDependencyResolver _dependencyResolver;
Expand All @@ -45,6 +46,7 @@ internal class ManagedLoadContext : AssemblyLoadContext
IReadOnlyCollection<string> resourceProbingPaths,
AssemblyLoadContext defaultLoadContext,
bool preferDefaultLoadContext,
bool lazyLoadReferences,
bool isCollectible,
bool loadInMemory,
bool shadowCopyNativeLibraries)
Expand All @@ -64,12 +66,13 @@ internal class ManagedLoadContext : AssemblyLoadContext
_basePath = Path.GetDirectoryName(mainAssemblyPath) ?? throw new ArgumentException(nameof(mainAssemblyPath));
_managedAssemblies = managedAssemblies ?? throw new ArgumentNullException(nameof(managedAssemblies));
_privateAssemblies = privateAssemblies ?? throw new ArgumentNullException(nameof(privateAssemblies));
_defaultAssemblies = defaultAssemblies ?? throw new ArgumentNullException(nameof(defaultAssemblies));
_defaultAssemblies = defaultAssemblies != null ? defaultAssemblies.ToList() : throw new ArgumentNullException(nameof(defaultAssemblies));
_nativeLibraries = nativeLibraries ?? throw new ArgumentNullException(nameof(nativeLibraries));
_additionalProbingPaths = additionalProbingPaths ?? throw new ArgumentNullException(nameof(additionalProbingPaths));
_defaultLoadContext = defaultLoadContext;
_preferDefaultLoadContext = preferDefaultLoadContext;
_loadInMemory = loadInMemory;
_lazyLoadReferences = lazyLoadReferences;

_resourceRoots = new[] { _basePath }
.Concat(resourceProbingPaths)
Expand Down Expand Up @@ -105,6 +108,19 @@ internal class ManagedLoadContext : AssemblyLoadContext
var defaultAssembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName);
if (defaultAssembly != null)
{
// Add referenced assemblies to the list of default assemblies.
// This is basically lazy loading
if (_lazyLoadReferences)
{
foreach (var reference in defaultAssembly.GetReferencedAssemblies())
{
if (reference.Name != null && !_defaultAssemblies.Contains(reference.Name))
{
_defaultAssemblies.Add(reference.Name);
}
}
}

// Older versions used to return null here such that returned assembly would be resolved from the default ALC.
// However, with the addition of custom default ALCs, the Default ALC may not be the user's chosen ALC when
// this context was built. As such, we simply return the Assembly from the user's chosen default load context.
Expand Down
20 changes: 16 additions & 4 deletions src/Plugins/PluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,22 @@ public PluginConfig(string mainAssemblyPath)
/// </summary>
public bool PreferSharedTypes { get; set; }

/// <summary>
/// <summary>
/// If enabled, will lazy load dependencies of all shared assemblies.
/// Reduces plugin load time at the expense of non-determinism in how transitive dependencies are loaded
/// between the plugin and the host.
///
/// Please be aware of the danger of using this option:
/// <seealso href="https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873">
/// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
/// </seealso>
/// </summary>
public bool IsLazyLoaded { get; set; } = false;

/// <summary>
/// If set, replaces the default <see cref="AssemblyLoadContext"/> used by the <see cref="PluginLoader"/>.
/// Use this feature if the <see cref="AssemblyLoadContext"/> of the <see cref="Assembly"/> is not the Runtime's default load context.
/// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != <see cref="AssemblyLoadContext.Default"/>
/// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != <see cref="AssemblyLoadContext.Default"/>
/// </summary>
public AssemblyLoadContext DefaultContext { get; set; } = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default;

Expand Down Expand Up @@ -104,9 +116,9 @@ public bool LoadInMemory
/// </remarks>
public bool EnableHotReload { get; set; }

/// <summary>
/// <summary>
/// Specifies the delay to reload a plugin, after file changes have been detected.
/// Default value is 200 milliseconds.
/// Default value is 200 milliseconds.
/// </summary>
public TimeSpan ReloadDelay { get; set; } = TimeSpan.FromMilliseconds(200);
#endif
Expand Down
10 changes: 9 additions & 1 deletion src/Plugins/PluginLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
Expand Down Expand Up @@ -112,9 +113,15 @@ public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sh
{
if (sharedTypes != null)
{
var uniqueAssemblies = new HashSet<Assembly>();
foreach (var type in sharedTypes)
{
config.SharedAssemblies.Add(type.Assembly.GetName());
uniqueAssemblies.Add(type.Assembly);
}

foreach (var assembly in uniqueAssemblies)
{
config.SharedAssemblies.Add(assembly.GetName());
}
}
configure(config);
Expand Down Expand Up @@ -374,6 +381,7 @@ private static AssemblyLoadContextBuilder CreateLoadContextBuilder(PluginConfig
}
#endif

builder.IsLazyLoaded(config.IsLazyLoaded);
foreach (var assemblyName in config.SharedAssemblies)
{
builder.PreferDefaultLoadContextAssembly(assemblyName);
Expand Down
3 changes: 3 additions & 0 deletions src/Plugins/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
McMaster.NETCore.Plugins.PluginConfig.IsLazyLoaded.get -> bool
McMaster.NETCore.Plugins.PluginConfig.IsLazyLoaded.set -> void
McMaster.NETCore.Plugins.Loader.AssemblyLoadContextBuilder.IsLazyLoaded(bool isLazyLoaded) -> McMaster.NETCore.Plugins.Loader.AssemblyLoadContextBuilder!
3 changes: 2 additions & 1 deletion src/Plugins/releasenotes.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
<PropertyGroup>
<PackageReleaseNotes Condition="'$(VersionPrefix)' == '1.4.0'">
Changes:
* @Sewer56 - search in additional probing paths (PR #172)
* @Sewer56 - feature: add option to support lazy loading of transitive dependencies to increase performance (PR #164)
* @Sewer56 - bugfix: search in additional probing paths (PR #172)
</PackageReleaseNotes>
<PackageReleaseNotes Condition="'$(VersionPrefix)' == '1.3.1'">
Changes:
Expand Down
11 changes: 5 additions & 6 deletions test/Plugins.Tests/SharedTypesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,12 @@ public void PluginsCanForceSharedTypes()
/// accounted for. Without this, the order in which code loads
/// could cause different assembly versions to be loaded.
/// </summary>
[Fact]
public void TransitiveAssembliesOfSharedTypesAreResolved()
[Theory]
[InlineData(true)]
[InlineData(false)]
public void TransitiveAssembliesOfSharedTypesAreResolved(bool isLazyLoaded)
{
using var loader = PluginLoader.CreateFromAssemblyFile(
TestResources.GetTestProjectAssembly("TransitivePlugin"),
sharedTypes: new[] { typeof(SharedType) });

using var loader = PluginLoader.CreateFromAssemblyFile(TestResources.GetTestProjectAssembly("TransitivePlugin"), sharedTypes: new[] { typeof(SharedType) }, config => config.IsLazyLoaded = isLazyLoaded);
var assembly = loader.LoadDefaultAssembly();
var configType = assembly.GetType("TransitivePlugin.PluginConfig", throwOnError: true)!;
var config = Activator.CreateInstance(configType);
Expand Down