Русский
Русский
English
Статистика
Реклама

Just for fun

C17. Функция стандартной библиотеки stdlaunder и задача девиртуализации

05.02.2021 12:22:10 | Автор: admin

В этой статье мы попробуем разобраться с одним из самых неоднозначных и непонятных нововведений стандарта C++17 функцией стандартной библиотеки std::launder. Мы посмотрим на std::launder с другой стороны, посмотрим на источник. Разберем что лежит в основе функции на примере решения задачи девиртуализации и реализации виртуальных указателей в LLVM.



Девиртуализация это крайне важная оптимизация компилятора, которая позволяет заменить виртуальные (dynamic, indirect) вызовы обычными (static, direct). Виртуальные вызовы приводят к снижению производительности, не могут быть встроены, имеют более сложные механизмы спекулятивного выполнения (Indirect branch prediction), которые могут приводить к снижению безопасности и служить вектором атаки (например, Spectre). Clang и LLVM обеспечивают полную девиртуализацию виртуальных вызовов, когда удается статически определить динамический тип объекта или определить отсутствие переопределения метода и устраняют избыточную загрузку динамической информации (виртуальных таблиц) во всех остальных случаях.


Виртуальный вызов


Виртуальные вызовы реализуют концепцию, которая часто называется динамической диспетчеризацией (dynamic dispatch). Динамическая диспетчеризация это механизм, позволяющий выбрать реализацию полиморфного метода на основе динамического (фактического) типа объекта во время выполнения программы. По умолчанию вызовы методов выполняются в рамках статической диспетчеризации т.е. на основе статического типа известного во время компиляции. Спецификатор virtual у метода позволяет сменить тип диспетчеризации.


В выражение вида E1.E2, например, вызов метода a->foo() или a.foo(), выражение E1 называется object expression. Динамический тип в данном контексте это тип объекта, на который ссылается текущее значение выражения E1.


struct A{    int foo() { return 1; }    // объявление метода с virtual меняет статическую диспетчеризацию на динамическую.    virtual int bar() { return 1; }};struct B : A{    int foo() { return 2; }    int bar() override { return 2; }};int main(){    B b;    A *a = &b;    // вызов A::foo() на основе статического типа а    const int v1 = a->foo();    // вызов B::foo() на основе динамического (фактического) типа а.    // динамический тип это тип объекта, на который ссылается указатель а.    // в данном случае, это объект типа B.    const int v2 = a->bar();    assert(v1 == 1);    assert(v2 == 2);}

Стандарт не регулирует каким именно образом должен быть реализован механизм динамической диспетчеризации. Все современные компиляторы C++ реализуют динамическую диспетчеризацию и виртуальные вызовы с помощью специальных структур: виртуальных таблиц (virtual table, vtable, vtbl).


Виртуальные таблицы


Виртуальные таблицы генерируются для каждого класса с виртуальными методами или виртуальными базовыми классами. Во время создания экземпляра такого класса, адрес соответствующей таблицы записывается в специальное техническое поле: виртуальный указатель (virtual pointer). Виртуальных указателей может быть несколько, для каждого базового класса. При этом под адресом виртуальной таблицы т.е. адрес, который будет записан в виртуальный указатель экземпляра класса, не всегда понимается адрес начала таблицы. В терминах ABI этот адрес называется address point. Доступ ко всем записям таблицы осуществляется фиксированными смещениями, как положительными, так и отрицательными, относительно этого адреса.


Виртуальные таблицы содержат следующую информацию:


  1. Virtual call offsets. Записи содержат смещения для корректировки указателя (this) от виртуального базового класса до класса-потомка. Используются для вызова переопределенного в классе-потомке метода виртуального базового класса.
  2. Virtual base offsets. Записи содержат смешения от адреса поля виртуального указателя до адреса подобъекта виртуального базового класса. Добавляется для каждого виртуального базового класса.
  3. Offset to top. Запись содержит смешение от адреса поля виртуального указателя до адреса начала объекта т.е. адреса первого байта. Смещение позволяет найти начало объекта из любого базового подобъекта с помощью виртуального указателя.
  4. Typeinfo pointer. Запись содержит адрес объекта с данными о типе RTTI.
  5. Virtual function pointers. Записи содержат адреса виртуальных методов класса или адреса вторичных точек входа (thunk). Используются для динамической диспетчеризации. Реализация таких указателей определяется конкретным ABI. Последовательность записей совпадает с последовательностью объявлений соответствующих методов в классах. Все записи в таблицах иерархии совместимых классов находятся в согласованном состояние т.е. для каждого виртуального метода в дереве наследования имеется одно, фиксированное смещение в таблице. Если дочерний класс перегружает реализацию базового метода, то в таблице этого класса меняется соответствующая запись на адрес перегруженного метода. Такая таблица совместима с таблицей базового класса.

Например, для иерархии классов:


struct A{    void s();    virtual void f();    virtual void g();};struct B : A{    void f() override;    virtual void h();};struct C{    virtual void k();};struct D : A, C{    void g() override;    void k() override;};

Будут сгенерированы следующие таблицы (x86-64 clang 10.0.0):


# виртуальная таблица для класса Аvtable for A:    .quad 0                # Смещение Offset To Top    .quad typeinfo for A   # адрес объекта typeinfo с данными о типе RTTI    .quad A::f()           # адрес базовой реализации метода f()    .quad A::g()           # адрес базовой реализации метода g()# виртуальная таблица для класса Bvtable for B:    .quad 0                # Смещение Offset To Top    .quad typeinfo for B   # адрес объекта typeinfo с данными о типе RTTI    .quad B::f()           # адрес переопределенного метода f()    .quad A::g()           # адрес базовой реализации метода g()    .quad B::h()           # адрес метода h()# виртуальная таблица для класса Cvtable for C:    .quad 0                # Смещение Offset To Top    .quad typeinfo for C   # адрес объекта typeinfo с данными о типе RTTI    .quad C::k()           # адрес базовой реализации метода k()# виртуальная таблица для класса D# состоит из двух таблиц,# одна совместима с виртуальной таблицей B, другая с виртуальной таблицей Cvtable for D:    #                        Cовместимая с B таблица    .quad 0                # Смещение Offset To Top    .quad typeinfo for D   # адрес объекта typeinfo с данными о типе RTTI    .quad A::f()           # адрес базовой реализации метода f()    .quad D::g()           # адрес переопределенного метода g()    .quad D::k()           # адрес переопределенного метода k()    #                        Cовместимая с С таблица    .quad -8               # Смещение Offset To Top    .quad typeinfo for D   # адрес объекта typeinfo с данными о типе RTTI    .quad thunk for D::k() # вторичная точка входа для переопределенного метода D::k()

Можно заметить, что записи о виртуальных методах в таблицах совместимы, например, метод f() имеет одно и тоже смещение во всех таблицах совместимых типов (А, B, D).


Класс D использует множественное наследование с несколькими базовыми классами: A и C. Объект типа D содержит два виртуальных указателя. Один принадлежит базовому подобъекту A и указывает на таблицу совместимую с таблицей класса A, другой принадлежит базовому подобъекту C и содержит адрес таблицы совместимой с виртуальной таблицей класса C.


Чтобы понять почему в таблице совместимой с классом С вместо указателя на метод D::k() помещен thunk for D::k(), рассмотрим следующий код:


D *d = new D;A *a = dynamic_cast<A*>(d);C *c = dynamic_cast<C*>(d);c->k();

Если посмотреть на адреса d, a, c и this в методе k(), то они будут, например, такими


d:    0x1edcee0a:    0x1edcee0c:    0x1edcee8this: 0x1edcee0

Адреса d, а, this совпадают, а адрес с смещен на 8 байт относительно d. Так происходит из-за того, что в памяти базовый подобъект C смещен на 8 байт из-за технического поля (виртуального указателя) базового подобъекта A. Поэтому при вызове, мы не можем передать c в качестве this в метод k(). Перед вызовом this должен быть скорректирован, чтобы указывать на объект класса, который выполнил переопределение метода. Именно эту корректировку и делает thunk перед передачей управления методу k().


Таким образом, вторичная точка входа thunk это сегмент кода ассоциированный с целевым методом, который выполняет определенные корректировки входных параметров (например, this) перед передачей управления методу. Thunk может содержать просто дополнительную инcтрукцию, которая будет выполнена до перехода к целевому методу или быть полноценной функцией со своим стековым кадром и дальнейшим полноценным вызовом метода. Используется при множественном наследовании.


Ассоциация виртуальных таблиц


При создании объекта в конструкторах генерируется код, сохраняющий адрес соответствующей виртуальной таблицы в поле виртуального указателя.


void test(A *a){    a->s();    a->f();    a->f();    a->g();}int main(){    A *a = new B;    test(a);}

Будет сгенерирован следующий код (x86-64 clang 10.0.0):


# виртуальная таблица для класса Аvtable for A:    .quad 0    .quad typeinfo for A    .quad A::f()   # address point    .quad A::g()# виртуальная таблица для класса Bvtable for B:    .quad 0    .quad typeinfo for B    .quad B::f()   # address point    .quad A::g()    .quad B::h()main:    push    rbx    mov     edi, 8    # new expression    # вызов оператора new    call    operator new(unsigned long)    mov     rbx, rax    mov     rdi, rax    # вызов конструктора B    call    B::B() [base object constructor]    mov     rdi, rbx    call    test(A*)    xor     eax, eax    pop     rbx    ret# конструктор класса BB::B() [base object constructor]:    push    rbx    mov     rbx, rdi    # вызов конструктора базового класса A    call    A::A() [base object constructor]    # сохраняем адрес виртуальной таблицы (address point) класса B    # переписываем значение сохраненное в конструкторе базового класса    # 16 - это смещение до address point таблицы B    mov     qword ptr [rbx], offset vtable for B+16    pop     rbx    ret# конструктор класса AA::A() [base object constructor]:    # сохраняем адрес виртуальной таблицы (address point) класса A    # 16 - это смещение до address point таблицы A    mov     qword ptr [rdi], offset vtable for A+16    ret

Т.к. таблицы в иерархии совместимы с точки зрения адресации, то каждый конструктор просто сохраняет адрес, соответствующей его типу таблицы, переписывая значение базового класса. В данном случае, адресом виртуальной таблицы является адрес записи с адресом первого виртуального метода в иерархии: f(). Относительно этого адреса осуществляется доступ ко всем остальным записям в таблице.


Вызов виртуального метода


В общем случае виртуальный вызов состоит из следующих шагов:


