Ситуация: несколько потоков получают доступ к лениво инициализируемому объекту. Безопасно ли читать 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;
}
Да, чтение 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.
Если вызов init выбрасывает исключение, инициализация не считается успешно завершённой: один из следующих вызовов может повторить попытку. Исключение передаётся потоку, в котором оно возникло.
Гарантия распространяется на действия, выполненные до успешного завершения call_once. Если после инициализации один поток начинает изменять value, а другой читает его без дополнительной синхронизации, это уже отдельная гонка данных.
Альтернативой может быть потокобезопасная инициализация локальной статической переменной или явный мьютекс. Локальная статическая переменная удобна для простого объекта, а call_once подходит, когда инициализация должна быть отделена от места хранения или требует нескольких действий. Атомик сам по себе не заменяет безопасную публикацию составного объекта.
В кэше конфигурации первый запрос должен загрузить файл, распарсить его и опубликовать готовую структуру. Вариант с обычным флагом initialized дешевле по коду, но содержит гонку между проверкой и установкой флага. Вариант с мьютексом корректен, однако каждый запрос вынужден входить в критическую секцию.
Выбран std::call_once: только первый успешный вызов выполняет загрузку, остальные ждут завершения и получают синхронизированный доступ к готовой структуре. Если загрузка завершилась исключением, следующий запрос может повторить её; это лучше, чем навсегда опубликовать частично созданный объект.
Вопрос: Что произойдёт, если функция инициализации выбросит исключение?
Ответ: Текущий вызов std::call_once завершится с этим исключением, а флаг не будет считаться выполненным. Последующий вызов может снова запустить функцию инициализации. Поэтому функция должна быть либо повторяемой, либо сама обеспечивать очистку всех ресурсов, созданных до места исключения.
Вопрос: Можно ли после std::call_once безопасно изменять объект из одного потока и читать его из другого?
Ответ: Нет, не автоматически. call_once синхронизирует завершение инициализации с последующими возвратами из call_once, но не создаёт постоянную защиту объекта. Для дальнейших изменений нужны мьютекс, атомики с подходящим протоколом публикации или другая явная синхронизация.
Вопрос: Почему проверка обычного флага перед вызовом std::call_once может быть ошибочной?
Ответ: Например, условие if (!initialized) std::call_once(flag, init); читает initialized без синхронизации. Если другой поток записывает этот флаг, возникает гонка данных, даже если сам call_once корректен. Дополнительный флаг не нужен: состояние выполнения уже хранится внутри once_flag.