delphisynchronizationdelphi-2009

Behaviour of TMultiReadExclusiveWriteSynchronizer when promoting read-lock to write-lock


How can I achieve a synchronization structure like that:

Lock.BeginRead
try
  if Changed then
    begin
    Lock.BeginWrite;
    try
      Update;
    finally
      Lock.EndWrite;
    end;
    // ... do some other stuff ...
    end;
finally
  Lock.EndRead;
end;

without loosing the read lock after the EndWrite, so that no other writers can execute while this code block is executed.

How does Delphi 2009's TMuliReadExclusiveWriteSynchronizer behave in this case?


Solution

  • It seems there are two criteria wrapped up in this question:

    I will not address the first point further since others have already done so. However the second point is very delicate and needs explanation.

    First of all, let me say I am referring to Delphi 2007. I do not have access to 2009. However it is unlikely that the behavior I'm describing would have changed.

    The code you shows does make it possible for other writers to change the value during the code block. When the read lock is promoted to a write lock, the read lock is temporarily lost. There is an instant of time when your thread has neither a read or write lock. This is by design, since otherwise deadlock would be almost certain. If the thread which is promoting a read lock to a write lock actually held the read lock while doing so, the following scenario could quite easily occur:

    1. (thread 1) get read lock
    2. (thread 2) get read lock (ok, read lock is shared)
    3. (thread 1) get write lock (blocks; thread 2 has a read lock)
    4. (thread 2) get write lock (blocks; thread 1 has a read lock--now deadlocked)

    To prevent this, TMuliReadExclusiveWriteSynchronizer releases the read lock for some "instant" before obtaining the write lock.

    (Side note: The article Working with TMultiReadExclusiveWriteSynchronizer on EDN, in the section "Lock it up Chris, I'm about to..." seems to incorrectly suggest that the scenario I just mentioned actually would deadlock. This could have been written about a prior version of Delphi or it might just be mistaken. Or I might be misunderstanding what it is claiming. However look at some of the comments on the article.)

    So, not assuming anything more about the context, the code you have shown is almost certainly incorrect. Checking a value while you have a read lock, then promoting it to a write lock and assuming the value has not changed is a mistake. This is a very subtle catch with TMuliReadExclusiveWriteSynchronizer.

    Here are a few selected parts of the comments in the Delphi library code:

    Other threads have an opportunity to modify the protected resource when you call BeginWrite before you are granted the write lock, even if you already have a read lock open. Best policy is not to retain any info about the protected resource (such as count or size) across a write lock. Always reacquire samples of the protected resource after acquiring or releasing a write lock. The function result of BeginWrite indicates whether another thread got the write lock while the current thread was waiting for the write lock. Return value of True means that the write lock was acquired without any intervening modifications by other threads. Return value of False means another thread got the write lock while you were waiting, so the resource protected by the MREWS object should be considered modified. Any samples of the protected resource should be discarded. In general, it's better to just always reacquire samples of the protected resource after obtaining a write lock. The boolean result of BeginWrite and the RevisionLevel property help cases where reacquiring the samples is computationally expensive or time consuming.

    Here is some code to try. Create a global TMultiReadExclusiveWriteSynchronizer named Lock. Create two global Booleans: Bad and GlobalB. Then start one instance of each of these threads and monitor the value of Bad from your main program thread.

    type
      TToggleThread = class(TThread)
      protected
        procedure Execute; override;
      end;
    
      TTestThread = class(TThread)
      protected
        procedure Execute; override;
      end;
    
    { TToggleThread }
    
    procedure TToggleThread.Execute;
    begin
      while not Terminated do
      begin
        Lock.BeginWrite;
        try
          GlobalB := not GlobalB;
        finally
          Lock.EndWrite;
        end;
      end;
    end;
    
    { TTestThread }
    
    procedure TTestThread.Execute;
    begin
      while not Terminated do
      begin
        Lock.BeginRead;
        try
          if GlobalB then
          begin
            Lock.BeginWrite;
            try
              if not GlobalB then
              begin
                Bad := True;
                Break;
              end;
            finally
              Lock.EndWrite;
            end;
          end;
        finally
          Lock.EndRead;
        end;
      end;
    end;
    

    Although it is non-deterministic, you will probably see very quickly (less than 1 second) that the value Bad gets set to True. So basically you see the value of GlobalB is True and then when you check it a second time it is False, even though both checks occurred between a BeginRead/EndRead pair (and the reason is because there was also a BeginWrite/EndWrite pair inside).

    My personal advice: Just never promote a read lock to a write lock. It is way too easy to get it wrong. In any case, you never are really promoting a read lock to a write lock (because you temporarily lose the read lock), so you may as well make it explicit in the code by just calling EndRead before BeginWrite. And yes, that means that you'd have to check the condition again inside the BeginWrite. So in the case of the code you showed originally, I would not even bother with a read lock at all. Just start with BeginWrite because it may decide to write.