Deferred the Lock Option Until It Must Be Needed

The fact is locks are expensive. Mutexes are expensive even in the absence of contention. So, it is reasonable to avoid locks when the programmer ensures that a lock is not required. If a shared data structure is accessed exclusively by a single thread early in the frame, that same data is accessed again by a single thread later in the frame, then a lock can be avoided. If those two threads could overlap, a lock would definitely be needed. However, in practice, there exists a situation in which the programmer might know that such overlap can never occur.

There is an option that Gregory recommends in this kind of situation. Assertion that a lock is not required can be inserted. This has two benefits. First, it can be done very cheaply, and the assertions can be removed in release mode. Second, it automatically detects problems if this assumption about the overlap of the threads proves to be incorrect.

But how can it be detected that a lock is needed? The trick is to realize that detecting overlaps between critical operations on a shared object matters only. And that detection need not be 100% reliable. A 90% hit rate is probably just fine. If the programmer knows that the program can be run by a lot of developers and a QA department, someone will likely detect the problem. So, instead of atomic Boolean, a volatile Boolean is proper in this situation. The volatile keyword does not do much to prevent concurrent race bugs. But it guarantees that reads and writes of the Boolean will not be optimized away. This makes the detection have a reasonably good detection rate and very cheap as well. The following code has been tried and true to catch cases of critical operations overlapping.

  class UnnecessaryLock
  {
     volatile bool m_locked;

  public:
     void acquire()
     {
        assert( !m_locked ); // assert no one already has the lock

        m_locked = true; // it can detect overlapping critical operations if they happen
     }

     void release()
     {
        assert( m_locked ); // it ensures that release() is only called after acquire()

        m_locked = false; // unlock
     }
  }

  #if ASSERTIONS_ENABLED
  #define BEGIN_ASSERT_UNNECESSARY_LOCK(L) (L).acquire()
  #define END_ASSERT_UNNECESSARY_LOCK(L) (L).release()
  #else
  #define BEGIN_ASSERT_UNNECESSARY_LOCK(L)
  #define END_ASSERT_UNNECESSARY_LOCK(L)
  #endif

  // Eaxmple
  UnnecessaryLock g_lock;
  void doCriticalOperation()
  {
     BEGIN_ASSERT_UNNECESSARY_LOCK( g_lock );

     std::cout << "perform critical operations ..." << std::endl;

     END_ASSERT_UNNECESSARY_LOCK( g_lock );
  }

It is also possible to wrap the locks in a janitor.

  class UnnecessaryJanitor
  {
     UnnecessaryLock* m_pLock;

  public:
     explicit UnnecessaryJanitor(UnnecessaryLock& lock) : m_pLock( &lock ) { m_pLock->acquire(); }
     ~UnnecessaryJanitor() { m_pLock->release(); }
  }

  #if ASSERTIONS_ENABLED
  #define ASSERT_UNNECESSARY_LOCK(J,L) UnnecessaryJanitor J(L)
  #else
  #define ASSERT_UNNECESSARY_LOCK(J,L)
  #endif

  // Eaxmple
  UnnecessaryLock g_lock;
  void doCriticalOperation()
  {
     ASSERT_UNNECESSARY_LOCK( janitor, g_lock );

     std::cout << "perform critical operations ..." << std::endl;
  }

Reference

[1] J. Gregory, Game Engine Architecture, Third Edition, CRC Press


© 2024. All rights reserved.