Как стать автором
Обновить

Заменяем глобальный аллокатор в Rust

Время на прочтение11 мин
Количество просмотров7.7K

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



static и const


В японском зоопарке родился слоненок — казалось бы, при чем тут Лужков? Глобальный аллокатор, static и const?! — Спокойно, действуем по ранее утвержденному плану. Да и все равно изучать.


Рассмотрим такой пример:


struct Point {
    x: i32,
    y: i32,
}

static STATIC_POINT: Point = new_point(10, 20);
static mut MUT_STATIC_POINT: Point = Point{x: 30, y: 40};
const CONST_POINT: Point = new_point(50, 60);

Каковы сходства и различия между разными "точками", в особенности между static STATIC_POINT и const CONST_POINT?


static и const:: Адрес


Для всех значений можно получить сырой указатель и напечатать его значение:


fn main() {

    print_pointer(100, &STATIC_POINT);
    unsafe {
        print_pointer(200, &MUT_STATIC_POINT);
    }
    print_pointer(300, &CONST_POINT);
}

#[inline(never)]
fn print_pointer<T>(label: i32, p: *const T) {
    println!("label:{} pointer:{:p}", label, p);
}

Результат какой-то такой:


label:100 pointer:0x562316fb2000
label:200 pointer:0x562316fc1008
label:300 pointer:0x562316fb2008

  • Взятие адреса изменяемой глобальной статической переменной является опасной операцией
  • print_pointer() с параметром label введена для удобства работы с ассемблерным листингом

Посмотрим в ассемблер, благо чудесный play.rust-lang.org это позволяет:


    push    rax
    lea rsi, [rip + playground::STATIC_POINT]
    mov edi, 100
    call    playground::print_pointer
    lea rsi, [rip + playground::MUT_STATIC_POINT]
    mov edi, 200
    call    playground::print_pointer
    lea rsi, [rip + .L__unnamed_2]
    mov edi, 300
    pop rax
    jmp playground::print_pointer

Ну т.е. вроде как отдельные адреса заведены под все значения. Стоит ли полагаться на то, что адрес const-значения (L__unnamed_2) постоянен? Как водится, внятное описание мы найдем в "старом учебнике":


More specifically, constants in Rust have no fixed address in memory. This is because they’re effectively inlined to each place that they’re used. References to the same constant are not necessarily guaranteed to refer to the same memory address for this reason.

Нет, не стоит. Если нужен гарантированный глобальный адрес, используем static.


static и const:: Встраивание (Inlining)


"...they’re effectively inlined" — ой ли?


Любую документацию по Rust надо рассматривать либо как неполную, либо как противоречивую (собственно, Гёдель), а в The Rust Reference авторы сами пишут красными буквами:


Warning: This book is incomplete

Так что будем проверять все, что можно проверить, благо, инструментарий имеется.


Погрузимся в глубины присваивания, напрямую можно присваивать только const:


    let p1 = CONST_POINT;
    dbg!(p1);

    // let p2 = STATIC_POINT; // Err: cannot move out of static item `STATIC_POINT`
    // let p3 = MUT_STATIC_POINT; // Err: cannot move out of static item `STATIC_POINT`

Посмотрим в ассемблер вот такого кода:


    ...
    let p1 = CONST_POINT;
    print_pointer(200, &p1);
    ...

    mov ecx, dword ptr [rip + .L__unnamed_2]
    mov eax, dword ptr [rip + .L__unnamed_2+4]
    mov dword ptr [rsp + 96], ecx
    mov dword ptr [rsp + 100], eax
    mov edi, 200
    lea rsi, [rsp + 96]
    call playground::print_pointer

Ну, такое себе "встраивание" — сначала все копируется в ecx и eax, затем оттуда в стек (локальную переменную p1). В чем дело? А, так это режим Debug, а что в Release?:


    movabs  rbx, 257698037810
    mov qword ptr [rsp + 8], rbx
    lea rsi, [rsp + 8]
    mov edi, 200
    call    playground::print_pointer

Вот тут, действительно, встраивание. 257698037810 это аккурат (60_i64 << 32) + 50, т.е. упакованное значение для const CONST_POINT: Point = new_point(50, 60).


Но постойте, мы же можем и static присвоить, для этого надо реализовать Copy:


#[derive(Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

Вот что выходит:


    ...
    let p1 = STATIC_POINT;
    print_pointer(200, &p1);
    ...
    movabs  rax, 85899345930
    mov qword ptr [rsp], rax
    mov rsi, rsp
    mov edi, 200
    call playground::print_pointer

Тоже inlining, (20_i64 << 32) + 10 = 85899345930 как раз соответствует static STATIC_POINT: Point = new_point(10, 20).


Короче, разницы, в плане "встраивания", между static и const на нашем примере не видно (понятно, что с поправкой на то, что Copy is-a-must).


