Двойная проверка блокировки - Double-checked locking

В программная инженерия, двойная проверка блокировки (также известный как «оптимизация блокировки с двойной проверкой»[1]) это шаблон разработки программного обеспечения используется для уменьшения накладных расходов на приобретение замок проверяя критерий блокировки («подсказку блокировки») перед получением блокировки. Блокировка происходит только в том случае, если проверка критерия блокировки указывает, что блокировка требуется.

Шаблон, реализованный в некоторых сочетаниях языка и оборудования, может быть небезопасным. Иногда это можно считать антипаттерн.[2]

Обычно он используется для уменьшения накладных расходов на блокировку при реализации "ленивая инициализация "в многопоточной среде, особенно как часть Шаблон Singleton. Ленивая инициализация позволяет избежать инициализации значения до первого обращения к нему.

Использование в C ++ 11

Для шаблона singleton двойная проверка блокировки не требуется:

Если элемент управления входит в объявление одновременно во время инициализации переменной, параллельное выполнение должно ждать завершения инициализации.

— § 6.7 [stmt.dcl] p4
Синглтон& GetInstance() {  статический Синглтон s;  вернуть s;}

Если кто-то желает использовать идиому с двойной проверкой вместо тривиально работающего примера, приведенного выше (например, потому что Visual Studio до выпуска 2015 года не реализовывал язык стандарта C ++ 11 о параллельной инициализации, цитированный выше [3] ) необходимо использовать заборы захвата и снятия:[4]

#включают <atomic>#включают <mutex>класс Синглтон { общественный:  Синглтон* GetInstance(); частный:  Синглтон() = по умолчанию;  статический стандартное::атомный<Синглтон*> s_instance;  статический стандартное::мьютекс s_mutex;};Синглтон* Синглтон::GetInstance() {  Синглтон* п = s_instance.грузить(стандартное::memory_order_acquire);  если (п == nullptr) { // 1-я проверка    стандартное::lock_guard<стандартное::мьютекс> замок(s_mutex);    п = s_instance.грузить(стандартное::memory_order_relaxed);    если (п == nullptr) { // 2-я (двойная) проверка      п = новый Синглтон();      s_instance.магазин(п, стандартное::memory_order_release);    }  }  вернуть п;}

Использование в Голанге

пакет основнойимпорт "синхронизировать"вар arrOnce синхронизировать.однаждывар обр []int// getArr получает arr, лениво инициализируя его при первом вызове. Дважды проверенный// блокировка реализована с помощью библиотечной функции sync.Once. Первый// горутина для победы в гонке вызов Do () инициализирует массив, а// другие будут блокироваться до завершения Do (). После запуска Do только// для получения массива потребуется одиночное атомарное сравнение.func getArr() []int {	arrOnce.Делать(func() {		обр = []int{0, 1, 2}	})	вернуть обр}func основной() {	// благодаря блокировке с двойной проверкой две горутины пытаются выполнить getArr ()	// не вызовет двойной инициализации	идти getArr()	идти getArr()}

Использование в Java

Рассмотрим, например, этот сегмент кода в Язык программирования Java как дано [2] (как и все остальные сегменты кода Java):

// Однопоточная версиякласс Фу {    частный Помощник помощник;    общественный Помощник getHelper() {        если (помощник == значение NULL) {            помощник = новый Помощник();        }        вернуть помощник;    }    // другие функции и члены ...}

Проблема в том, что это не работает при использовании нескольких потоков. А замок должен быть получен в случае вызова двух потоков getHelper () одновременно. В противном случае либо они оба могут попытаться создать объект одновременно, либо один может получить ссылку на не полностью инициализированный объект.

Блокировка достигается дорогой синхронизацией, как показано в следующем примере.

// Правильная, но, возможно, дорогая многопоточная версиякласс Фу {    частный Помощник помощник;    общественный синхронизированный Помощник getHelper() {        если (помощник == значение NULL) {            помощник = новый Помощник();        }        вернуть помощник;    }    // другие функции и члены ...}

Однако первый звонок в getHelper () создаст объект, и только несколько потоков, пытающихся получить к нему доступ в течение этого времени, должны быть синхронизированы; после этого все вызовы просто получают ссылку на переменную-член. поскольку синхронизация метода может в некоторых крайних случаях снизить производительность в 100 раз или больше,[5] накладные расходы на получение и снятие блокировки каждый раз, когда вызывается этот метод, кажутся ненужными: после завершения инициализации получение и снятие блокировок будет казаться ненужным. Многие программисты пытались оптимизировать эту ситуацию следующим образом:

  1. Убедитесь, что переменная инициализирована (без получения блокировки). Если он инициализирован, немедленно верните его.
  2. Получите замок.
  3. Дважды проверьте, была ли переменная уже инициализирована: если другой поток первым получил блокировку, возможно, он уже выполнил инициализацию. Если да, верните инициализированную переменную.
  4. В противном случае инициализируйте и верните переменную.
