__cxa_guard
В C++ есть такая креветка: статические переменные в функциях. Видя в первый раз, можно подумать, что они инициализируются как обычные глобальные переменные до main.
#include <iostream>
#include <string>
struct Talkative {
Talkative() {
std::cout << "hello\n";
}
std::string GiveHello() {
return "hello2";
}
};
Talkative global_talkative;
std::string GiveHello() {
static Talkative static_talkative;
return static_talkative.GiveHello();
}
int main() {
std::cout << "main start\n";
std::cout << GiveHello() << '\n';
}
что выведет программа?
hello
main start
hello
hello2
Почему? Потому что статические переменные инициализируются при первом вызове, а глобальные переменные инициализируются до вызова main.
Какие выводы можно из этого сделать? Например, что для рантайма программы где-то должна лежать информация о том, была ли наша переменная инициализирована. Я довольно много интересуюсь темой многопоточности и следующий вопрос, который у меня возникает: а как обеспечивается thread safety при вызовах из нескольких потоков? Компилятору же нужно как-то обеспечить отсутствие гонок. Посмотрим на пример:
#include <chrono>
#include <iostream>
#include <thread>
using ms = std::chrono::milliseconds;
using clk = std::chrono::steady_clock;
static auto t0 = clk::now();
auto elapsed() { return std::chrono::duration_cast<ms>(clk::now() - t0).count(); }
struct Slow {
Slow() {
std::cout << "[t=" << elapsed() << "ms] конструктор начался\n";
std::this_thread::sleep_for(ms(2000));
std::cout << "[t=" << elapsed() << "ms] конструктор закончился\n";
}
};
Slow& get() { static Slow s; return s; }
int main() {
std::thread t1([] {
std::cout << "[t=" << elapsed() << "ms] поток 1: иду в get()\n";
get();
std::cout << "[t=" << elapsed() << "ms] поток 1: вышел из get()\n";
});
std::this_thread::sleep_for(ms(100));
std::thread t2([] {
std::cout << "[t=" << elapsed() << "ms] поток 2: иду в get()\n";
get();
std::cout << "[t=" << elapsed() << "ms] поток 2: вышел из get()\n";
});
t1.join();
t2.join();
}
[t=0ms] поток 1: иду в get()
[t=0ms] конструктор начался
[t=105ms] поток 2: иду в get()
[t=2005ms] конструктор закончился
[t=2005ms] поток 1: вышел из get()
[t=2005ms] поток 2: вышел из get()
Тут видно, как второй поток ждал выполнение конструктора Slow. Это интересно, то есть получается, потоки синхронизировались. А как? Посмотрим в godbolt на ассемблерный код примера с struct Talkative.
GiveHello[abi:cxx11]():
push rbp
mov rbp, rsp
sub rsp, 48
mov qword ptr [rbp - 40], rdi
mov rax, rdi
mov qword ptr [rbp - 32], rax
mov qword ptr [rbp - 8], rdi
mov al, byte ptr [rip + guard variable for GiveHello[abi:cxx11]()::static_talkative]
cmp al, 0
jne .LBB2_4
lea rdi, [rip + guard variable for GiveHello[abi:cxx11]()::static_talkative]
call __cxa_guard_acquire@PLT
cmp eax, 0
je .LBB2_4
lea rdi, [rip + GiveHello[abi:cxx11]()::static_talkative]
call Talkative::Talkative() [base object constructor]
jmp .LBB2_3
.LBB2_3:
lea rdi, [rip + guard variable for GiveHello[abi:cxx11]()::static_talkative]
call __cxa_guard_release@PLT
.LBB2_4:
mov rdi, qword ptr [rbp - 40]
lea rsi, [rip + GiveHello[abi:cxx11]()::static_talkative]
call Talkative::GiveHello[abi:cxx11]()
mov rax, qword ptr [rbp - 32]
add rsp, 48
pop rbp
ret
mov rcx, rax
mov eax, edx
mov qword ptr [rbp - 16], rcx
mov dword ptr [rbp - 20], eax
lea rdi, [rip + guard variable for GiveHello[abi:cxx11]()::static_talkative]
call __cxa_guard_abort@PLT
mov rdi, qword ptr [rbp - 16]
call _Unwind_Resume@PLT
Спокойно. Не боимся. Здесь всё не очень сложно.
mov al, byte ptr [rip + guard variable for GiveHello[abi:cxx11]()::static_talkative]
cmp al, 0
jne .LBB2_4
Здесь мы читаем некую guard variable и затем проверяем, равна ли она 0. Семантика такая: если равно 0 — переменная не инициализирована, если больше 0 — инициализирована. В llvm для инициализированной переменной там будет значение COMPLETE_BIT = (1 << 0).
Предположим, что у нас уже всё хорошо и переменная инициализирована. Тогда мы прыгаем в метку LBB2_4. В хорошем случае там исполнится такой код:
mov rdi, qword ptr [rbp - 40]
lea rsi, [rip + GiveHello[abi:cxx11]()::static_talkative]
call Talkative::GiveHello[abi:cxx11]()
mov rax, qword ptr [rbp - 32]
add rsp, 48
pop rbp
ret
С ним всё хорошо и понятно. Теперь предположим, что переменная ещё не была инициализирована. Тогда дальше исполнится код:
lea rdi, [rip + guard variable for GiveHello[abi:cxx11]()::static_talkative]
call __cxa_guard_acquire@PLT
cmp eax, 0
je .LBB2_4
Что здесь происходит? Мы кладём в rdi адрес guard variable и вызываем некую функцию __cxa_guard_acquire. Тут надо сказать, что такое guard variable. Структурно это 8 байт с данными с таким layout:
Guard variable на Itanium/x86-64:
┌─────────────┬─────────────┬─────────────┬────────────────────┐
│ байт 0 │ байт 1 │ байты 2-3 │ байты 4-7 │
│ guard byte │ init byte │ unused │ thread id │
└─────────────┴─────────────┴─────────────┴────────────────────┘
guard byte по смыслу это просто флаг инициализировано/не инициализировано. Также появляется некий init byte. Ключевой момент, что init byte несёт роль байта для блокировок потоков (на нём висят syscall futex).
Вот код этой и нескольких других функций (можно найти вот здесь):
_LIBCXXABI_FUNC_VIS int __cxa_guard_acquire(guard_type* raw_guard_object) {
SelectedImplementation imp(raw_guard_object);
return static_cast<int>(imp.cxa_guard_acquire());
}
_LIBCXXABI_FUNC_VIS void __cxa_guard_release(guard_type *raw_guard_object) {
SelectedImplementation imp(raw_guard_object);
imp.cxa_guard_release();
}
_LIBCXXABI_FUNC_VIS void __cxa_guard_abort(guard_type *raw_guard_object) {
SelectedImplementation imp(raw_guard_object);
imp.cxa_guard_abort();
}
Поверьте мне, что SelectedImplementation на обычных линуксовых машинах развернётся в:
GuardObject<
InitByteFutex<
PlatformFutexWait, // syscall(SYS_futex, WAIT)
PlatformFutexWake, // syscall(SYS_futex, WAKE)
PlatformThreadID // syscall(SYS_gettid)
>
>
Что это за структура? Целиком можно посмотреть здесь. Внутри она хранит два поля:
GuardByte guard_byte;
InitByteT init_byte;
И инициализирует их вот так:
explicit GuardObject(uint64_t* raw_guard_object)
: guard_byte(reinterpret_cast<uint8_t*>(raw_guard_object)),
init_byte(reinterpret_cast<uint8_t*>(raw_guard_object) + 1,
reinterpret_cast<uint32_t*>(raw_guard_object) + 1) {
}
То есть наше guard variable раскладывается по схеме, которая была выше. Внутри InitByteT работает очень похоже на устройство mutex в связке с futex (можно почитать и разобраться). Интересно также было бы разобраться, как они тестируют этот код (за рамками этого поста). Дальше в ассемблерном коде идёт вызов конструктора, и в happy path мы проставим guard byte complete значением и отпустим лочку.
Остался один непокрытый момент. Задам наводящий вопрос: а что за код идёт после ret в LBB2_4? Рассмотрим такой пример:
#include <iostream>
#include <stdexcept>
int attempt = 0;
struct Flaky {
Flaky() {
++attempt;
std::cout << "constructor attempt #" << attempt << "\n";
if (attempt < 3) {
throw std::runtime_error("not ready yet");
}
std::cout << "success!\n";
}
};
Flaky& get() {
static Flaky f;
return f;
}
int main() {
for (int i = 0; i < 4; ++i) {
try {
get();
std::cout << "got instance\n";
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << "\n";
}
}
}
Здесь на вид происходит плохая вещь: исключения в конструкторе.
что выведет программа?
constructor attempt #1
caught: not ready yet
constructor attempt #2
caught: not ready yet
constructor attempt #3
success!
got instance
got instance
По сути здесь три раза вызывается конструктор, первые два раза он выдаёт исключение и затем нормально завершается. Но тогда нам нужно абортнуть наше изменение и разбудить другие потоки, которые висят на init_byte. Этот код вызывается при развороте стека и служит подушкой безопасности для плохих сценариев. В нём как раз вызывается __cxa_guard_abort@PLT для аборта.
Может ещё возникнуть такой вопрос: а зачем нам два байта, если можно хранить весь state в одном guard байте? Ответ достаточно простой: так быстрее. На горячем пути быстрее просто прочитать guard байт без заборов, которые могут появиться при операциях с атомиками.
Причём забавно, что никакой гарантии на положение guard variable нет. Компилятор сам решает, где положить эту переменную относительно охраняемой переменной в bss.
Глубокой мысли в конце не будет: надо всегда думать головой, и если конструктор долгий, а многопоточный код под нагрузкой — лучше прогреть вызовом в начале программы.