static и const:: Изменяемость (Mutability)


Код примеров тут.


Ни одно из "значений" не может быть изменено в безопасном режиме, по разным причинам:


    // STATIC_POINT = new_point(1, 2); // Err: cannot assign to immutable static item `STATIC_POINT`
    // MUT_STATIC_POINT = new_point(1, 2); // Err: use of mutable static is unsafe and requires unsafe function or block
    // CONST_POINT = new_point(1, 2); // Err: invalid left-hand side of assignment

MUT_STATIC_POINT можно изменить в unsafe:


    unsafe {
        MUT_STATIC_POINT.x += 2;
        MUT_STATIC_POINT.y += 4;
    }
    unsafe {
      let p_point = &MUT_STATIC_POINT as *const _ as *mut Point;
      (*p_point).x += 1;
    }

  • static mut являет собой глобальную переменную, каковые часто рассматриваются как глобальное зло, а с такими вещами нужно работать осторожно, огораживая и помечая опасные участки при помощи unsafe.

Попытки изменить STATIC_POINT или CONST_POINT заканчиваются ужасами Segmentation fault:


    // unsafe {
    //   let p_point = &STATIC_POINT as *const _ as *mut Point;
    //   (*p_point).x += 1;
    // }
    // unsafe {
    //   let p_point = &CONST_POINT as *const _ as *mut Point;
    //   (*p_point).x += 1;
    // }

static и const:: Инициализация


Ну и, наконец, общим для всех этих… ээээ… "переменных" является то, что функции, их инициализирующие, должны быть объявлены с квалификатором const:


const fn new_point(x: i32, y: i32) -> Point {
    return Point { x: x, y: y };
}

  • Функции такого типа являются "чистыми" (pure), результат их известен во время компиляции и вызов функции может быть заменен ее результатом
  • Соответственно, в "кучу" лезть из таких функций нельзя

static и const:: Деструктор


Как утверждает отличное старое руководство, метод Drop::drop() для static переменных может быть реализован, но он не вызывается.


static и const:: Что использовать?


Прекрасный ответ дает RFC-0246:


  • constants declare constant values. These represent a value, not a memory address. This is the most common thing one would reach for and would replace static as we know it today in almost all cases.
  • statics declare global variables. These represent a memory address. They would be rarely used: the primary use cases are global locks, global atomic counters, and interfacing with legacy C libraries.

Для замены глобального аллокатора осталось освоить немного: тип unit, unit-like структуры, и значения параметров типа по умолчанию.


Тип unit


In the area of mathematical logic and computer science known as type theory, a unit type is a type that allows only one value (and thus can hold no information).

In Haskell and Rust, the unit type is called () and its only value is also (), reflecting the 0-tuple interpretation.

In Java, the unit type is called Void and its only value is null.

Unit type

Итак, пустой кортеж (tuple) () означает одновременно и тип, и литерал (фиксированное значение). Можно писать так:


fn no_result() {
}

Или так:


fn no_result() {
    return ()
}

Или так:


fn no_result() -> () {
    return ()
}

  • unit похож на void в C, но там у void нет значения
  • Запись HashMap<T, ()> означает словарь, у которого информацию несут только ключи (как бы HashSet)

Unit-like структуры


Unit-like структуры, как понятно из названия, также представляют собой и тип, и литерал:


struct MyUnitStruct;
static MY_UNIT_STRUCT1: MyUnitStruct = MyUnitStruct;
static MY_UNIT_STRUCT2: MyUnitStruct = MyUnitStruct;

Для комплекта возьмем еще std::alloc::System (мы потихоньку подбираемся к глобальному аллокатору) и напечатаем их адреса:


    // println!("&MyUnitStruct: {:p}", &MyUnitStruct);    
    // println!("&std::alloc::System: {:p}", &std::alloc::System);

    print_pointer(100, &MyUnitStruct);
    print_pointer(200, &std::alloc::System);

    print_pointer(300, &MY_UNIT_STRUCT1);
    print_pointer(400,  &MY_UNIT_STRUCT2); 

  • Ну и дела, они все равны!
  • Если раскомментировать // println..., то получаются два уникальных значения для адресов
  • Если нужен гарантированный адрес, то наверняка нужно использовать static

Ассемблер намекает, что MyUnitStruct и System трактуются как const, а MY_UNIT_STRUCT* — как static:


    mov edi, 100
    lea rsi, [rip + .L__unnamed_2]
    call    playground::print_pointer
    mov edi, 200
    lea rsi, [rip + .L__unnamed_2]
    call    playground::print_pointer
    mov edi, 300
    lea rsi, [rip + playground::MY_UNIT_STRUCT1]
    call    playground::print_pointer
    mov edi, 400
    lea rsi, [rip + playground::MY_UNIT_STRUCT2]
    call    playground::print_pointer

