| Lesson 5 | Application variables and the race condition |
| Objective | Explain race conditions in classic ASP and show modern, safe counters in ASP.NET (Core) with code. |
This page was originally a workflow page that linked out to several non-workflow articles. Those links have been commented out and the non-workflow content has been embedded inline. We keep the legacy classic ASP sequence (with images) as a historical artifact, but the solution section below uses ASP.NET Core patterns that avoid race conditions in single-server and multi-server deployments.
A race condition occurs when two or more requests attempt to read-modify-write the same shared value concurrently (e.g., a “current users online” counter). The sequence “read → +1 → write” is not atomic; parallel requests can overwrite each other and produce a wrong total.
UPDATE … SET n = n + 1, or Redis INCR), or enforce mutual exclusion (locks/semaphores) only for single-process scenarios like local dev.Interlocked + IMemoryCacheFor a single Kestrel process (e.g., development or a tiny site), keep the counter in memory and update it atomically:
// Startup: builder.Services.AddMemoryCache();
// Counter service
public sealed class LocalCounter
{
private int _count;
public int Increment() => Interlocked.Increment(ref _count);
public int Decrement() => Interlocked.Decrement(ref _count);
public int Value => Volatile.Read(ref _count);
}
// Register: builder.Services.AddSingleton<LocalCounter>();
Store the counter in a table and update it with a single SQL statement—this is atomic at the DB engine:
// Table
// CREATE TABLE SiteStats (Id int PRIMARY KEY, CurrentUsers int NOT NULL);
// INSERT INTO SiteStats(Id, CurrentUsers) VALUES (1, 0);
// EF Core service
public sealed class StatsService(AppDbContext db)
{
public async Task<int> IncrementAsync(CancellationToken ct = default)
{
await db.Database.ExecuteSqlRawAsync(
"UPDATE SiteStats SET CurrentUsers = CurrentUsers + 1 WHERE Id = 1;", ct);
return await db.SiteStats.Where(s => s.Id == 1)
.Select(s => s.CurrentUsers)
.SingleAsync(ct);
}
public async Task<int> DecrementAsync(CancellationToken ct = default)
{
await db.Database.ExecuteSqlRawAsync(
"UPDATE SiteStats SET CurrentUsers = CASE WHEN CurrentUsers > 0 THEN CurrentUsers - 1 ELSE 0 END WHERE Id = 1;", ct);
return await db.SiteStats.Where(s => s.Id == 1)
.Select(s => s.CurrentUsers)
.SingleAsync(ct);
}
}
INCR/DECRUse a distributed cache like Redis for true atomic increments across a farm:
// package: StackExchange.Redis
public sealed class RedisCounter(IConnectionMultiplexer mux)
{
private readonly IDatabase _db = mux.GetDatabase();
private const string Key = "stats:current-users";
public async Task<long> IncrementAsync() => await _db.StringIncrementAsync(Key);
public async Task<long> DecrementAsync() => await _db.StringDecrementAsync(Key);
public async Task<long> GetAsync() => (long)(await _db.StringGetAsync(Key));
}
Classic ASP had Session_OnStart/Session_OnEnd. ASP.NET Core does not raise identical lifecycle hooks, but you can approximate:
IncrementAsync().DecrementAsync().For simple session-based tracking with cookie sessions:
// Startup:
builder.Services.AddSession();
// Middleware:
app.Use(async (ctx, next) =>
{
ctx.Session.LoadAsync().Wait();
if (ctx.Session.GetString("counted") != "1")
{
var counter = ctx.RequestServices.GetRequiredService<RedisCounter>();
await counter.IncrementAsync();
ctx.Session.SetString("counted", "1");
}
await next();
});
Decrementing carefully: sessions can expire without a clean “end.” Consider a periodic reconciliation job that recomputes “currently online” by last-seen timestamps, or accept eventual accuracy rather than exactness.
In classic ASP, developers often stored a “current users” counter in Application("NumCurrUsers"). Without locking, concurrent requests could overwrite each other, producing a lower total than expected. The canonical fix used Application.Lock/Application.Unlock—safe only within a single IIS process.
Session_OnStart and Session_OnEnd procedures. Currently there are five users.
Session_OnStart reads NumCurrUsers = 5 and plans to write 6.
Locking the Application object serializes updates but does not scale across multiple worker processes or servers:
<%
Application.Lock
Application("NumCurrUsers") = Application("NumCurrUsers") + 1
Application.Unlock
%>
Interlocked on an in-memory integer (Option A).UPDATE (Option B) or Redis INCR (Option C).