В этой статье мы хотели бы поднять обсуждение безопасности кода в корпоративных решениях, которые должны работать круглосуточно, без выходных, без простоев и сбоев. Существует множество передовых методов безопасного написания кода, но теперь мы хотели бы поговорить о другом, что может вызвать плавающие проблемы в многопоточных решениях. Очевидно, что все разработчики программного обеспечения знают, что такое шаблон Singleton, и часто используют его в своих решениях. Но не все знают, какие потенциальные проблемы синглтон может принести многопоточному приложению. Традиционная реализация синглтона основана на создании указателя на объект при первом запросе объекта.

В однопоточном приложении он обычно работает нормально. Но мир не лишен своих недостатков, и когда мы работаем над реальными производственными решениями, мы не можем использовать однопоточные приложения по разным причинам. Хотя мы используем многопоточность, доступ к экземпляру синглтона может выполняться из различных потоков, это может быть проблемой при создании экземпляров синглтона. Если вы находитесь в Singleton :: Instance () и получаете прерывание, вызовите Singleton :: Instance () из другого потока, вы можете увидеть, как у вас могут возникнуть проблемы. .

Допустим, поток A входит в функцию экземпляра, выполняется через строку 14 и затем приостанавливается. В момент приостановки он только что определил, что mInstance имеет значение null, что означает, что объект Singleton еще не создан. Поток B теперь входит в функцию экземпляра и выполняет Строку 14. Он видит, что mInstance имеет значение null, поэтому он переходит к Строке 15 и создает синглтон для mInstance, на который он указывает. Затем он возвращает mInstance вызывающей стороне экземпляра. В какой-то момент позже потоку A разрешается продолжить работу, и первое, что он делает, это перемещается в строку 15, где он создает другой объект Singleton и заставляет mInstance указывать на него. Должно быть ясно, что это нарушает смысл синглтона, поскольку теперь существует два объекта синглтона, и один из них является утечкой для приложения.

Кажется, что сделать классическую реализацию Singleton поточно-ориентированной несложно. Мы могли просто получить Lock перед тестированием mInstance.

Выглядит хорошо и безопасно. Но такая реализация кажется дорогостоящей. Это связано с тем, что каждый вызов Singleton :: Instance требует получения блокировки, но на самом деле это необходимо только один раз при создании экземпляра singleton. Зачем нам платить за приобретение других замков, если мы точно знаем, что на самом деле нужно только один раз?

Есть еще одно решение этой проблемы, называемое шаблоном блокировки с двойной проверкой (DCLP).

Используя такой подход, мы дважды проверяем mInstance на NULL - перед блокировкой и после получения блокировки. Блокировка устанавливается только в том случае, если mInstance еще не инициализирован, и после этого тест выполняется снова, чтобы убедиться, что mInstance по-прежнему имеет значение NULL. Второй тест необходим, как описано выше. У нас может возникнуть ситуация, когда другой поток пытается инициализировать mInstance в период между первым тестированием в другом потоке. В документах, определяющих DCLP, обсуждаются некоторые вопросы реализации (например, важность определения изменчивости одноэлементного указателя и влияние отдельных кэшей на многопроцессорные системы, оба из которых мы рассмотрим в другой статье; а также необходимость обеспечения атомарности некоторые операции чтения и записи, которые мы не обсуждаем в этой статье), но они не учитывают гораздо более фундаментальную проблему - обеспечение того, чтобы машинные инструкции, выполняемые во время DCLP, выполнялись в приемлемом порядке. Мы фокусируемся именно на этой фундаментальной проблеме.

Давайте посмотрим, что происходит, когда мы создаем экземпляр любого класса:

Это утверждение вызывает три вещи, которые должны произойти:

  • Выделить память для хранения объекта Singleton
  • Создать объект Singleton в выделенной памяти
  • Сделать указатель mInstance на выделенную память

Чрезвычайно важно отметить, что компиляторы не обязаны выполнять эти шаги в указанном порядке! В частности, компиляторам иногда разрешается менять местами шаги 2 и 3. Почему они могут захотеть это сделать, - это вопрос, к которому мы обратимся чуть позже. А пока давайте сосредоточимся на том, что произойдет, если они это сделают. Рассмотрим следующий код, в котором мы расширили строку инициализации pInstance на три составляющие задачи, упомянутые выше, и где мы объединили шаги 1 (выделение памяти) и 3 (назначение mInstance) в один оператор, который предшествует шагу 2 (построение Singleton ). Идея не в том, что этот код мог бы написать человек. Скорее, компилятор может сгенерировать код, эквивалентный этому, в ответ на обычный исходный код DCLP (показанный ранее), который написал бы человек.

В общем, это недопустимый перевод исходного исходного кода DCLP, потому что конструктор Singleton, вызванный на шаге 2, может вызвать исключение, и если возникает исключение, важно, чтобы mInstance еще не был изменен. Вот почему, как правило, компиляторы не могут переместить шаг 3 выше шага 2. Однако существуют условия, при которых это преобразование допустимо. Возможно, самое простое условие - это когда компилятор может доказать, что конструктор Singleton не может генерировать (например, с помощью анализа потока после встраивания), но это не единственное условие. Инструкции некоторых конструкторов, которые выбрасывают, также могут быть переупорядочены таким образом, что возникает эта проблема. Учитывая приведенный выше перевод, рассмотрим следующую последовательность событий:

  • Поток A входит в метод, выполняет первый тест mInstance, получает блокировку и выполняет оператор, состоящий из шагов 1 и 3. Затем он приостанавливается. На данный момент mInstance не равен нулю, но объект Singleton еще не создан в памяти, на которую указывает mInstance.
  • Поток B входит в метод, определяет, что mInstance не равно нулю, и возвращает его вызывающей стороне экземпляра. Затем вызывающий объект разыменовывает указатель для доступа к синглтону, который, к сожалению, еще не создан.

DCLP будет работать, только если шаги 1 и 2 будут выполнены до выполнения шага 3, но нет способа выразить это ограничение на C или C ++.

Как мы видим, существуют различные сценарии, которые могут вызвать проблемы с такими очевидными вещами, как шаблон Singleton. Большинство сценариев, вероятно, не могут быть проблемой в реальной жизни, но есть вероятность, что это может произойти в вашем решении. И когда это могло произойти, действительно трудно выявить проблему из-за того, что она возникает редко или зависит от конфигурации оборудования и т.д. для выполнения вызова Singleton :: Instance () в функции Main () приложения до создания каких-либо потоков.

Автор Дмитрий Грицай
Вычитка Игорь Коротач