  1. Прочитать адрес виртуальной таблицы из виртуального указателя;
  2. Сместить значение загруженного указателя до записи в таблице с адресом вызываемого метода;
  3. Прочитать адрес метода;
  4. Выполнить косвенный вызов по прочитанному адресу.

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


Сгенерированный код функции test() из предыдущего примера (x86-64 clang 10.0.0 -std=c++17 -O1):


test(A*):    push    rbx    mov     rbx, rdi    # вызов a->e()    # прямой вызов метода по фиксированному адресу, статическая диспетчеризация,    # тип известен на этапе компиляции    call    A::e()    # вызов a->f()    # читаем виртуальный указатель, хранит    # адрес записи в виртуальной таблице с адресом метода f()    mov     rax, qword ptr [rbx]    # читаем адрес f() из таблицы и выполняем косвенный вызов метода    call    qword ptr [rax]    # еще один вызов a->f()    # читаем виртуальный указатель, хранит    # адрес записи в виртуальной таблице с адресом метода в f()    mov     rax, qword ptr [rbx]    mov     rdi, rbx    # читаем адрес f() из таблицы и выполняем косвенный вызов метода    call    qword ptr [rax]    # вызов a->g()    # читаем виртуальный указатель, хранит    # адрес записи в виртуальной таблице с адресом метода в f()    mov     rax, qword ptr [rbx]    mov     rdi, rbx    # смещаем на следующую запись относительно f(): adress point + 8    # запись содержит адрес метода g()    # читаем адрес g() из таблицы и выполняем косвенный вызов метода    call    qword ptr [rax + 8]    pop     rbx    ret

Задача девиртуализации заключается в замене виртуальных (косвенных) вызовов прямыми (с фиксированным адресом), в случае когда удается статически (во время компиляции) определить динамический тип объекта или убедиться, что виртуальный метод не переопределен. Если на этапе компиляции динамический тип определить не удалось, то устранить избыточное чтение виртуальных указателей и адресов методов.


Девиртуализация может быть выполнена на разных уровнях:


  1. Front-end. На уровне конкретного языка, эксплуатируя конкретные языковые особенности;
  2. Middle-end. На уровне промежуточного представления, в нашем случае LLVM IR, до этапа генерации кода.

Front-end. Девиртуализация на уровне C++


Девиртуализация по своей природе является оптимизацией конкретного языка, поэтому естественно ожидать, что она будет реализована во внешнем интерфейсе. Исключая LTO (Link-Time Optimization), в обрабатываемом юните трансляции есть только несколько случаев, когда компилятор может сделать вывод об отсутствии переопределения метода или статически определить динамический тип объекта.


Динамический тип совпадает со статическим


struct A{    virtual void f();};struct B : A{    void f() override;};void test(){    B a;    a.f();    a.f();}

По стандарту, вызов a.f() также является виртуальным. Любой вызов виртуального метода является виртуальным, исключая вызовы с явно квалифицированным именем метода. Т.е. вызов a.A::f() выполняется в рамках статической диспетчеризации.


Напомним, что в выражении вида E1.E2, например, вызов метода a.f(), динамический тип это тип объекта, на который ссылается значение выражения E1 (object expression). Значение выражения в данном случае, является сам объект а, т.е. динамический и статический тип совпадают. Поэтому мы можем просто заменить косвенный вызов на прямой.


Будет сгенерирован следующий код (x86-64 clang 10.0.0 -std=c++17 -O0):


test():    push    rbp    mov     rbp, rsp    sub     rsp, 16    lea     rdi, [rbp - 8]    call    B::B() [base object constructor]    lea     rdi, [rbp - 8]    # прямой вызов B::f()    call    B::f()    lea     rdi, [rbp - 8]    # прямой вызов B::f()    call    B::f()    add     rsp, 16    pop     rbp    ret

Вызов виртуальных методов в конструкторе/деструкторе


struct A{    A()    {        f();    }    virtual ~A()    {        f();    }    virtual void f();};struct B : A{    B()    {        f();    }    ~B()    {        f();    }    void f() override;};

Поcмотрим еще раз на генерируемый код и на порядок инициализации виртуального указателя в конструкторе/деструкторе.


# конструктор класса AA::A() [base object constructor]:    push    rax    # сохраняем адрес виртуальной таблицы для класса А    mov     qword ptr [rdi], offset vtable for A+16    # прямой вызов A::f();    call    A::f()    pop     rax    ret# деструктор класса AA::~A() [base object destructor]:    push    rax    # сохраняем адрес виртуальной таблицы для класса А    mov     qword ptr [rdi], offset vtable for A+16    # прямой вызов A::f()    call    A::f()    pop     rax    ret# конструктор класса BB::B() [base object constructor]:    push    rbx    mov     rbx, rdi    # вызов конструктора базового класса    call    A::A() [base object constructor]    # сохраняем адрес виртуальной таблицы для класса B    mov     qword ptr [rbx], offset vtable for B+16    mov     rdi, rbx    # прямой вызов B::f()    call    B::f()    pop     rbx    ret# деструктор класса BB::~B() [base object destructor]:    push    rbx    mov     rbx, rdi    # сохраняем адрес виртуальной таблицы для класса B    mov     qword ptr [rdi], offset vtable for B+16    # прямой вызов B::()    call    B::f()    mov     rdi, rbx    # вызов деструктора базового класса    call    A::~A() [base object destructor]    pop     rbx    ret

На момент вызова конструктора базового класса, вызов конструктора класса-потомка еще не завершен, указатель на виртуальную таблицу не переписан и указывает на таблицу базового класса. На момент вызова деструктора базового класса (вызывается после деструктора класс-потомка), объект класса-потомка уже деинициализирован и вызов методов такого объекта в общем случае может быть небезопасным. Компилятор не позволяет нам выполнить такой вызов и защищает от ошибок. Поэтому в деструкторе виртуальный указатель инициализируется так же как в конструкторе, т.е. адресом своей виртуальной таблицы. В результате, даже при отсутствии дервиртуазиции, в конструкторе и деструкторе (виртуализация деструктора ни как не влияет на результат) виртуальный указатель всегда будет указывать на свою виртуальную таблицу, а не на таблицу класса-потомка. Т.е. cитуация сводится к ситуации, когда компилятор может сделать вывод о том, что вызываемые виртуальные методы не переопределяются в классах-потомках. Вызовы могут быть замены на прямые вызовы.


На front-end'е эту оптимизацию делают GCC и MSVC. Clang оставляет эту задачу LLVM. LLVM оптимизирует эти вызовы с помощью store to load propagation (в конструкторе/деструкторе видны запись и чтение виртуального указателя) в рамках прохода GVN (Global Value Numbering).


Локализация класса в юните трансляции


namespace{    struct A    {        virtual int f() { return 1; }    };}int test(A *a){    return a->f();}

Т.к. класс локализован в юните трансляции (имеет внутреннее связывание) и нет ни одного класса-потомка, то компилятор мог бы выполнить замену вызова, однако ни один компилятор на данный момент этого не делает. Clang не может этого сделать из-за особенностей трансляции исходного кода (не может определить, что ниже по коду нету классов-потомков).


Финализация класса/метода


struct A{    virtual int f() final;    virtual int g();};struct B final : A{    int g() override;};void test(A *a, B *b){    a->f();    a->g();    b->g();}

Сгенерированный код (x86-64 clang 10.0.0 -std=c++17 -O0):


test(A*, B*):    push    rbp    mov     rbp, rsp    sub     rsp, 32    mov     qword ptr [rbp - 8], rdi    mov     qword ptr [rbp - 16], rsi    mov     rdi, qword ptr [rbp - 8]    # прямой вызов A::f(),    # метод f() финализирован в классе А и не может быть переопределен    call    A::f()    mov     rcx, qword ptr [rbp - 8]    mov     rdx, qword ptr [rcx]    mov     rdi, rcx    mov     dword ptr [rbp - 20], eax    # косвенный вызов метода g(),     # т.к. может существовать класс-потомок переопределяющий метод    call    qword ptr [rdx + 8]    mov     rdi, qword ptr [rbp - 16]    mov     dword ptr [rbp - 24], eax    # прямой вызов B::g(), т.к. класс B финализирован    call    B::g()    add     rsp, 32    pop     rbp    ret

Middle-end. Девиртуализация на уровне промежуточного представления


Рассмотрим небольшой пример, чтобы понять на чем оcнован традиционный подход к девиртуализации:


struct A{    virtual void f();};struct B : A{    void f() override;};void test(){    A *a = new B;    a->f();}

Для девиртуализации метода, компилятору нужно знать три вещи:


