Двойная проверка блокировки
В обеспечения разработке программного блокировка с двойной проверкой (также известная как «оптимизация блокировки с двойной проверкой»). [1] ) — это шаблон проектирования программного обеспечения, используемый для снижения затрат на получение блокировки путем проверки критерия блокировки («подсказка блокировки») перед получением блокировки. Блокировка происходит только в том случае, если проверка критерия блокировки показывает, что блокировка требуется.
Исходная форма шаблона, появляющаяся в «Языках шаблонов проектирования программ 3» , [2] имеет гонки данных , в зависимости от используемой модели памяти , и это трудно сделать правильно. Некоторые считают это антипаттерном . [3] Существуют допустимые формы шаблона, включая использование volatile
ключевое слово в Java и явные барьеры памяти в C++. [4]
Этот шаблон обычно используется для уменьшения накладных расходов на блокировку при реализации « ленивой инициализации » в многопоточной среде, особенно как часть шаблона Singleton . Ленивая инициализация позволяет избежать инициализации значения до первого доступа к нему.
Мотивация и оригинальный узор
[ редактировать ]Рассмотрим, например, этот сегмент кода на языке программирования Java : [4]
// Single-threaded version
class Foo {
private static Helper helper;
public Helper getHelper() {
if (helper == null) {
helper = new Helper();
}
return helper;
}
// other functions and members...
}
Проблема в том, что это не работает при использовании нескольких потоков. Блокировка вызывают должна быть получена в случае, если два потока getHelper()
одновременно. В противном случае либо они оба попытаются создать объект одновременно, либо кто-то может получить ссылку на не полностью инициализированный объект.
Синхронизация с блокировкой может исправить это, как показано в следующем примере:
// Correct but possibly expensive multithreaded version
class Foo {
private Helper helper;
public synchronized Helper getHelper() {
if (helper == null) {
helper = new Helper();
}
return helper;
}
// other functions and members...
}
Это правильно и, скорее всего, будет иметь достаточную производительность. Однако первый звонок в getHelper()
создаст объект, и только несколько потоков, пытающихся получить к нему доступ в течение этого времени, должны быть синхронизированы; после этого все вызовы просто получают ссылку на переменную-член. Поскольку в некоторых крайних случаях синхронизация метода может снизить производительность в 100 и более раз, [5] накладные расходы на получение и освобождение блокировки каждый раз при вызове этого метода кажутся ненужными: как только инициализация будет завершена, получение и освобождение блокировок станут ненужными. Многие программисты, в том числе авторы шаблона проектирования блокировки с двойной проверкой, пытались оптимизировать эту ситуацию следующим образом:
- Убедитесь, что переменная инициализирована (без получения блокировки). Если он инициализирован, немедленно верните его.
- Получите замок.
- Дважды проверьте, была ли уже инициализирована переменная: если другой поток первым получил блокировку, возможно, он уже выполнил инициализацию. Если да, верните инициализированную переменную.
- В противном случае инициализируйте и верните переменную.
// Broken multithreaded version
// original "Double-Checked Locking" idiom
class Foo {
private Helper helper;
public Helper getHelper() {
if (helper == null) {
synchronized (this) {
if (helper == null) {
helper = new Helper();
}
}
}
return helper;
}
// other functions and members...
}
Интуитивно этот алгоритм является эффективным решением проблемы. Но если шаблон написан неаккуратно, в нем возникнет гонка данных . Например, рассмотрим следующую последовательность событий:
- Поток A замечает, что значение не инициализировано, поэтому получает блокировку и начинает инициализировать значение.
- Из-за семантики некоторых языков программирования код, сгенерированный компилятором, может обновлять общую переменную, чтобы она указывала на частично созданный объект, прежде чем A завершит выполнение инициализации. Например, в Java, если вызов конструктора был встроен, общая переменная может быть обновлена немедленно после выделения памяти, но до того, как встроенный конструктор инициализирует объект. [6]
- Поток B замечает, что общая переменная инициализирована (или так кажется), и возвращает ее значение. Поскольку поток B считает, что значение уже инициализировано, он не получает блокировку. Если B использует объект до того, как вся инициализация, выполненная A, будет увидена B (либо потому, что A не завершил его инициализацию, либо потому, что некоторые из инициализированных значений в объекте еще не просочились в память, которую использует B ( когерентность кэша )) , программа, скорее всего, выйдет из строя.
Большинство сред выполнения имеют барьеры памяти или другие методы управления видимостью памяти между исполнительными модулями. Без детального понимания поведения языка в этой области алгоритм сложно реализовать корректно. Одна из опасностей использования блокировки с двойной проверкой заключается в том, что даже простая реализация в большинстве случаев будет работать: нелегко отличить правильную реализацию метода от реализации, имеющей тонкие проблемы. В зависимости от компилятора , чередования потоков планировщиком и характера другой параллельной системной активности сбои, возникающие в результате неправильной реализации блокировки с двойной проверкой, могут возникать только время от времени. Воспроизвести неудачи может быть сложно.
Использование в C++11
[ редактировать ]Для шаблона Singleton блокировка с двойной проверкой не требуется:
Если элемент управления входит в объявление одновременно с инициализацией переменной, параллельное выполнение должно дождаться завершения инициализации.
— § 6.7 [stmt.dcl] стр. 4
Singleton& GetInstance() {
static Singleton s;
return s;
}
C++11 и более поздние версии также предоставляют встроенный шаблон блокировки с двойной проверкой в виде std::once_flag
и std::call_once
:
#include <mutex>
#include <optional> // Since C++17
// Singleton.h
class Singleton {
public:
static Singleton* GetInstance();
private:
Singleton() = default;
static std::optional<Singleton> s_instance;
static std::once_flag s_flag;
};
// Singleton.cpp
std::optional<Singleton> Singleton::s_instance;
std::once_flag Singleton::s_flag{};
Singleton* Singleton::GetInstance() {
std::call_once(Singleton::s_flag,
[]() { s_instance.emplace(Singleton{}); });
return &*s_instance;
}
Если кто-то действительно желает использовать идиому с двойной проверкой вместо тривиально работающего примера, приведенного выше (например, потому, что Visual Studio до выпуска 2015 года не реализовывал язык стандарта C++ 11 о параллельной инициализации, указанный выше [7] ), необходимо использовать заборы захвата и освобождения: [8]
#include <atomic>
#include <mutex>
class Singleton {
public:
static Singleton* GetInstance();
private:
Singleton() = default;
static std::atomic<Singleton*> s_instance;
static std::mutex s_mutex;
};
Singleton* Singleton::GetInstance() {
Singleton* p = s_instance.load(std::memory_order_acquire);
if (p == nullptr) { // 1st check
std::lock_guard<std::mutex> lock(s_mutex);
p = s_instance.load(std::memory_order_relaxed);
if (p == nullptr) { // 2nd (double) check
p = new Singleton();
s_instance.store(p, std::memory_order_release);
}
}
return p;
}
Использование в POSIX
[ редактировать ]pthread_once()
необходимо использовать
для инициализации кода библиотеки (или подмодуля), когда ее API не имеет специальной инициализации
процедуру необходимо вызывать в однопоточном режиме.
Использование в Go
[ редактировать ]package main
import "sync"
var arrOnce sync.Once
var arr []int
// getArr retrieves arr, lazily initializing on first call. Double-checked
// locking is implemented with the sync.Once library function. The first
// goroutine to win the race to call Do() will initialize the array, while
// others will block until Do() has completed. After Do has run, only a
// single atomic comparison will be required to get the array.
func getArr() []int {
arrOnce.Do(func() {
arr = []int{0, 1, 2}
})
return arr
}
func main() {
// thanks to double-checked locking, two goroutines attempting to getArr()
// will not cause double-initialization
go getArr()
go getArr()
}
Использование в Java
[ редактировать ]Начиная с J2SE 5.0 , ключевое слово Летучие определено для создания барьера памяти. Это позволяет найти решение, гарантирующее, что несколько потоков правильно обрабатывают одноэлементный экземпляр. Эта новая идиома описана в [3] и [4] .
// Works with acquire/release semantics for volatile in Java 1.5 and later
// Broken under Java 1.4 and earlier semantics for volatile
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = helper;
if (localRef == null) {
synchronized (this) {
localRef = helper;
if (localRef == null) {
helper = localRef = new Helper();
}
}
}
return localRef;
}
// other functions and members...
}
Обратите внимание на локальную переменную " localRef ", что кажется ненужным. Результатом этого является то, что в тех случаях, когда помощник уже инициализирован (т.е. большую часть времени), доступ к изменчивому полю осуществляется только один раз (из-за " вернуть локальную ссылку; " вместо " возвратный помощник; "), что может улучшить общую производительность метода на целых 40 процентов. [9]
В Java 9 появилась VarHandle
класс, который позволяет использовать расслабленную атомарность для доступа к полям, обеспечивая несколько более быстрое чтение на машинах со слабыми моделями памяти, за счет более сложной механики и потери последовательной согласованности (доступ к полям больше не участвует в порядке синхронизации, глобальном порядке доступ к изменчивым полям). [10]
// Works with acquire/release semantics for VarHandles introduced in Java 9
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = getHelperAcquire();
if (localRef == null) {
synchronized (this) {
localRef = getHelperAcquire();
if (localRef == null) {
localRef = new Helper();
setHelperRelease(localRef);
}
}
}
return localRef;
}
private static final VarHandle HELPER;
private Helper getHelperAcquire() {
return (Helper) HELPER.getAcquire(this);
}
private void setHelperRelease(Helper value) {
HELPER.setRelease(this, value);
}
static {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
HELPER = lookup.findVarHandle(Foo.class, "helper", Helper.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
// other functions and members...
}
Если вспомогательный объект является статическим (по одному на каждый загрузчик классов), альтернативой является идиома держателя инициализации по требованию. [11] (См. листинг 16.6. [12] из ранее цитированного текста.)
// Correct lazy initialization in Java
class Foo {
private static class HelperHolder {
public static final Helper helper = new Helper();
}
public static Helper getHelper() {
return HelperHolder.helper;
}
}
Это основано на том факте, что вложенные классы не загружаются до тех пор, пока на них не будет сделана ссылка.
Семантика Поле Final в Java 5 можно использовать для безопасной публикации вспомогательного объекта без использования изменчивый : [13]
public class FinalWrapper<T> {
public final T value;
public FinalWrapper(T value) {
this.value = value;
}
}
public class Foo {
private FinalWrapper<Helper> helperWrapper;
public Helper getHelper() {
FinalWrapper<Helper> tempWrapper = helperWrapper;
if (tempWrapper == null) {
synchronized (this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper<Helper>(new Helper());
}
tempWrapper = helperWrapper;
}
}
return tempWrapper.value;
}
}
Локальная переменная tempWrapper необходим для корректности: просто используя helperWrapper как для проверки null, так и для оператора return может завершиться неудачей из-за переупорядочения чтения, разрешенного в модели памяти Java. [14] Производительность этой реализации не обязательно лучше, чем у нестабильная реализация.
Использование в C#
[ редактировать ]В .NET Framework 4.0 Lazy<T>
был введен класс, который по умолчанию использует внутреннюю блокировку с двойной проверкой (режим ExecutionAndPublication) для хранения либо исключения, которое было создано во время построения, либо результата функции, которая была передана в Lazy<T>
: [15]
public class MySingleton
{
private static readonly Lazy<MySingleton> _mySingleton = new Lazy<MySingleton>(() => new MySingleton());
private MySingleton() { }
public static MySingleton Instance => _mySingleton.Value;
}
См. также
[ редактировать ]- Test и Test-and-set Идиомы для низкоуровневого механизма блокировки.
- Идиома держателя инициализации по требованию для потокобезопасной замены в Java.
Ссылки
[ редактировать ]- ^ Шмидт, Д. и др. Архитектура программного обеспечения, ориентированная на шаблоны, том 2, 2000, стр. 353-363.
- ^ Языки шаблонов проектирования программ. 3 (PDF) (Начдр. ред.). Ридинг, Массачусетс: Аддисон-Уэсли. 1998. ISBN 978-0201310115 .
- ^ Грегуар, Марк (24 февраля 2021 г.). Профессиональный С++ . Джон Уайли и сыновья. ISBN 978-1-119-69545-5 .
- ^ Перейти обратно: а б Дэвид Бэкон и др. Декларация «Двойная проверка блокировки нарушена» .
- ^ Бём, Ханс-Дж (июнь 2005 г.). «Потоки не могут быть реализованы как библиотека» (PDF) . Уведомления ACM SIGPLAN . 40 (6): 261–268. дои : 10.1145/1064978.1065042 . Архивировано из оригинала (PDF) 30 мая 2017 г. Проверено 12 августа 2014 г.
- ^ Хаггар, Питер (1 мая 2002 г.). «Блокировка с двойной проверкой и шаблон Singleton» . ИБМ. Архивировано из оригинала 27 октября 2017 г. Проверено 19 мая 2022 г.
- ^ «Поддержка функций C++11-14-17 (современный C++)» .
- ^ Блокировка с двойной проверкой исправлена в C++11.
- ^ Блох, Джошуа (2018). Эффективная Java (Третье изд.). Аддисон-Уэсли. п. 335. ИСБН 978-0-13-468599-1 .
На моей машине описанный выше метод примерно в 1,4 раза быстрее, чем очевидная версия без локальной переменной.
- ^ «Глава 17. Нитки и замки» . docs.oracle.com . Проверено 28 июля 2018 г.
- ^ Брайан Гетц и др. Параллелизм Java на практике, 2006, стр. 348
- ^ Гетц, Брайан; и др. «Параллелизм Java на практике – списки на веб-сайте» . Проверено 21 октября 2014 г.
- ^ [1] Список рассылки для обсуждения модели памяти Javamemory
- ^ [2] Мэнсон, Джереми (14 декабря 2008 г.). «Отложенная инициализация с гонкой по датам для повышения производительности — параллелизм Java (&c)» . Проверено 3 декабря 2016 г.
- ^ Альбахари, Джозеф (2010). «Поточность в C#: использование потоков» . Кратко о C# 4.0 . О'Рейли Медиа. ISBN 978-0-596-80095-6 .
Lazy<T>
на самом деле реализует […] блокировку с двойной проверкой. Блокировка с двойной проверкой выполняет дополнительное энергозависимое чтение, чтобы избежать затрат на получение блокировки, если объект уже инициализирован.
Внешние ссылки
[ редактировать ]- Проблемы с механизмом блокировки с двойной проверкой, описанные в блогах Джеу Джорджа.
- Описание блокировки с двойной проверкой из репозитория шаблонов Портленда.
- Описание «Блокировка с двойной проверкой сломана» из репозитория шаблонов Портленда.
- Статья « C++ и опасности двойной проверки блокировок » (475 КБ). Скотта Мейерса и Андрея Александреску
- Статья « Блокировка с двойной проверкой: умно, но сломано » Брайана Гетца
- Статья « Внимание! Потоки в многопроцессорном мире » Аллена Голуба
- Блокировка с двойной проверкой и шаблон Singleton
- Шаблон Singleton и безопасность потоков
- изменчивое ключевое слово в VC++ 2005
- Примеры Java и сроки принятия решений по блокировке с двойной проверкой
- «Более эффективный Java с Джошуа Блохом из Google» .