// Неработающая многопоточная версия// Идиома "Double-Checked Locking"класс Фу {    частный Помощник помощник;    общественный Помощник getHelper() {        если (помощник == значение NULL) {            синхронизированный (этот) {                если (помощник == значение NULL) {                    помощник = новый Помощник();                }            }        }        вернуть помощник;    }    // другие функции и члены ...}

Интуитивно этот алгоритм кажется эффективным решением проблемы. Однако у этого метода есть много тонких проблем, и его обычно следует избегать. Например, рассмотрим следующую последовательность событий:

  1. Нить А замечает, что значение не инициализировано, поэтому получает блокировку и начинает инициализировать значение.
  2. Из-за семантики некоторых языков программирования код, сгенерированный компилятором, может обновлять общую переменную, чтобы она указывала на частично построенный объект перед А завершил выполнение инициализации. Например, в Java, если вызов конструктора был встроен, общая переменная может быть немедленно обновлена ​​после выделения хранилища, но до того, как встроенный конструктор инициализирует объект.[6]
  3. Нить B замечает, что общая переменная была инициализирована (или так кажется), и возвращает ее значение. Потому что нить B считает, что значение уже инициализировано, он не получает блокировку. Если B использует объект перед всей инициализацией, выполненной А видит B (либо потому что А не завершил инициализацию или потому что некоторые из инициализированных значений в объекте еще не проникли в память B использует (согласованность кеша )) программа скорее всего выйдет из строя.

Одна из опасностей использования блокировки с двойной проверкой J2SE 1.4 (и более ранние версии) заключается в том, что он часто оказывается работающим: нелегко отличить правильный реализация техники и тот, у которого есть тонкие проблемы. В зависимости от компилятор, чередование потоков планировщик и характер других одновременная системная активность, сбои в результате неправильной реализации блокировки с двойной проверкой могут возникать только периодически. Воспроизведение неудач может быть трудным.

По состоянию на J2SE 5.0, Эта проблема была исправлена. В летучий ключевое слово теперь гарантирует, что несколько потоков правильно обрабатывают экземпляр синглтона. Эта новая идиома описана в [3] и [4].

// Работает с семантикой получения / выпуска для volatile в Java 1.5 и новее// Не работает в семантике Java 1.4 и более ранних для volatileкласс Фу {    частный летучий Помощник помощник;    общественный Помощник getHelper() {        Помощник localRef = помощник;        если (localRef == значение NULL) {            синхронизированный (этот) {                localRef = помощник;                если (localRef == значение NULL) {                    помощник = localRef = новый Помощник();                }            }        }        вернуть localRef;    }    // другие функции и члены ...}

Обратите внимание на локальную переменную "localRef", что кажется ненужным. В результате в случаях, когда помощник уже инициализировано (т.е. большую часть времени), к полю volatile можно получить доступ только один раз (из-за "return localRef;" вместо того "вернуть помощника;"), что может улучшить общую производительность метода на 40 процентов.[7]

Java 9 представила VarHandle класс, который позволяет использовать ослабленную атомику для доступа к полям, обеспечивая несколько более быстрое чтение на машинах со слабыми моделями памяти, за счет более сложной механики и потери последовательной согласованности (доступ к полям больше не участвует в порядке синхронизации, глобальном порядке доступ к изменчивым полям).[8]

// Работает с семантикой получения / выпуска для VarHandles, представленных в Java 9класс Фу {    частный летучий Помощник помощник;    общественный Помощник getHelper() {        Помощник localRef = getHelperAcquire();        если (localRef == значение NULL) {            синхронизированный (этот) {                localRef = getHelperAcquire();                если (localRef == значение NULL) {                    localRef = новый Помощник();                    setHelperRelease(localRef);                }            }        }        вернуть localRef;    }    частный статический окончательный VarHandle ПОМОЩЬ;    частный Помощник getHelperAcquire() {        вернуть (Помощник) ПОМОЩЬ.getAcquire(этот);    }    частный пустота setHelperRelease(Помощник ценность) {        ПОМОЩЬ.setRelease(этот, ценность);    }    статический {        пытаться {            Метод: ручки.Искать искать = Метод: ручки.искать();            ПОМОЩЬ = искать.findVarHandle(Фу.класс, "помощник", Помощник.класс);        } ловить (ReflectiveOperationException е) {            бросить новый ExceptionInInitializerError(е);        }    }    // другие функции и члены ...}

Если вспомогательный объект является статическим (по одному на загрузчик классов), альтернативой является Идиома держателя инициализации по требованию[9] (См. Листинг 16.6.[10] из ранее процитированного текста.)