  1. Значение виртуального указателя. Адрес конкретной виртуальной таблицы. Т.е. по сути, компилятор должен видеть значение, которое записывается в виртуальный указатель в конструкторе.
  2. Адрес конкретного метода. Т.к. виртуальные таблицы константны: генерируются для каждого типа в момент компиляции и не могут меняться во время выполнения, то для получения адреса метода из таблицы, наблюдая значение виртуального указателя (конкретную таблицу), нам достаточно просто определения этой таблицы (virtual table definition).
  3. Компилятор должен быть уверен, что с момента инициализации виртуального указателя (записи значения) в конструкторе и до момента вызова конкретного виртуального метода т.е. чтения виртуального указателя, его значение не переписывается (no clobbering).

Если все эти условия соблюдены, то компилятор может выполнить store to load propagation на виртуальном указателе и заменить виртуальный вызов прямым. Здесь важно понимать, что это оптимизация на уровне промежуточного представления (target-independed optimization) и код обрабатывается без какой-либо семантической нагрузки из C++.


Например, выше мы рассматривали вызов виртуального метода в конструкторе. Там все условия соблюдаются, поэтому Clang/LLVM выполнят девиртуализацию таких вызовов.


Для функции test после всех оптимизаций в IR будет сгенерирован следующий код (x86-64 clang 10.0.0 -std=c++17 -O2):


test():    push    rax    mov     edi, 8    call    operator new(unsigned long)    # заинлайненый конструктор объекта B    # сохранение адреса виртуальной таблицы в виртуальный указатель    mov     qword ptr [rax], offset vtable for B+16    mov     rdi, rax    pop     rax    # прямой вызов f()    jmp     B::f()

В этом подходе есть ряд ограничений.


Если у нас определен внешний конструктор или его не удалось встроить, то мы нарушим первое условие и компилятор не сможет девиртуализировать вызов. Что бы это продемонстрировать достаточно собрать предыдущий пример с выключенным инлайненгом.


Для функции test будет сгенерирован следующий код (x86-64 clang 10.0.0 -std=c++17 -O2 -fno-inline):


test():    push    rbx    mov     edi, 8    call    operator new(unsigned long)    mov     rbx, rax    mov     rdi, rax    # внешний конструктор    call    B::B() [base object constructor]    mov     rax, qword ptr [rbx]    mov     rdi, rbx    pop     rbx    # косвенный вызов f()    jmp     qword ptr [rax]B::B() [base object constructor]:    push    rbx    mov     rbx, rdi    call    A::A() [base object constructor]    mov     qword ptr [rbx], offset vtable for B+16    pop     rbx    ret

В точке вызова компилятор не видит инициализацию виртуального указателя и не может сделать предположение о его значении после вызова конструктора объекта.


Вторая проблема заключается в отслеживании инварианта виртуального указателя. Следует отметить, что ни каких дополнительных знаний о виртуальном указателе у компилятора нет и отслеживание состояния сводится к отслеживанию инструкций записи. Эта задача лежит на модуле Alias Analysis (Pointer Analysis). Это набор алгоритмов, с помощью которых модуль в общем случае пытается определить: могут ли два указателя ссылаться на один и тот же объект в памяти. Для поиска предшествующих операций с памятью, от которых зависит заданная инструкция, LLVM может использовать интерфейс MemDep (Memory Dependence Analysis) или более эффективный MemSSA (Memory SSA). Одно из основных положение такого анализа это отслеживание: выходит ли указатель за пределы функции или юнита трансляции т.е. обращаются ли к памяти со стороны или мы работаем с памятью локально. Для локальной обработки алгоритмы могут достаточно точно определить какие указатели могут указывать на объект и соответственно какие инструкции этот объект меняют. В случае выхода указателя за пределы анализатор предполагает, что память может быть перезаписана.


Дополним пример выше:


struct A{    virtual void f();};struct B : A{    void f() override;};void test(){    A *a = new B;    a->f();    // второй вызов виртуального метода,     // компилятор не видит определение метода f()    a->f();}void foo(A *a);void test1(){    A *a = new B;    // вызов внешней функции и передача указателя    foo(a);    a->f();}

Будет сгенерирован следующий код (x86-64 clang 10.0.0 -std=c++17 -O2):


test():    push    rbx    mov     edi, 8    call    operator new(unsigned long)    mov     rbx, rax    mov     qword ptr [rax], offset vtable for B+16    mov     rdi, rax    # прямой вызов метода f()    call    B::f()    mov     rax, qword ptr [rbx]    mov     rdi, rbx    pop     rbx    # косвенный вызов метода f()    jmp     qword ptr [rax]test1():    push    rbx    mov     edi, 8    call    operator new(unsigned long)    mov     rbx, rax    # встроенный конструктор    mov     qword ptr [rax], offset vtable for B+16    mov     rdi, rax    # вызов внешней функции    call    foo(A*)    mov     rax, qword ptr [rbx]    mov     rdi, rbx    pop     rbx    # косвенный вызов метода f()    jmp     qword ptr [rax]

Второй вызов метода f() в функции test() не будет оптимизирован. Так происходит потому, что анализатор не знает что на самом деле делает метод f() и у него нет другой альтернативы кроме как предположить худший вариант: представление объекта, на который указывает а может поменяться, смениться динамический тип и соответственно значение виртуального указателя. Аналогичная ситуация с функцией test1() и вызовом foo(a). Еще раз заметим, что с точки зрения LLVM виртуальный указатель это просто указатель с адресом, как любой другой. Ни какой семантики из C++ на него не наложено.


Ключевой вопрос здесь, каким образом мы может изменить динамический тип объекта и значение виртуального указателя в C++?


Кроме того, что виртуальный указатель несколько раз меняется в процессе вызова конструктора и деструктора, объектная модель C++ дает нам возможность завершить время жизни (lifetime) любого объекта и переиспользовать выделенную под него память, создав там другой объект.


Стандарт не дает нам четкого определения, что такое время жизни. Причина введения в стандарт концепции времени жизни, как свойство объекта это дать нам знание о том, когда мы можем использовать объект без каких-либо ограничений и выразить правила работы с ним более общими понятиями, независимо от его типа и правил инициализации.


Существование объекта можно разделить на следующие этапы:


  1. Память под объект была выделана, время жизни объекта еще не началось;
  2. Процесс создания объекта (constructor);
  3. Существование объекта;
  4. Процесс уничтожения объекта (destructor);
  5. Время жизни объекта закончилось, память или переиспользуется другим объектом или освобождена.

Если указатель ссылается на выделенную память, но процесс создания объекта еще не начался или указатель ссылается на объект, время жизни которого уже закончилось, то на указатель накладываются следующие ограничения и нарушение хотя бы одного из этих ограничений приводит к Undefined Behavior:


  • Указатель не может выступать в качестве операнда delete expression, если время жизни объекта было закончено. Исключая объекты с тривиальным деструктором;
  • Указатель не может использоваться для доступа к полям и методам класса;
  • Указатель не может быть приведен к указателю на виртуальный базовый класс;
  • Указатель не может выступать в качестве операнда static_cast. Исключая приведение к void* и/или дальнейшее приведение к char*, unsigned char* или std::byte*;
  • Указатель не может выступать операндом dynamic_cast.

Т.е. каждое из этих условий требует доступ к объекту, который еще не был создан или уже был удален.


Здесь есть важное замечание. Если после завершения времени жизни объекта мы переиспользуем память и вновь созданный объект имеет заменяемый тип (transparently replaceable), то все указатели, ссылки и имена, которые ссылались на исходный объект, автоматически начинают указывать на новый и могут быть использованы для оперирования новым объектом. Два типа называются заменяемыми, в данном контексте, если выполнены следующие условия:


