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.

Shared reading and controlled exclusive access to a common resource.
Shared reading and controlled exclusive access to a common resource.
💡 In short. Use TReadWriteLock's upgradeable read mode to inspect shared state and promote to a write lock without a release-and-recheck gap.

1. Start from an exclusive lock

The simplest correct way to check and then modify is to take the write lock around both steps.

Pascal
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.

Pascal
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.

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.

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, TryAcquireReadLock and ReleaseReadLock.
  • Upgradeable read can be held together with ordinary readers only, so there is at most one upgradeable reader. Its methods are AcquireUpgradeableReadLock, TryAcquireUpgradeableReadLock and ReleaseUpgradeableReadLock.
  • Write is exclusive. Its methods are AcquireWriteLock, TryAcquireWriteLock and ReleaseWriteLock.

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 raises ELockRecursionException. Pass lrpSupportsRecursion to Create to allow nested acquisitions.
  • Disposal requires an idle lock. Dispose raises if any thread still owns or waits for the lock. Stop and join worker threads before freeing it.
â„šī¸ Everything that runs under a read or upgradeable read must really be read-only. A lazy cache fill or a shared cursor movement inside a "lookup" is a write and needs the write lock.

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.

sample.zip
Download the complete worked example and build instructions (ZIP).

For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .

Author: Herman Schoenfeld