Tuesday, December 08, 2020

Readers-writer lock - Part 2: Implementation

In the previous installment I introduced the idea of a readers-writer lock. Today I'll look into readers-writer lock implementations (yes, multiple) that are available in the Delphi run-time library.

For a very long time (since Delphi 4), we had at our disposal unfortunately named TMultiReadExclusiveWriteSynchronizer. The important parts of this class which is defined in the System.SysUtils unit are shown below:

type
  TMultiReadExclusiveWriteSynchronizer = class(TInterfacedObject, IReadWriteSync)
  public
    constructor Create;
    destructor Destroy; override;
    procedure BeginRead;
    procedure EndRead;
    function BeginWrite: Boolean;
    procedure EndWrite;
    property RevisionLevel: Cardinal read FRevisionLevel;
  end;

This is how the class is implemented on Windows - and only on Windows. On all other platforms TMultiReadExclusiveWriteSynchronizer is simply an alias for TSimpleRWSync, which is not really a readers-writer lock, but a simple critical section. Because of that it doesn't allow multiple readers to work in parallel. Yes, the code that uses `TMultiReadExclusiveWriteSynchronizer` on a non-Windows platform will compile, but it will not reap any benefits from that.

type
  TMultiReadExclusiveWriteSynchronizer = TSimpleRWSync;

As it is really annoying to keep typing TMultiReadExclusiveWriteSynchronizer, Delphi also defines a nice short alias:

type
  TMREWSync = TMultiReadExclusiveWriteSynchronizer;  // short form

Since version 10.4.1, Delphi offers alternative readers-writer lock implementation TLightweightMREW, which is implemented in the System.SyncObjs unit:

type
  TLightweightMREW = record
  private
{$IFDEF MSWINDOWS}
    FNativeRW: SRWLOCK;
{$ENDIF MSWINDOWS}
{$IFDEF POSIX}
    FNativeRW: pthread_rwlock_t;
{$ENDIF POSIX}
  public
    class operator Initialize(out Dest: TLightweightMREW);
    procedure BeginRead;
    function TryBeginRead: Boolean; {$IF defined(LINUX) or defined(ANDROID)}overload;{$ENDIF}
{$IF defined(LINUX) or defined(ANDROID)}
    function TryBeginRead(Timeout: Cardinal): Boolean; overload;
{$ENDIF LINUX or ANDROID}
    procedure EndRead;
    procedure BeginWrite;
    function TryBeginWrite: Boolean; {$IF defined(LINUX) or defined(ANDROID)}overload;{$ENDIF}
{$IF defined(LINUX) or defined(ANDROID)}
    function TryBeginWrite(Timeout: Cardinal): Boolean; overload;
{$ENDIF LINUX or ANDROID}
    procedure EndWrite;
  end;

This implementation offers slightly richer interface than TMREWSync, but more on that later.

Let's look into similarities and differences between TMREWSync and TLightweightMREW.

  1. They both implement classical readers-write lock API. By calling BeginRead and EndRead a thread acquires and releases a shared read lock. Similary, a thread acquires and releases an exclusive write lock by calling BeginWrite and EndWrite.
  2. TLightweightMREW also implements functions that try to acquire a read or write access and return boolean status -  TryBeginRead and TryBeginWrite. On Android they optionally accept a Timeout parameter specifying maximum time in milliseconds the lock will try to acquire the access.
  3. TMREWSync is implemented as a class. To use it you have to create an instance of this class which you can then share between all interested parties. TLightweightMREW is implemented as a record. You cannot share it around; rather than that you should declare a field of this type inside a larger object which manages the access. This may look as a weird way to do things, but it makes the code faster because it prevents false sharing.
  4. TMREWSync implements readers-writer lock by using a combination of other synchronization primitives. On Windows it uses two events and on other plaforms one critical setion. TLightweightMREW, on the other hand, wraps underlying OS implementation. (That's why only the Android version supports the Timeout parameter.) It uses Slim Reader/Writer Lock on Windows and Posix pthread_rwlock family of functions on other platforms.
    • TMREWSync is much slower than TLightweightMREW and the main reason for that is exactly the usage of events. Windows events were designed to work across the process boundary (you can share an event between two or more processes) and are implemented inside the Windows kernel, which makes them much slower than slim reader/writer locks which run directly in the process' user code.
  5. In TMREWSync both BeginRead and BeginWrite are reentrant (recursive). If a thread already owns a lock, it can request access of the same type again and it will be granted. Each call to BeginXXX must of course be accompanied by the corresponding EndXXX. In TLightweightMREW only BeginRead is reentrant. If a thread that already owns a write lock calls BeginWrite again, it will deadlock (on Windows) or raise an exception (on other platforms).
  6. In TMREWSync read locks are upgradeable. If a thread owns a read lock, it can successfully request a write lock. (It must later release the write lock first and read lock second.) This would not work with TLightweightMREW and BeginWrite would deadlock/raise an exception.
  7. The TMREWSync is write-biased. If a thread tries to acquire write lock (but is currently unsuccessful in that as some other thread already holds a read lock), the TMREWSync denies all attempts to acquire further read locks. This prevents writer starvation - a situation in which a writer cannot get a lock because there is always at least one reader active. The TLightweightMREW, however, does no such thing. (We say it is read-biased.)

Let me end today's post with an interesting tidbit. The TLightweightMREW record is -- unless I missed something -- a very first custom managed record in the Delphi's runtime library.


No comments:

Post a Comment