Подвійна перевірка блокування () — у програмній інженерії це шаблон проєктування програмного забезпечення, який використовується для зменшення накладних витрат на захоплення ресурсу шляхом перевірки умови блокування («підказки блокування», ) перед захопленням ресурсу. Блокування виконується лише тоді, коли перевірка умови блокування показує, що воно є необхідним.Schmidt, D et al. Pattern-Oriented Software Architecture Vol 2, 2000, pp. 353–363.)
Початкова форма цього шаблону, наведена в книзі Pattern Languages of Program Design 3, містить стани гонитви залежно від використовуваної моделі пам'яті, і її важко правильно реалізувати. Деякі автори вважають її антипатерном. Існують і коректні варіанти цього шаблону, зокрема із використанням ключового слова volatile у Java та явних бар'єрів пам'яті в C++.David Bacon et al. The "Double-Checked Locking is Broken" Declaration.
Цей шаблон зазвичай застосовують для зменшення накладних витрат на блокування під час реалізації лінивої ініціалізації у багатопотоковому середовищі, особливо як складову шаблону «Одинак». Лінива ініціалізація дає змогу уникнути ініціалізації значення до моменту першого звернення до нього.
Мотивація та початковий шаблон Розглянемо приклад на мові Java: // Single-threaded version public class Foo { private static Bar bar;
public Bar bar() { if (bar == null) { bar = new Bar();
return bar;
// other functions and members... }
Але такий підхід не працює в багатопотоковому середовищі. Необхідно використовувати блокування на випадок, якщо два потоки одночасно викличуть bar(). Інакше обидва потоки можуть одночасно спробувати створити об'єкт, або один із них може отримати посилання на ще не повністю ініціалізований об'єкт.
Цю проблему можна усунути, синхронізувавши доступ за допомогою блокування, як показано в наступному прикладі: // Correct but possibly expensive multithreaded version public class Foo { private Bar bar;
public synchronized Bar bar() { if (bar == null) { bar = new Bar();
return bar;
// other functions and members... }
Цей варіант є правильним і, найімовірніше, забезпечує достатню продуктивність. Проте лише перший виклик bar() створює об'єкт, і лише кілька потоків, які намагаються отримати до нього доступ у цей момент, потребують synchronized; після цього всі наступні виклики просто отримують посилання на змінну-член. Оскільки синхронізація методу в деяких крайніх випадках може знизити продуктивність у 100 і більше разів, накладні витрати на захоплення та звільнення блокування під час кожного виклику цього методу після завершення ініціалізації здаються зайвими. Багато програмістів, зокрема й автори цього шаблону, намагалися оптимізувати цю ситуацію таким чином: Перевірити, чи ініціалізовано змінну (не захоплюючи блокування). Якщо її вже ініціалізовано, негайно повернути її. Захопити блокування. Повторно перевірити, чи змінну вже ініціалізовано: якщо інший потік першим захопив блокування, він міг уже виконати ініціалізацію. Якщо так, повернути вже ініціалізовану змінну. Інакше ініціалізувати змінну та повернути її. // Broken multithreaded version // original "Double-Checked Locking" idiom public class Foo { private Bar bar;
public Bar bar() { if (bar == null) { synchronized (this) { if (bar == null) { bar = new Bar();
return bar;
// other functions and members... }
Потік A виявляє, що значення ще не ініціалізовано, тому захоплює блокування і починає його ініціалізацію. Через семантику деяких мов програмування код, згенерований компілятором, може оновити спільну змінну так, щоб вона вказувала на частково сконструйований об'єкт, ще до того, як потік A завершить його ініціалізацію. Наприклад, у Java, якщо виклик конструктора було вбудовано (), то спільну змінну можуть оновити відразу після виділення пам'яті, але ще до того, як вбудований конструктор ініціалізує об'єкт.
Потік B виявляє, що спільну змінну вже ініціалізовано (або принаймні так виглядає), і повертає її значення. Оскільки потік B вважає, що значення вже ініціалізовано, він не захоплює блокування. Якщо B використовує об'єкт до того, як побачить усі результати ініціалізації, виконаної потоком A (або тому, що A ще не завершив ініціалізацію, або тому, що деякі вже ініціалізовані значення об'єкта ще не стали видимими в пам'яті, яку використовує B через когерентність кешу), програма, найімовірніше, аварійно завершиться.
Більшість середовищ виконання мають бар'єри пам'яті або інші механізми керування видимістю пам'яті між одиницями виконання. Без детального розуміння поведінки мови програмування в цій сфері правильно реалізувати цей алгоритм дуже складно. Однією з небезпек використання подвійно перевіреного блокування є те, що навіть наївна реалізація більшість часу виглядатиме працездатною: нелегко відрізнити правильну реалізацію цього прийому від такої, що містить приховані помилки. Залежно від компілятора, чергування потоків, яке виконує планувальник, а також характеру іншої паралельної активності системи, збої, спричинені неправильною реалізацією подвійно перевіреного блокування, можуть виникати лише час від часу. Відтворити такі збої може бути складно.
Використання в C++ Для шаблону «Одинак» така перевірка не потрібна:
Singleton& instance() { static Singleton s; return s; }
C++11 і вище надають такі засоби подвійної перевірки блокування як std::once_flag та std::call_once: import std;
using std::once_flag; using std::optional;
class Singleton { private: Singleton() = default;
static optional<Singleton> inst; static once_flag flag; public: static Singleton* instance() { std::call_once( Singleton::flag, [] -> void { inst.emplace(Singleton()); } ); return &inst;
Якщо потрібно вручну реалізувати явний і коректний варіант ідіоми подвійно перевіреного блокування замість наведеного вище простого прикладу, який працює без додаткових зусиль (наприклад, тому що до випуску 2015 року Visual Studio не реалізовувала положення стандарту C++11 щодо паралельної ініціалізації, наведені вище), необхідно щонайменше використовувати атомарні операції з порядком доступу до пам'яті (memory ordering) типу acquire/release, як у наведеному нижче коді (явні інструкції бар'єра пам'яті також працюють, але можуть використовувати повільніші інструкції процесора, наприклад на ARM64):Double-Checked Locking is Fixed In C++11 import std;
using std::atomic; using std::lock_guard; using std::memory_order; using std::mutex;
class Singleton { private: Singleton() = default;
static atomic<Singleton*> inst; static mutex m; public: static Singleton* instance() { Singleton* s = inst.load(memory_order::acquire); // First check if (!s) { lock_guard<mutex> lock(m); s = inst.load(memory_order::relaxed); // Second (double) check if (!s) { s = new Singleton(); inst.store(s, memory_order::release);
return s;
~Singleton() { // cleanup logic
Використання в 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, ключове слово volatile призначене для створення бар'єра. Це дозволяє бути впевненим, що багато потоків правильно звертатимуться до шаблону «Одинак». Це описано в та . // Works with acquire/release semantics for volatile in Java 1.5 and later // Broken under Java 1.4 and earlier semantics for volatile public class Foo { private volatile Bar bar;
public Bar bar() { Bar local = bar; if (bar == null) { synchronized (this) { local = bar; if (local == null) { bar = local = new Bar();
return local;
// other functions and members... }
Зверніть увагу на локальну змінну local, яка на перший погляд здається зайвою. Її призначення полягає в тому, що у випадках, коли bar уже ініціалізовано (тобто в більшості випадків), доступ до поля volatile виконується лише один раз (завдяки return local; замість return bar;), що може підвищити загальну продуктивність методу майже на 40 %.
У Java 9 було представлено клас VarHandle, який дає змогу використовувати послаблені атомарні операції () для доступу до полів. Це забезпечує дещо швидше читання на системах зі слабкими моделями пам'яті, але ціною складнішої реалізації та втрати послідовної узгодженості (доступ до полів більше не бере участі в порядку синхронізації — глобальному порядку доступу до полів volatile). import java.lang.invoke.MethodHandles; import java.lang.invoke.VarHandle;
// Works with acquire/release semantics for VarHandles introduced in Java 9 public class Foo { private volatile Bar bar; private static final VarHandle BAR;
private Bar getBarAcquire() { return (Bar) BAR.getAcquire(this);
private void setBarRelease(Bar value) { BAR.setRelease(this, value);
static { try { MethodHandles.Lookup lookup = MethodHandles.lookup(); BAR = lookup.findVarHandle(Foo.class, "bar", Bar.class); } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e);
public Bar bar() { Bar local = barAcquire(); if (local == null) { synchronized (this) { local = getBarAcquire(); if (local == null) { local = new Bar(); setBarRelease(local);
return local;
// other functions and members... }
Якщо допоміжний об'єкт є статичним (тобто існує один екземпляр на кожен завантажувач класів), альтернативою є ідіома відкладеної ініціалізації через клас-утримувач ().Brian Goetz et al. Java Concurrency in Practice, 2006, p. 348. (Див. лістинг 16.6 із раніше цитованої книги.) // Correct lazy initialization in Java public class Foo { private static class Holder { public static final Bar INSTANCE = new Bar();
public static Bar bar() { return Holder.INSTANCE;
Цей підхід ґрунтується на тому, що вкладені класи не завантажуються, доки на них не буде виконано посилання.
Семантику полів final у Java 5 можна використати для безпечної публікації допоміжного об'єкта без використання volatile: Javamemorymodel-discussion mailing list class FinalWrapper<T> { public final T value;
public FinalWrapper(T value) { this.value = value;
public class Foo { private FinalWrapper<Bar> bar;
public Bar bar() { FinalWrapper<Bar> temp = bar;
if (temp == null) { synchronized (this) { if (bar == null) { bar = new FinalWrapper<Bar>(new Bar());
temp = bar;
return temp.value;
Локальна змінна temp є необхідною для коректності: просте використання bar як для обох перевірок на null, так і для оператора повернення може призвести до помилки через перестановку операцій читання, дозволену моделлю пам'яті Java. Продуктивність цієї реалізації не обов'язково є кращою, ніж реалізації з використанням volatile.
Використання в C#
У .NET Framework 4.0 було представлено клас Lazy<T>, який за замовчуванням внутрішньо використовує подвійно перевірене блокування (режим LazyThreadSafetyMode.ExecutionAndPublication) для збереження або винятку, що виник під час створення об'єкта, або результату функції, переданої до Lazy<T>:
using System;
public class MySingleton { private static readonly Lazy<MySingleton> _mySingleton = new(() => new MySingleton());
private MySingleton() { }
public static MySingleton Instance => _mySingleton.Value; }
Див. також Test and test-and-set
Примітки
Посилання Issues with the double checked locking mechanism captured in Jeu George's Blogs