  • Новый объект использует в точности всю память старого объекта;
  • Объекты имеют одинаковый тип с точностью до cv-квалификаторов;
  • Тип исходный объект не является константным и тип не содержит константных или ссылочных полей (в случае пользовательского типа данных).

Ниже мы увидим что transparently replaceable является крайне точным термином.


Вернемся к нашему примеру:


void B::f(){    // виртуальный метод класса меняет динамический тип объекта    // мы завершаем время жизни существующего объекта    // и создаем новый другого типа    new(this) A;}void test(){    A *a = new B;    a->f();    // неопределенное поведение    a->f();}

Код метода B::f(), с точки зрения описанных выше правил, корректен, но т.к. типы исходного и нового объектов не заменяемы, то все существующие указатели (включая this) мы можем использовать только ограниченным образом, т.е. только как указатели на область памяти, а не как указатели на конкретные объекты. Как следствие, второй вызов метода a->f() приводит к неопределенному поведению, т.к. указатель a ссылается на объект, чье время жизни было закончено.


Для полноты описания так же заметим, что placement new возвращает указатель, который может быть использован для манипулирования новым объектом.


Например:


void B::f(){    // виртуальный метод класса меняет динамический тип объекта    // мы завершаем время жизни существующего объекта    // и создаем новый, другого типа    A *a = new(this) A;    // a может может использоваться для доступа к методам и полям нового объекта    // однако вызов g(), используя this является Undefined Behavior,    // при этом this и a, по умолчанию, указываю на одну и ту же область памяти    a->g();    // создаем еще один объект с исходным типом    // использование this является корректным, а    // использование указателя a для доступа к полям Undefined Behavior    new (this) B;    g();}

В общем случае, компилятор вправе предполагать, что программа определена корректно и случаев неопределенного поведения в ней нет. В частности, в рамках девиртуализации, компилятор может предположить, что между двумя виртуальными вызовами с разыменованием одного и того же указателя, динамический тип этого указателя измениться не может (иначе поведение стало бы неопределенным). Следовательно, повторная загрузка виртуального указателя и адреса метода является избыточной.


Основная сложность представления в LLVM IR описанной семантики заключается в том, что указатели ссылающиеся на один и тот же адрес могут указывать на объекты с разным временем жизни и не должны рассматриваться как эквивалентные т.е. с точки зрения оптимизатора не могут участвовать в подстановке (substitution).
Реализация Clang/LLVM позволяющая выразить семантику выше, время жизни динамических объектов и инварианты виртуальных указателей, доступна с флагом -fstrict-vtable-pointers.


Инварианты виртуальных указателей


Мы будем рассматривать код:


struct A{    virtual void f();    virtual void g();};struct B : A{    void f() override;    void g() override;};A* get_object();void test(){    A *a = get_object();    a->f();    a->f();    a->f();    a->g();}

Используя традиционный, описанный выше, подход к девиртуализации, cо всеми оптимизациями в IR мы получим следующий код (x86-64 clang 10.0.0 -std=c++17 -O2, код немного упрощен, убраны несущественные детали, атрибуты, метаданные).


define dso_local void @test(){    ; запрос объекта    %1 = call %struct.A* @get_object()    %2 = bitcast %struct.A* %1 to void (%struct.A*)***    ; вызов метода f()    ; чтение виртуального указателя    %3 = load void (%struct.A*)**, void (%struct.A*)*** %2    ; чтение адреса метода f()    %4 = load void (%struct.A*)*, void (%struct.A*)** %3    ; косвенный вызов f()    call void %4(%struct.A* %1)    ; второй вызов метода f()    ; повторное чтение виртуального указателя    %5 = load void (%struct.A*)**, void (%struct.A*)*** %2    ; повторное чтение адреса метода f()    %6 = load void (%struct.A*)*, void (%struct.A*)** %5    ; косвенный вызов f()    call void %6(%struct.A* %1)    ; третий вызов f()    ; еще одно чтение виртуального указателя    %7 = load void (%struct.A*)**, void (%struct.A*)*** %2    ; еще одно чтение адреса метода f()    %8 = load void (%struct.A*)*, void (%struct.A*)** %7    ; косвенный вызов f()    call void %8(%struct.A* %1)    ; вызов метода g()    ; еще одно чтение виртуального указателя    %9 = load void (%struct.A*)**, void (%struct.A*)*** %2    ; смещение адреса виртуальной таблицы до записи с адресом метода g()    %10 = getelementptr inbounds void (%struct.A*)*, void (%struct.A*)** %9, i64 1    ; чтение адреса метода g()    %11 = load void (%struct.A*)*, void (%struct.A*)** %10    ; косвенный вызов g()    call void %11(%struct.A* %1)}

В данном случае, ни один вызов не будет девиртуализирован, потому что компилятор не видит что делает функция get_object, не видит создание объекта и запись виртуального указателя. Задача LLVM, в данном примере, учесть факт того, что динамический тип не может поменяться в процессе вызовов, выразив инвариантность виртуального указателя и константность виртуальной таблицы, избавившись в итоге от избыточного чтения.


LLVM расширяет список возможных метаданных инструкций чтения и записи новым элементом invariant.group. Метаданные инструкций это подсказка оптимизатору об уникальных свойствах инструкции. Наличие или отсутствие таких метаданных не меняет семантику программы.


Присутствие метаданных в инструкции invariant.group указывает оптимизатору, что чтение/запись с одним и тем же операндом или другими словами, если операнд принадлежит одной и той же инвариантной группе, возвращает/сохраняет одно и тоже значение. Чтобы выразить инвариант виртуального указателя, каждая инструкция чтения виртуального указателя (в рамках виртуального вызова) и каждая инструкция записи виртуального указателя (в рамках инициализации в конструкторе/деструкторе) сопровождается метаданными invariant.group.


Так же каждую инструкцию чтения виртуальной таблицы в процессе виртуального вызова LLVM сопровождает метаданными invariant.load. Эти метаданные указывают на то, что область памяти, на которую ссылается операнд инструкции чтения, содержит одно и тоже значение независимо от того в какой момент и где мы эти данные читаются. Это позволяет выразить тот факт, что содержимое виртуальной таблицы постоянно и в процессе выполнения меняется не может.


define dso_local void @test(){    ; запрос объекта    %1 = tail call %struct.A* @get_object()    %2 = bitcast %struct.A* %1 to void (%struct.A*)***    ; вызов метода f()    ; чтение виртуального указателя,    ; инструкция содержит метаданные invariant.group    %3 = load void (%struct.A*)**, void (%struct.A*)*** %2, !invariant.group !{}    ; чтение адреса метода f() из виртуальной таблицы,    ; инструкция содержит метаданные invariant.load    %4 = load void (%struct.A*)*, void (%struct.A*)** %3, !invariant.load !{}    tail call void %4(%struct.A* %1)    ; второй вызов метода f()    ; повторное чтение виртуального указателя, инструкция помечена invariant.group    ; оптимизатор может предположить что данные уже прочитаны в %3    %5 = load void (%struct.A*)**, void (%struct.A*)*** %2, !invariant.group !{}    ; чтение адреса метода f() из виртуальной таблицы,    ; инструкция содержит метаданные invariant.load    ; оптимизатор знает что данные загружаемые инструкцией по переданному адресу    ; постоянны и не могут изменится в процессе выполнения    %6 = load void (%struct.A*)*, void (%struct.A*)** %5, !invariant.load !{}    tail call void %6(%struct.A* %1)    ; третий вызов f()    ; еще одно чтение виртуального указателя, инструкция помечена invariant.group    ; оптимизатор может предположить что данные уже прочитаны в %3    %7 = load void (%struct.A*)**, void (%struct.A*)*** %2, !invariant.group !{}    ; чтение адреса метода f() из виртуальной таблицы,    ; инструкция содержит метаданные invariant.load    ; оптимизатор знает что данные загружаемые инструкцией по переданному адресу    ; постоянны и не могут изменится в процессе выполнения    %8 = load void (%struct.A*)*, void (%struct.A*)** %7, !invariant.load !{}    tail call void %8(%struct.A* %1)    ; вызов метода g()    ; еще одно чтение виртуального указателя, инструкция помечена invariant.group    ; оптимизатор может предположить что данные уже прочитаны в %3    %9 = load void (%struct.A*)**, void (%struct.A*)*** %2, !invariant.group !{}    %10 = getelementptr inbounds void (%struct.A*)*, void (%struct.A*)** %9, i64 1    ; чтение адреса метода g() из виртуальной таблицы,    ; инструкция содержит метаданные !invariant.load    %11 = load void (%struct.A*)*, void (%struct.A*)** %10, !invariant.load !{}    tail call void %11(%struct.A* %1)}

Обработку этих метаданных берет на себя модуль MemDep. В итоге после оптимизации (-std=c++17 -O2 -fstrict-vtable-pointers) мы получаем следующее:


define dso_local void @test(){    ; запрос объекта    %1 = tail call %struct.A* @get_object()    %2 = bitcast %struct.A* %1 to void (%struct.A*)***    ; чтение виртуального указателя, избыточные чтения удалены    %3 = load void (%struct.A*)**, void (%struct.A*)*** %2, !invariant.group !{}    ; чтение адреса метода f() из виртуальной таблицы    %4 = load void (%struct.A*)*, void (%struct.A*)** %3, !invariant.load !{}    ; три косвенных вызова метода f()    tail call void %4(%struct.A* %1)    tail call void %4(%struct.A* %1)    tail call void %4(%struct.A* %1)    ; смещение адреса виртуальной таблицы до записи с адресом метода g()    %5 = getelementptr inbounds void (%struct.A*)*, void (%struct.A*)** %3, i64 1    ; чтение адреса метода g() из виртуальной таблицы,    ; виртуальный указатель уже прочитан    %6 = load void (%struct.A*)*, void (%struct.A*)** %5, !invariant.load !{}    ; косвенный вызов метода g()    tail call void %6(%struct.A* %1)}

И после генерации кода back-end'ом:


test():        push    r15        push    r14        push    rbx        call    get_object()        mov     rbx, rax        mov     rax, qword ptr [rax]        mov     r14, qword ptr [rax]        mov     r15, rax        mov     rdi, rbx        call    r14        mov     rdi, rbx        call    r14        mov     rdi, rbx        call    r14        mov     rdi, rbx        mov     rax, r15        pop     rbx        pop     r14        pop     r15        jmp     qword ptr [rax + 8]

Проблема внешнего конструктора


Проблема внешнего конструктора решается с помощью техники assumption loads. Это набор дополнительных инструкций, которые генерируются после вызова конструктора и информируют компилятор о фактическом значение виртуального указателя нового объекта. Напомним, что из-за внешнего конструктора традиционный подход к девиртуализации не справляется с вызовами.


Рассмотрим пример с отключенным инлайненгом (x86-64 clang 10.0.0 -std=c++17 -O2 -fno-inline -fstrict-vtable-pointers):


struct A{    virtual void f();};void test(){    A *a = new A;    a->f();}

define void @test(){    %1 = call dereferenceable(8) i8* @New(i64 8)    %2 = bitcast i8* %1 to %struct.A*    ; вызов конструктора объекта A::A()    call void @Constructor_A(%struct.A* nonnull %2)    %3 = bitcast i8* %1 to i8***    ; читаем адрес сохраненный в виртуальном указателе    ; важно что чтение также сопровождается метаданными invariant.group    %4 = load i8**, i8*** %3, !invariant.group !{}    ; сравниваем прочитанное значение с адресом виртуальной таблицы для класса A    %5 = icmp eq i8** %4, @VTABLE_A    ; вызываем llvm.assume и передаем результат сравнения.    call void @llvm.assume(i1 %5)    ; в итоге прямой вызов метода A::f()    call void @f(%struct.A* nonnull %2)    ret void}; конструктор класса Adefine void @Constructor_A(%struct.A* %0){    %2 = getelementptr %struct.A, %struct.A* %0, i64 0, i32 0    ; сохранение адреса виртуальной таблицы A в виртуальный указатель объекта    store i32 (...)** @VTABLE_A, i32 (...)*** %2, !invariant.group !{}    ret void}

После вызова конструктора генерируются три дополнительные инструкции:


  1. Чтение виртуального указателя вновь созданного объекта. Чтение сопровождается метаданными invariant.group.
  2. Сравнение загруженного адреса виртуальной таблицы с адресом предполагаемой таблицы (исходя из типа объекта);
  3. Вызов и передача результата сравнения в @llvm.assume. Встроенная функция @llvm.assume не генерирует ни каких дополнительных инструкций. Семантически вызов похож на assert на уровне оптимизатора и позволяет предположить, что переданный результат сравнения всегда истинен, если условие нарушается, то дальнейшее поведение считается неопределенным.

В результате к моменту вызова у оптимизатора уже есть актуальное значение виртуального указателя: оптимизатор сделал предположение чему он равен. И вызов может быть девиртуализирован.


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


    %1 = call dereferenceable(8) i8* @New(i64 8)    %2 = bitcast i8* %1 to %struct.A*    %3 = bitcast i8* %1 to i8***    ; встроенный конструктор,    ; сохраняем адрес виртуальной таблицы в виртуальном указателе.    ; запись помечена invariant.group, это значит  что в рамках этой инвариантной группы    ; в этом виртуальном указателе может быть сохранен только @VTABLE_A    store i32 (...)** @VTABLE_A, i32 (...)*** %3, !invariant.group !{}    ; читаем адрес сохраненный в виртуальном указателе.    ; чтение сопровождается метаданными invariant.group    ; чтение принадлежит той же инвариантной группе, что и запись выше.    ; у оптимизатора уже есть информация, что по этому адресу может быть записан только @VTABLE_A    ; и чтение может быть заменено на @VTABLE_A    %4 = load i8**, i8*** %3, !invariant.group !{}    ; после оптимизации чтения это условие всегда true: @VTABLE_A == @VTABLE_A    %5 = icmp eq i8** %4, @VTABLE_A    ; вызов llvm.assume(true) можно удалить    call void @llvm.assume(i1 %5)

Барьеры оптимизации


Существует ряд семантических случаев, когда виртуальный указатель меняет свое значение:


  • Конструкторы. Конструктор каждого класса в иерархии инициализирует виртуальный указатель адресом своей виртуальной таблицы;
  • Деструкторы. Деструкторы каждого класса восстанавливают значение виртуального указателя для безопасного использования виртуальных методов;
  • Placement new. Выражение завершает время жизни объекта (если используется тривиальный деструктор) и переиспользует выделенную под него память, создав там другой объект. В результате меняется динамический тип и соответственно значение виртуального указателя. Возвращает указатель, для доступа к новому объекту.

В рамках семантики инвариантных групп у инструкций чтения и записи нам нужны инструменты, чтобы сообщить оптимизатору, что информация об инварианте обновилась или ее не нужно учитывать. Необходимо предусмотреть случай сравнения двух указателей в рамках оптимизации. Указатели могут ссылаться на одну и туже область памяти, но на объекты с разным временем жизни. Равенство таких указателей не должна вводить в заблуждение оптимизатор: оптимизатор не должен иметь возможность выполнить подстановку (substitution). Необходимо выразить концепцию equal-but-not-equivalent.


LLVM вводит две дополнительные операции: strip и launder.


Операция strip


Операция strip может использоваться, когда нам нужен указатель на те же данные с возможностью доступа к ним, но без инвариантной информации установленной метаданными invariant.group.
Операция представлена новой встроенной функцией @llvm.strip.invariant.group. Функция возвращает новый указатель, который является псевдонимом (alias) переданному аргументу, т.е. указывает и может использоваться для доступа к той же памяти что и аргумент и убирает ассоциацию указателя с инвариантной группой.


Свойства операции:


  • Операция является детерминированной и не обладает побочными эффектами. Т.е. возвращаемое значение зависит только от переданного аргумента. Такие операции часто называются чистыми (pure);
  • Возвращает псевдоним аргумента (alias). Возвращает указатель, который указывает и может использоваться для доступа к той же памяти что и аргумент;
  • strip(X) == strip(strip(X)). Т.к. операция не берет во внимание ассоциированные инвариантные данные.

Один из случаев использования операции strip это сравнение указателей:


void test(){    A *a = new A;    a->f();    A *b = new(a) B;    if (a == b)        b->f();}

    ...    ; читаем адрес виртуальной таблицы а    %vtable_a = load void (%struct.A*)**, void (%struct.A*)*** %a, !invariant.group !{}    ...    ; if(a == b)    %res = icmp eq %struct.A* %a, %b    br %res, label %if, label %after    if:        ; если %b будет заменен на %a,        ; то оптимизатор заменить чтение адреса виртуальной таблицы %vtable_b        ; на уже прочитанный %vtable_a.        ; т.к. чтение сопровождается метаданными invariant.group        %vtable_b = load void (%struct.B*)**, void (%struct.B*)*** %b, !invariant.group !{}        ...    ...

Проблема в том, что LLVM основываясь на результате сравнения a и b может заменить SSA значение b на a (в проходе GVN). Это абсолютно легальная оптимизация. Но в рамках девиртуализации и обработки инвариантов, такая замена приведет к неопределенному поведению, потому что при втором вызове метода f() будет использоваться не та виртуальная таблица.


Решение заключается в сравнение указателей без учета инвариантной информации:


    ...    ; читаем адрес виртуальной таблицы a    %vtable_a = load void (%struct.A*)**, void (%struct.A*)*** %a, !invariant.group !{}    ...    ; if(a == b)    ; сравниваем указатели без инвариантов    ; strip_a и strip_b имеют доступ и указывают на ту же область,    ; на которую указывают a и b соответственно    %strip_a = call i8 * @llvm.strip.invariant.group(i8 * %a)    %strip_b = call i8 * @llvm.strip.invariant.group(i8 * %b)    %res = icmp eq %struct.A* %strip_a, %strip_b    br %res, label %if, label %after    if:        ; Равенство псевдонимов не является достаточной причиной для оптимизатора,        ; чтобы выполнить подстановку исходных указателей        %vtable_b = load void (%struct.B*)**, void (%struct.B*)*** %b, !invariant.group !{}        ...    ...

Операция launder


Операция launder используется, когда нам нужен указатель на те же данные с возможностью доступа к ним, но c очищенной информацией об инварианте.
Операция представлена новой встроенной функцией @llvm.launder.invariant.group. Функция возвращает новый указатель, который является псевдонимом (alias) переданному аргументу, т.е. указывает и может использоваться для доступа к той же памяти что и аргумент и ассоциирует указатель с новой инвариантной группой. Т.е. переданный в функцию указатель и новый указатель, в контексте обработки метаданных invariant.group на операциях чтения и записи, рассматриваются как два разных указателя.


Свойства операции:


  • Операция по своей сути является недетерминированной. Ни какие два вызова не возвращают одинаковое значение;
  • Возвращает псевдоним аргумента (alias). Возвращает указатель, который указывает и может использоваться для доступа к той же памяти что и аргумент;
  • strip(x) == strip(launder(x)).

LLVM использует операцию launder во всех семантических случаях, когда виртуальный указатель меняет свое значение т.е. каждое новое значение виртуального указателя должно принадлежать своей инвариантной группе, это позволяет в процессе девиртуализации избежать неопределенного поведения. Также операция используется в ряде краевых случаев, таких как доступ к членам union.


В случае конструктора/деструктора, каждый класс-потомок передает в конструктор/деструктор базового класса launder(this):


; конструктор класса Adefine void @Constructor_A(%struct.A* %0){    %2 = getelementptr %struct.A, %struct.A* %0, i64 0, i32 0    ; запись адреса виртуальной таблицы A    store i32 (...)** @VTABLE_A, i32 (...)*** %2, !invariant.group, !{}    ret void}; конструктор класса Bdefine void @Constructor_B(%struct.B* %0){    %2 = bitcast %struct.B* %0 to i8*    ; запрос нового указателя с новой инвариантной группой launder(this)    %3 = call i8* @llvm.launder.invariant.group(i8* %2)    %4 = bitcast i8* %3 to %struct.A*    ; передача launder(this) в конструктор базового класса A    call void @Constructor_A(%struct.A* %4)    %5 = getelementptr %struct.B, %struct.B* %0, i64 0, i32 0, i32 0    ; запись адреса виртуальной таблицы B    store i32 (...)** @VTABLE_B, i32 (...)*** %5, !invariant.group, !{}    ret void}

Детально рассмотрим случай выражения placement new:


void test(){    A *a = new A;    a->f();    A *b = new(a) B;    b->f();}

Если мы используем умалчиваемую реализацию оператора размещающего new, то указатель a будет побитово равен указателю b. Но в рамках объектной модели C++, a и b указывают на разные объекты: a указывает на объект типа A, время жизни которого завершено, b указывает на вновь созданный, в той же области памяти, объект типа B. Соответственно, в контексте девиртуализации вызов b->f() должен загружать правильную виртуальную таблицу: таблицу класса B и указатели a и b не должны рассматриваться как эквивалентные. С помощью операции launder оптимизатор может достичь описанной семантики.


define void @test(){    ; new expression    ; вызов оператора new    %1 = call dereferenceable(8) i8* @New(i64 8)    %2 = bitcast i8* %1 to %struct.A*    ; создание объекта типа A, вызов конструктора A    %3 = call %struct.A* @Constructor_A(%struct.A* nonnull %2)    %4 = bitcast i8* %1 to void (%struct.A*)***    ; чтение виртуального указателя созданного объекта типа A    ; т.к. чтение сопровождается метаданными invariant.group    ; для указателя регистрируется новая инвариантная группа    %5 = load void (%struct.A*)**, void (%struct.A*)*** %4, !invariant.group !{}    %6 = load void (%struct.A*)*, void (%struct.A*)** %5, !invariant.load !{}    ; косвенный вызов A::f(). После того как оптимизатор заинлайнет конструктор    ; (или сделает предположение о значение виртуального указателя, assumtion loads)    ; вызов будет девиртуализирован в прямой вызов A::f()    call void %6(%struct.A* nonnull %2)    ; placement new expression    ; создание нового указателя и    ; ассоциация его с новой инвариантной группой.    ; указатель ссылается и имеет доступ к области памяти,    ; которая была выделена ранее под объект типа A    %7 = call i8* @llvm.launder.invariant.group(i8* nonnull %1)    %8 = bitcast i8* %7 to %struct.B*    ; создание объекта типа B, вызов конструктора B    ; в конструктор передается новый указатель    ; это важно т.к. конструктор меняет значение виртуального указателя    %9 = call %struct.B* @Constructor_B(%struct.B* nonnull %8)    %10 = bitcast i8* %7 to void (%struct.B*)***    ; чтение виртуального указателя созданного объекта типа B    ; оптимизатор не сможет здесь использовать ранее загруженную таблицу для старого объекта    ; т.к. указатели принадлежат разным инвариантным группам.    %11 = load void (%struct.B*)**, void (%struct.B*)*** %10, !invariant.group !{}    %12 = load void (%struct.B*)*, void (%struct.B*)** %11, !invariant.load !{}    ; косвенный вызов B::f(). После того как оптимизатор заинлайнет конструктор    ; (или сделает предположение о значение виртуального указателя, assumtion loads)    ; вызов будет девиртуализирован в прямой вызов B::f()    call void %12(%struct.B* nonnull %8)    ret void}

Важно, что операции strip и launder возвращают псевдонимы (aliases) своих аргументов, это не дает оптимизатору выполнить неверную подстановку, но дает возможность делать предположения о значение, не позволяя подавлять существующие оптимизации. Вызовы соответствующих функций удаляются перед фазой генерации кода back-end'ом и не несут нагрузки на выполнение и размер объектного кода.


std::launder


Если посмотреть более внимательно на последний пример использования операции launder с placement new, то можно заметить, что на уровне LLVM IR, указатель b это не что иное, как launder(a). И семантика выражения размещающего new сводится к следующему:


  1. Запрос нового указателя вызовом функции @llvm.launder.invariant.group на основе переданного аргумента.
  2. Создание нового объекта в заданной области памяти: вызов конструктора. В нашем случае переиспользование памяти завершает время жизни, существующего в переданной области памяти, объекта. В конструктор передается новый указатель, т.к. конструктор меняет значение виртуального указателя (та же семантика, что и при вызове конструктора базового класса). В итоге новый указатель ссылается на новый объект со своим значением виртуального указателя и ассоциирован с "чистой" (новой) инвариантной группой. Этот указатель рассматривается как результат вычисления выражения placement new.

Вернемся к коду и к уже упомянутому состоянию Undefined Behavior:


void test(){    A *a = new A;    a->f();    A *b = new(a) B;    // определенное поведение    b->f();    // неопределенное поведение    a->f();}

На состояния Undefined Behavior можно смотреть как на компромисс, договоренность между программистом и компилятором. Это набор дополнительных правил и ограничений, позволяющий компилятору генерировать более эффективный код. В нашем случае оптимизатор эксплуатирует объектную модель C++ и описанное неопределенное поведение, что бы реализовать девиртуализацию методов. Если девиртуализация не используется, то второй вызов a->f() будет иметь ожидаемое поведение: косвенный вызов B::f(). Но с описанной моделью девиртуализации, вызов a->f() будет оптимизирован в прямой вызов A::f(). Вызов b->f() всегда имеет ожидаемой поведение, т.к. указатель учитывает состояние инварианта и не позволяет оптимизатору выполнить подстановку (substitution), т.е. использовать, прочитанные для исходного объекта, данные в рамках вновь созданного объекта.


Операция launder позволяет правильно оперировать инвариантными группами в рамках девиртуализации. С++ дает возможность принудительно генерировать вызов операции в промежуточном представление.


Вызов std::launder это принудительная (ручная) генерация вызова функции @llvm.launder.invariant.group в IR. Точная реализация на стороне IR зависит от настройки компиляции и обрабатываемого типа. Если мы не используем описанную модель девиртуализации (флаг -fstrict-vtable-pointers) или обрабатываем не полиморфный тип, то в IR просто генерируется алиасный указатель.


Таким образом, исходя из семантики выражения placement new эти две реализации функции test генерируют один и тот же код и не приводят к неопределенному поведению:


void test(){    A *a = new A;    a->f();    new(a) B;    std::launder(a)->f();}

void test(){    A *a = new A;    a->f();    A *b = new(a) B;    b->f();}


Использование std::launder


Мотивирующий пример из стандарта:


struct X{    const int n;};void test(){    X *p = new X{1};    const int a = p->n;    X *p1 = new (p) X{2};    // определенное поведение    const int b = p1->n;    // неопределенное поведение, значение p->n может быть 1.    const int c = p->n;    // определенное поведение    const int d = std::launder(p)->n;}

Выше мы описывали, что такое заменяемый тип (transparently replaceable) в терминах объектной модели C++. После переиспользования памяти, все имена, ссылки и указатели ссылающиеся на исходный объект, станут автоматически указывать на вновь созданный объект, только если типы исходного и нового объекта являются заменяемыми.


В этом примере как и в примере с девиртуализацией неопределенное поведение обусловлено тем, что типы не являются заменяемыми, из-за наличия константных полей. Это дает возможность оптимизатору предположить, что динамический тип объекта измениться не может и значение константного поля не меняется. Поэтому значение поля n можно прочитать только один раз и заменить все дальнейшие чтения этого поля через данный указатель на прочитанное значение. Ситуация аналогичная чтению виртуального указателя. Указатели p1 или std::launder(p) должны учитывать состояние инвариантов и не позволять оптимизатору выполнить подстановку (substitution), т.е. использовать, прочитанные для исходного объекта, данные (значение a) в рамках вновь созданного.


Более абстрактно семантику std::launder можно свести к следующему:


Функция всегда возвращает указатель на текущий объект размещенный в памяти, запрещая в точке вызова выполнять подстановку ранее прочитанных данных исходного объекта, если мы нарушили требования к заменяемым типам. Тем самым избежав неопределенного поведения.

Из определения этой семантики можно сделать следующие выводы:


  • std::launder не оказывает ни какого эффекта, если аргумент не указывает на объект, время жизни которого закончилось, после переиспользования памяти.
  • В области памяти, на который ссылается аргумент функции, должeн существовать новый объект, который переиспользует память.

Ограничения на использование ссылок и констант в таком контексте было введено еще в C++03 3.8 7. Но, на данный момент ни один из популярных компиляторов (GCC, Clang, MSVC) не эксплуатирует это неопределенное поведение. Следует отметить только компилятор IBM XL, который выполняет подобную оптимизацию константных полей, но только в ограниченных случаях:


struct A{    const int x;    int y;};// оптимизация выполняется только для static storage durationA a = { 42, 0 };void foo();int test(){    // внешняя функция, компилятор не видит определения    foo();    // чтение a.х заменяется на 42    return a.x;}

Причин, почему компиляторы не делают такую const/reference оптимизацию несколько. До введения функции std::launder избежать неопределенного поведения можно было только использовав возвращаемое значение placement new. Но, на практике, не всегда легко это сделать, например:


template <typename T>union Storage{    constexpr Storage(T &&v)        : value(std::forward<T>(v))    {}    unsigned char dummy;    T value;};template <typename T>struct SimpleOptional{    constexpr SimpleOptional(T&& v)        : storage{std::forward<T>(v)}    {}    template <typename... Args>    void emplace(Args&&... args)    {        storage.value.~T();        ::new (&storage.value) T{std::forward<Args>(args)...};    }    constexpr const T& operator*() const    {        // чтение значение может привести к неопределенному поведению        // после переиспользования памяти        return storage.value;    }private:    Storage<T> storage;};struct A{    const int x;    int y;}void test(){    SimpleOptional<A> so = A{1, 0.5f};    const int n = (*so).x;    so.emplace(2, 0.4f);    // неопределенное поведение, x может быть равен 1 или 2    const int n1 = (*so).x;}

В данном случае, чтобы избежать неопределенного поведения придется отдельно хранить результат выражения placement new.


Более интересный пример это стандартные аллокаторы. Точнее метод construct, который используя placement new, возвращает void. Такой интерфейс аллокаторов приводит к неопределенному поведению при использование стандартных контейнеров, например, std::vector с элементами содержащими константные или ссылочные поля. Заметим, что до С++11 использование таких типов в стандартных контейнерах было невозможно. С приходом C++11 и move-семантики этого ограничение не стало.


Пример использования в контейнере:


template <typename T, typename A = std::allocator<T>>class vector{public:    using allocator_traits = typename std::allocator_traits<A>;    using pointer = typename allocator_traits::pointer pointer;    ...public:    ...    void push_back(const T& value)    {        // reserve        ...        // вызов std::allocator::construct,        // использование placement new и игнорирование возвращаемого значения        allocator_traits::construct(allocator, end, value);        ++end;    }    T& operator[] (size_t i)    {        // чтение значение может приводит к неопределенному поведению        // после переиспользования памяти в случае нарушения требований         // к заменяемым типам        return begin[i];    }    ...private:    pointer begin;    pointer end;    A allocator;    size_t capacity;};struct A{    const int x;    int y;};void test(){    vector<X> c;    c.push_back(X{1});    c.clear();    c.push_back(X{2});    // неопределенное поведение    assert(c[0].x == 2);}

Использование std::launder в общем случае так же не может решить проблему с использованием стандартных аллокаторов:


...    using pointer = typename allacoter_traits::pointer pointer;...    T& operator[] (size_t i)    {        // тип pointer в общем случае может не быть указателем        // в этом случае мы не можем использовать std::launder        return std::launder(begin)[i];    }

В результате использование стандартных аллокаторов с пользовательскими типами, содержащими константные или ссылочные поля и стандартных контейнеров будет приводить к неопределенному поведению. Эта одна из причин, почему начиная с C++17 метод construct помечен как deprecated и удален в C++20. И в конечном итоге в C++20 изменили требования к заменяемым типам, убрав ограничение на наличие константных и ссылочных подобъектов в пользовательских типах данных RU007. Поэтому пример выше формально больше не приводит к неопределенному поведению.


Заключение


К счастью, стандарт языка C++ не разрабатывается в вакууме. Автор std::launder и связанных с ним изменений является также тех.лидом в Clang и одним из авторов новой модели девиртуализации. Новые требования и std::launder хорошо ложатся в архитектуру Clang, чего нельзя сказать о GCC. В GCC эта фича до сих пор носит статус экспериментальной и не во всех случаях работает правильно. Например, Bug 95349.




Буду рад комментариям и предложениям (можно по почте yegorov.alex@gmail.com)
Спасибо!

Подробнее..

Русификация баша

08.01.2021 06:09:33 | Автор: admin

Чтож... Начну с откашливаний - я не писака ( в хорошем смысле этого слова, писать " на поток" это надо уметь), хоть и немного писатель. И даже не очень уверен в том, что я хочу вам сегодня поведать.
Но моей Прокрастинации нет границ, так что пристёгивайте ремни, это будет ухабистая поездка!)

Вступление

Началось всё с комментария моего хорошего знакомого @Oxyd, который меня заинтересовал.
Я уже до этого успел с ним подискутировать на тему русского в програмировании ( безответно) ), и не мог пропустить такой шанс. Да и интересно стало) Поэтому появился первый сферический прототип в вакууме:

Из $1 выбрать  Демон) запустить $скрипт/демон.об;;  Аминь) запустить $скрипт/демон.об очистка;;ГотовоЕсли [ $2 ]; тогда  вывести "| $ном_проц | $путь_окр |"илсе# Местная подсветка синтаксиса совершенно не понимает что твориться :D

Это было забавно и интересно, но как-то... синтетически, что ли.
Поэтому я остался недоволен и, вспомнив что в моём любимом баше есть alias-ы (синонимы для команд), решил переписать целый скрипт на русский bash.

Русифицированный bash

Мой выбор пал на другой шуточный проект, window slicer. Он был вдохновлён не менее шуточным проектом с reddit, предназначенным для другого оконного менеджера.
Вкратце, этот скрипт разделяет окно надвое на основе координат курсора и заданного направления. Если привязать его к любому демону "мышиных" жестов, то можно сыграть в забавный аналог известной игры Fruct Ninja, только с окнами вместо фруктов)

Несколько тестов в оболочке спустя я был готов к переписыванию, только уже на zsh - аналог bash. Почему, спрашивается, ведь в названии статьи указан баш?
Ответ прост - bash не понимает переменных на кириллице. Увы)
Но разница между ними незаметна для нашей темы, поэтому будем считать что мы просто работаем с оболочкой.
Всё равно слово "баш" время от времени используют для её обозначения.

И вот, собственно, что получилось:

#! /bin/zsh# Магия, не подглядывай!ТЕРМИНАЛ=$TERMalias если="if" \      тогда="then" \      илсе="fi" \      из="case" \      выбрать="in" \      готово="done" \      вывод="echo" \      оценить="eval" \      пока="until" \      делаем="do" \      спим="sleep" \      иначе="else" \      иначе_если="elif"# Собственно программа# Объявляем переменныеX=0Y=0x_окна=0y_окна=0ширину_окна=2высоту_окна=2приложение=$ТЕРМИНАЛ# Количество окон до старта. Костыль \_()_/кол_окон=$(i3-msg -t get_tree | jq -r '.. | .nodes?[]? | select(.window_type == "normal") | .name' | wc -l)# Получаем координаты курсораоценить $(xdotool getmouselocation --shell)# Получаем параметры окна от i3wmоценить $( i3-msg -t get_tree | jq -r '..|try select(.focused == true)| "x_окна=\(.rect.x)\ny_окна=\(.rect.y)\nширину_окна=\(.rect.width)\nвысоту_окна=\(.rect.height)\nприложение=\(.window_properties.instance)"' )ждём(){    пока [ $кол_окон -lt $нов_кол_окон ];    делаем        нов_кол_окон=$(i3-msg -t get_tree | jq -r '.. | .nodes?[]? | select(.window_type == "normal") | .name' | wc -l)        спим 0.1    готово}рубим(){    разница=$(( $1 / 2 - ($2 - $3) )) # количество пикселей от от центра окна до места "рубки"    i3-msg split "$5" && $приложение & disown && ждём    # Подтягиваем новое окно до нужного размера    если [ $разница -gt 0 ]; тогда        i3-msg resize grow "$4" "$разница"    иначе        разница=$(( разница * - 1 ))        i3-msg resize shrink "$4" "$разница"    илсе}если [ "$1" = "гор" ]; тогда рубим $высоту_окна $Y $y_окна height vиначе_если [ "$1" = "вер" ]; тогда рубим $ширину_окна $X $x_окна width hилсе

Для удобства и домашнего насилия изучения есть ссылка на gist

Анализ

Честно говоря, я отделался лёгким испугом.Оказывается 13 алиасов и переменных на русском с головой достаточно для полной трансформации кода. Я был готов расписать весь скрипт чуть ли не побуквенно, но после очередного прочтения и добавления пары коментариев это оказалось просто не нужно.
Небольшой хитростью было оставить все обращения к "внешним" программам нетронутыми, но это можно назвать вынужденной мерой - реализация полного перевода явно выходит за рамки проекта just for fun. Так же пришлось притянуть за уши слово "оценить", потому как в русском нету красивого слова со значением "одарить/придать/задать/дать значение/цену" - аналога evaluate.
Тем не менее, результат уже на лицо и любой не знающий английского IT-шник (есть такие, интересно?) мгновенно поймёт функцию скрипта и даже его структуру, с поправкой на неведомый механизм добычи переменных (коментарии помогут).
Искать ошибки проще, делиться тоже, казалось бы, всё прекрасно, но не тут то было!

Писать этот код было ужасно неудобно. Вот совсем. Отчасти в этом виноват мой editor-of-choise, vim, который не дружит с русской раскладкой для хоткеев. Но даже если убрать его из уравнения, в русской раскладке просто нету нужных символов!
Все необычные скобки, доллар, амперсант и всё что я не вспомнил приходилось писать на английской расскладке. А уж ошибок из-за привычек привязанных к раскладками ( как примеры написание точек, запятых и кавычек) просто не счесть.

Можно ли считать это большой проблемой? По хорошему да. У нас нету нужных клавиатур, нету нужных символов под рукой, нету нужных привычек...
Но этот вопрос я оставлю для статьи с которой всё это началось, О русском языке в програмировании.

Моё мнение

  1. Это действительно удобно для понимания кода. Можно советовать новичкам, а то и вовсе написать транслятор, который будет выводить привычный код в "читабельном" виде. Наверняка пригодится на обсуждениях логики работы.

  2. Писать это неудобно. Может разработкой языка заточеного под русскую раскладку и решаться некоторые проблемы, но таких маловато. ( и я не пробовал )
    Так же возможно поможет IDE, но это надо проверять.

  3. Лично я этим пользоваться не буду. Я свободно владею английским и потому в моей голове происходит ровно то же проговаривание, при чём без перевода. А вот людям не знающим английского свободно посоветую хотя бы попробовать.

И в общем-то на этом всё. Не так страшен чёрт как его рисуют)

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

Подробнее..

Полноценный Kubernetes с нуля на Raspberry Pi

13.08.2020 10:12:29 | Автор: admin


Совсем недавно одна известная компания объявила, что переводит линейку своих ноутбуков на ARM-архитектуру. Услышав эту новость, я вспомнил: просматривая в очередной раз цены на EC2 в AWS, обратил внимание на Graviton'ы с очень вкусной ценой. Подвох, конечно же, был в том, что это ARM. Тогда мне и в голову не приходило, что ARM это довольно серьезно

Для меня эта архитектура всегда была уделом мобильных и прочих IoT-штучек. Настоящие серверы на ARM как-то необычно, в чем-то даже дико Однако новая мысль засела в голову, поэтому в один из выходных решил проверить, что вообще можно сегодня запустить на ARM. И для этого решил начать с близкого и родного кластера Kubernetes. Причем не просто какого-то условного кластера, а всё по-взрослому, чтобы он был максимально таким же, каким я привык его видеть в production.

По моей задумке, кластер должен быть доступным из интернета, в нём должно выполняться некоторое веб-приложение и еще должен быть как минимум мониторинг. Для реализации этой идеи понадобится пара (или больше) Raspberry Pi не ниже модели 3B+. Площадкой для экспериментов могла бы стать и AWS, но мне были интересны именно малины (которые всё равно стояли без дела). Итак, мы развернём на них кластер Kubernetes с Ingress, Prometheus и Grafana.

Подготовка малин


Установка ОС и SSH


С выбором ОС для установки я сильно не заморачивался: просто взял самый свежий Raspberry Pi OS Lite с официального сайта. Там же доступна документация по установке, все действия из которой нужно выполнить на всех узлах будущего кластера. Далее потребуется произвести следующие манипуляции (тоже на всех узлах).

Подключив монитор и клавиатуру, необходимо предварительно настроить сеть и SSH:

  1. Для работы кластера на мастере обязательно должен быть статический IP-адрес, а на рабочих узлах по усмотрению. Я предпочел статичные адреса везде из соображений удобства настройки.
  2. Статический адрес можно сконфигурировать в ОС (в файле /etc/dhcpcd.conf есть подходящий пример) или путем фиксации lease в DHCP-сервере используемого (в моём случае домашнего) маршрутизатора.
  3. ssh-server просто включается в raspi-config (interfacing options ssh).

После этого можно уже залогиниться по SSH (по умолчанию логин pi, а пароль raspberry или тот, на который поменяли) и продолжить настройки.

Другие настройки


  1. Установим имя хоста. В моём примере будут использоваться pi-control и pi-worker.
  2. Проверим, что файловая система расширена на весь диск (df -h /). При необходимости её можно расширить с помощью raspi-config.
  3. Изменим пароль пользователя по умолчанию в raspi-config.
  4. Выключим swap-файл (таково требование Kubernetes; если вам интересны подробности по этой теме, см. issue #53533):

    dphys-swapfile swapoffsystemctl disable dphys-swapfile
    
  5. Обновим пакеты до последних версий:

    apt-get update && apt-get dist-upgrade -y
    
  6. Установим Docker и дополнительные пакеты:

    apt-get install -y docker docker.io apt-transport-https curl bridge-utils iptables-persistent
    

    При установке iptables-persistent потребуется сохранить настройки iptables для ipv4, а в файле /etc/iptables/rules.v4 добавить правила в цепочку FORWARD, вот так:

    # Generated by xtables-save v1.8.2 on Sun Jul 19 00:27:43 2020*filter:INPUT ACCEPT [0:0]:FORWARD ACCEPT [0:0]:OUTPUT ACCEPT [0:0]-A FORWARD -s 10.1.0.0/16  -j ACCEPT-A FORWARD -d 10.1.0.0/16  -j ACCEPTCOMMIT
    
  7. Осталось только перезагрузиться.

Теперь все готово к установке кластера Kubernetes.

Инсталляция Kubernetes


На этом этапе я специально отложил все свои и наши корпоративные наработки по автоматизации установки и конфигурации кластера K8s. Вместо этого, воспользуемся официальной документацией с kubernetes.io (слегка дополненной комментариями и сокращениями).

Добавим репозиторий Kubernetes:

curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -cat <<EOF | sudo tee /etc/apt/sources.list.d/kubernetes.listdeb https://apt.kubernetes.io/ kubernetes-xenial mainEOFsudo apt-get update

Далее в документации предлагается установить CRI (container runtime interface). Поскольку Docker уже установлен, двигаемся дальше и инсталлируем основные компоненты:

sudo apt-get install -y kubelet kubeadm kubectl kubernetes-cni

На шаге установки основных компонентов я сразу добавил kubernetes-cni, который необходим для работы кластера. И тут есть важный момент: пакет kubernetes-cni по каким-то причинам не создает директорию по умолчанию для настроек CNI-интерфейсов, поэтому мне пришлось создать ее вручную:

mkdir -p /etc/cni/net.d

Для работы network-бэкенда, речь о котором пойдет ниже, необходимо доустановить плагин для CNI. Я выбрал привычный и понятный мне плагин portmap (полный их список см. в документации):

curl -sL https://github.com/containernetworking/plugins/releases/download/v0.7.5/cni-plugins-arm-v0.7.5.tgz | tar zxvf - -C /opt/cni/bin/ ./portmap

Настройка Kubernetes


Узел с control plane


Установка самого кластера делается довольно просто. А для ускорения этого процесса и проверки того, что образы Kubernetes доступны, можно предварительно выполнить:

kubeadm config images pull

Теперь проводим саму установку инициализируем control plane кластера:

kubeadm init --pod-network-cidr=10.1.0.0/16 --service-cidr=10.2.0.0/16 --upload-certs

Обратите внимание, что подсети для сервисов и pod'ов не должны пересекаться между собой и с существующими сетями.

В конце нам покажут сообщение о том, что все хорошо, и заодно подскажут, как присоединить рабочие узлы к control plane:

Your Kubernetes control-plane has initialized successfully!To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/configYou should now deploy a pod network to the cluster.Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/You can now join any number of the control-plane node running the following command on each as root: kubeadm join 192.168.88.30:6443 --token a485vl.xjgvzzr2g0xbtbs4 \   --discovery-token-ca-cert-hash sha256:9da6b05aaa5364a9ec59adcc67b3988b9c1b94c15e81300560220acb1779b050 \   --contrl-plane --certificate-key 72a3c0a14c627d6d7fdade1f4c8d7a41b0fac31b1faf0d8fdf9678d74d7d2403Please note that the certificate-key gives access to cluster sensitive data, keep it secret!As a safeguard, uploaded-certs will be deleted in two hours; If necessary, you can use"kubeadm init phase upload-certs --upload-certs" to reload certs afterward.Then you can join any number of worker nodes by running the following on each as root:kubeadm join 192.168.88.30:6443 --token a485vl.xjgvzzr2g0xbtbs4 \   --discovery-token-ca-cert-hash sha256:9da6b05aaa5364a9ec59adcc67b3988b9c1b94c15e81300560220acb1779b050

Выполним рекомендации по добавлению конфига для пользователя. А заодно рекомендую сразу добавить автодополнение для kubectl:

 kubectl completion bash > ~/.kube/completion.bash.inc printf " # Kubectl shell completion source '$HOME/.kube/completion.bash.inc' " >> $HOME/.bash_profile source $HOME/.bash_profile

На данном этапе уже можно увидеть первый узел в кластере (правда, он еще не готов):

root@pi-control:~# kubectl get noNAME         STATUS     ROLES    AGE   VERSIONpi-control   NotReady   master   29s   v1.18.6

Конфигурация сети


Далее, как было сказано в сообщении после установки, потребуется установить сеть в кластер. В документации предлагают выбор из Calico, Cilium, contiv-vpp, Kube-router и Weave Net Здесь я отступил от официальной инструкции и выбрал более привычный и понятный мне вариант: flannel в режиме host-gw (подробнее о доступных бэкендах см. в документации проекта).

Установить его в кластер довольно просто. Для начала скачиваем манифесты:

wget https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml

Затем меняем в настройках тип с vxlan на host-gw:

sed -i 's/vxlan/host-gw/' kube-flannel.yml

и подсеть pod'ов со значения по умолчанию на ту, которая указана при инициализации кластера:

sed -i 's#10.244.0.0/16#10.1.0.0/16#' kube-flannel.yml

После этого создаем ресурсы:

kubectl create -f kube-flannel.yml

Готово! Через некоторое время первый узел K8s перейдет в статус Ready:

NAME         STATUS   ROLES    AGE   VERSIONpi-control   Ready    master   2m    v1.18.6

Добавление рабочего узла


Теперь можно добавить worker'а. Для этого на нем после установки собственно Kubernetes по сценарию, описанному выше, нужно просто выполнить ранее полученную команду:

kubeadm join 192.168.88.30:6443 --token a485vl.xjgvzzr2g0xbtbs4 \    --discovery-token-ca-cert-hash sha256:9da6b05aaa5364a9ec59adcc67b3988b9c1b94c15e81300560220acb1779b050

На этом можно считать, что кластер готов:

root@pi-control:~# kubectl get noNAME         STATUS   ROLES    AGE    VERSIONpi-control   Ready    master   28m    v1.18.6pi-worker    Ready    <none>   2m8s   v1.18.6

У меня под рукой было всего две Raspberry Pi, так что отдавать одну из них только под control plane мне не хотелось. Поэтому я снял автоматически установленный taint с узла pi-control, запустив:

root@pi-control:~# kubectl edit node pi-control

и удалив строки:

 - effect: NoSchedule   key: node-role.kubernetes.io/master

Наполнение кластера необходимым минимумом


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

Итак, заходим на helm.sh в раздел docs/installation и выполняем команду оттуда:

curl -s https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash

После этого добавляем репозиторий чартов:

helm repo add stable https://kubernetes-charts.storage.googleapis.com/

Теперь установим инфраструктурные компоненты в соответствии с задумкой:

  • Ingress controller;
  • Prometheus;
  • Grafana;
  • cert-manager.

Ingress controller


Первый компонент Ingress controller устанавливается довольно просто и готов к использованию из коробки. Для этого достаточно зайти в раздел bare-metal на сайте и выполнить команду установки оттуда:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.34.1/deploy/static/provider/baremetal/deploy.yaml

Однако в этот момент малина начала напрягаться и упираться в дисковый IOPS. Дело в том, что вместе с Ingress-контроллером устанавливается большое количество ресурсов, выполняется много запросов к API и, соответственно, много данных записывается в etcd. В общем, либо карта памяти 10 класса не очень производительна, либо SD-карты в принципе не хватает для такой нагрузки. Тем не менее, через минут 5 все запустилось.

Был создан namespace и в нем появился контроллер и всё ему необходимое:

root@pi-control:~# kubectl -n ingress-nginx get podNAME                                        READY   STATUS      RESTARTS   AGEingress-nginx-admission-create-2hwdx        0/1     Completed   0          31singress-nginx-admission-patch-cp55c         0/1     Completed   0          31singress-nginx-controller-7fd7d8df56-68qp5   1/1     Running     0          48s

Prometheus


Следующие два компонента довольно просто установить через Helm из chart repo.

Находим Prometheus, создаем namespace и устанавливаем в него:

helm search repo stable | grep prometheuskubectl create ns monitoringhelm install prometheus --namespace monitoring stable/prometheus --set server.ingress.enabled=True --set server.ingress.hosts={"prometheus.home.pi"}

По умолчанию Prometheus заказывает 2 диска: под данные самого Prometheus и под данные AlertManager. Поскольку в кластере не создан storage class, диски не закажутся и pod'ы не запустятся. Для bare metal-инсталляций Kubernetes мы обычно используем Ceph rbd, однако в случае с Raspberry Pi это явный перебор.

Поэтому создадим простой local storage на hostpath. Манифесты PV (persistent volume) для prometheus-server и prometheus-alertmanager объединены в файле prometheus-pv.yaml в Git-репозитории с примерами для статьи. Директорию для PV необходимо заранее создать на диске того узла, к которому хотим привязать Prometheus: в примере прописан nodeAffinity по hostname pi-worker и на нем созданы директории /data/localstorage/prometheus-server и /data/localstorage/prometheus-alertmanager.

Скачиваем (клонируем) манифест и добавляем в Kubernetes:

kubectl create -f prometheus-pv.yaml

На этом этапе я впервые столкнулся с проблемой ARM-архитектуры. Kube-state-metrics, который по умолчанию устанавливается в чарте Prometheus, отказался запускаться. Он выдавал ошибку:

root@pi-control:~# kubectl -n monitoring logs prometheus-kube-state-metrics-c65b87574-l66d8standard_init_linux.go:207: exec user process caused "exec format error"

Дело в том, что для kube-state-metrics используется образ проекта CoreOS, который не собирают под ARM:

kubectl -n monitoring get deployments.apps prometheus-kube-state-metrics -o=jsonpath={.spec.template.spec.containers[].image}quay.io/coreos/kube-state-metrics:v1.9.7

Пришлось слегка погуглить и найти, например, вот этот образ. Чтобы им воспользоваться, обновим релиз, указав, какой образ использовать для kube-state-metrics:

helm upgrade prometheus --namespace monitoring stable/prometheus --set server.ingress.enabled=True --set server.ingress.hosts={"prometheus.home.pi"} --set kube-state-metrics.image.repository=carlosedp/kube-state-metrics --set kube-state-metrics.image.tag=v1.9.6

Проверяем, что все запустилось:

root@pi-control:~# kubectl -n monitoring get poNAME                                             READY   STATUS              RESTARTS   AGEprometheus-alertmanager-df65d99d4-6d27g          2/2     Running             0          5m56sprometheus-kube-state-metrics-5dc5fd89c6-ztmqr   1/1     Running             0          5m56sprometheus-node-exporter-49zll                   1/1     Running             0          5m51sprometheus-node-exporter-vwl44                   1/1     Running             0          4m20sprometheus-pushgateway-c547cfc87-k28qx           1/1     Running             0          5m56sprometheus-server-85666fd794-z9qnc               2/2     Running             0          4m52s

Grafana и cert-manager


Для графиков и dashboard'ов ставим Grafana:

helm install grafana --namespace monitoring stable/grafana  --set ingress.enabled=true --set ingress.hosts={"grafana.home.pi"}

В конце вывода нам покажут, как получить пароль для доступа:

kubectl get secret --namespace monitoring grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo

Для заказа сертификатов установим cert-manager. Для его установки обратимся к документации, которая предлагает соответствующие команды для Helm:

helm repo add jetstack https://charts.jetstack.iohelm install \  cert-manager jetstack/cert-manager \  --namespace cert-manager \  --version v0.16.0 \  --set installCRDs=true

Для самоподписанных сертификатов в домашнем использовании этого вполне достаточно. Если же нужно получать тот же Let's Encrypt, то необходимо настроить еще cluster issuer. Подробности об этом можно найти в нашей статье SSL-сертификаты от Let's Encrypt с cert-manager в Kubernetes.

Сам я остановился на варианте из примера в документации, решив, что staging-варианта LE будет достаточно. Изменяем в примере e-mail, сохраняем в файл и добавляем в кластер (cert-manager-cluster-issuer.yaml):

kubectl create -f cert-manager-cluster-issuer.yaml

Теперь можно заказать сертификат, например, для Grafana. Для этого потребуется домен и доступ в кластер извне. Домен у меня есть, а трафик я настроил пробросом портов 80 и 443 на домашнем маршрутизаторе в соответствии с созданным сервисом ingress-controller'a:

kubectl -n ingress-nginx get svcNAME                                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGEingress-nginx-controller             NodePort    10.2.206.61    <none>        80:31303/TCP,443:30498/TCP   23d

80-й порт в данном случае транслируется в 31303, а 443 в 30498. (Порты генерируются случайным образом, поэтому у вас они будут другие.)

Вот пример сертификата (cert-manager-grafana-certificate.yaml):

apiVersion: cert-manager.io/v1alpha2kind: Certificatemetadata:  name: grafana  namespace: monitoringspec:  dnsNames:    - grafana.home.pi  secretName: grafana-tls  issuerRef:    kind: ClusterIssuer    name: letsencrypt-staging

Добавляем его в кластер:

kubectl create -f cert-manager-grafana-certificate.yaml

После этого появится ресурс Ingress, через который будет происходить валидация Let's Encrypt'ом:

root@pi-control:~# kubectl -n monitoring get ingNAME                        CLASS    HOSTS                        ADDRESS         PORTS   AGEcm-acme-http-solver-rkf8l   <none>   grafana.home.pi      192.168.88.31   80      72sgrafana                     <none>   grafana.home.pi      192.168.88.31   80      6d17hprometheus-server           <none>   prometheus.home.pi   192.168.88.31   80      8d

После того, как валидация пройдет, мы увидим, что ресурс certificate готов, а в указанном выше секрете grafana-tls сертификат и ключ. Можно сразу проверить, кто выпустил сертификат:

root@pi-control:~# kubectl -n monitoring get certificateNAME      READY   SECRET        AGEgrafana   True    grafana-tls   13mroot@pi-control:~# kubectl -n monitoring get secrets grafana-tls -ojsonpath="{.data['tls\.crt']}" | base64 -d | openssl x509 -issuer -nooutissuer=CN = Fake LE Intermediate X1

Вернемся к Grafana. Нам потребуется немного исправить её Helm-релиз, изменив настройки для TLS в соответствии с созданным сертификатом.

Для этого скачиваем чарт, правим и обновляем из локальной директории:

helm pull --untar stable/grafana

Редактируем в файле grafana/values.yaml параметры TLS:

  tls:    - secretName: grafana-tls      hosts:        - grafana.home.pi

Здесь же можно сразу настроить установленный Prometheus в качестве datasource:

datasources:  datasources.yaml:    apiVersion: 1    datasources:    - name: Prometheus      type: prometheus      url: http://prometheus-server:80      access: proxy      isDefault: true

Теперь из локальной директории обновляем чарт Grafana:

helm upgrade grafana --namespace monitoring ./grafana  --set ingress.enabled=true --set ingress.hosts={"grafana.home.pi"}

Проверяем, что в Ingress grafana добавился 443 порт и есть доступ по HTTPS:

root@pi-control:~# kubectl -n monitoring get ing grafanaNAME      CLASS    HOSTS                     ADDRESS         PORTS     AGEgrafana   <none>   grafana.home.pi           192.168.88.31   80, 443   63mroot@pi-control:~# curl -kI https://grafana.home.piHTTP/2 302server: nginx/1.19.1date: Tue, 28 Jul 2020 19:01:31 GMTcontent-type: text/html; charset=utf-8cache-control: no-cacheexpires: -1location: /loginpragma: no-cacheset-cookie: redirect_to=%2F; Path=/; HttpOnly; SameSite=Laxx-frame-options: denystrict-transport-security: max-age=15724800; includeSubDomains

Для демонстрации Grafana в действии можно скачать и добавить dashboard для kube-state-metrics. Вот как это выглядит:



Еще рекомендую добавить dashboard для node exporter: он детально покажет, что происходит с малинами (нагрузка CPU, использование памяти, сети, диска и т.д.).

После этого считаю, что кластер готов принимать и запускать приложения!

Примечание про сборку


Для сборки приложений под ARM-архитектуру есть как минимум два варианта. Во-первых, можно собирать на ARM-устройстве. Однако, посмотрев на текущую утилизацию двух Raspberry Pi, я понял, что еще и сборку они не выдержат. Поэтому заказал себе новую Raspberry Pi 4 (она помощнее и в ней есть аж 4 GB памяти) планирую собирать на ней.

Второй вариант сборка мультиархитектурного образа Docker на более мощной машине. Для этого есть расширение docker buildx. Если приложение на компилируемом языке, то потребуется кросс-компиляция для ARM. Описывать все настройки для такого пути не буду, т.к. это потянет на отдельную статью. При реализации такого подхода можно добиться универсальных образов: Docker, запущенный на ARM-машине, сам будет автоматически загружать соответствующий архитектуре образ.

Заключение


Проведенный эксперимент превзошел все мои ожидания: [как минимум] ванильный Kubernetes с необходимой базой неплохо себя чувствует на ARM, а при его конфигурации возникла лишь пара нюансов.

Сами Raspberry Pi 3B+ держат нагрузку на CPU, однако их SD-карты явное бутылочное горлышко. Коллеги подсказали, что в каких-то версиях есть возможность загружаться с USB, куда можно подключить SSD: тогда скорее всего ситуация станет получше.

Вот пример загрузки CPU при установке Grafana:



Для экспериментов и на попробовать, на мой взгляд, Kubernetes-кластер на малинах гораздо лучше передаёт ощущения от эксплуатации, чем тот же самый Minikube, потому что все компоненты кластера и устанавливаются, и работают по-взрослому.

В перспективе есть идея добавить к кластеру весь цикл CI/CD, реализованный полностью на Raspberry Pi. А также я буду рад, если кто-то поделится своим опытом по настройке K8s на AWS Graviton'ах.

P.S. Да, production может быть ближе, чем я думал:



P.P.S.


Читайте также в нашем блоге:

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru