Это вольный перевод-конспект “книги” 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;
Продолжение следует…