// Правильная отложенная инициализация в Javaкласс Фу {    частный статический класс HelperHolder {       общественный статический окончательный Помощник помощник = новый Помощник();    }    общественный статический Помощник getHelper() {        вернуть HelperHolder.помощник;    }}

Это основано на том факте, что вложенные классы не загружаются, пока на них нет ссылки.

Семантика окончательный поле в Java 5 можно использовать для безопасной публикации вспомогательного объекта без использования летучий:[11]

общественный класс FinalWrapper<Т> {    общественный окончательный Т ценность;    общественный FinalWrapper(Т ценность) {        этот.ценность = ценность;    }}общественный класс Фу {   частный FinalWrapper<Помощник> helperWrapper;   общественный Помощник getHelper() {      FinalWrapper<Помощник> tempWrapper = helperWrapper;      если (tempWrapper == значение NULL) {          синхронизированный (этот) {              если (helperWrapper == значение NULL) {                  helperWrapper = новый FinalWrapper<Помощник>(новый Помощник());              }              tempWrapper = helperWrapper;          }      }      вернуть tempWrapper.ценность;   }}

Локальная переменная tempWrapper требуется для корректности: просто используя helperWrapper как для нулевых проверок, так и для оператора return может произойти сбой из-за переупорядочения чтения, разрешенного в модели памяти Java.[12] Производительность этой реализации не обязательно лучше, чем у летучий реализация.

Использование в C #

Блокировка с двойной проверкой может быть эффективно реализована в .NET. Распространенным шаблоном использования является добавление блокировки с двойной проверкой к реализациям Singleton:

общественный класс MySingleton{    частный статический объект _myLock = новый объект();    частный статический MySingleton _mySingleton = значение NULL;    частный MySingleton() { }    общественный статический MySingleton GetInstance()    {        если (_mySingleton == значение NULL) // Первая проверка        {            замок (_myLock)            {                если (_mySingleton == значение NULL) // Вторая (двойная) проверка                {                    _mySingleton = новый MySingleton();                }            }        }        вернуть mySingleton;    }}

В этом примере «подсказка блокировки» - это объект mySingleton, который больше не является нулевым, когда он полностью построен и готов к использованию.

В .NET Framework 4.0 Ленивый был представлен класс, который по умолчанию использует блокировку с двойной проверкой (режим ExecutionAndPublication) для хранения либо исключения, которое было сгенерировано во время построения, либо результата функции, которая была передана в Ленивый :[13]

общественный класс MySingleton{    частный статический только чтение Ленивый<MySingleton> _mySingleton = новый Ленивый<MySingleton>(() => новый MySingleton());    частный MySingleton() { }    общественный статический MySingleton Пример => _mySingleton.Ценность;}

Смотрите также

использованная литература

  1. ^ Schmidt, D et al. Шаблонно-ориентированная архитектура программного обеспечения Том 2, 2000, стр. 353-363
  2. ^ а б Дэвид Бэкон и др. Декларация «Двойная проверка блокировки нарушена».
  3. ^ «Поддержка функций C ++ 11-14-17 (современный C ++)».
  4. ^ Двойная проверка блокировки исправлена ​​в C ++ 11
  5. ^ Бём, Ханс-Дж (июнь 2005 г.). «Потоки не могут быть реализованы как библиотека» (PDF). Уведомления ACM SIGPLAN. 40 (6): 261–268. Дои:10.1145/1064978.1065042.
  6. ^ Хаггар, Питер (1 мая 2002 г.). «Двойная проверка блокировки и шаблон Singleton». IBM.
  7. ^ Джошуа Блох "Эффективная Java, третье издание", стр. 372
  8. ^ «Глава 17. Потоки и замки». docs.oracle.com. Получено 2018-07-28.
  9. ^ Брайан Гетц и др. Параллелизм Java на практике, 2006, стр. 348
  10. ^ Гетц, Брайан; и другие. «Java Concurrency на практике - списки на сайте». Получено 21 октября 2014.
  11. ^ [1] Список рассылки Javamemorymodel-обсуждения
  12. ^ [2] Мэнсон, Джереми (2008-12-14). "Отложенная инициализация Date-Race-Ful для повышения производительности - параллелизм Java (и c)". Получено 3 декабря 2016.
  13. ^ Альбахари, Джозеф (2010). «Потоки в C #: Использование потоков». C # 4.0 в двух словах. O'Reilly Media. ISBN  978-0-596-80095-6. Ленивый фактически реализует […] блокировку с двойной проверкой. Блокировка с двойной проверкой выполняет дополнительное энергозависимое чтение, чтобы избежать затрат на получение блокировки, если объект уже инициализирован.

внешние ссылки