.NET
How I Understand Concurrency in .NET
I thought concurrency just meant threads. It covers race conditions, deadlocks, transactions, and the lost update problem, and a C# lock does nothing to protect a database row.
When I first heard the word concurrency, I thought it simply meant using multiple threads.
After studying more about .NET and SQL Server, I learned that concurrency is a broader subject. It happens whenever multiple operations are in progress and may access or modify the same resources. Two threads changing the same variable. Two API requests updating the same database record. Two transactions waiting for resources held by each other. Two users editing the same information at nearly the same time.
As a junior developer I don't need to know every internal detail of threads, locks, and the SQL Server engine. I should understand the most common problems and the tools available to deal with them.
What concurrency actually means
Concurrency means multiple operations are making progress during the same period.
Imagine an ASP.NET Core API receives two requests at almost the same time:
Request A: Buy the last available product
Request B: Buy the last available product
Both requests may read that the product has one unit available. If both continue without any protection, they may both complete the purchase, and the system sells the same item twice.
That's why this matters. Code that works correctly with one user can produce unexpected results when several users hit it simultaneously.
Concurrency shows up at three levels: inside the .NET application when threads or tasks share data, inside SQL Server when transactions access the same rows or tables, and between application instances when the app runs on multiple servers or containers.
These levels are connected, but they aren't controlled the same way. A C# lock can protect memory inside one application instance. It cannot protect a SQL Server row from another application server.
Race conditions
A race condition happens when the result of an operation depends on which thread reaches a piece of code first.
Consider a simple counter:
public class RequestCounter
{
private int _count;
public void Increment()
{
_count++;
}
}
That _count++ looks like one operation, but internally it's three steps: read the current value, add one, store the new value.
Suppose the current value is 10. Two threads could both read 10, both calculate 11, and both store 11. The expected result was 12. The actual result is 11.
For simple numeric operations, .NET provides the Interlocked class:
using System.Threading;
public class RequestCounter
{
private int _count;
public void Increment()
{
Interlocked.Increment(ref _count);
}
}
Interlocked.Increment performs the increment as one atomic operation, so another thread can't interfere halfway through it.
Using lock in C#
For more complex operations, C# provides the lock statement:
public class BankAccount
{
private readonly object _balanceLock = new();
private decimal _balance;
public void Deposit(decimal amount)
{
lock (_balanceLock)
{
_balance += amount;
}
}
}
Only one thread can execute the protected section using that lock object at a time.
Adding locks everywhere isn't a good solution though. Locks reduce performance, make threads wait, increase complexity, introduce deadlocks, and can hide problems caused by poor design. Where possible it's better to avoid shared mutable state than to protect everything with locks.
A C# lock also only works inside the process that owns it. If an application runs on three servers, each server has its own lock object. Database concurrency has to be handled by the database or through another distributed mechanism.
Deadlocks
A deadlock occurs when two operations wait permanently for resources held by each other.
Thread A:
1. Locks Resource 1
2. Waits for Resource 2
Thread B:
1. Locks Resource 2
2. Waits for Resource 1
Neither thread can continue. The same thing happens in SQL Server:
Transaction A:
1. Updates Customer 1
2. Tries to update Customer 2
Transaction B:
1. Updates Customer 2
2. Tries to update Customer 1
SQL Server detects this cycle and picks one transaction as the deadlock victim. That transaction is rolled back so the other can continue. Applications should be ready to receive deadlock errors, and depending on the operation, retrying may be appropriate.
Some basic ways to reduce deadlocks: keep transactions short, access resources in a consistent order, avoid unnecessary operations inside transactions, create appropriate indexes, never wait for user input while a transaction is open, and implement retry logic for operations that are safe to retry.
Deadlocks can't always be eliminated in systems with high concurrency. The goal is to reduce how often they happen and handle them properly when they do.
Blocking is not a deadlock
I used to treat these as the same thing. They aren't.
Blocking happens when one operation waits because another holds a resource it needs:
Transaction A updates a product and keeps the transaction open.
Transaction B tries to update the same product.
Transaction B waits until Transaction A finishes.
That waiting can be completely normal. A deadlock requires a circular dependency, where A waits for B and B waits for A.
Blocking usually ends when the first transaction commits or rolls back. A deadlock can't resolve naturally, which is why SQL Server has to interrupt one of the transactions. Long-running transactions make blocking worse, because locks may be held until the transaction completes.
Why transactions matter
A database transaction groups several operations into one logical unit of work. The classic example is transferring money:
1. Remove $100 from Account A.
2. Add $100 to Account B.
Both should succeed or both should fail. Without a transaction, the first could succeed and the second could fail, and money disappears.
await using var transaction =
await dbContext.Database.BeginTransactionAsync();
try
{
sender.Balance -= 100m;
receiver.Balance += 100m;
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
EF Core already wraps the changes from a single SaveChanges call in a transaction when the provider supports it. Manual control matters more when one business operation involves multiple SaveChanges calls, raw SQL, or other operations that must succeed together.
ACID
A reliable transaction follows four properties.
Atomicity means the transaction is one unit. Either all operations happen or none do. SQL Server must not debit the sender without crediting the receiver.
Consistency means the transaction leaves the database in a valid state, with constraints and business rules still satisfied. A required field shouldn't become null, a foreign key should still point at an existing record, a balance shouldn't go negative when the business doesn't allow it.
Isolation controls how concurrent transactions observe each other's changes. Another request shouldn't see the money after it left the sender but before it reached the receiver.
Durability means that once a transaction commits, its changes survive even if the server crashes afterward. SQL Server uses its transaction log for this.
The lost update problem
This is the database concurrency problem I found easiest to understand.
Two employees open the same customer record, where the phone number is 1111. Employee A changes it to 2222. Employee B, still holding the old version, changes it to 3333. A saves first, then B saves.
Without concurrency checking, B's update overwrites A's, and A's change is lost with nobody warned about it.
Optimistic concurrency
Optimistic concurrency assumes conflicts are possible but uncommon. Instead of locking a record for the whole time someone is editing it, the application checks whether the record changed before saving.
Read a row and its version, let it be modified, send the update with the original version, update only when the version still matches, and report a conflict when it doesn't.
This suits web applications, because users don't hold database locks open while viewing and editing pages.
EF Core supports this by letting a property be configured as a concurrency token. During an update it includes the original token in the SQL WHERE clause. If another process modified the row, nothing matches, and EF Core throws a DbUpdateConcurrencyException.
SQL Server rowversion
SQL Server provides the rowversion type. Despite its old synonym timestamp, it isn't a date or a time. It's an automatically generated binary value that changes whenever the row is updated, which makes it useful for detecting whether a row changed since you read it.
using System.ComponentModel.DataAnnotations;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
The generated SQL follows this idea:
UPDATE Products
SET Price = @newPrice
WHERE Id = @id
AND RowVersion = @originalRowVersion;
If another request already updated the product, its RowVersion is different, the update affects zero rows, and EF Core detects the conflict.
try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException(
"This record was changed by another user. Reload it and try again.");
}
In a real API that becomes an appropriate HTTP response, usually 409 Conflict. The application can ask the user to reload, show the current values, let them choose which version to keep, merge compatible changes, or retry automatically where that's safe.
Pessimistic and optimistic
Concurrency strategies split into two general approaches.
Pessimistic concurrency assumes a conflict is likely, so the system locks the resource and other operations wait. Useful when conflicts would be expensive or operations must strictly coordinate, at the cost of more blocking and deadlock risk.
Optimistic concurrency assumes conflicts are uncommon. Operations proceed without a long lock, and the application verifies nothing changed before saving. rowversion and EF Core concurrency tokens are the common examples.
Neither is always better. It depends on the data, the expected number of conflicts, and the business rules.
Isolation levels
Isolation levels control how much one transaction is isolated from others. SQL Server's main levels are READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SNAPSHOT, and SERIALIZABLE.
The important lesson at my level is that stronger isolation gives greater consistency but increases blocking and reduces concurrency. Lower isolation improves concurrency but may let transactions observe data in ways some business operations can't accept.
SNAPSHOT and row-version-based options let reads use stored row versions instead of waiting for writers, which reduces read-and-write blocking. It adds other considerations, and it doesn't remove the need to understand update conflicts.
I wouldn't change the isolation level just because an application has blocking problems. The queries, indexes, transaction boundaries, and the actual business requirement all come first.
What I take from this
Don't assume one line means one atomic operation, because _count++ is three steps and can race.
Keep transactions short. A transaction should hold the database operations for one logical business action, and shouldn't stay open while calling external APIs, running long calculations, or waiting for user input.
Don't use a C# lock as a database lock. It protects memory inside one process and coordinates nothing across servers.
Expect conflicts. In a multi-user system, two requests updating the same record isn't an impossible edge case, and the application should decide what happens when it occurs.
Understand the business rule first, because the right solution depends on the operation. Updating a blog post title is not the same as reserving the last hotel room, processing a payment, reducing inventory, transferring money, or redeeming a one-use voucher. The more important the operation, the more carefully its concurrency behaviour has to be designed.
There's much more to learn here, including lock types, execution plans, deadlock graphs, retry strategies, and distributed systems. These foundations already help me write safer applications and spot problems that only appear when several requests run at the same time.
References
- Microsoft Learn, Managed Threading Best Practices: race conditions, synchronization, and
Interlocked - Microsoft Learn, Synchronizing Data for Multithreading
- Microsoft Learn, Deadlocks Guide for SQL Server
- Microsoft Learn, Transaction Locking and Row Versioning Guide
- Microsoft Learn, Handling Concurrency Conflicts in EF Core
- Microsoft Learn, SQL Server Value Generation in EF Core
- Microsoft Learn, Transactions in EF Core
- Microsoft Learn, SET TRANSACTION ISOLATION LEVEL