How to execute queries in parallel using EF Core

How to execute queries in parallel using EF Core

Because the DbContext class in EF Core is not thread-safe, we must take steps to ensure thread-safety whenever we execute multiple queries concurrently. EF Core is Microsoft’s flagship ORM (object-relational mapper), the software layer that allows .NET developers to work with relational databases. The DbContext class is the core component of the EF Core framework for managing database operations. However, the DbContext class in EF Core is not thread-safe. Hence, if you share DbContext instances between multiple threads, you will often encounter data corruption issues and the InvalidOperationException. In this article, we’ll learn how we can execute queries in parallel in EF Core by handling thread-safety issues to avoid concurrency errors. To work with the code examples provided in this article, you should have Visual Studio 2026 installed in your system. You can download Visual Studio 2026 here. Executing EF Core queries in parallel – the problem When working in today’s data-driven applications, you will often need to fetch data from multiple unrelated datasets. In applications that use concurrency, thread-safety is critical to guaranteeing correct execution, avoiding data corruption and race conditions, and ensuring data consistency. Let’s understand this with an example. Let’s say we want to populate a dashboard that displays all recently processed orders, metrics, logs, and traces, as well as your application’s performance metadata. We might write the following code. public class Dashboard { public List Orders { get; set; } = new(); public Metrics Metrics { get; set; } = new(); public List Logs { get; set; } = new(); public List Traces { get; set; } = new(); } public static async Task LoadDashboardAsync(ProductService productService) { Task> ordersTask = productService.GetProcessedOrdersAsync(); Task metricsTask = productService.GetMetricsAsync(); Task> logsTask = productService.GetRecentLogsAsync(); Task> tracesTask = productService.GetTracesAsync(); await Task.WhenAll(ordersTask, metricsTask, logsTask, tracesTask); return new Dashboard { Orders = await ordersTask, Metrics = await metricsTask, Logs = await logsTask, Traces = await tracesTask }; } In the preceding code snippet, there are four read operations that are executed by four different Task instances. Our objective is to ensure that the database round trips run in parallel instead of in sequence. We can accomplish this by usingTask.WhenAll, which starts the four tasks, waits for every task to finish, then returns the data wrapped inside a new Dashboard instance. If we executed these queries sequentially, the user would have to wait until each query completed its execution in turn—for a total wait time equal to the sum of the times for all four queries. However, by running these queries in parallel, we reduce the wait time considerably. The user will need to wait only as long as it takes for the slowest of the four queries to complete its execution. However, there is a danger with the above approach. If you run multiple operations on the same DbContext instance, you will see an InvalidOperationException with the following message: A second operation started in this context before the previous operation was completed. This is usually caused by multiple threads using the same DbContext instance; instance members are not guaranteed to be thread-safe. Databases such as SQL Server, PostgreSQL, and Oracle Database follow a request-response communication model at the connection level: a single connection can process only one command at a time. Hence, you cannot run multiple queries concurrently using the connection. If you await several operations using the same connection, EF Core detects the overlapping use of a non-thread-safe context and throws an InvalidOperationException. To run queries in parallel, you must give each task its own connection or context. Why DbContext isn’t thread-safe – and how to work around it The DbContext class in EF Core is designed to manage a single unit of work. To be more precise, EF Core does not provide support for running multiple operations on the same DbContext instance. This design approach creates inherent challenges when you use the same DbContext instance across multiple threads. If DbContext were thread-safe, extensive locking would be required, which would degrade data access performance. This stateful design of DbContext makes it unsuitable for concurrent access patterns that involve loading, modifying, or tracking different sets of data simultaneously, because it needs to maintain the internal representation of database state. The change tracker is one of the most important components of DbContext in EF Core. It monitors all entities loaded into memory and detects any changes made to them after they have been loaded. It keeps track of the original, current, and changed values of the entities, thereby enabling the EF Core runtime to know the current state of these entities when you call the SaveChanges() method on the DbContext instance. To implement thread-safety when working with DbContext, we must write our code to ensure that each concurrent operation gets its own copy of a short-lived instance. Now, we could accomplish this by wrapping a shared DbContext instance inside a thread-safe block using the lock keyword, so that all calls to the database take place using one and only one thread at a time. This approach is illustrated in the code snippet below. using Microsoft.EntityFrameworkCore; public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } public int Quantity { get; set; } } public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Products => Set(); } However, while the above approach gives us the thread-safety we need, it can degrade data access performance considerably. A better approach is to use IDbContextFactory , which creates fresh DbContext instances on demand. Calling its CreateDbContext() method is cheap and produces a fresh, isolated context every time. The following code snippet shows how you can register an instance of type IDbContextFactory as a singleton. You can safely call this code from any thread. builder.Services.AddDbContextFactory(options => options.UseSqlServer( builder.Configuration.GetConnectionString("Default"))); Executing EF Core queries in parallel – the solution Now let’s see how we can put IDbContextFactory to work. The following code illustrates a class named ProductService that uses a factory to create DbContext instances for each scope of work. public class ProductService { private readonly IDbContextFactory _factory; public ProductService(IDbContextFactory factory) => _factory = factory; public async Task GetByIdAsync(int id) { await using var context = await _factory.CreateDbContextAsync(); return await context.Products.FindAsync(id); } public async Task UpdateStockQuantityAsync(int id, int updateQuantity) { await using var context = await _factory.CreateDbContextAsync(); var product = await context.Products.FindAsync(id); if (product is null) return; product.Quantity += updateQuantity; await context.SaveChangesAsync(); } } Note that ProductService has two methods, GetByIdAsync and UpdateStockQuantityAsync. An instance of the DbContext class is created locally in each of these methods. Now, suppose you have two threads, T1 and T2, that execute these methods concurrently. That is, thread T1 executes the GetByIdAsync method while thread T2 executes the UpdateStockQuantityAsync method. Because each of these methods is executed in isolation, they will have their own context, connection, and change-tracking information, and there will be no mutable state, so you don’t need to implement thread synchronization in either of these methods. Consider the following code that executes a read operation and an update operation in two separate tasks. public static async Task RunMethodsInParallelAsync(ProductService productService) { Task readTask = productService.GetByIdAsync(1); Task updateTask = productService.UpdateStockQuantityAsync(3, 5); await Task.WhenAll(readTask, updateTask); Product? product = await readTask; } The Task.WhenAll method runs the two tasks in parallel and waits until both have finished. The reason this approach is thread-safe, and will not create concurrency errors, is that each of these two methods creates its own DbContext instance internally. Therefore the read operation and the update operation use independent DbContext instances. Use DbContext pooling to reduce allocation cost Although creating DbContext instances is not that costly, you should consider using pooled contexts in applications that require high scalability and high performance. The following code snippet shows how you can register a pooled context. builder.Services.AddPooledDbContextFactory(options => options.UseSqlServer(connectionString)); A call to AddDbContext() will register a DbContext instance as scoped per HTTP request. Each request will run on a different thread and each will have its own context. However, keep in mind that the default scoped registration of the DbContext will not always suffice. You will need a factory to create instances of DbContext when you’re using a background service, or performing some work inside a particular request, or running some business logic operation over multiple contexts. Key takeaways If you use EF Core in the data access layer of your application, you must implement thread safety measures whenever you run your queries in parallel. You cannot execute multiple queries in parallel in EF Core using the same DbContext instance. The IDbContextFactory enables you to create a DbContext instance for each thread, thereby enabling you to work with these instances in isolation. Although using a DbContext pool involves a small allocation overhead, it becomes a non-issue if you need high throughput.

Original Source

Read the full article at Infoworld →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.