Double-Check Locking
Definition
Section titled “Definition”The Double-Check Locking pattern optimizes concurrent access to a shared resource by checking the condition before and after acquiring a lock. The first check (without lock) serves as a fast-path for the nominal case (cache hit). The second check (after lock) protects against races.
Diagram
Section titled “Diagram”flowchart TD
REQ[GetOrAddAsync] --> C1{Check 1<br/>without lock}
C1 -->|hit| RET[Return value]
C1 -->|miss| ACQ[Acquire SemaphoreSlim]
ACQ --> C2{Check 2<br/>after lock}
C2 -->|hit| REL1[Release lock] --> RET
C2 -->|miss| FAC[Execute factory]
FAC --> SET[Store in cache]
SET --> REL2[Release lock] --> RET
style C1 fill:#2d5a27,color:#fff
style ACQ fill:#ff6b6b,color:#fff
style C2 fill:#4a9eff,color:#fff
Implementation in Granit
Section titled “Implementation in Granit”| Component | File | Lines |
|---|---|---|
DistributedCacheService.GetOrAddAsync() | src/Granit.Caching/DistributedCacheService.cs | 74-92 |
- Check 1 (line 74): cache read without lock — fast-path
- Acquire (line 78):
SemaphoreSlim.WaitAsync(cancellationToken) - Check 2 (line 82): re-read cache after lock
- Factory (line 86): execute factory if still a miss
- Set (line 89): store in cache
- Release (line 92):
SemaphoreSlim.Release()in afinally
Anti-stampede
Section titled “Anti-stampede”If 100 simultaneous requests have a cache miss:
- All 100 pass Check 1 (miss)
- 1 acquires the lock, 99 wait
- The first executes the factory and populates the cache
- The 99 pass Check 2 — cache hit, no factory call
Result: 1 single DB query instead of 100.
Rationale
Section titled “Rationale”Double-check locking is essential for feature resolution in a high-concurrency environment. Without protection, a simultaneous cache miss (cache expiration, restart) could overwhelm the database.
Usage example
Section titled “Usage example”// Double-check locking is internal -- the API is simpleICacheService<PatientDto> cache = provider.GetRequiredService<ICacheService<PatientDto>>();
// 100 simultaneous calls with the same cache key:// -> 1 DB query (the first one)// -> 99 responses from cache (after the lock)PatientDto patient = await cache.GetOrAddAsync( $"patient:{patientId}", async ct => await db.Patients.FindAsync([patientId], ct), cancellationToken);