I have a problem with my ASP.NET Core application (.NET 7, EF Core 7, Npgsql PostgreSQL 7).
As an example, I wrote a simple web service that reproduces the problem:
In the controller in a loop we get the context from the ServiceProvider, we see a memory leak:
The same thing, but with the creation of scope - no memory leak:
Why is this happening? Is creating a scope the only correct solution?
Controller:
[ApiController]
[Route("[controller]/[action]")]
public class TestController : ControllerBase
{
private IServiceProvider ServiceProvider { get; }
public TestController(IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
}
private const int IterationsCount = 20_000;
[HttpGet]
public async Task<IActionResult> Leak()
{
for (var i = 0; i < IterationsCount; i++)
{
using var context = ServiceProvider.GetRequiredService<TestContext>();
var a = await context.TestEntitys.FirstOrDefaultAsync();
}
GC.Collect();
return Ok("There is leak");
}
[HttpGet]
public async Task<IActionResult> NoLeak()
{
for (var i = 0; i < IterationsCount; i++)
{
using var scope = ServiceProvider.CreateScope();
using var context = scope.ServiceProvider.GetRequiredService<TestContext>();
var a = await context.TestEntitys.FirstOrDefaultAsync();
}
GC.Collect();
return Ok("There is NO leak");
}
}
Context:
public class TestContext : DbContext
{
public TestContext(DbContextOptions<TestContext> options) : base(options)
{
}
public DbSet<TestEntity> TestEntitys { get; set; } = null!;
}
public class TestEntity
{
public int Id { get; set; }
}
Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<TestContext>(opts =>
{
var connectionString = builder.Configuration.GetConnectionString(typeof(TestContext).Name);
opts.UseNpgsql(connectionString).UseSnakeCaseNamingConvention();
}, ServiceLifetime.Transient, ServiceLifetime.Singleton);
var app = builder.Build();
var context = app.Services.GetRequiredService<TestContext>();
await context.Database.MigrateAsync();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers();
app.Run();
Project file:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>
Your two examples will have quite different behaviours based on the fact that you are scoping your DbContext as Transient.
In the first example using the common injected scope, if you have 20,000 iterations you will be instantiating 20,000 instances of the DbContext, each tracking the entity being loaded. Those contexts will not be collected until the request scope is collected. While you are disposing each DbContext instance within the loop, the lifetime scope still tracks a reference to Transient instances so the GC will only release them once the scope is disposed.
In the second example you are constructing and disposing an inner scope within each iteration. This means that in this case the outer scope is tracking references of each inner scope, but within each iteration a single DbContext instance is created, disposed, and can be collected as the scope within the iteration itself is disposed, releasing any references.
The memory use for the first example would more closely match the second if switched to using a shared lifetime scope rather than transient. There would be a difference in behavior though in that requests for the same item within the loop would return tracked instances unless queried with AsNoTracking
or disabling tracking on the DbContext.
Edit: Something to try to confirm the references being hung onto:
[HttpGet]
public async Task<IActionResult> NoLeak2()
{
using (var scope = ServiceProvider.CreateScope())
{
for (var i = 0; i < IterationsCount; i++)
{
using var context = scope.ServiceProvider.GetRequiredService<TestContext>();
var a = await context.TestEntitys.FirstOrDefaultAsync();
}
}
GC.Collect();
return Ok("There is NO leak?");
}
I suspect this will work with an explicitly scoped using() {}
for the scope, but if you were to use an implicitly scoped using();
or tried moving the GC.Collect();
and return
inside the using
scope you would see the same apparent memory usage if inspecting on a breakpoint.