Это все, конечно, интересно, но каково же практическое применение? Еще немного теории, и приступим к практике.


Значения параметров типа по умолчанию


Мы почти у цели, осталось немного. Допустим, мы сделали сервер с методами start(), stop(), handle_request() и запускаем его так:


struct Server{}

impl Server{
    fn start(&self) {
        println!("Server started");
    }
...    
fn run_server(s: &Server){
    s.start();
    s.handle_request("req1");
    s.handle_request("req2");
    s.stop();
}

Все работает, но в процессе эксплуатации понимаем, что println!() надо бы заменить вызовами некоего нормального логгера, и Server у нас теперь выглядит вот так:


trait Logger {
    fn info(&self, msg: &str);
}

struct Server<L: Logger> {
    logger: L,
}

impl<L: Logger> Server<L> {
...
fn run_server<L: Logger>(s: &Server<L>) {
    s.start();
    s.handle_request("req1");
    s.handle_request("req2");
    s.stop();
}

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


Но сперва надо где-то взять реализацию Logger. Если нет желания создавать экземпляр, можно использовать как "носитель" любую unit-like структуру в области видимости, например, реализуем Logger для std::alloc::System:


impl Logger for std::alloc::System {
    fn info(&self, msg: &str) {
        println!("INFO: {}", msg);
    }
}

fn main() {
    std::alloc::System.info("Hello");
}

