Entity Framework — Concurrency Conflicts Handling
Originally published on Medium · July 31, 2023 — examples target the tooling of that time.
Any time multiple threads touch the same database row, file, or repository, you're one bad timing window away from a data corruption bug that's painful to reproduce. C# gives you two fundamentally different ways to deal with this: optimistic concurrency, which lets threads proceed and checks for conflicts afterward, and pessimistic concurrency, which locks the resource up front. I've used both in production, and this guide walks through how each one works with real code so you can pick the right tool for your situation.
What Concurrency Control Actually Means
Concurrency control refers to the methods employed to manage access to shared resources by multiple threads. It helps prevent data corruption, race conditions, and other synchronization-related issues. Two common approaches for concurrency control are optimistic concurrency and pessimistic concurrency.
Option 1 — Optimistic Concurrency
In EF Core, optimistic concurrency is a mechanism that assumes concurrency conflicts are relatively rare. Unlike pessimistic approaches that lock data before modification, optimistic concurrency avoids locks and instead detects if the data has changed since it was queried. If a concurrency conflict is detected during a save operation, the application is informed, allowing it to handle the situation, possibly by retrying the entire operation with the updated data.
To implement optimistic concurrency in EF Core, a property is designated as a concurrency token. This token is automatically updated in the database every time the corresponding row is modified. Let's consider a Payment entity type with a Version property as an example:
public class PaymentEntity
{
[Key]
public int Id { get; set; }
public decimal Amount { get; set; }
public Guid ExternalProviderId { get; set; }
[Timestamp]
public byte[] Version { get; set; }
}
The [Timestamp] attribute on the Version property configures it as the concurrency token. Now, let's see how this works with a simple update operation:
var payment = await _databaseContext.Payments.Single(x => x.Id == 1);
payment.Comment = comment;
await _databaseContext.SaveChanges();
When we execute this code, the following steps occur:
- The
Paymentrecord, including the concurrency token, is loaded from the database, and EF Core tracks it like any other property. - We modify the
Commentproperty of thePaymentinstance. - During the
SaveChanges()call, EF Core generates the following SQL statement:
UPDATE [Payment] SET [Comment] = @p0
WHERE [Id] = @p1 AND [Version] = @p2;
Note that the WHERE clause includes the Version column, ensuring that the row is only updated if the Version value matches the one read when the entity was loaded.
In the optimistic scenario, where no concurrent update occurs, the UPDATE completes successfully, and the row is modified. The database reports to EF Core that one row was affected by the update.
However, if a concurrent update happened before our update, the UPDATE operation will find no matching rows, and the database will report that zero rows were affected. In this case, EF Core's SaveChanges() throws a DbUpdateConcurrencyException, which the application should catch and handle appropriately.
Here is an example of how to handle the exception:
public async Task<bool> UpdatePayment(Guid id, string comment)
{
var payment = await _databaseContext.Payments
.SingleAsync(x => x.ExternalProviderId == id);
try
{
payment.Comment = comment;
await _databaseContext.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException ex)
{
_logger.LogError(ex, "Failed to update payment with Id - {id}, try again", id);
}
return false;
}
By utilizing optimistic concurrency, you can efficiently manage concurrent data modifications in your EF Core applications, promoting better performance and ensuring data integrity. Handling concurrency conflicts gracefully is essential for robust and reliable applications, and EF Core's optimistic concurrency mechanism provides a reliable means to achieve this.
Option 2 — Pessimistic Concurrency
Pessimistic concurrency, on the other hand, takes a more cautious approach. It assumes conflicts are likely to happen and uses locks to restrict access to a shared resource. When a thread wants to work with a resource, it requests a lock. If the lock is available, the thread proceeds with its operation; otherwise, it waits until the lock is released.
Two popular classes for implementing pessimistic concurrency in C# are SemaphoreSlim and DistributedLock. Let's explore how they work and how you can use them in your C# repository.
Using SemaphoreSlim for Pessimistic Concurrency
SemaphoreSlim is a lightweight synchronization primitive in C# that can be used to limit the number of threads that can access a resource concurrently. It's suitable for scenarios where you want to restrict the number of threads accessing your repository simultaneously.
public class PaymentsRepository : IPaymentsRepository
{
private readonly DatabaseContext _databaseContext;
private static SemaphoreSlim _semaphore;
private ILogger<PaymentRepository> _logger;
public PaymentsRepository(
DatabaseContext databaseContext,
ILogger<PaymentRepository> logger)
{
_databaseContext = databaseContext;
_logger = logger;
_semaphore = new SemaphoreSlim(1, 1);
}
public async Task Update(Guid id, string comment)
{
await _semaphore.WaitAsync();
try
{
var payment = await _databaseContext.Payments
.SingleAsync(x => x.ExternalProviderId == id);
payment.Comment = comment;
await _databaseContext.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update payment with Id {id}", id);
}
finally
{
_semaphore.Release();
}
}
}
In this example, we create a SemaphoreSlim with a maximum count of 1, meaning only one thread can acquire the semaphore at a time. The Update method acquires the semaphore before performing any repository operation and releases it once the operation is completed.
It's important to note that SemaphoreSlim provides a lock within a single application instance, making it less suitable for large-scale applications that require coordination across multiple instances or machines.
Using DistributedLock for Pessimistic Concurrency
DistributedLock is a more advanced form of pessimistic concurrency that can be used in distributed systems or scenarios where you want to manage locks across multiple processes or servers.
To use DistributedLock, you typically rely on external services like Redis. However, for this example, we will use a MySQL database as our distributed lock provider using the DistributedLock.Core NuGet package along with the DistributedLock.MySql NuGet package.
Let's see how you can implement pessimistic concurrency with DistributedLock in your ProductRepository.
First, install the required NuGet packages:
Install-Package DistributedLock.Core
Install-Package DistributedLock.MySql
Next, we will add this line to Program.cs.
builder.Services.AddSingleton<IDistributedLockProvider>(_ => new MySqlDistributedSynchronizationProvider(dbConnectionString!));
Next, update your PaymentRepository to use DistributedLock:
public class PaymentsRepository
{
private readonly DatabaseContext _databaseContext;
private ILogger<PaymentRepository> _logger;
private readonly IDistributedLockProvider _distributedLockProvider;
public PaymentsRepository(
DatabaseContext databaseContext,
ILogger<PaymentRepository> logger,
IDistributedLockProvider distributedLockProvider)
{
_databaseContext = databaseContext;
_logger = logger;
_distributedLockProvider = distributedLockProvider;
}
public async Task Update(Guid id, string comment)
{
var directDebitLock = _distributedLockProvider.CreateLock($"payment-update_{id}");
await using (await directDebitLock.AcquireAsync(TimeSpan.FromSeconds(1)))
{
var payment = await _databaseContext.Payments
.SingleAsync(x => x.ExternalProviderId == id);
payment.Comment = comment;
await _databaseContext.SaveChangesAsync();
}
}
}
In this example, we assume an IDistributedLockProvider interface that abstracts the distributed lock provider. The Update method acquires the distributed lock before performing any repository operation and releases it once the operation is completed.
Which Approach Should You Pick?
The choice between pessimistic and optimistic concurrency depends on your application's specific requirements and the level of concurrency you expect. Pessimistic concurrency with locks provides a more controlled approach and is suitable when conflicts are more likely to occur. Optimistic concurrency, on the other hand, allows higher parallelism and is more suitable for scenarios where conflicts are rare.
Conclusion
Thread safety is a crucial consideration when dealing with shared resources in multi-threaded applications. In this comprehensive guide, we explored both optimistic and pessimistic concurrency, providing code examples using SemaphoreSlim and DistributedLock to help you understand how to ensure thread safety in your C# repository.
By utilizing the appropriate concurrency control mechanisms, you can ensure that your C# repository functions correctly and efficiently in a multi-threaded environment, minimizing the risk of data corruption, race conditions, and other synchronization-related issues. A well-designed thread-safe repository is essential for building high-performance, scalable, and reliable multi-threaded applications. Choose the approach that best aligns with your application's needs, and remember that understanding and implementing concurrency control are vital skills for any C# developer.