Зелёные потоки в 200 строчек на Rust. Часть 1

Это вольный перевод-конспект “книги” Green Threads Explained in 200 Lines of Rust.

Пользовательские потоки, горутины, файберы – у них много названий, но далее для простоты мы будем называть их зелёные потоки. Чтобы разобраться как они устроены мы напишем игрушечную реализацию.

Итак, как мы знаем, есть два вида многозадачности:

  • Вытесняющая – внешний планировщик принимает решения о том когда будет выполняться какой поток и отвечает за переключение между ними. Используется в операционных системах.
  • Кооперативная – поток сам решает когда нужно отдать процессорное время другим задачам. Это имеет смысл делать при ожидании чего-либо, например, мы часто ждём I/O. При блокировке поток передаёт управление планировщику, который переключается на выполнение другой задачи/потока.

Нас интересует кооперативная.

Попробуем описать состояние CPU и стек для нашей игрушечной реализации зелёных потоков. Поскольку я экспериментирую на macbook c процессором M1, то будем писать ассемблер для arm64.

Нам понадобится макрос asm!:

use core::arch::asm;

Размер стека выберем не более 48 байт, чтобы было удобно смотреть на него в терминале:

const SSIZE: isize = 48;

Теперь добавим структуру, которая будет описывать состояние (контекст) потока. Сейчас нам понадобится только регистр указателя на стек:

#[derive(Debug, Default)]
#[repr(C)]
struct ThreadContext {
    sp: u64,
}

Мы будем работать с этой структурой используя ассемблер и хотим быть уверены относительно того как она будет размещена в памяти. Например, что первые 8 байт это содержимое регистра SP. Для этого при описании структуры мы используем директиву repr(C).

Нам нужна функция для переключения между потоками. Вот такую реализацию можно использовать для arm64:

unsafe fn thread_switch(new: *const ThreadContext) {
    asm!(
        // Загружаем сохранённый ранее указатель стека
        "MOV SP, {}",
        // Считываем в ссылочный регистр LR адрес функции из
        // области памяти, на которую указывает SP
        "LDR LR, [SP], #16",
        // Прыгаем на функцию
        "RET",
        in(reg) (*new).sp
    );
}

Не уверен, что я делаю это правильно для arm64, но оно работает. Для x86-64 это выглядело бы немного иначе:

 unsafe fn thread_switch(new: *const ThreadContext) {
    asm!(
        "MOV RSP, [{0} + 0x00]",
        "RET",
        in(reg) new,
    );
}

Мы используем этот трюк, чтобы переключаться между функциями. Инструкция MOV RSP, {0} загружает в регистр RSP адрес функции на выполнение которой мы хотим переключиться, а RET передаёт управление на адрес возврата, расположенный на вершине стека.

Для эксперимента нам нужна какая-нибудь тривиальная функция:

fn foo() -> ! {
    println!("foo");
    loop {}
}
fn main() {
    let mut ctx = ThreadContext::default();
    let mut stack = vec![0_u8; SSIZE as usize];

    unsafe {
        // Мы знаем, что в x86-64 и в arm64 стек выровнен на 16 байт

        // Указатель на байты стека (он растёт вниз)
        let stack_bottom = stack.as_mut_ptr().offset(SSIZE);
        // Получаем выровненный на 16-байт адрес стека
        let sb_aligned = (stack_bottom as usize & !15) as *mut u8;

        // Нам нужно записать 8 байт (64 бита),
        // Поэтому приводим адрес foo и указатель на стек к u64

        // Адрес нашей функции
        let foo_src = foo as u64;
        // Смещаемся назад (вверх) на 16 байт
        // (помним про выравнивание в x86-64 и arm64)
        let foo_dst = sb_aligned.offset(-16) as *mut u64;

        // Записываем в стек указатель на foo
        std::ptr::write(foo_dst, foo_src);

        ctx.sp = sb_aligned.offset(-16) as u64;
        thread_switch(&mut ctx);
    }
}

Мы можем сделать foo as u64 потому что функция это уже 64-битный указатель. А вот эта строчка нужна, чтобы получить выровненный на 16 байт указатель на стек:

let sb_aligned = (stack_bottom as usize & !15) as *mut u8;

Продолжение следует…