В чем причина двойной 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 проверку, пытаясь оптимизировать.