В чем причина двойной NULL-проверки указателя для блокировки мьютекса?

Я недавно прочитал книгу о системном программном обеспечении. В этом есть пример, который я не понимаю.

volatile T* pInst = 0;
T* GetInstance()
{
  if (pInst == NULL)
  {
   lock();
   if (pInst == NULL)
     pInst = new T;
   unlock();
  }
  return pInst;
}

Почему автор проверяет (pInst == NULL) дважды?

Ответ 1

Когда два потока попробуют вызвать GetInstance() в первый раз одновременно, оба увидят pInst == NULL при первой проверке. Один поток сначала получает блокировку, что позволяет ему изменять pInst.

Второй поток будет ждать, пока блокировка не станет доступной. Когда первый поток снимает блокировку, второй получит ее, и теперь значение pInst уже было изменено первым потоком, поэтому второму не нужно создавать новый экземпляр.

Только вторая проверка между lock() и unlock() безопасна. Это будет работать без первой проверки, но будет медленнее, потому что каждый вызов GetInstance() будет вызывать lock() и unlock(). Первая проверка позволяет избежать ненужных вызовов lock().

volatile T* pInst = 0;
T* GetInstance()
{
  if (pInst == NULL) // unsafe check to avoid unnecessary and maybe slow lock()
  {
   lock(); // after this, only one thread can access pInst
   if (pInst == NULL) // check again because other thread may have modified it between first check and returning from lock()
     pInst = new T;
   unlock();
  }
  return pInst;
}

См. Также https://en.wikipedia.org/wiki/Double-checked_locking (скопировано из комментария между сумасшедшими).

Примечание: эта реализация требует, чтобы доступ чтения и записи к volatile T* pInst атомарным. В противном случае второй поток может прочитать частично записанное значение, просто записываемое первым потоком. Для современных процессоров доступ к значению указателя (а не к указанным данным) является атомарной операцией, хотя не гарантируется для всех архитектур.

Если доступ к pInst не был атомарным, второй поток может прочитать частично записанное не-NULL значение при проверке pInst перед получением блокировки, а затем может выполнить return pInst до того, как первый поток завершит свою работу, что приведет к возвращению неверного указателя значение.

Ответ 2

Я предполагаю, что lock() является дорогостоящей операцией. Я также предполагаю, что чтение на указателях T* на этой платформе выполняется атомарно, поэтому вам не нужно блокировать простые сравнения pInst == NULL, так как операция pInst значения pInst будет ex. единая инструкция по сборке на этой платформе.

Предполагая, что: если lock() является дорогостоящей операцией, лучше ее не выполнять, если нам это не нужно. Итак, сначала мы проверим, если pInst == NULL. Это будет отдельная инструкция по сборке, поэтому нам не нужно ее lock(). Если pInst == NULL, нам нужно изменить его значение, выделить новое pInst = new...

Но - представьте себе ситуацию, когда 2 (или более) потока находятся прямо в точке между первым pInst == NULL и прямо перед lock(). Оба потока будут pInst = new. Они уже проверили первый pInst == NULL и для них обоих это было правдой.

Первый (любой) поток запускает его выполнение и выполняет lock(); pInst = new T; unlock() lock(); pInst = new T; unlock() lock(); pInst = new T; unlock(). Затем второй поток, ожидающий lock() запускает его выполнение. Когда это начинается, pInst != NULL, потому что другой поток выделил это. Поэтому нам нужно проверить это pInst == NULL внутри lock() снова, чтобы память не просочилась и pInst перезаписан.

Ответ 3

Потому что вызов lock() может изменить значение pInst.

Объявление *pInst с определителем volatile указывает реализации C не переставлять порядок инструкций и следовать порядку оценки абстрактной машины C.

Не объявляя его изменчивым, реализация C может привести к тому, что он не будет мутирован внутри GetInstance и вставить только 1 проверку, пытаясь оптимизировать.