Repository: dotnet/efcore
Pull request #35835: Changes to AsyncLocal usage for better lazy loading performance
Author: login: henriquewr
Labels: name: community-contribution
Created: 2025-03-24T05:11:54Z | Merged: 2025-03-25T21:40:12Z

Changed AsyncLocal to ThreadId for better performance
Fixes #35832 

Linked issue #35832: Lazy loader performance issues
The current implementation of LazyLoader is using AsyncLocal
the performance issue occurs when the GC collects the AsyncLocal instance created by the LazyLoader

On my tests loading a list of around 570 items takes:
674 ms with AsyncLocal
3 ms without AsyncLocal

![Image](https://github.com/user-attachments/assets/71c3214b-f055-4d41-bae6-2c70c2a91853)

This issue is related to #35528 

Code that reproduces the issue
I didn't use BenchmarkDotNet because it takes a very, very long time to complete
and that code reproduces the bug
```
    internal class Program
    {
        static void Main()
        {
            //change the implementation of LazyLoader in TestDbContext class
            var dbContext = new TestDbContext();

            dbContext.LazyLoadEntity.ExecuteDelete();
            dbContext.SomeEntity.ExecuteDelete();

            for (int i = 0; i < 10000; i++)
            {
                dbContext.LazyLoadEntity.Add(new LazyLoadEntity
                {
                    SomeEntity = new SomeEntity
                    {
                        StringData = Random.Shared.Next().ToString()
                    }
                });
            }

            dbContext.SaveChanges();

            dbContext.Dispose();

            // Get rid of any caches
            dbContext = new TestDbContext();

            var sw = Stopwatch.StartNew();

            var list = dbContext.LazyLoadEntity.ToList();

            foreach(var item in list)
            {
                var variable = item.SomeEntity.Id;
            }

            sw.Stop();

            Console.WriteLine($"Elapsed {sw.ElapsedMilliseconds} ms");
        }
    }

    public class TestDbContext : DbContext
    {
        public TestDbContext() : base()
        {
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(CONNECTION_STRING);
            optionsBuilder.UseLazyLoadingProxies();


            //comment/uncomment the implementation
            optionsBuilder.ReplaceService<ILazyLoader, LazyLoaderAsyncLocal>(); //current implementation
            //optionsBuilder.ReplaceService<ILazyLoader, LazyLoaderWithoutAsyncLocal>();


            base.OnConfiguring(optionsBuilder);
        }

        public DbSet<SomeEntity> SomeEntity { get; set; }
        public DbSet<LazyLoadEntity> LazyLoadEntity { get; set; }
    }

    public class LazyLoaderAsyncLocal : ILazyLoader, IInjectableService
    {
        private QueryTrackingBehavior? _queryTrackingBehavior;
        private bool _disposed;
        private bool _detached;
        private IDictionary<string, bool>? _loadedStates;
        private readonly Lock _isLoadingLock = new Lock();
        private readonly Dictionary<(object Entity, string NavigationName), (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth)> _isLoading = new(NavEntryEqualityComparer.Instance);
        private HashSet<string>? _nonLazyNavigations;

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public LazyLoaderAsyncLocal(
            ICurrentDbContext currentContext,
            IDiagnosticsLogger<DbLoggerCategory.Infrastructure> logger)
        {
            Context = currentContext.Context;
            Logger = logger;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void Injected(DbContext context, object entity, QueryTrackingBehavior? queryTrackingBehavior, ITypeBase structuralType)
        {
            _queryTrackingBehavior = queryTrackingBehavior;
            _nonLazyNavigations ??= InitNavigationsMetadata(
                structuralType as IEntityType
                ?? throw new NotImplementedException("Navigations on complex types are not supported"));
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void SetLoaded(
            object entity,
            [CallerMemberName] string navigationName = "",
            bool loaded = true)
        {
            _loadedStates ??= new Dictionary<string, bool>();

            _loadedStates[navigationName] = loaded;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual bool IsLoaded(object entity, string navigationName = "")
            => _loadedStates != null
                && _loadedStates.TryGetValue(navigationName, out var loaded)
                && loaded;

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        protected virtual IDiagnosticsLogger<DbLoggerCategory.Infrastructure> Logger { get; }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        protected virtual DbContext? Context { get; set; }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        // ReSharper disable once AssignNullToNotNullAttribute
        public virtual void Load(object entity, [CallerMemberName] string navigationName = "")
        {
            var navEntry = (entity, navigationName);

            bool exists;
            (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth) isLoadingValue;

            lock (_isLoadingLock)
            {
                ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
                if (!exists)
                {
                    refIsLoadingValue = (new(), new());
                }
                isLoadingValue = refIsLoadingValue!;
                isLoadingValue.Depth.Value++;
            }

            if (exists)
            {
                // Only waits for the outermost call on the call stack. See  #35528.
                if (isLoadingValue.Depth.Value == 1)
                {
                    isLoadingValue.TaskCompletionSource.Task.Wait();
                }
                return;
            }

            try
            {
                // ShouldLoad is called after _isLoading.Add because it could attempt to load the property. See #13138.
                if (ShouldLoad(entity, navigationName, out var entry))
                {
                    try
                    {
                        entry.Load(
                            _queryTrackingBehavior == QueryTrackingBehavior.NoTrackingWithIdentityResolution
                                ? LoadOptions.ForceIdentityResolution
                                : LoadOptions.None);
                    }
                    catch
                    {
                        entry.IsLoaded = false;
                        throw;
                    }
                }
            }
            finally
            {
                isLoadingValue.TaskCompletionSource.TrySetResult();
                lock (_isLoadingLock)
                {
                    _isLoading.Remove(navEntry);
                }
            }
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual async Task LoadAsync(
            object entity,
            CancellationToken cancellationToken = default,
            [CallerMemberName] string navigationName = "")
        {
            var navEntry = (entity, navigationName);

            bool exists;
            (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth) isLoadingValue;

            lock (_isLoadingLock)
            {
                ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
                if (!exists)
                {
                    refIsLoadingValue = (new(), new());
                }
                isLoadingValue = refIsLoadingValue!;
                isLoadingValue.Depth.Value++;
            }

            if (exists)
            {
                // Only waits for the outermost call on the call stack. See  #35528.
                if (isLoadingValue.Depth.Value == 1)
                {
                    await isLoadingValue.TaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
                }
                return;
            }

            try
            {
                // ShouldLoad is called after _isLoading.Add because it could attempt to load the property. See #13138.
                if (ShouldLoad(entity, navigationName, out var entry))
                {
                    try
                    {
                        await entry.LoadAsync(
                                   _queryTrackingBehavior == QueryTrackingBehavior.NoTrackingWithIdentityResolution
                                       ? LoadOptions.ForceIdentityResolution
                                       : LoadOptions.None,
                                   cancellationToken).ConfigureAwait(false);
                    }
                    catch
                    {
                        entry.IsLoaded = false;
                        throw;
                    }
                }
            }
            finally
            {
                isLoadingValue.TaskCompletionSource.TrySetResult();
                lock (_isLoadingLock)
                {
                    _isLoading.Remove(navEntry);
                }
            }
        }

        private sealed class NavEntryEqualityComparer : IEqualityComparer<(object Entity, string NavigationName)>
        {
            public static readonly NavEntryEqualityComparer Instance = new();

            private NavEntryEqualityComparer()
            {
            }

            public bool Equals((object Entity, string NavigationName) x, (object Entity, string NavigationName) y)
                => ReferenceEquals(x.Entity, y.Entity)
                    && string.Equals(x.NavigationName, y.NavigationName, StringComparison.Ordinal);

            public int GetHashCode((object Entity, string NavigationName) obj)
                => HashCode.Combine(RuntimeHelpers.GetHashCode(obj.Entity), obj.NavigationName.GetHashCode());
        }

        private bool ShouldLoad(object entity, string navigationName, [NotNullWhen(true)] out NavigationEntry? navigationEntry)
        {
            if (!_detached && !IsLoaded(entity, navigationName))
            {
                if (_nonLazyNavigations == null
                    || !_nonLazyNavigations.Contains(navigationName))
                {
                    if (_disposed)
                    {
                        Logger.LazyLoadOnDisposedContextWarning(Context, entity, navigationName);
                    }
                    else if (Context!.ChangeTracker.LazyLoadingEnabled)
                    {
                        navigationEntry = Context.Entry(entity).Navigation(navigationName); // Will use local-DetectChanges, if enabled.
                        if (!navigationEntry.IsLoaded) // Check again because the nav may be loaded without the loader knowing
                        {
                            Logger.NavigationLazyLoading(Context, entity, navigationName);

                            return true;
                        }
                    }
                }
            }

            navigationEntry = null;
            return false;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void Dispose()
        {
            Context = null;
            _disposed = true;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual bool Detaching(DbContext context, object entity)
        {
            _detached = true;
            Dispose();
            return false;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void Attaching(DbContext context, IEntityType entityType, object entity)
        {
            _disposed = false;
            _detached = false;
            Context = context;
            _nonLazyNavigations ??= InitNavigationsMetadata(entityType);
        }

        private HashSet<string> InitNavigationsMetadata(IEntityType entityType)
            => entityType!.GetNavigations()
                .Cast<INavigationBase>()
                .Concat(entityType.GetSkipNavigations())
                .Where(n => !n.LazyLoadingEnabled)
                .Select(t => t.Name)
                .ToHashSet();
    }

    public class LazyLoaderWithoutAsyncLocal : ILazyLoader, IInjectableService
    {
        private QueryTrackingBehavior? _queryTrackingBehavior;
        private bool _disposed;
        private bool _detached;
        private IDictionary<string, bool>? _loadedStates;
        private readonly Lock _isLoadingLock = new Lock();
        private readonly Dictionary<(object Entity, string NavigationName), (TaskCompletionSource TaskCompletionSource, int ThreadId)> _isLoading = new(NavEntryEqualityComparer.Instance);
        private HashSet<string>? _nonLazyNavigations;

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public LazyLoaderWithoutAsyncLocal(
            ICurrentDbContext currentContext,
            IDiagnosticsLogger<DbLoggerCategory.Infrastructure> logger)
        {
            Context = currentContext.Context;
            Logger = logger;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void Injected(DbContext context, object entity, QueryTrackingBehavior? queryTrackingBehavior, ITypeBase structuralType)
        {
            _queryTrackingBehavior = queryTrackingBehavior;
            _nonLazyNavigations ??= InitNavigationsMetadata(
                structuralType as IEntityType
                ?? throw new NotImplementedException("Navigations on complex types are not supported"));
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void SetLoaded(
            object entity,
            [CallerMemberName] string navigationName = "",
            bool loaded = true)
        {
            _loadedStates ??= new Dictionary<string, bool>();

            _loadedStates[navigationName] = loaded;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual bool IsLoaded(object entity, string navigationName = "")
            => _loadedStates != null
                && _loadedStates.TryGetValue(navigationName, out var loaded)
                && loaded;

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        protected virtual IDiagnosticsLogger<DbLoggerCategory.Infrastructure> Logger { get; }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        protected virtual DbContext? Context { get; set; }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        // ReSharper disable once AssignNullToNotNullAttribute
        public virtual void Load(object entity, [CallerMemberName] string navigationName = "")
        {
            var navEntry = (entity, navigationName);

            bool exists;
            (TaskCompletionSource TaskCompletionSource, int ThreadId) isLoadingValue;
            var threadId = Environment.CurrentManagedThreadId;

            lock (_isLoadingLock)
            {
                ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
                if (!exists)
                {
                    refIsLoadingValue = (new(), threadId);
                }
                isLoadingValue = refIsLoadingValue!;
            }

            if (exists)
            {
                // If it's the same thread it comes from a recursive ShouldLoad call, and waiting for it makes a deadlock,  See  #35528, #35832.
                if (threadId != isLoadingValue.ThreadId)
                {
                    isLoadingValue.TaskCompletionSource.Task.Wait();
                }
                return;
            }

            try
            {
                // ShouldLoad is called after _isLoading.Add because it could attempt to load the property. See #13138.
                if (ShouldLoad(entity, navigationName, out var entry))
                {
                    try
                    {
                        entry.Load(
                            _queryTrackingBehavior == QueryTrackingBehavior.NoTrackingWithIdentityResolution
                                ? LoadOptions.ForceIdentityResolution
                                : LoadOptions.None);
                    }
                    catch
                    {
                        entry.IsLoaded = false;
                        throw;
                    }
                }
            }
            finally
            {
                isLoadingValue.TaskCompletionSource.TrySetResult();
                lock (_isLoadingLock)
                {
                    _isLoading.Remove(navEntry);
                }
            }
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual async Task LoadAsync(
            object entity,
            CancellationToken cancellationToken = default,
            [CallerMemberName] string navigationName = "")
        {
            var navEntry = (entity, navigationName);

            bool exists;
            (TaskCompletionSource TaskCompletionSource, int ThreadId) isLoadingValue;

            lock (_isLoadingLock)
            {
                ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
                if (!exists)
                {
                    refIsLoadingValue = (new(), 0);
                }
                isLoadingValue = refIsLoadingValue!;
            }

            if (exists)
            {
                await isLoadingValue.TaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
                return;
            }

            try
            {
                // ShouldLoad is called after _isLoading.Add because it could attempt to load the property. See #13138.
                if (ShouldLoad(entity, navigationName, out var entry))
                {
                    try
                    {
                        await entry.LoadAsync(
                                   _queryTrackingBehavior == QueryTrackingBehavior.NoTrackingWithIdentityResolution
                                       ? LoadOptions.ForceIdentityResolution
                                       : LoadOptions.None,
                                   cancellationToken).ConfigureAwait(false);
                    }
                    catch
                    {
                        entry.IsLoaded = false;
                        throw;
                    }
                }
            }
            finally
            {
                isLoadingValue.TaskCompletionSource.TrySetResult();
                lock (_isLoadingLock)
                {
                    _isLoading.Remove(navEntry);
                }
            }
        }

        private sealed class NavEntryEqualityComparer : IEqualityComparer<(object Entity, string NavigationName)>
        {
            public static readonly NavEntryEqualityComparer Instance = new();

            private NavEntryEqualityComparer()
            {
            }

            public bool Equals((object Entity, string NavigationName) x, (object Entity, string NavigationName) y)
                => ReferenceEquals(x.Entity, y.Entity)
                    && string.Equals(x.NavigationName, y.NavigationName, StringComparison.Ordinal);

            public int GetHashCode((object Entity, string NavigationName) obj)
                => HashCode.Combine(RuntimeHelpers.GetHashCode(obj.Entity), obj.NavigationName.GetHashCode());
        }

        private bool ShouldLoad(object entity, string navigationName, [NotNullWhen(true)] out NavigationEntry? navigationEntry)
        {
            if (!_detached && !IsLoaded(entity, navigationName))
            {
                if (_nonLazyNavigations == null
                    || !_nonLazyNavigations.Contains(navigationName))
                {
                    if (_disposed)
                    {
                        Logger.LazyLoadOnDisposedContextWarning(Context, entity, navigationName);
                    }
                    else if (Context!.ChangeTracker.LazyLoadingEnabled)
                    {
                        navigationEntry = Context.Entry(entity).Navigation(navigationName); // Will use local-DetectChanges, if enabled.
                        if (!navigationEntry.IsLoaded) // Check again because the nav may be loaded without the loader knowing
                        {
                            Logger.NavigationLazyLoading(Context, entity, navigationName);

                            return true;
                        }
                    }
                }
            }

            navigationEntry = null;
            return false;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void Dispose()
        {
            Context = null;
            _disposed = true;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual bool Detaching(DbContext context, object entity)
        {
            _detached = true;
            Dispose();
            return false;
        }

        /// <summary>
        ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
        ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
        ///     any release. You should only use it directly in your code with extreme caution and knowing that
        ///     doing so can result in application failures when updating to a new Entity Framework Core release.
        /// </summary>
        public virtual void Attaching(DbContext context, IEntityType entityType, object entity)
        {
            _disposed = false;
            _detached = false;
            Context = context;
            _nonLazyNavigations ??= InitNavigationsMetadata(entityType);
        }

        private HashSet<string> InitNavigationsMetadata(IEntityType entityType)
            => entityType!.GetNavigations()
                .Cast<INavigationBase>()
                .Concat(entityType.GetSkipNavigations())
                .Where(n => !n.LazyLoadingEnabled)
                .Select(t => t.Name)
                .ToHashSet();
    }


    public class LazyLoadEntity
    {
        [Key]
        public int Id { get; set; }

        [ForeignKey(nameof(SomeEntity))]
        public int SomeEntityId { get; set; }

        [ForeignKey(nameof(SomeEntityId))]
        public virtual SomeEntity SomeEntity { get; set; }
    }

    public class SomeEntity
    {
        [Key]
        public int Id { get; set; }

        public string? StringData { get; set; }
    }
```
Linked issue comments: user: login: roji
created_at: 2025-03-24T08:09:14Z
body: @AndriySvyryd assigning to you, but @henriquewr when reporting issues, please always submit a proper BenchmarkDotNet benchmark - we need to see what kind of code you're testing here etc.
author_association: MEMBER

Diff:
diff --git a/src/EFCore/Infrastructure/Internal/LazyLoader.cs b/src/EFCore/Infrastructure/Internal/LazyLoader.cs
index 8215392c8d8..c575cd4f957 100644
--- a/src/EFCore/Infrastructure/Internal/LazyLoader.cs
+++ b/src/EFCore/Infrastructure/Internal/LazyLoader.cs
@@ -23,7 +23,8 @@ public class LazyLoader : ILazyLoader, IInjectableService
     private bool _detached;
     private IDictionary<string, bool>? _loadedStates;
     private readonly Lock _isLoadingLock = new Lock();
-    private readonly Dictionary<(object Entity, string NavigationName), (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth)> _isLoading = new(NavEntryEqualityComparer.Instance);
+    private readonly Dictionary<(object Entity, string NavigationName), TaskCompletionSource> _isLoading = new(NavEntryEqualityComparer.Instance);
+    private static readonly AsyncLocal<int> _isLoadingCallDepth = new();
     private HashSet<string>? _nonLazyNavigations;
 
     /// <summary>
@@ -112,26 +113,28 @@ public virtual void Load(object entity, [CallerMemberName] string navigationName
         var navEntry = (entity, navigationName);
 
         bool exists;
-        (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth) isLoadingValue;
+        TaskCompletionSource isLoadingValue;
 
         lock (_isLoadingLock)
         {
             ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
             if (!exists)
             {
-                refIsLoadingValue = (new(), new());
+                refIsLoadingValue = new();
             }
+            _isLoadingCallDepth.Value++;
             isLoadingValue = refIsLoadingValue!;
-            isLoadingValue.Depth.Value++;
         }
 
         if (exists)
         {
-            // Only waits for the outermost call on the call stack. See  #35528.
-            if (isLoadingValue.Depth.Value == 1)
+            // Only waits for the outermost call on the call stack. See #35528.
+            // if _isLoadingCallDepth.Value > 1 the call is recursive, waiting probably generates a deadlock See #35832.
+            if (_isLoadingCallDepth.Value == 1)
             {
-                isLoadingValue.TaskCompletionSource.Task.Wait();
+                isLoadingValue.Task.Wait();
             }
+            _isLoadingCallDepth.Value--;
             return;
         }
 
@@ -156,7 +159,8 @@ public virtual void Load(object entity, [CallerMemberName] string navigationName
         }
         finally
         {
-            isLoadingValue.TaskCompletionSource.TrySetResult();
+            isLoadingValue.TrySetResult();
+            _isLoadingCallDepth.Value--;
             lock (_isLoadingLock)
             {
                 _isLoading.Remove(navEntry);
@@ -181,26 +185,28 @@ public virtual async Task LoadAsync(
         var navEntry = (entity, navigationName);
 
         bool exists;
-        (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth) isLoadingValue;
+        TaskCompletionSource isLoadingValue;
 
         lock (_isLoadingLock)
         {
             ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
             if (!exists)
             {
-                refIsLoadingValue = (new(), new());
+                refIsLoadingValue = new();
             }
+            _isLoadingCallDepth.Value++;
             isLoadingValue = refIsLoadingValue!;
-            isLoadingValue.Depth.Value++;
         }
 
         if (exists)
         {
-            // Only waits for the outermost call on the call stack. See  #35528.
-            if (isLoadingValue.Depth.Value == 1)
+            // Only waits for the outermost call on the call stack. See #35528.
+            // if _isLoadingCallDepth.Value > 1 the call is recursive, waiting probably generates a deadlock See #35832.
+            if (_isLoadingCallDepth.Value == 1)
             {
-                await isLoadingValue.TaskCompletionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
+                await isLoadingValue.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
             }
+            _isLoadingCallDepth.Value--;
             return;
         }
 
@@ -226,7 +232,8 @@ await entry.LoadAsync(
         }
         finally
         {
-            isLoadingValue.TaskCompletionSource.TrySetResult();
+            isLoadingValue.TrySetResult();
+            _isLoadingCallDepth.Value--;
             lock (_isLoadingLock)
             {
                 _isLoading.Remove(navEntry);
diff --git a/test/EFCore.Specification.Tests/LoadTestBase.cs b/test/EFCore.Specification.Tests/LoadTestBase.cs
index 1b216612cc2..109dd169a2a 100644
--- a/test/EFCore.Specification.Tests/LoadTestBase.cs
+++ b/test/EFCore.Specification.Tests/LoadTestBase.cs
@@ -5059,14 +5059,31 @@ public virtual async Task Lazy_loading_is_thread_safe(bool noTracking, bool asyn
         var parent = query.Single();
 
         var children = (await parent.LazyLoadChildren(async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+        var childrenInvert = (await parent.LazyLoadChildren(!async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+
         var singlePkToPk = (await parent.LazyLoadSinglePkToPk(async))?.Id;
+        var singlePkToPkInvert = (await parent.LazyLoadSinglePkToPk(!async))?.Id;
+
         var single = (await parent.LazyLoadSingle(async))?.Id;
+        var singleInvert = (await parent.LazyLoadSingle(!async))?.Id;
+
         var childrenAk = (await parent.LazyLoadChildrenAk(async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+        var childrenAkInvert = (await parent.LazyLoadChildrenAk(!async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+
         var singleAk = (await parent.LazyLoadSingleAk(async))?.Id;
+        var singleAkInvert = (await parent.LazyLoadSingleAk(!async))?.Id;
+
         var childrenShadowFk = (await parent.LazyLoadChildrenShadowFk(async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+        var childrenShadowFkInvert = (await parent.LazyLoadChildrenShadowFk(!async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+
         var singleShadowFk = (await parent.LazyLoadSingleShadowFk(async))?.Id;
+        var singleShadowFkInvert = (await parent.LazyLoadSingleShadowFk(!async))?.Id;
+
         var childrenCompositeKey = (await parent.LazyLoadChildrenCompositeKey(async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+        var childrenCompositeKeyInvert = (await parent.LazyLoadChildrenCompositeKey(!async))?.Select(x => x.Id).OrderBy(x => x).ToList();
+
         var singleCompositeKey = (await parent.LazyLoadSingleCompositeKey(async))?.Id;
+        var singleCompositeKeyInvert = (await parent.LazyLoadSingleCompositeKey(!async))?.Id;
 
         var parent2 = query2.Single();
 
@@ -5075,18 +5092,57 @@ public virtual async Task Lazy_loading_is_thread_safe(bool noTracking, bool asyn
             MaxDegreeOfParallelism = Environment.ProcessorCount * 500
         };
 
-        await Parallel.ForAsync(0, 50000, parallelOptions, async (i, ct) =>
-        {
-            Assert.Equal(children, (await parent2.LazyLoadChildren(async))?.Select(x => x.Id).OrderBy(x => x).ToList());
-            Assert.Equal(singlePkToPk, (await parent2.LazyLoadSinglePkToPk(async))?.Id);
-            Assert.Equal(single, (await parent2.LazyLoadSingle(async))?.Id);
-            Assert.Equal(childrenAk, (await parent2.LazyLoadChildrenAk(async))?.Select(x => x.Id).OrderBy(x => x).ToList());
-            Assert.Equal(singleAk, (await parent2.LazyLoadSingleAk(async))?.Id);
-            Assert.Equal(childrenShadowFk, (await parent2.LazyLoadChildrenShadowFk(async))?.Select(x => x.Id).OrderBy(x => x).ToList());
-            Assert.Equal(singleShadowFk, (await parent2.LazyLoadSingleShadowFk(async))?.Id);
-            Assert.Equal(childrenCompositeKey, (await parent2.LazyLoadChildrenCompositeKey(async))?.Select(x => x.Id).OrderBy(x => x).ToList());
-            Assert.Equal(singleCompositeKey, (await parent2.LazyLoadSingleCompositeKey(async))?.Id);
+        await Parallel.ForAsync(0, 10000, parallelOptions, async (i, ct) =>
+        {
+            await Task.WhenAll(
+                AssertEqual(
+                    (children, async () => (await parent2.LazyLoadChildren(async))?.Select(x => x.Id).OrderBy(x => x).ToList()),
+                    (childrenInvert, async () => (await parent2.LazyLoadChildren(!async))?.Select(x => x.Id).OrderBy(x => x).ToList())
+                ),
+                AssertEqual(
+                    (singlePkToPk, async () => (await parent2.LazyLoadSinglePkToPk(async))?.Id),
+                    (singlePkToPkInvert, async () => (await parent2.LazyLoadSinglePkToPk(!async))?.Id)
+                ),
+                AssertEqual(
+                    (single, async () => (await parent2.LazyLoadSingle(async))?.Id),
+                    (singleInvert, async () => (await parent2.LazyLoadSingle(!async))?.Id)
+                ),
+                AssertEqual(
+                    (childrenAk, async () => (await parent2.LazyLoadChildrenAk(async))?.Select(x => x.Id).OrderBy(x => x).ToList()),
+                    (childrenAkInvert, async () => (await parent2.LazyLoadChildrenAk(!async))?.Select(x => x.Id).OrderBy(x => x).ToList())
+                ),
+                AssertEqual(
+                    (singleAk, async () => (await parent2.LazyLoadSingleAk(async))?.Id),
+                    (singleAkInvert, async () => (await parent2.LazyLoadSingleAk(!async))?.Id)
+                ),
+                AssertEqual(
+                    (childrenShadowFk, async () => (await parent2.LazyLoadChildrenShadowFk(async))?.Select(x => x.Id).OrderBy(x => x).ToList()),
+                    (childrenShadowFkInvert, async () => (await parent2.LazyLoadChildrenShadowFk(!async))?.Select(x => x.Id).OrderBy(x => x).ToList())
+                ),
+                AssertEqual(
+                    (singleShadowFk, async () => (await parent2.LazyLoadSingleShadowFk(async))?.Id),
+                    (singleShadowFkInvert, async () => (await parent2.LazyLoadSingleShadowFk(!async))?.Id)
+                ),
+                AssertEqual(
+                    (childrenCompositeKey, async () => (await parent2.LazyLoadChildrenCompositeKey(async))?.Select(x => x.Id).OrderBy(x => x).ToList()),
+                    (childrenCompositeKeyInvert, async () => (await parent2.LazyLoadChildrenCompositeKey(!async))?.Select(x => x.Id).OrderBy(x => x).ToList())
+                ),
+                AssertEqual(
+                    (singleCompositeKey, async () => (await parent2.LazyLoadSingleCompositeKey(async))?.Id),
+                    (singleCompositeKeyInvert, async () => (await parent2.LazyLoadSingleCompositeKey(!async))?.Id)
+                )
+            );
         });
+
+        static async Task AssertEqual<T>((T Data, Func<Task<T>> Expected) data, (T Data, Func<Task<T>> Expected) dataInvert)
+        {
+            //Do the processing at the same time
+            var dataTask = data.Expected();
+            var dataInvertTask = dataInvert.Expected();
+
+            Assert.Equal(data.Data, await dataTask);
+            Assert.Equal(dataInvert.Data, await dataInvertTask);
+        }
     }
 
     private static void SetState(


Reviews:
user: login: AndriySvyryd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T22:35:26Z
user: login: AndriySvyryd
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T22:36:06Z
user: login: AndriySvyryd
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T19:27:45Z

Review comments:
diff_hunk: @@ -112,25 +113,26 @@ public virtual void Load(object entity, [CallerMemberName] string navigationName
         var navEntry = (entity, navigationName);
 
         bool exists;
-        (TaskCompletionSource TaskCompletionSource, AsyncLocal<int> Depth) isLoadingValue;
+        TaskCompletionSource isLoadingValue;
 
         lock (_isLoadingLock)
         {
             ref var refIsLoadingValue = ref CollectionsMarshal.GetValueRefOrAddDefault(_isLoading, navEntry, out exists);
             if (!exists)
             {
-                refIsLoadingValue = (new(), new());
+                refIsLoadingValue = new();
             }
+            s_isLoadingCallDepth.Value++;
path: src/EFCore/Infrastructure/Internal/LazyLoader.cs
user: login: AndriySvyryd
body: ```suggestion
            _isLoadingCallDepth.Value++;
```
created_at: 2025-03-24T22:35:26Z
author_association: MEMBER
diff_hunk: @@ -156,7 +158,7 @@ public virtual void Load(object entity, [CallerMemberName] string navigationName
         }
         finally
         {
-            isLoadingValue.TaskCompletionSource.TrySetResult();
+            isLoadingValue.TrySetResult();
path: src/EFCore/Infrastructure/Internal/LazyLoader.cs
user: login: AndriySvyryd
body: ```suggestion
            isLoadingValue.TrySetResult();
            s_isLoadingCallDepth.Value--;
```
created_at: 2025-03-24T22:36:06Z
author_association: MEMBER

PR comments:
user: login: roji
created_at: 2025-03-24T08:07:30Z
body: @henriquewr I'm not very familiar with that particular part of the code, but async code is executed across different threads - every time the code awaits, it may continue executing on a different thread. So checking the thread ID is probably wrong here - the AsyncLocal is being used to propagate the state across different threads.

But I may be misunderstanding things here. @AndriySvyryd assigning this (and #35832) to you as you know the area best.
author_association: MEMBER
user: login: AndriySvyryd
created_at: 2025-03-24T16:41:50Z
body: I agree with @roji, for recursive lazy loading async calls the thread might change between the outer call and the nested call leading to a deadlock. If the issue is indeed caused by `AsyncLocal` allocations, consider using a static instance for the case when there's only one entry in `_isLoading`.
author_association: MEMBER
user: login: henriquewr
created_at: 2025-03-24T22:13:20Z
body: @AndriySvyryd 
I removed the threadId and now im using the static AsyncLocal
I didn't realize until now that I don't need to create a new AsyncLocal instance in order to get a unique value on this context


author_association: CONTRIBUTOR
user: login: AndriySvyryd
created_at: 2025-03-24T22:42:16Z
body: With this approach there still might be a deadlock issue with different lazy loaded navs trying to load the same nav in a nested call, but this use case is probably so rare that we don't need to worry about it.
author_association: MEMBER
user: login: AndriySvyryd
created_at: 2025-03-25T21:40:26Z
body: Thanks for your contribution!
author_association: MEMBER
user: login: OwnageIsMagic
created_at: 2025-05-26T07:40:26Z
body: @maumar the resulting diff doesn't use ThreadId and this wrong title propagated to net10 release notes. 
author_association: NONE
user: login: stevendarby
created_at: 2025-06-16T18:22:46Z
body: Came here from the release notes too
author_association: CONTRIBUTOR
user: login: roji
created_at: 2025-06-16T18:38:04Z
body: Corrected the PR title and submitted https://github.com/dotnet/EntityFramework.Docs/pull/5054 to fix the what's new docs.
author_association: MEMBER