← salmpac.ru

__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.

Глубокой мысли в конце не будет: надо всегда думать головой, и если конструктор долгий, а многопоточный код под нагрузкой — лучше прогреть вызовом в начале программы.