Ситуация: несколько потоков получают доступ к лениво инициализируемому объекту. Безопасно ли читать value п...

Ситуация: несколько потоков получают доступ к лениво инициализируемому объекту. Безопасно ли читать value после успешного std::call_once, не делая его атомарным?

#include <mutex>
#include <thread>

std::once_flag flag;
int value;

void init() {
    value = 42;
}

int read() {
    std::call_once(flag, init);
    return value;
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Да, чтение value после возврата из успешного std::call_once безопасно без атомика или мьютекса. Завершение функции init, успешно выполненной внутри std::call_once, синхронизируется с возвратом из std::call_once в других потоках, поэтому запись value = 42 happens-before их чтения.

Исторический контекст

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

В C++11 появился стандартный механизм std::call_once, который объединил гарантию однократного выполнения и необходимую синхронизацию между потоком-инициализатором и потоками-потребителями.

Постановка проблемы

Потоки могут одновременно вызвать read(). Один из них выполняет init, а остальные либо ждут его завершения, либо обнаруживают, что инициализация уже выполнена. После возврата из std::call_once они читают обычную переменную value.

Если бы между записью и чтением не было отношения happens-before, одновременный доступ к обычному объекту мог бы стать гонкой данных и привести к неопределённому поведению. Сам факт, что запись обычно выполняется одной машинной инструкцией, такой проблемы не устраняет.

Подробное решение

std::call_once связывает успешное завершение вызова функции, переданной в него, с возвратом из соответствующих вызовов std::call_once в других потоках. Поэтому операции внутри init, включая запись в обычный value, становятся видимыми после возврата из std::call_once.

В результате атомарность value не требуется: доступы к нему не конфликтуют во времени с инициализацией. Синхронизация обеспечивается самим протоколом call_once, а не типом int.

#include <mutex> #include <thread> #include <iostream> std::once_flag flag; int value; void init() { value = 42; } void worker() { std::call_once(flag, init); std::cout << value << ' '; } int main() { std::thread a(worker), b(worker); a.join(); b.join(); }

Если вызов init выбрасывает исключение, инициализация не считается успешно завершённой: один из следующих вызовов может повторить попытку. Исключение передаётся потоку, в котором оно возникло.

Гарантия распространяется на действия, выполненные до успешного завершения call_once. Если после инициализации один поток начинает изменять value, а другой читает его без дополнительной синхронизации, это уже отдельная гонка данных.

Альтернативой может быть потокобезопасная инициализация локальной статической переменной или явный мьютекс. Локальная статическая переменная удобна для простого объекта, а call_once подходит, когда инициализация должна быть отделена от места хранения или требует нескольких действий. Атомик сам по себе не заменяет безопасную публикацию составного объекта.

Ситуация из практики

В кэше конфигурации первый запрос должен загрузить файл, распарсить его и опубликовать готовую структуру. Вариант с обычным флагом initialized дешевле по коду, но содержит гонку между проверкой и установкой флага. Вариант с мьютексом корректен, однако каждый запрос вынужден входить в критическую секцию.

Выбран std::call_once: только первый успешный вызов выполняет загрузку, остальные ждут завершения и получают синхронизированный доступ к готовой структуре. Если загрузка завершилась исключением, следующий запрос может повторить её; это лучше, чем навсегда опубликовать частично созданный объект.

Что кандидаты часто упускают

  1. Вопрос: Что произойдёт, если функция инициализации выбросит исключение?

    Ответ: Текущий вызов std::call_once завершится с этим исключением, а флаг не будет считаться выполненным. Последующий вызов может снова запустить функцию инициализации. Поэтому функция должна быть либо повторяемой, либо сама обеспечивать очистку всех ресурсов, созданных до места исключения.

  2. Вопрос: Можно ли после std::call_once безопасно изменять объект из одного потока и читать его из другого?

    Ответ: Нет, не автоматически. call_once синхронизирует завершение инициализации с последующими возвратами из call_once, но не создаёт постоянную защиту объекта. Для дальнейших изменений нужны мьютекс, атомики с подходящим протоколом публикации или другая явная синхронизация.

  3. Вопрос: Почему проверка обычного флага перед вызовом std::call_once может быть ошибочной?

    Ответ: Например, условие if (!initialized) std::call_once(flag, init); читает initialized без синхронизации. Если другой поток записывает этот флаг, возникает гонка данных, даже если сам call_once корректен. Дополнительный флаг не нужен: состояние выполнения уже хранится внутри once_flag.