В многопоточном C++ несколько потоков впервые одновременно обращаются к локальной статической переменной функции. Как стандарт защищает её инициализацию?
Начиная с C++11, инициализация локальной переменной со статическим временем хранения выполняется потокобезопасно: ровно один поток инициализирует объект, а остальные ожидают завершения этой инициализации. После этого все потоки используют один и тот же объект, а не отдельную копию на поток.
Эта гарантия относится именно к однократной инициализации. Она не делает последующие операции над объектом автоматически безопасными: если объект изменяется из нескольких потоков, нужна отдельная синхронизация.
Локальные статические объекты позволяют отложить создание объекта до первого обращения к функции. Такой подход удобен для кэширования, ленивого создания ресурса и реализации объекта, который логически должен существовать в единственном экземпляре.
Проблема возникала при одновременном первом вызове функции из разных потоков: без стандартизированной гарантии несколько потоков могли увидеть незавершённую инициализацию или создать объект некорректно. Стандарт C++11 закрепил механизм потокобезопасной инициализации, чтобы разработчику не приходилось вручную защищать сам факт первого создания объекта.
Предположим, функция возвращает ссылку на локальный статический объект. Два потока одновременно входят в неё впервые. Если оба начнут конструирование, возможны двойное создание, гонка при записи во внутреннее состояние и чтение частично сконструированного объекта.
Неверно считать, что после добавления локального статического объекта вся функция стала потокобезопасной. Гарантируется только корректное завершение его инициализации; вызовы методов объекта, его изменение и взаимодействие с другими данными требуют отдельного анализа.
При первом прохождении управления через объявление объекта среда выполнения выбирает один поток для инициализации. Другие потоки, достигшие того же объявления, блокируются или иным способом ждут, пока инициализация не завершится.
После успешной инициализации объект считается созданным, и последующие обращения не повторяют конструктор. Все потоки, продолжившие выполнение после ожидания, видят результат завершённой инициализации.
В этом примере строка создаётся при первом вызове configuration. Сам объект имеет статическое время хранения, но область видимости имени ограничена функцией. Он существует до завершения программы, если его инициализация действительно произошла.
Если конструктор завершился исключением, инициализация не считается успешной. Следующее обращение снова попытается инициализировать объект. Это позволяет не оставлять объект в частично созданном состоянии, но означает, что исключение может возникать при нескольких попытках вызова.
Потокобезопасность инициализации не распространяется на уничтожение объекта при завершении программы в смысле произвольной координации с другими потоками. Кроме того, порядок уничтожения локальных статических объектов из разных функций может создать проблемы, если один объект при разрушении обращается к уже уничтоженному другому объекту.
Для данных, которые должны быть отдельными для каждого потока, локальный static не подходит: он общий для всех потоков. В таком случае применяют thread_local или другой явно выбранный механизм владения и синхронизации.
В библиотеке конфигурации объект разбора настроек создавался при первом вызове функции и затем использовался всеми рабочими потоками. Инициализацию сначала защищали внешним мьютексом. Это работало, но добавляло ручной код, усложняло обработку исключений и создавало риск забыть захват блокировки в новом месте.
Рассматривались следующие варианты:
Выбран третий вариант, поскольку конфигурация неизменяема после создания. Если бы после создания она обновлялась, одной гарантии локального static было бы недостаточно: потребовалась бы модель синхронизации для чтения и записи. В результате убрали самописный код однократной инициализации, сохранив отдельную защиту для операций обновления.
static последующие изменения объекта?Нет. Стандарт гарантирует безопасную однократную инициализацию объекта, но не делает его методы или поля атомарными. Если один поток изменяет объект, а другой одновременно читает или изменяет его, возникает гонка данных, если только тип или внешний код не обеспечивает синхронизацию.
Например, потокобезопасно создать static-контейнер можно, но параллельные добавления в этот контейнер без блокировки всё равно некорректны. Для неизменяемого объекта после конструирования дополнительная синхронизация может не потребоваться, если его публикация происходит через завершение стандартной инициализации.
Неудачная попытка не считается завершённой инициализацией. Следующий поток или следующий вызов функции может повторить попытку; потоки, ожидавшие текущую попытку, не должны получить ссылку на частично сконструированный объект.
Практическое следствие — конструктор должен быть рассчитан на повторные вызовы или использовать внешний механизм, гарантирующий стабильность входных условий. Если ошибка постоянная, каждый вызов может снова приводить к исключению, поэтому иногда полезно отдельно кэшировать результат ошибки, но это уже задача приложения, а не автоматическая функция локального static.
Не всегда. Сам факт потокобезопасной инициализации не устраняет зависимости между временем жизни разных статических объектов. Если один статический объект при разрушении или конструировании использует другой, порядок их инициализации и уничтожения должен быть проверен.
Особенно опасна ситуация, когда при завершении программы один объект обращается к локальному статическому объекту, уже уничтоженному ранее. Поэтому зависимости между такими объектами следует минимизировать, явно управлять временем жизни или хранить ресурс так, чтобы его уничтожение не требовалось на этапе завершения программы.