  • "Навесить" методов можно на что угодно, не только на "свое"
  • Ключевым моментом является то, что не требуется создавать экземпляр реализатора — он уже есть (как выяснилось, один на все unit-like структуры) и передается как первый параметр (&self)

Теперь System можно использовать как значение по умолчанию в параметрах типа:


struct Server<L: Logger = std::alloc::System> {
    logger: L,
}

impl<L: Logger> Server<L> {
    fn start(&self) {
        self.logger.info("Server started");        
...
fn run_server(s: &Server) {
    s.start();
    s.handle_request("req1");
    s.handle_request("req2");
    s.stop();
}
fn main() {
    let s = Server {
        logger: std::alloc::System,
    };
    run_server(&s);
}

  • voila — функция run_server() вернулась к первоначальному виду, что и требовалось
  • PS: DarkEld3r дал ценный комментарий, действительно, необходимо отметить, что в качестве "значения по умолчанию" можно использовать любые, а не только unit-like структуры, например: struct Server<L: Logger = MyLogger>

Может создаться впечатление, что здесь происходит процесс, известный как "Внедрение зависимости" (Dependency Injection), т.е. код:


    let s = Server {
        logger: std::alloc::System,
    };

… выступает в роли "внедренца" и связывает экземпляр типа Server с интерфейсом Logger. Нет, за кулисами генерируются разные "классы" Server под каждую конкретную реализацию Logger.


Этот момент чрезвычайно важен, для его понимания сделаем второй логгер:


struct MyLogger {}

impl Logger for MyLogger {
    fn info(&self, msg: &str) {
        println!("MyLogger-INFO: {}", msg);
    }
}
...
fn main() {
    let s1 = Server {
        logger: std::alloc::System,
    };
    run_server(&s1);

    let s2 = Server {
        logger: MyLogger{}
    };
    run_server(&s2);

… и заглянем за кулисы:


playground::Server<L>::start:
    push    rax
    mov qword ptr [rsp], rdi
    lea rsi, [rip + .L__unnamed_4]
    mov edx, 14
    call    <playground::MyLogger as playground::Logger>::info
    pop rax
    ret

playground::Server<L>::start:
    push    rax
    mov qword ptr [rsp], rdi
    lea rsi, [rip + .L__unnamed_4]
    mov edx, 14
    call    <std::alloc::System as playground::Logger>::info
    pop rax
    ret

  • Налицо две версии класса кодовой базы Server, по одной для каждого используемого логгера
  • Никакого DI, конкретный вариант "класса" Server точно "знает", с каким логгером он работает
  • Такая "механика" работает быстрее, чем вызов через vtable, но при этом исполняемый файл "раздувается" в размере

Замена глобального аллокатора


Замена глобального аллокатора описана здесь, так заменим:


struct Counter;
...
unsafe impl GlobalAlloc for Counter {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let ret = System.alloc(layout);
        ...
}

#[global_allocator]
static A: Counter = Counter;

  • В общем, надо поставить #[global_allocator] и реализовать trait GlobalAlloc
  • Предлагаемая реализация GlobalAlloc пробрасывает вызовы к std::alloc::System и попутно ведет счетчик
  • Постановка #[global_allocator] перед struct Counter приводит к ошибке allocators must be statics, т.е. таки Counter это константа. Наверное

System.alloc(), где это и что это? Про сам std::alloc::System мы уже знаем, это pub struct System; а откуда взялось System.alloc()? Вопрос интересный, как уже понятно, навесить alloc() на unit-like структуру System можно где угодно (в пределах видимости).


Через std::alloc::System реализуется std::alloc::GlobalAlloc:


pub unsafe trait GlobalAlloc {
    ...
    unsafe fn alloc(&self, layout: Layout) -> *mut u8;
    ...
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
    ...

Реализация зависит от платформы, "условная компиляция", видимо, происходит здесь:


cfg_if::cfg_if! {
    if #[cfg(unix)] {
        mod unix;
        pub use self::unix::*;
    } else if #[cfg(windows)] {
        mod windows;
        pub use self::windows::*;

Вот реализация для windows:


unsafe impl GlobalAlloc for System {
    #[inline]
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // SAFETY: Pointers returned by `allocate` satisfy the guarantees of `System`
        let zeroed = false;
        unsafe { allocate(layout, zeroed) }
    }
    ...

allocate():


extern "system" {
    ...
    // See https://docs.microsoft.com/windows/win32/api/heapapi/nf-heapapi-heapalloc
    fn HeapAlloc(hHeap: c::HANDLE, dwFlags: c::DWORD, dwBytes: c::SIZE_T) -> c::LPVOID;
    ...
}
unsafe fn allocate(layout: Layout, zeroed: bool) -> *mut u8 {
    ...
    if layout.align() <= MIN_ALIGN {
        // SAFETY: `heap` is a non-null handle returned by `GetProcessHeap`.
        // The returned pointer points to the start of an allocated block.
        unsafe { HeapAlloc(heap, flags, layout.size()) as *mut u8 }

  • Ну все, добрались до системных вызовов

Теперь посмотрим, как работает std::alloc::alloc() из предыдущей серии.


std::alloc::alloc()


std::alloc::alloc():


/// This function is expected to be deprecated in favor of the `alloc` method
/// of the [`Global`] type when it and the [`Allocator`] trait become stable.
...
pub unsafe fn alloc(layout: Layout) -> *mut u8 {
    unsafe { __rust_alloc(layout.size(), layout.align()) }
}

  • "...is expected to be deprecated in favor of Global and Allocator" — запомним это

__rust_alloc():


    // These are the magic symbols to call the global allocator.  rustc generates
    // them to call `__rg_alloc` etc. if there is a `#[global_allocator]` attribute
    // (the code expanding that attribute macro generates those functions), or to call
    // the default implementations in libstd (`__rdl_alloc` etc. in `library/std/src/alloc.rs`)
    // otherwise.
...
    fn __rust_alloc(size: usize, align: usize) -> *mut u8;

С #[global_allocator] все понятно, идем в __rdl_alloc():


    pub unsafe extern "C" fn __rdl_alloc(size: usize, align: usize) -> *mut u8 {
        // SAFETY: see the guarantees expected by `Layout::from_size_align` and
        // `GlobalAlloc::alloc`.
        unsafe {
            let layout = Layout::from_size_align_unchecked(size, align);
            System.alloc(layout)
        }
    }

Вот и все, доплыли до знакомого System.alloc().


Еще глянем RawVec, и на этом закончим.


Аллокатор в RawVec


RawVec:


pub struct RawVec<T, A: Allocator = Global> {
...

  • Не зря учили матчасть, теперь можно с чувством глубокого понимания искать trait Allocator, struct Global; и impl Allocator for Global
  • Nota Bene: Именно этим и заменят std::alloc::alloc()

trait std::alloc::Allocator:


pub unsafe trait Allocator {
...
    fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError>;
    ...
    fn allocate_zeroed(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> {
    ...

struct std::alloc::Global:


/// This type implements the [`Allocator`] trait by forwarding calls
/// to the allocator registered with the `#[global_allocator]` attribute
...
pub struct Global;

impl Allocator for Global:


unsafe impl Allocator for Global {
    fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> {
        self.alloc_impl(layout, false)
    }
    ...

Global.alloc_impl():


impl Global {
...
    fn alloc_impl(&self, layout: Layout, zeroed: bool) -> Result<NonNull<[u8]>, AllocError> {
        ...
        let raw_ptr = if zeroed { alloc_zeroed(layout) } else { alloc(layout) };

Далее пойдем только в ветку с alloc(layout):


pub unsafe fn alloc(layout: Layout) -> *mut u8 {
    unsafe { __rust_alloc(layout.size(), layout.align()) }
}

  • А __rust_alloc() уже проходили, уф

Не прощаюсь.


Далее: Замыкания

Теги:
Хабы:
Всего голосов 20: ↑20 и ↓0+20
Комментарии6

Публикации

Истории

Работа

Rust разработчик
8 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань