How to Use Upgradeable Read Locks in Delphi and Free Pascal
A common pattern in threaded code is to inspect shared state and then modify it only if something is missing. A read lock is enough for the inspection, but a read lock cannot become a write lock. Releasing it and taking a write lock leaves a gap in which another thread can make the same decision, so the check has to be repeated.
TReadWriteLock in Sphere 10 Pascal Framework provides a third mode for this case. An upgradeable read lets a thread inspect alongside ordinary readers while reserving the right to take the write lock without releasing first. This article explains the three modes, how promotion works and the ownership rules the lock enforces.
1. Start from an exclusive lock
The simplest correct way to check and then modify is to take the write lock around both steps.
function AddIfMissingExclusive(AGate: TReadWriteLock; AItems: TStringList; const AName: String): Integer;
begin
AGate.AcquireWriteLock;
try
Result := AItems.IndexOf(AName);
if Result < 0 then
Result := AItems.Add(AName);
finally
AGate.ReleaseWriteLock;
end;
end;
This excludes every reader on every call, including the calls that find the entry and change nothing.
2. Reserve promotion before inspecting shared state
The upgradeable version holds the upgradeable read for the inspection and acquires the write lock only when a change is needed.
function AddIfMissingUpgradeable(AGate: TReadWriteLock; AItems: TStringList; const AName: String): Integer;
begin
AGate.AcquireUpgradeableReadLock;
try
Result := AItems.IndexOf(AName);
if Result < 0 then begin
if not AGate.TryAcquireWriteLock(1000) then
raise Exception.Create('Write lock timed out');
try
Result := AItems.Add(AName);
finally
AGate.ReleaseWriteLock;
end;
end;
if AGate.IsWriteLockHeld or not AGate.IsUpgradeableReadLockHeld then
raise Exception.Create('Expected only the upgradeable read to remain held');
finally
AGate.ReleaseUpgradeableReadLock;
end;
end;
Promotion does not convert the upgradeable read. It adds a separate write acquisition on top of it, and each is released by its own call. The write request waits until the ordinary readers have left. No other thread can write or reserve promotion in the meantime, so the result of the earlier inspection is still valid when the write is granted.
Diagram source
---
config:
sequence:
actorMargin: 8
width: 96
mirrorActors: false
messageMargin: 28
noteMargin: 8
---
sequenceDiagram
participant R as Reader
participant L as TReadWriteLock
participant U as Upgrader
R->>L: AcquireReadLock
U->>L: upgradeable read
Note over L: both hold it
U->>L: TryAcquireWriteLock
Note over L: waits for reader
R->>L: ReleaseReadLock
L-->>U: write granted
The write must be released before the surrounding upgradeable read. After ReleaseWriteLock the thread is back in the upgradeable state, which the sample confirms through the IsWriteLockHeld and IsUpgradeableReadLockHeld properties.
The state diagram follows one thread. Unlocked, Upgradeable and Writing describe what that thread holds.
Diagram source
stateDiagram-v2
direction TB
[*] --> Unlocked
Unlocked --> Upgradeable: AcquireUpgradeableReadLock
Upgradeable --> Writing: AcquireWriteLock
Writing --> Upgradeable: ReleaseWriteLock
Upgradeable --> Unlocked: ReleaseUpgradeableReadLock
3. The three lock modes
TReadWriteLock lives in the UConcurrency unit and follows the ownership semantics of .NET's ReaderWriterLockSlim. Each mode has a blocking Acquire method, a timed TryAcquire method and a matching Release method.
-
Read can be held together with other readers and the one upgradeable reader. Its methods are
AcquireReadLock,TryAcquireReadLockandReleaseReadLock. -
Upgradeable read can be held together with ordinary readers only, so there is at most one upgradeable reader. Its methods are
AcquireUpgradeableReadLock,TryAcquireUpgradeableReadLockandReleaseUpgradeableReadLock. -
Write is exclusive. Its methods are
AcquireWriteLock,TryAcquireWriteLockandReleaseWriteLock.
Only one thread can hold the upgradeable read at a time. That restriction is what makes promotion safe. If two ordinary readers could both ask for the write lock, each would wait for the other to leave. The lock rejects that attempt with ELockRecursionException, so a thread that might write must choose the upgradeable mode before it starts reading.
4. Timeouts and waiting
Every TryAcquire method takes a timeout in milliseconds and returns False if the mode could not be granted in time.
| Timeout | Behaviour |
0 |
Tries once and returns immediately. |
| Positive | Waits up to that many milliseconds in total. |
-1 |
Waits indefinitely, like the Acquire method. |
A queued writer takes priority. Once a writer is waiting, new readers and new upgradeable readers wait behind it, while threads that already hold the lock can finish. The CurrentReadCount, WaitingReadCount, WaitingWriteCount and WaitingUpgradeCount properties expose the current state for diagnostics.
5. Keep ownership on the acquiring thread
The lock tracks ownership per thread and enforces the following rules.
-
Release on the acquiring thread. A release from a thread that does not hold that mode raises
ESynchronizationLockException. -
No recursion by default. The constructor's default policy is
lrpNoRecursion, under which acquiring a mode the thread already holds raisesELockRecursionException. PasslrpSupportsRecursiontoCreateto allow nested acquisitions. -
Disposal requires an idle lock.
Disposeraises if any thread still owns or waits for the lock. Stop and join worker threads before freeing it.
6. When to use the upgradeable mode
Choose the mode from what the code path can do. A path that always modifies should take the write lock directly, and a path that never modifies should take an ordinary read. The upgradeable read fits the path that usually only inspects.
Each mode also has a scope-returning form, EnterReadScope, EnterWriteScope and EnterUpgradeableReadScope, which releases automatically when the returned TScope goes out of scope. The
resource cleanup article
covers those. The full contract is in the
lock implementation
. Whether shared reads are faster than a plain critical section depends on the workload, so measure before relying on a speedup.
7. Get the Framework and run the example
Get Sphere 10 Pascal Framework and follow its package instructions . You can use the units directly or build the optional Lazarus or Delphi runtime package.
The sample runs both functions above and then has two threads call the upgradeable version 100 times each for the same name, which must add exactly one entry. It was compiled and run on Windows with Free Pascal 3.2.0 against
source revision
2cc6558
. Its Build.ps1 accepts the Framework, compiler and output paths.
Download the complete runnable sample.
For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .
Author: Herman Schoenfeld