Реализация синглтона в многопоточном приложении



    Введение


    В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке. Конечно, существует ряд простых задач, для которых один поток более, чем достаточен. Однако так бывает далеко не всегда и большинство задач средней или высокой сложности так или иначе используют многопоточность. В этой статье я буду говорить об использовании синглтонов в многопоточной среде. Несмотря на кажущуюся простоту эта тема содержит множество нюансов и интересных вопросов, поэтому считаю, что она заслуживает отдельной статьи. Здесь не будет затрагиваться обсуждение того, зачем использовать синглтоны, а также как их правильно использовать. Для прояснения этих вопросов я рекомендую обратиться к моим предыдущим статьям, посвященным разным вопросам, связанным с синглтонами [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 происходит следующее:
    1. запуск функции threadFunction для потока 1
    2. запуск функции threadFunction для потока 2
    3. начало инициализации синглтона
    4. запуск функции threadFunction для потока 3
    5. запуск функции threadFunction для потока 4
    6. запуск функции threadFunction для потока 5
    7. завершение инициализации синглтона
    8. выход из функции threadFunction последовательно для всех потоков 1-5
    9. завершение программы и уничтожение синглтона

    Здесь не происходит каких-либо неожиданностей: синглтон инициализируется только один раз и функция threadFunction завершает свою работу только после завершения инициализации синглтона => GCC корректно инициализирует объект в многопоточном окружении.

    Ситуация с MSVC несколько иная:
    1. запуск функции threadFunction для потока 1
    2. запуск функции threadFunction для потока 2
    3. начало инициализации синглтона
    4. ошибка: синглтон не проинициализирован
    5. запуск функции threadFunction для потока 3
    6. выход из функции threadFunction для потока 2
    7. ошибка: синглтон не проинициализирован
    8. ...
    9. выход из функции threadFunction для потока 5
    10. завершение инициализации синглтона
    11. выход из функции threadFunction для потока 1
    12. завершение программы и уничтожение синглтона

    В этом случае компилятор для первого потока начинает инициализировать синглтон, а для остальных — возвращает сразу объект, который даже не успел проинициализироваться. Таким образом, 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~

    Последовательность операций:
    1. запуск функции threadFunction для потока 1
    2. запуск функции threadFunction для потока 2
    3. взятие глобальной блокировки: mutex.lock()
    4. начало инициализации синглтона
    5. запуск функции threadFunction для потока 3
    6. запуск функции threadFunction для потока 4
    7. запуск функции threadFunction для потока 5
    8. завершение инициализации синглтона
    9. снятие глобальной блокировки: mutex.unlock()
    10. выход из функции threadFunction для потока 1
    11. взятие глобальной блокировки: mutex.lock()
    12. снятие глобальной блокировки: mutex.unlock()
    13. выход из функции threadFunction для потока 2
    14. ...
    15. выход из функции threadFunction для потока 5
    16. завершение программы и уничтожение синглтона

    Такая реализация полностью избавляет от проблемы возвращения неинициализированного объекта: перед началом инициализации вызывается mutex.lock(), а после завершения инициализации вызывается mutex.unlock(). Остальные потоки ожидают завершения инициализации перед тем, как начать его использовать. Однако, у такого подхода есть существенный минус: блокировка используется всегда, вне зависимости от того, проинициализирован ли уже объект или нет. Для повышения производительности хотелось бы, чтобы синхронизация использовалась только в то время, когда мы хотим получить доступ к объекту, который еще не был проинициализирован (как это реализовано для GCC).

    Double-checked locking pattern


    Для реализации приведенной выше идеи часто используется подход, который носит название Double-checked locking pattern (DCLP) или шаблон проектирования «блокировка с двойной проверкой». Суть его описывается следующим набором действий:
    1. проверка условия: проинициализирован или нет? Если да — то сразу возвращаем ссылку на объект
    2. берем блокировку
    3. проверяем условие второй раз, если проинициализирован — то снимаем блокировку и возвращаем ссылку
    4. проводим инициализацию синглтона
    5. меняем условие на «проинициализирован»
    6. снимаем блокировку и возвращаем ссылку

    Из этой последовательности действий становится понятно, откуда такое название: мы проверяем условие 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)
    	{
    		T* tmp;
    		{
    			StaticLock lock;
    			tmp = &singleUnsafe<T>();
    		}
    		pt = tmp;
    	}
    	return *pt;
    }
    

    Идея состоит в следующем. Мы знаем, что наша первоначальная реализация (теперь это функция singleUnsafe) отлично работает в однопоточном приложении. Поэтому, все, что нам необходимо — это сериализация вызовов, которая достигается правильным использованием блокировок. В каком-то смысле здесь происходит тоже 2 проверки, только первая проверка вне блокировки использует указатель, а вторая — внутреннюю переменную, которая сгенерирована компилятором. Здесь также используется ключевое слово volatile для предотвращения переупорядочивания операций в случае чрезмерной оптимизации компилятором. Также стоит отметить, что присваивание указателя pt происходит вне блокировок. Это сделано для того, чтобы не происходило переупорядочивание операций процессором во время исполнения кода.

    Результат компиляции такой реализации приведен ниже:

    template<typename T>
    T& single()
    {
    ; обработка исключений
    00083B30  push        0FFFFFFFFh  
    00083B32  push        offset __ehhandler$??$single@UA@@@@YAAAUA@@XZ (0A13B6h)  
    00083B37  mov         eax,dword ptr fs:[00000000h]  
    00083B3D  push        eax  
    00083B3E  mov         dword ptr fs:[0],esp  
    00083B45  push        ecx  
    	static T* volatile pt;
    	if (pt == 0)
    ; первая проверка вне блокировки
    00083B46  mov         eax,dword ptr [pt (0E3950h)]  
    00083B4B  test        eax,eax  
    00083B4D  jne         single<A>+7Dh (83BADh)  
    	{
    		T* tmp;
    		{
    			StaticLock lock;
    ; вызов функции EnterCriticalSection для взятия блокировки
    00083B4F  push        offset staticMutex (0E3954h)  
    00083B54  mov         dword ptr [esp+4],offset staticMutex (0E3954h)  
    00083B5C  call        dword ptr [__imp__EnterCriticalSection@4 (0ED6A4h)]  
    			tmp = &singleUnsafe<T>();
    00083B62  mov         eax,1  
    00083B67  mov         dword ptr [esp+0Ch],0  
    ; вторая проверка
    00083B6F  test        byte ptr [`singleUnsafe<A>'::`2'::`local static guard' (0E394Ch)],al  
    00083B75  jne         single<A>+68h (83B98h)  
    00083B77  or          dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (0E394Ch)],eax  
    00083B7D  mov         ecx,offset t (0E3948h)  
    00083B82  mov         byte ptr [esp+0Ch],al  
    ; инициализация объекта: вызов конструктора
    00083B86  call        A::A (1105Fh)  
    00083B8B  push        offset `singleUnsafe<A>'::`2'::`dynamic atexit destructor for 't'' (0AD4D0h)  
    00083B90  call        atexit (60BB1h)  
    00083B95  add         esp,4  
    		}
    ; вызов функции LeaveCriticalSection для снятия блокировки
    00083B98  push        offset staticMutex (0E3954h)  
    00083B9D  call        dword ptr [__imp__LeaveCriticalSection@4 (0ED6ACh)]  
    		pt = tmp;
    ; запись указателя в pt после всех блокировок
    00083BA3  mov         dword ptr [pt (0E3950h)],offset t (0E3948h)  
    	}
    	return *pt;
    }
    00083BAD  mov         ecx,dword ptr [esp+4]  
    ; возвращение результата в регистре eax
    00083BB1  mov         eax,dword ptr [pt (0E3950h)]  
    00083BB6  mov         dword ptr fs:[0],ecx  
    00083BBD  add         esp,10h  
    00083BC0  ret  
    

    Я добавил комментарии к ассемблерному коду, чтобы было понятно, что происходит. Интересно отметить код обработки исключений: довольно внушительный кусок. Можно сравнить с кодом GCC, где используются таблицы при раскрутке стека с нулевыми накладными расходами при отсутствии сгенерированного исключения. Если же посмотреть код для платформы x64 компилятора MSVC, то можно увидеть несколько иной подход к обработке исключений:

    template<typename T>
    T& single()
    {
    000000013F401600  push        rdi  
    000000013F401602  sub         rsp,30h  
    000000013F401606  mov         qword ptr [rsp+20h],0FFFFFFFFFFFFFFFEh  
    000000013F40160F  mov         qword ptr [rsp+48h],rbx  
    	static T* volatile pt;
    	if (pt == 0)
    000000013F401614  mov         rax,qword ptr [pt (13F4F5890h)]  
    000000013F40161B  test        rax,rax  
    000000013F40161E  jne         single<A>+75h (13F401675h)  
    	{
    		T* tmp;
    		{
    			StaticLock lock;
    000000013F401620  lea         rbx,[staticMutex (13F4F58A0h)]  
    000000013F401627  mov         qword ptr [lock],rbx  
    000000013F40162C  mov         rcx,rbx  
    000000013F40162F  call        qword ptr [__imp_EnterCriticalSection (13F50CCC0h)]  
    ; nop !!!
    000000013F401635  nop  
    			tmp = &singleUnsafe<T>();
    000000013F401636  mov         eax,dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (13F4F588Ch)]  
    000000013F40163C  lea         rdi,[t (13F4F5888h)]  
    000000013F401643  test        al,1  
    000000013F401645  jne         single<A>+65h (13F401665h)  
    000000013F401647  or          eax,1  
    000000013F40164A  mov         dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (13F4F588Ch)],eax  
    000000013F401650  mov         rcx,rdi  
    000000013F401653  call        A::A (13F401087h)  
    000000013F401658  lea         rcx,[`singleUnsafe<A>'::`2'::`dynamic atexit destructor for 't'' (13F4A6FF0h)]  
    000000013F40165F  call        atexit (13F456664h)  
    ; nop !!!
    000000013F401664  nop  
    		}
    000000013F401665  mov         rcx,rbx  
    000000013F401668  call        qword ptr [__imp_LeaveCriticalSection (13F50CCD0h)]  
    		pt = tmp;
    000000013F40166E  mov         qword ptr [pt (13F4F5890h)],rdi  
    	}
    	return *pt;
    000000013F401675  mov         rax,qword ptr [pt (13F4F5890h)]  
    }
    000000013F40167C  mov         rbx,qword ptr [rsp+48h]  
    000000013F401681  add         rsp,30h  
    000000013F401685  pop         rdi  
    000000013F401686  ret  
    

    Я специально отметил nop-инструкции. Они используются как маркеры при раскрутке стека в случае сгенерированного исключения. Такой подход также не имеет накладных расходов на исполнение кода при отсутствии сгенерированного исключения.

    Выводы


    Итак, пора сформулировать выводы. В статье показано, что разные компиляторы по разному относятся к новому стандарту: GCC всячески старается адаптироваться к современным реалиям и корректно обрабатывает инициализацию синглтонов в многопоточной среде; MSVC слегка отстает, поэтому требуется аккуратная реализация синглтона, описанная в статье. Приведенный подход представляет собой универсальную и эффективную реализацию без серьезных накладных расходов на синхронизацию.

    P.S.


    Данная статья — введение в вопросы многопоточности. Она решает проблему доступа в случае создания объекта-синглтона. При дальнейшем использовании его данных возникают другие серьезные вопросы, которые будут подробно рассмотрены в следующей статье.

    Update


    Подправлена реализация синглтона с учетом комментариев к статье.

    Литература


    [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
    Support the author
    Share post

    Similar posts

    Comments 64

      +2
      Вот тут ошибка:
          if (pt == 0)
          {
              StaticLock lock;
              pt = &singleUnsafe<T>();
          }
          return *pt;
      

      После захвата мьютекса надо снова проверить флаг, чтобы убедиться, что никто не успел инициализировать переменную, пока вы захватывали мьютекс. Но и такой подход не всегда будет работать.

      Про реализацию потокобезопасной инициализации статических переменных в gcc можно еще прочитать вот тут:
      habrahabr.ru/post/149683/
      Там как раз и применяется double-checked locking pattern с поправкой на то, что он используется только на архитектурах, где он будет работать. Советую вместо просмотра ассемблерного кода залезть в gcc/gcc/cp/dec.c и посмотреть на реализацию функции expand_static_init.

      На некоторых архитектурах не спасет volatile, так как запись и чтения в память будут переупорядочены самим процессором. На них надо всегжа использовать мьютекс. Про то, какие архитектуры что переупорядочивают можно почитать тут:
      en.wikipedia.org/wiki/Memory_ordering
        +1
        Опечатался gcc/gcc/cp/decl.c
          +3
          singleUnsafe единовременно будет вызван только один раз и во второй просто вернет тот же указатель, никакой ошибки нет же, вроде?
            +1
            Действительно, в этом я не прав. Получается, что существует два флага, проинициализирована ли переменная: один пользовательский, а один созданный компилятором.
              0
              Верно. Именно так и написано в статье:
              В каком-то смысле здесь происходит тоже 2 проверки, только первая проверка вне блокировки использует указатель, а вторая — внутреннюю переменную, которая сгенерирована компилятором.
            0
            С memory ordering прикол в том, что в данном подходе не важно, когда произошла запись в память правильного адреса. Если он по каким-то причинам отложился, то мы войдем вовнутрь условия и возьмем лок, что по-любому приведет к правильному считыванию указателя. Главное — это атомарность записи адреса в нужную ячейку, что практически всегда выполняется.
              0
              А если отложится запись самого объекта? Сначала из кэша процессора в память запишется значение флага, мол все в порядке переменная инициализирована, а только потом в памяти появится инициализированный объект.
              А другой поток увидит флаг и воспользуется еще не инициализированным объектом.
                0
                Чтобы ему увидеть флаг, надо сначала взять лок. Соответственно, задача лока — правильно сбросить кеши, в противном случае этот лок будет приводить к гонкам.
                  +1
                  Нет, вы же сначала проверяете флаг, а только потом, если он false, захватываете лок. И проверяете флаг еще раз, под локом. Но если флаг запишется в память раньше, чем объект, то попытка захватить лок вообще не будет предпринята.
                    0
                    Если я правильно понял, под флагом имелся ввиду указатель на объект. Можно обратить внимание на эту строчку:

                    pt = &singleUnsafe<T>();
                    

                    Здесь возвращается указатель на объект. Задача компилятора гарантировать, что результат функции singleUnsafe будет содержать валидный, т.е. проинициализированный объект. Иначе в случае однопоточного приложения будут те же грабли: если нам возвращается недоконструированный объект, то в следующий момент использования возможен креш. Затем мы этот указатель на валидный объект и присваиваем.
                      0
                      Да, под флагом имелся с виду указатель на объект. Он валидный, но только для текущего потока. Сам объект силит в кэше процессора. Текущий поток смотрит на память через кэш и поэтому компилятор выполнил все требования: для потока изменения видны в корректной последовательности.
                      Но другой поток, исполняющийся на другом ядре, может видеть изменения в другом порядке. И он может увидеть изменение в указателе реньше, чем увидит созданный объект. И это допускается стандартом, он не регламентирует в каком порядке видны изменения потокам, за исключением atomic переменных, если не ошибаюсь.
                        0
                        Да, действительно, такое может произойти. Я поправил реализацию. Теперь запись указателя происходит вне блокировок, поэтому результат создания объекта будет закомичен в памяти до того, как произойдет проверка указателя. Спасибо за наводку.
                          0
                          поэтому результат создания объекта будет закомичен в памяти до того, как произойдет проверка указателя.


                          Чем поможет вынос присвоения за unlock? Для этого необходим memory-barrier каковым не обязан являться unlock (говоря про pthread).
            –1
            А почему для потоков/вывода не использовали stl? std::cout, std::thread, std::mutex и иже с ними?
              0
              Можно и это использовать. На мой взгляд, это не принципиально, подход остается ровно таким же.
              +2
              > В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке.

              Откройте для себя node.js/twisted/gevent/tornado.
                0
                Уж тогда asio или libev и иже с ними.
                  0
                  Возможно, стоило как-то по-другому сформулировать. Просто я в своей профессиональной деятельности работал только с многопоточными приложениями. Стоило привести полную цитату:
                  В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке. Конечно, существует ряд простых задач, для которых один поток более, чем достаточен. Однако так бывает далеко не всегда и большинство задач средней или высокой сложности так или иначе используют многопоточность.

                    0
                    Читая множество статей про инициализацию синглтона в многопточных приложениях я все никак не пойму: ну неужели так обязательно использовать для синглтона Lazy-инициализацию, да еще и из доп. потоков? Неужели сложно его создать в основном потоке до запуска доп. потоков которые могут его использовать? Либо опишите подробную практическую причину, почему он обязательно должен отложенно инициализироваться из многопоточного участка программы
                      +4
                      Не всегда у разработчика есть доступ к этому самому «основному потоку», например, в контейнерах приложений. В этом случае инициализация дорогого ресурса (e. g. ConnectionFactory) будет производится из многопоточного окружения.
                  –3
                  Статические синглтоны не нужны, они убивают архитектуру и тестирование.
                    +1
                    Не согласен, синглтоны полезны (как и любой другой паттерн) в определенных случаях и я не вижу смысла беспредельно усложнять код, если в конкретном случае можно обойтись синглтоном
                      0
                      Добавление static scope неотвратимо усложняет ментальную модели работающего приложения. Хотя, с другой стороны, если нужно чтобы по-быстрому и просто работало…
                      0
                      Чтобы синглтоны не убили архитектуру и тестирование, советую почитать первую статью на эту тему: Использование паттерна синглтон.
                        0
                        К вашей статье есть очень правильный коммент: habrahabr.ru/post/116577/#comment_3855983. Пока в программе фигурирует слово «static», будут те же яйца, только сбоку.
                          0
                          Я там ответил, что это раскрыто в следующей статье. Конкретно: в объект An можно заливать любую реализацию, будь то синглтон, созданный фабрикой или что-то еще. Это задача использующего, а не используемого, обеспечить правильный экземпляр. Этот подход является продолжением DIP — Dependency inversion principle.
                      0
                      Рассмотрим это поподробнее. Данную строчку можно переписать следующим образом (я опущу обработку исключений для краткости)

                      Это все равно что сказать, что «a = b + c + d» можно записать в виде «a = b; a += c; a += d». Да, можно, но ткните меня носом в место стандарта, где сказано, что компилятор может так делать. Сначала будет вычислена правая часть выражения, и только потом произведено присваивание. Инициализация объекта входит в этот самый процесс вычисления правой части.
                        0
                        Собственно, это придумал не я. Можно ознакомиться со статьей: Double-Checked Locking Optimization

                        Example:

                        The Singleton object is not created yet and the instance is NULL.

                        Thread 1: is creating the Singleton object (the Singleton is not created in the virtual memory yet) due to optimizing actions taken by the compiler.

                        m_instance = new SingletonObject( )
                        

                        However, the pointer to the Singleton is already created and is not NULL.

                        Thread 2: this thread gets focus, and will not fall through the first conditional check since the pointer is valid. As already mentioned before, the Singleton object is not created in the memory yet and the instance is returned.

                        Thread 2: will crash using this pointer, since it’s pointing to a memory which is not allocated yet.
                          0
                          Ну, в общем, эту гениальную догадку автора статьи можно смело убирать, поскольку она противоречит, в том числе, и стандарту.
                            0
                            А можно ссылочку на соответствующий параграф в стандарте? Для более конструктивной беседы.
                              0
                              6.5 Expressions; 6.5.16.1 Simple assignment
                                0
                                Пардон, для C++11 это будет 5 Expressions и 5.17 Assignment and compound assignment operators
                                  0
                                  А что стандарт говорит в данном случае о многопоточном выполнении? Видят ли все потоки все изменения в одном и том же порядке? В частности присвоения, выполненные в конструкторе и просвоение указателя на синглтон.
                                    0
                                    Я для галочки чтоли дал ссылку на пункт «Expressions»? В pt не будет записано значение до тех пор, пока не будет вычислена правая часть выражения «pt = new T», то есть пока не завершится вычисление «new T».
                                      +1
                                      Это понятно, естественно сначала будет вычислено значение «new T», в котором будет выполнен конструктор и инициализирован объект, а затем будет выполнено присваивание pt.
                                      Но при этом стандарт не гарантирует, что другой поток увидит присваивания, сделанные в конструкторе, раньше, чем присваивание адреса, возвращенного new. Это гарантируется только для потока, в рамках которого выполнялись эти операции.

                                      А не гарантируется это потому, что есть архитектуры, на которых такую гарантию предоставить нельзя.
                                        0
                                        Тогда чем такая ситуация отличается от финального варианта со статическим синглтоном под мьютексом?
                                          +1
                                          1.10.5: мьютекс добавляет барьер в памяти и гарантирует, что изменения сделанные под ним станут видны всем, когда мьютекс будет освобожден.

                                          В старом стандарте это вообще не регламентировалось и, теоретически, компилятор имел право, скажем, вытащить код из критической секции. Но компиляторы, естественно, такую фигню не делали.
                                            0
                                            Я рассматриваю вариант, когда под мьютексом стоит pt = new T — полная эквивалентность за исключением выделения памяти.
                                              0
                                              Согласен
                                    +1
                                    Указанный источник не прав в том, что ссылается на оптимизации, проводимые компилятором. Компиятор таких оптимизаций сделать не может. Но их может сделать процессор и, на некоторых архитектурах, даже так делает.

                                    Стандарт определяет поведение, наблюдаемое программой. Компилятор может переставлять местами операции и делать иные оптимизации, если наблюдаемое поведение не меняется. Самый шикарный баг, про который я слышал — это перестановка местами освобождения мьютекса и присвоения флага в Double-Checked Locking.
                                      0
                                      О каких оптимизациях идет речь? О том, что mov [mem],value, выполенный на одном ядре, может быть «не виден» на другом?
                                        0
                                        Именно. Некоторое время он будет не виден, причем порядок, в котором изменения видны ядрам может быть любым.
                                          +1
                                          Ни в коем случае! Завершение записи значения value в память по адресу mem означает, что чтение этого адреса на любом из ядер вернет одинаковый результат — value. Но если в программе есть код
                                          a = b; c = d;
                                          то возможно, что в момент присваивания c = d, в a еще не лежит значение b, потому что возможна параллельная запись в непересекающиеся регионы памяти. Однако чтение a в любой момент после a = b даст b.
                                          Некая «недетерминированность» между ядрами как раз и отражает это: переход к «c = d» на одном ядре не означает, что оно завершило «a = b», из-за чего новое значение a может быть «не видно» остальным ядрам. Для этого и придумали memory barriers (которые, кстати, одинаково отсутствуют в обоих вариантах.
                                            0
                                            Ни в коем случае! Завершение записи значения value в память по адресу mem означает, что чтение этого адреса на любом из ядер вернет одинаковый результат — value.

                                            А откуда такая информация?
                                              0
                                              Из интеловскиз мануалов.
                                                0
                                                Но при чем здесь интеловские мануалы? Мы же говорим не про x86 и не про x86_64, правда? Это же даже не самые распространенные процессоры.
                                                  0
                                                  В общем случае, как говорит стандарт языка, implementation defined outside scope of this document. Я, честно говоря, потерял нить дискуссии.
                                                  Ты меня пытаешься убедить, что «static T t; T *pt = &t;» лучше, чем «T* pt = new T» в многопоточной среде?
                                                    0
                                                    Нет. Я пытаюсь убедить, что вообще Double-Checked Locking не та вешь, которой можно пользоваться. Где-то была замечательная pdf'ка от гугла, если не ошибаюсь, как написать корректный Double-Checked Locking. Она заканчивалась тем, что после довабления всех необходимых барьеров памяти получался тот же самый результат, как если бы с самого начала был просто захвачен lock.
                                                      0
                                                      Отнють, первичную проверку разумно делать вне мьютекса, а под мьютексом перед второй проверкой уже и mfence можно выполнить.
                                                        –1
                                                        Если вы пишете непереносимое ПО под конкретную архитектуру, то можно. Но потом придет dell с сервером на arm и вам понадобится mfence ДО проверки флага, иначе будет гонка. А это все равно, что сразу использовать лок.

                                                        Сделать проверку до захвата мьютекса может компилятор (и gcc это делает), но не вы, так как вы не знаете, на какой платформе будет работать ваше ПО.
                                            +2
                                            Порядок, да, может быть любым. Но, если рассматривать одну конкретную переменную, то dimoclus прав: сразу после выполнения mov [mem],value в [mem] увидим значение value с любого ядра. Это гарантирует когерентность кэша. Протоколы разные, но почти все архитектуры их имеют (за исключением парочки экзотических). Перед тем как прочитать [mem] гарантируется, что протокол поддержки когерентности отработал.
                                            Вот хорошая ссылка по Memory Barriers.
                                              0
                                              Я знаю про когерентность кэша. Но, скажем, на ARM она отключаема. Можете выбирать между эффективным исполнениме программ за счет переупорядочиавния интсрукций и удобством написания многопоточных приложений.

                                              Есть еще и другие подводные камни, например:
                                              Сразу после выполнения mov [mem],value в [mem] увидим значение value с любого ядра. Это гарантирует когерентность кэша.

                                              Когренетность кэша этого не гарантирует. Гарантировано, что процессор вилит свои изменения и все процессоры видят изменения в каком-то порядке. Вы все еще можете считать старое значение. Можете прочитать про это тут:
                                              bartoszmilewski.com/2008/11/05/who-ordered-memory-fences-on-an-x86/
                                              В том числе, если вы вдруг не выровняли переменную и она попала в несколько кэшлайнов, то её модификации не атомарны: процессор может увидеть значение, которого в ней никогда не было.

                                              Кстати, я вспомнил про замечательный документ, где описывается, почему DCLP не работает:
                                              www.nwcpp.org/Downloads/2004/DCLP_notes.pdf

                                              В итоге имеем огромное количество тонкостей и условий, необходимых для корректной работы алгоритма. Пытаемся обмануть компилятор, чтобы он не проводил легитимных оптимизаций. Оно того не стоит. Не используйте DCLP.
                              0
                              Тут дело в другом. Нет там никакого динамического выделения памяти
                              pt = operator new(sizeof(T));
                              . Память выделяется на этапе компиляции в серкии .bss или .data. В данном случае в .bss т.к. объект не мог быть создан на этапе компиляции. Есть только вызов конструктора объекта по этому адресу. Причем скорее всего не через placement new, а напрямую.
                                0
                                Нет там никакого динамического выделения памяти

                                RTFM
                                  0
                                  Простите, это вы к чему? Подскажите, с каких пор статические переменные живут в куче?
                                    0
                                    >void *pt = operator new(sizeof(T));
                                    И где здесь статическая переменная?
                                      0
                                      В предложенной реализации синглтона используются статические переменные:
                                      T& singleUnsafe() { static T t; return t; }
                                        +1
                                        А если посмотреть еще внимательнее, то пункт относится к коду, где выделение динамическое есть
                                                if (pt == 0)  // вторая проверка, под мьютексом
                                                    pt = new T;
                                        
                              0
                              Извиняюсь за глупый вопрос, но разве нельзя использовать критические секции?
                                +2
                                Они там и используются, точнее одна. Но неявно. Смотрите последний листинг.
                                0
                                Интересно, как поведет себя «static Mutex mutex» при одновременном доступе из разных потоков… ;)
                                  +1
                                  Насколько я понимаю, в данном случае его инициализацию произведёт CRT ещё до перехода к main(), т.е. инициализация заведомо однопоточная.
                                    0
                                    Как знать… Сильно зависит от приложения. Никто не мешает создать класс, который в конструкторе запускает несколько потоков, и создать его статический экземпляр до вызова main. Мне встречались примеры.
                                      0
                                      На мой взгляд это — пример плохого дизайна приложения. В принципе, все действия можно запихать в конструктор, только зачем? Задача конструктора — инициализация, а не выполнение каких-либо действий вообще говоря. Встречаются исключения, типа scoped операций по типу lock/unlock, или rollback в случае исключений, но в основном конструктор — он для конструирования, как бы банально это ни звучало.

                                Only users with full accounts can post comments. Log in, please.