Введение
В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке. Конечно, существует ряд простых задач, для которых один поток более, чем достаточен. Однако так бывает далеко не всегда и большинство задач средней или высокой сложности так или иначе используют многопоточность. В этой статье я буду говорить об использовании синглтонов в многопоточной среде. Несмотря на кажущуюся простоту эта тема содержит множество нюансов и интересных вопросов, поэтому считаю, что она заслуживает отдельной статьи. Здесь не будет затрагиваться обсуждение того, зачем использовать синглтоны, а также как их правильно использовать. Для прояснения этих вопросов я рекомендую обратиться к моим предыдущим статьям, посвященным разным вопросам, связанным с синглтонами [1], [2], [3]. В этой статье речь будет идти о влиянии многопоточности на реализацию синглтонов и обсуждению вопросов, которые всплывают при разработке.
Постановка задачи
В предыдущих статьях была рассмотрена следующая реализация синглтона:
template<typename T>
T& single()
{
static T t;
return t;
}
Идея данной функции достаточно проста и незамысловата: для любого типа T мы можем создать экземпляр этого типа по требованию, т.е. «лениво», причем количество экземпляров, созданных этой функцией, не превышает значения 1. Если экземпляр нам не нужен, то проблем с точки зрения многопоточности (да и с точки зрения времени жизни и других проблем) вообще нет. Однако что произойдет, если в нашем многопоточном приложении одновременно 2 или более потоков захотят вызвать эту функцию с одним и тем же типом T?
Стандарт C++
Перед тем, как ответить на этот вопрос с практической точки зрения, предлагаю ознакомиться с теоретическим аспектом, т.е. ознакомимся со стандартом C++. На данный момент компиляторами поддерживается 2 стандарта: 2003 года и 2011 года.
$6.7.4, C++03
The zero-initialization (8.5) of all local objects with static storage duration (3.7.1) is performed before any other initialization takes place. A local object of POD type (3.9) with static storage duration initialized with constant-expressions is initialized before its block is first entered. An implementation is permitted to perform early initialization of other local objects with static storage duration under the same conditions that an implementation is permitted to statically initialize an object with static storage duration in namespace scope (3.6.2). Otherwise such an object is initialized the first time control passes through its declaration; such an object is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control re-enters the declaration (recursively) while the object is being initialized, the behavior is undefined.
$6.7.4, C++11
The zero-initialization (8.5) of all block-scope variables with static storage duration (3.7.1) or thread storage duration (3.7.2) is performed before any other initialization takes place. Constant initialization (3.6.2) of a block-scope entity with static storage duration, if applicable, is performed before its block is first entered. An implementation is permitted to perform early initialization of other block-scope variables with static or thread storage duration under the same conditions that an implementation is permitted to statically initialize a variable with static or thread storage duration in namespace scope (3.6.2). Otherwise such a variable is initialized the first time control passes through its declaration; such a variable is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization(*). If control re-enters the declaration recursively while the variable is being initialized, the behavior is undefined.
(*) The implementation must not introduce any deadlock around execution of the initializer
(выделено мной)
Если вкратце, то новый стандарт говорит о том, что если во время инициализации переменной (т.е. создания экземпляра) второй поток пытается получить доступ к этой же переменной, то он (поток) должен ожидать завершения инициализации, при этом реализация не должна допускать ситуации deadlock. В более раннем стандарте о многопоточности, как можно убедиться, не сказано ни слова.
Остается теперь выяснить, какие компиляторы действительно поддерживают новый стандарт, а какие лишь пытаются делать вид, что поддерживают. Для этого проведем следующий эксперимент.
Эксперимент
При использовании многопоточных примитивов я буду использовать фреймворк Ultimate++. Он достаточно легковесный и простой в использовании. В рамках данной статьи это не играет принципиального значения (можно, например, использовать boost).
Для нашего эксперимента напишем класс, создание которого занимает достаточно продолжительное время:
struct A
{
A()
{
Cout() << '{'; // маркер начала создания класса
Thread::Sleep(10); // ожидание 10 мс
if (++ x != 1)
Cout() << '2'; // маркер ошибки: повторная инициализация объекта
Cout() << '}'; // окончание создания класса
}
~A()
{
Cout() << '~'; // уничтожение класса
}
int x;
};
В начальный момент создания класса значение x равно 0, т.к. мы планируем его использовать только из синглтона, т.е. со словом static, при использовании которого все POD-типы будут проинициализированы значением 0. Затем мы ожидаем некоторое время, эмулируя длительность операции. В конце идет проверка на ожидаемое значение, если оно отличается от единицы, то выдаем сообщение об ошибке. Здесь я использовал вывод символов для того, чтобы более наглядно показать последовательность операций. Я специально не использовал сообщения, т.к. для этого потребовалась бы дополнительная синхронизация при многопоточном использовании, чего хотелось избежать.
Далее напишем функцию, вызываемую при создании новых потоков:
void threadFunction(int i)
{
Cout() << char('a'+i); // начало функции - маленькая буква английского алфавита, начиная с первой
A& a = single<A>(); // вызов нашего синглтона
if (a.x == 0)
Cout() << '0'; // маркер ошибки: синглтон не проинициализирован
Cout() << char('A'+i); // окончание функции - соответствующая большая буква
}
И будем вызывать функцию threadFunction одновременно из 5 потоков, тем самым эмулируя ситуацию, когда происходит конкурентный доступ к синглтону:
for (int i = 0; i < 5; ++ i)
Thread::Start(callback1(threadFunction, i));
Thread::ShutdownThreads();
Для проведения эксперимента я выбрал 2 достаточно популярных на сегодня компилятора: MSVC 2010 и GCC 4.5. Также проводилось тестирование с использованием компилятора MSVC 2012, результат полностью соответствовал версии 2010, поэтому в дальнейшем упоминание про него я опущу.
Результат запуска для GCC:
ab{cde}ABCDE~
Результат запуска для MSVC:
ab{0cB0dCe00DE}A~
Обсуждение результатов эксперимента
Обсудим полученные результаты. Для GCC происходит следующее:
- запуск функции threadFunction для потока 1
- запуск функции threadFunction для потока 2
- начало инициализации синглтона
- запуск функции threadFunction для потока 3
- запуск функции threadFunction для потока 4
- запуск функции threadFunction для потока 5
- завершение инициализации синглтона
- выход из функции threadFunction последовательно для всех потоков 1-5
- завершение программы и уничтожение синглтона
Здесь не происходит каких-либо неожиданностей: синглтон инициализируется только один раз и функция threadFunction завершает свою работу только после завершения инициализации синглтона => GCC корректно инициализирует объект в многопоточном окружении.
Ситуация с MSVC несколько иная:
- запуск функции threadFunction для потока 1
- запуск функции threadFunction для потока 2
- начало инициализации синглтона
- ошибка: синглтон не проинициализирован
- запуск функции threadFunction для потока 3
- выход из функции threadFunction для потока 2
- ошибка: синглтон не проинициализирован
- ...
- выход из функции threadFunction для потока 5
- завершение инициализации синглтона
- выход из функции threadFunction для потока 1
- завершение программы и уничтожение синглтона
В этом случае компилятор для первого потока начинает инициализировать синглтон, а для остальных — возвращает сразу объект, который даже не успел проинициализироваться. Таким образом, MSVC не обеспечивает правильную работу в многопоточной среде.
Анализ результатов эксперимента
Попытаемся разобраться, чем отличается результат, полученный рассмотренными компиляторами. Для этого скомпилируем и дизассемблируем код:
GCC:
5 T& single()
0x00418ad8 <+0>: push %ebp
0x00418ad9 <+1>: mov %esp,%ebp
0x00418adb <+3>: sub $0x28,%esp
6 {
7 static T t;
0x00418ade <+6>: cmpb $0x0,0x48e070
0x00418ae5 <+13>: je 0x418af0 <single<A>()+24>
0x00418ae7 <+15>: mov $0x49b780,%eax
0x00418aec <+20>: leave
0x00418aed <+21>: ret
0x00418af0 <+24>: movl $0x48e070,(%esp)
0x00418af7 <+31>: call 0x485470 <__cxa_guard_acquire>
0x00418afc <+36>: test %eax,%eax
0x00418afe <+38>: je 0x418ae7 <single<A>()+15>
0x00418b00 <+40>: movl $0x49b780,(%esp)
0x00418b07 <+47>: call 0x4195d8 <A::A()>
0x00418b0c <+52>: movl $0x48e070,(%esp)
0x00418b13 <+59>: call 0x4855cc <__cxa_guard_release>
0x00418b18 <+64>: movl $0x485f04,(%esp)
0x00418b1f <+71>: call 0x401000 <atexit>
8 return t;
9 }
0x00418b24 <+76>: mov $0x49b780,%eax
0x00418b29 <+81>: leave
0x00418b2a <+82>: ret
Видно, что перед тем, как вызвать конструктор объекта A, компилятор вставляет вызов функций синхронизации: __cxa_guard_acquire/__cxa_guard_release, что позволяет безопасно запускать функцию single одновременно при инициализации синглтона.
MSVC:
T& single()
{
00E51420 mov eax,dword ptr fs:[00000000h]
00E51426 push 0FFFFFFFFh
00E51428 push offset __ehhandler$??$single@UA@@@@YAAAUA@@XZ (0EE128Eh)
00E5142D push eax
static T t;
00E5142E mov eax,1
00E51433 mov dword ptr fs:[0],esp
; проверка инициализации
00E5143A test byte ptr [`single<A>'::`2'::`local static guard' (0F23944h)],al
00E51440 jne single<A>+47h (0E51467h)
; обновление флага на значение "инициализирован"
00E51442 or dword ptr [`single<A>'::`2'::`local static guard' (0F23944h)],eax
00E51448 mov ecx,offset t (0F23940h)
00E5144D mov dword ptr [esp+8],0
; вызов конструктора: создание объекта
00E51455 call A::A (0E51055h)
00E5145A push offset `single<A>'::`2'::`dynamic atexit destructor for 't'' (0EED390h)
00E5145F call atexit (0EA0AD1h)
00E51464 add esp,4
return t;
}
00E51467 mov ecx,dword ptr [esp]
00E5146A mov eax,offset t (0F23940h)
00E5146F mov dword ptr fs:[0],ecx
00E51476 add esp,0Ch
00E51479 ret
Здесь компилятор использует переменную по адресу 0x0F23944 для проверки инициализации. Если объект не был до сих пор инициализирован, то это значение устанавливается в единицу, а затем без затей вызывается инициализация синглтона. Видно, что никаких синхронизаций не предусмотрено, что и объясняет результат, полученный в результате нашего эксперимента.
Простое решение
Можно использовать достаточно простое решение, решающее нашу проблему. Для этого перед созданием объекта мы будем использовать мьютекс для синхронизации доступа к объекту:
// класс для автоматической работы с глобальным мьютексом
struct StaticLock : Mutex::Lock
{
StaticLock() : Mutex::Lock(mutex)
{
Cout() << '+';
}
~StaticLock()
{
Cout() << '-';
}
private:
static Mutex mutex;
};
Mutex StaticLock::mutex;
template<typename T>
T& single()
{
StaticLock lock; // сначала вызываем mutex.lock()
static T t; // инициализируем синглтон
return t; // вызываем mutex.unlock() и возвращаем результат
}
Результат запуска:
ab+{cde}-A+-B+-C+-D+-E~
Последовательность операций:
- запуск функции threadFunction для потока 1
- запуск функции threadFunction для потока 2
- взятие глобальной блокировки: mutex.lock()
- начало инициализации синглтона
- запуск функции threadFunction для потока 3
- запуск функции threadFunction для потока 4
- запуск функции threadFunction для потока 5
- завершение инициализации синглтона
- снятие глобальной блокировки: mutex.unlock()
- выход из функции threadFunction для потока 1
- взятие глобальной блокировки: mutex.lock()
- снятие глобальной блокировки: mutex.unlock()
- выход из функции threadFunction для потока 2
- ...
- выход из функции threadFunction для потока 5
- завершение программы и уничтожение синглтона
Такая реализация полностью избавляет от проблемы возвращения неинициализированного объекта: перед началом инициализации вызывается mutex.lock(), а после завершения инициализации вызывается mutex.unlock(). Остальные потоки ожидают завершения инициализации перед тем, как начать его использовать. Однако, у такого подхода есть существенный минус: блокировка используется всегда, вне зависимости от того, проинициализирован ли уже объект или нет. Для повышения производительности хотелось бы, чтобы синхронизация использовалась только в то время, когда мы хотим получить доступ к объекту, который еще не был проинициализирован (как это реализовано для GCC).
Double-checked locking pattern
Для реализации приведенной выше идеи часто используется подход, который носит название Double-checked locking pattern (DCLP) или шаблон проектирования «блокировка с двойной проверкой». Суть его описывается следующим набором действий:
- проверка условия: проинициализирован или нет? Если да — то сразу возвращаем ссылку на объект
- берем блокировку
- проверяем условие второй раз, если проинициализирован — то снимаем блокировку и возвращаем ссылку
- проводим инициализацию синглтона
- меняем условие на «проинициализирован»
- снимаем блокировку и возвращаем ссылку
Из этой последовательности действий становится понятно, откуда такое название: мы проверяем условие 2 раза, сначала перед блокировкой, а потом сразу после. Идея в том, что первая проверка может не означать, что объект не проинициализирован, например, в случае, когда 2 потока вошли в эту функцию одновременно. В этом случае оба потока получают статус: «не проинициализирован», а затем один из них берет блокировку, а другой ожидает. Так вот, ожидающий поток на блокировке, если не сделать дополнительную проверку, будет повторно инициализировать синглтон, что может привести к печальным последствиям.
DCLP можно проиллюстрировать следующим примером:
template<typename T>
T& single()
{
static T* pt;
if (pt == 0) // первая проверка, вне мьютекса
{
StaticLock lock;
if (pt == 0) // вторая проверка, под мьютексом
pt = new T;
}
return *pt;
}
Здесь в роли условия выступает указатель на наш создаваемый тип: если он равен нулю, то необходимо проинициализировать объект. Казалось бы, что все хорошо: проблем с производительностью нет, все работает замечательно. Однако оказалось, что не все так радужно. Одно время даже считалось, что это — не паттерн, а антипаттерн, т.е. его не стоит использовать, т.к. приводит к трудноуловимым ошибкам. Попробуем разобраться, в чем тут дело.
Ну во-первых, такой синглтон не будет удаляться, хотя это и не очень большая проблема: время жизни синглтона совпадает с временем работы приложения, поэтому операционная система сама все подчистит (если, конечно, не требуется какая-то нетривиальная обработка, типа написания в лог сообщения или отсылка определенного запроса в базу данных на изменение записи о состоянии приложения).
Вторая более серьезная проблема состоит в следующей строчке:
pt = new T;
Рассмотрим это поподробнее. Данную строчку можно переписать следующим образом (я опущу обработку исключений для краткости):
pt = operator new(sizeof(T)); // выделяем память под объект
new (pt) T; // placement new: вызов конструктора на уже выделенной памяти
Т.е. сначала выделяется память, а затем происходит инициализация объекта вызовом его конструктора. Так вот, может оказаться так, что память уже выделилась, значение pt обновилось, а объект еще не создался. Таким образом если какой-либо поток исполнит первую проверку вне блокировки, то функция single вернет ссылку на память, которая была выделена но не проинициализирована.
Попробуем теперь исправить обе проблемы, описанные выше.
Предлагаемый подход
Введем 2 функции для создания синглтона: одну будем использовать так, как будто у нас однопоточное приложение, а другую — для многопоточного использования:
// небезопасная функция в многопоточном окружении
template<typename T>
T& singleUnsafe()
{
static T t;
return t;
}
// функция для использования в многопоточном окружении
template<typename T>
T& single()
{
static T* volatile pt;
if (pt == 0)
{
StaticLock lock;
pt = &singleUnsafe<T>();
}
return *pt;
}
Идея состоит в следующем. Мы знаем, что наша первоначальная реализация (теперь это функция singleUnsafe) отлично работает в однопоточном приложении. Поэтому, все, что нам необходимо — это сериализация вызовов, которая достигается правильным использованием блокировок. В каком-то смысле здесь происходит тоже 2 проверки, только первая проверка вне блокировки использует указатель, а вторая — внутреннюю переменную, которая сгенерирована компилятором. Здесь также используется ключевое слово volatile для предотвращения переупорядочивания операций в случае чрезмерной оптимизации компилятором.
Результат компиляции такой реализации приведен ниже:
template<typename T>
T& single()
{
; обработка исключений
013E3B30 push 0FFFFFFFFh
013E3B32 push offset __ehhandler$??$single@UA@@@@YAAAUA@@XZ (14013B6h)
013E3B37 mov eax,dword ptr fs:[00000000h]
013E3B3D push eax
013E3B3E mov dword ptr fs:[0],esp
013E3B45 push ecx
static T* volatile pt;
if (pt == 0)
; первая проверка вне блокировки
013E3B46 mov eax,dword ptr [pt (1443950h)]
013E3B4B test eax,eax
013E3B4D jne single<A>+7Dh (13E3BADh)
{
StaticLock lock;
; вызов функции EnterCriticalSection для взятия блокировки
013E3B4F push offset staticMutex (1443954h)
013E3B54 mov dword ptr [esp+4],offset staticMutex (1443954h)
013E3B5C call dword ptr [__imp__EnterCriticalSection@4 (144D6A4h)]
pt = &singleUnsafe<T>();
013E3B62 mov eax,1
013E3B67 mov dword ptr [esp+0Ch],0
; вторая проверка
013E3B6F test byte ptr [`singleUnsafe<A>'::`2'::`local static guard' (144394Ch)],al
013E3B75 jne single<A>+68h (13E3B98h)
013E3B77 or dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (144394Ch)],eax
013E3B7D mov ecx,offset t (1443948h)
013E3B82 mov byte ptr [esp+0Ch],al
; инициализация объекта: вызов конструктора
013E3B86 call A::A (137105Fh)
013E3B8B push offset `singleUnsafe<A>'::`2'::`dynamic atexit destructor for 't'' (140D4D0h)
013E3B90 call atexit (13C0BB1h)
013E3B95 add esp,4
}
013E3B98 push offset staticMutex (1443954h)
013E3B9D mov dword ptr [pt (1443950h)],offset t (1443948h)
; вызов функции LeaveCriticalSection для снятия блокировки
013E3BA7 call dword ptr [__imp__LeaveCriticalSection@4 (144D6ACh)]
return *pt;
}
013E3BAD mov ecx,dword ptr [esp+4]
; возвращение результата в регистре eax
013E3BB1 mov eax,dword ptr [pt (1443950h)]
013E3BB6 mov dword ptr fs:[0],ecx
013E3BBD add esp,10h
013E3BC0 ret
Я добавил комментарии к ассемблерному коду, чтобы было понятно, что происходит. Интересно отметить код обработки исключений: довольно внушительный кусок. Можно сравнить с кодом GCC, где используются таблицы при раскрутке стека с нулевыми накладными расходами при отсутствии сгенерированного исключения. Если же посмотреть код для платформы x64 компилятора MSVC, то можно увидеть несколько иной подход к обработке исключений:
template<typename T>
T& single()
{
000000013F591600 push rdi
000000013F591602 sub rsp,30h
000000013F591606 mov qword ptr [rsp+20h],0FFFFFFFFFFFFFFFEh
000000013F59160F mov qword ptr [rsp+48h],rbx
static T* volatile pt;
if (pt == 0)
000000013F591614 mov rax,qword ptr [pt (13F685890h)]
000000013F59161B test rax,rax
000000013F59161E jne single<A>+75h (13F591675h)
{
StaticLock lock;
000000013F591620 lea rbx,[staticMutex (13F6858A0h)]
000000013F591627 mov qword ptr [lock],rbx
000000013F59162C mov rcx,rbx
000000013F59162F call qword ptr [__imp_EnterCriticalSection (13F69CCC0h)]
; nop !!!
000000013F591635 nop
pt = &singleUnsafe<T>();
000000013F591636 mov eax,dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (13F68588Ch)]
000000013F59163C lea rdi,[t (13F685888h)]
000000013F591643 test al,1
000000013F591645 jne single<A>+65h (13F591665h)
000000013F591647 or eax,1
000000013F59164A mov dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (13F68588Ch)],eax
000000013F591650 mov rcx,rdi
000000013F591653 call A::A (13F591087h)
000000013F591658 lea rcx,[`singleUnsafe<A>'::`2'::`dynamic atexit destructor for 't'' (13F636FF0h)]
000000013F59165F call atexit (13F5E6664h)
; nop !!!
000000013F591664 nop
000000013F591665 mov qword ptr [pt (13F685890h)],rdi
}
000000013F59166C mov rcx,rbx
000000013F59166F call qword ptr [__imp_LeaveCriticalSection (13F69CCD0h)]
return *pt;
000000013F591675 mov rax,qword ptr [pt (13F685890h)]
}
000000013F59167C mov rbx,qword ptr [rsp+48h]
000000013F591681 add rsp,30h
000000013F591685 pop rdi
000000013F591686 ret
Я специально отметил nop-инструкции. Они используются как маркеры при раскрутке стека в случае сгенерированного исключения. Такой подход также не имеет накладных расходов на исполнение кода при отсутствии сгенерированного исключения.
Выводы
Итак, пора сформулировать выводы. В статье показано, что разные компиляторы по разному относятся к новому стандарту: GCC всячески старается адаптироваться к современным реалиям и корректно обрабатывает инициализацию синглтонов в многопоточной среде; MSVC слегка отстает, поэтому требуется аккуратная реализация синглтона, описанная в статье. Приведенный подход представляет собой универсальную и эффективную реализацию без серьезных накладных расходов на синхронизацию.
P.S.
Данная статья — введение в вопросы многопоточности. Она решает проблему доступа в случае создания объекта-синглтона. При дальнейшем использовании его данных возникают другие серьезные вопросы, которые будут подробно рассмотрены в следующей статье.
Литература
[1] Хабрахабр: Использование паттерна синглтон
[2] Хабрахабр: Синглтон и время жизни объекта
[3] Хабрахабр: Обращение зависимостей и порождающие шаблоны проектирования
[4] Final Committee Draft (FCD) of the C++0x standard
[5] C++ Standard — ANSI ISO IEC 14882 2003
[6] Ultimate++: C++ cross-platform rapid application development framework
[7] Boost C++ libraries
[8] Wikipedia: Double-checked locking
[9] Знакомьтесь, антипаттерн double-checked locking
Автор: gridem